<template>
  <div class="min-h-screen bg-[#f6f4ef] text-[#2c2c2c]">
    <!-- 顶部栏 -->
    <header class="admin-header sticky top-0 z-30 bg-white/90 backdrop-blur border-b border-[#a88c6b]/15">
      <div class="admin-header-row">
        <div class="flex items-center gap-3 min-w-0 shrink-0">
          <span class="text-xl text-[#a88c6b] font-serif shrink-0">❀</span>
          <div class="min-w-0">
            <div class="text-sm tracking-[0.18em] font-light truncate">婚礼邀请函 · 内容编辑后台</div>
            <div class="text-[10px] text-[#8A8680] tracking-[0.12em] mt-0.5">
              制作者 · <span class="text-[#a88c6b]">年糕崽崽</span>
            </div>
          </div>
        </div>

        <!-- 顶部菜单：一级分类 + 下拉 -->
        <nav v-if="menuLayout === 'top'" class="admin-top-menu" aria-label="后台菜单">
          <a-dropdown
            v-for="group in adminMenuGroups"
            :key="group.key"
            :trigger="['hover', 'click']"
            placement="bottomLeft"
          >
            <button
              type="button"
              class="admin-top-menu-trigger"
              :class="{ active: isMenuGroupActive(group) }"
            >
              <span>{{ group.label }}</span>
              <i class="fa-solid fa-chevron-down admin-top-menu-caret" />
            </button>
            <template #overlay>
              <a-menu
                class="admin-top-dropdown"
                :selected-keys="[activeTab]"
                @click="({ key }) => { activeTab = String(key) }"
              >
                <a-menu-item v-for="item in group.items" :key="item.key">
                  <i :class="item.icon" class="mr-2 text-[12px] opacity-80" />
                  {{ item.label }}
                </a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </nav>

        <div class="admin-header-actions">
          <a-segmented
            v-model:value="menuLayout"
            size="small"
            class="admin-layout-seg"
            :options="menuLayoutOptions"
          />
          <a-button size="small" @click="openPreview">
            <i class="fa-solid fa-eye mr-1 text-[#a88c6b]"></i>预览请柬
          </a-button>
          <a-dropdown>
            <a-button size="small">
              <i class="fa-solid fa-scroll mr-1 text-[#a88c6b]"></i>预览海报 <i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i>
            </a-button>
            <template #overlay>
              <a-menu>
                <a-menu-item-group v-for="s in POSTER_SIDES" :key="'g-' + s.key" :title="`${s.label}(${s.key})`">
                  <a-menu-item
                    v-for="l in POSTER_LAYOUTS"
                    :key="`${s.key}-${l.value}`"
                    @click="openPosterPreview(s.key, l.value)"
                  >
                    {{ l.label }} {{ s.route }}/{{ l.value }}
                  </a-menu-item>
                </a-menu-item-group>
              </a-menu>
            </template>
          </a-dropdown>
          <a-button
            v-if="isConfigTab"
            type="primary"
            size="small"
            :loading="saving"
            @click="onSave"
            class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
          >
            <i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
          </a-button>
          <a-dropdown>
            <a-button size="small">{{ adminStore.user?.account || '管理员' }}<i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i></a-button>
            <template #overlay>
              <a-menu>
                <a-menu-item key="logout" @click="onLogout"><i class="fa-solid fa-right-from-bracket mr-2"></i>退出登录</a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </div>
      </div>
    </header>

    <div class="admin-shell" :class="menuLayout === 'side' ? 'admin-shell--side' : 'admin-shell--top'">
      <nav v-if="menuLayout === 'side'" class="admin-nav" aria-label="后台菜单">
        <div
          v-for="group in adminMenuGroups"
          :key="group.key"
          class="admin-nav-group"
        >
          <div class="admin-nav-group-title">{{ group.label }}</div>
          <div class="admin-nav-items">
            <button
              v-for="item in group.items"
              :key="item.key"
              type="button"
              class="admin-nav-item"
              :class="{ active: activeTab === item.key }"
              @click="activeTab = item.key"
            >
              <i :class="item.icon" class="admin-nav-icon" />
              <span>{{ item.label }}</span>
            </button>
          </div>
        </div>
        <div class="admin-nav-credit">制作者 · 年糕崽崽</div>
      </nav>

      <main class="admin-main">
        <!-- 概览 -->
        <section v-if="activeTab === 'overview'">
          <div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
            <div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">访客概览</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">指纹去重访客 · 地区 / 机型画像</div>
              </div>
              <a-button size="small" :loading="visitStatsLoading" @click="loadVisitStats">
                <i class="fa-solid fa-rotate-right mr-1"></i>刷新
              </a-button>
            </div>

            <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5">
              <div class="stat-card">
                <div class="stat-label">访客人数 UV</div>
                <div class="stat-value">{{ visitStats.uv }}</div>
              </div>
              <div class="stat-card">
                <div class="stat-label">访问次数 PV</div>
                <div class="stat-value">{{ visitStats.pv }}</div>
              </div>
              <div class="stat-card">
                <div class="stat-label">今日访客</div>
                <div class="stat-value">{{ visitStats.today_uv }}</div>
              </div>
            </div>

            <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
              <div class="chart-card">
                <div class="flex items-baseline justify-between gap-2 mb-2">
                  <div class="text-[12px] text-[#7a6550] tracking-wide">地区分布</div>
                  <div class="text-[10px] text-[#a89a88]">点击省份查看市级</div>
                </div>
                <div ref="regionChartRef" class="h-[280px] w-full cursor-pointer"></div>
              </div>
              <div class="chart-card">
                <div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">机型画像</div>
                <div ref="deviceChartRef" class="h-[280px] w-full"></div>
              </div>
            </div>

            <div v-if="selectedRegion" class="chart-card mb-5">
              <div class="flex items-center justify-between gap-2 mb-2 flex-wrap">
                <div class="text-[12px] text-[#7a6550] tracking-wide">
                  {{ selectedRegion }} · 市级分布
                  <span v-if="cityStatsLoading" class="text-[10px] text-[#a89a88] ml-2">加载中…</span>
                </div>
                <a-button size="small" type="text" class="!text-[#a88c6b]" @click="clearSelectedRegion">
                  <i class="fa-solid fa-xmark mr-1"></i>关闭
                </a-button>
              </div>
              <div ref="cityChartRef" class="h-[280px] w-full"></div>
            </div>

            <div class="flex justify-between items-center mb-2 gap-2 flex-wrap">
              <div class="text-[12px] text-[#7a6550] tracking-wide">近期访客预览</div>
              <a-button size="small" type="link" class="!text-[#a88c6b] !px-0" @click="activeTab = 'visits'">
                查看全部访客记录 <i class="fa-solid fa-arrow-right ml-1 text-[10px]"></i>
              </a-button>
            </div>
            <div class="visit-preview-wrap">
              <a-table
                class="admin-table"
                :columns="visitPreviewColumns"
                :data-source="visitPreviewList"
                :loading="visitListLoading"
                row-key="id"
                size="small"
                :pagination="false"
                :scroll="{ y: 260 }"
              >
                <template #bodyCell="{ column, record }">
                  <template v-if="column.key === 'fingerprint'">
                    <span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
                  </template>
                  <template v-else-if="column.key === 'location'">
                    <span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
                  </template>
                  <template v-else-if="column.key === 'device'">
                    <span class="text-[12px]">{{ record.device || '未知' }}</span>
                  </template>
                  <template v-else-if="column.key === 'last_seen_at'">
                    <span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
                  </template>
                </template>
              </a-table>
            </div>
          </div>
        </section>

        <!-- 礼物 -->
        <section v-if="activeTab === 'gifts'">
          <div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
            <div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">礼物统计</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
              </div>
              <a-button size="small" :loading="giftStatsLoading || giftListLoading" @click="() => Promise.all([loadGiftStats(), loadGiftList()])">
                <i class="fa-solid fa-rotate-right mr-1"></i>刷新
              </a-button>
            </div>
            <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
              <div v-for="g in giftTypeList" :key="g.key" class="stat-card">
                <div class="stat-label calligraphy">{{ g.label }}</div>
                <div class="stat-value">{{ giftStats.counts[g.key] || 0 }}</div>
                <div class="text-[10px] text-[#8A8680] mt-1">兑换 {{ giftCostsEdit[g.key] || g.cost }} 赞</div>
              </div>
            </div>

            <div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">礼物兑换配置</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">每个礼物消耗的点赞次数 · 默认阶梯 50 / 100 / 200</div>
              </div>
              <div class="flex gap-2">
                <a-button size="small" @click="resetGiftCostsEdit">恢复默认</a-button>
                <a-button size="small" type="primary" class="!bg-[#a88c6b]" :loading="giftCostsSaving" @click="saveGiftCostsEdit">
                  保存配置
                </a-button>
              </div>
            </div>
            <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
              <div v-for="g in giftTypeList" :key="'cost-' + g.key" class="stat-card !py-3">
                <div class="stat-label calligraphy mb-2">{{ g.label }}</div>
                <a-input-number
                  v-model:value="giftCostsEdit[g.key]"
                  :min="1"
                  :max="10000"
                  :step="10"
                  class="w-full"
                  addon-after="赞"
                />
              </div>
            </div>

            <div class="flex justify-between items-center mt-8 mb-3 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">礼物明细</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ giftList.length }} 条送礼记录</div>
              </div>
              <a-button size="small" :loading="giftListLoading" @click="loadGiftList">
                <i class="fa-solid fa-rotate-right mr-1"></i>刷新列表
              </a-button>
            </div>
            <a-table
              class="admin-table"
              :columns="giftListColumns"
              :data-source="giftList"
              :loading="giftListLoading"
              row-key="id"
              size="middle"
              :pagination="{ pageSize: 10, hideOnSinglePage: true }"
              :scroll="{ x: 720 }"
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key === 'gift_type'">
                  <span class="admin-pill admin-pill--rose">{{ giftTypeLabel(record.gift_type) }}</span>
                </template>
                <template v-else-if="column.key === 'cost'">
                  <span>{{ record.cost || 0 }} 赞</span>
                </template>
                <template v-else-if="column.key === 'created_at'">
                  <span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
                </template>
                <template v-else-if="column.key === 'actions'">
                  <a-button size="small" danger type="text" @click="onDeleteGift(record)">删除</a-button>
                </template>
              </template>
            </a-table>
          </div>
        </section>

        <!-- 访客记录 -->
        <section v-if="activeTab === 'visits'">
          <div class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
            <div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">访客记录</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ visitTotal }} 条，分页浏览</div>
              </div>
              <a-button size="small" :loading="visitListLoading" @click="loadVisitList">
                <i class="fa-solid fa-rotate-right mr-1"></i>刷新
              </a-button>
            </div>
            <div class="visit-list-wrap">
              <a-table
                class="admin-table"
                :columns="visitColumns"
                :data-source="visitList"
                :loading="visitListLoading"
                row-key="id"
                size="middle"
                :pagination="visitPagination"
                :scroll="{ y: 'calc(100dvh - 280px)', x: 960 }"
                @change="onVisitTableChange"
              >
                <template #bodyCell="{ column, record }">
                  <template v-if="column.key === 'fingerprint'">
                    <span class="font-mono text-[11px]" :title="record.fingerprint">{{ shortFp(record.fingerprint) }}</span>
                  </template>
                  <template v-else-if="column.key === 'location'">
                    <span>{{ record.raw_location || [record.region, record.city].filter(Boolean).join(' ') || '未知' }}</span>
                  </template>
                  <template v-else-if="column.key === 'device'">
                    <span class="text-[12px]" :title="record.user_agent">{{ record.device || '未知' }}</span>
                  </template>
                  <template v-else-if="column.key === 'user_agent'">
                    <span class="text-[11px] text-[#8A8680]" :title="record.user_agent">{{ shortUa(record.user_agent) }}</span>
                  </template>
                  <template v-else-if="column.key === 'last_seen_at'">
                    <span class="text-[12px] text-[#8A8680]">{{ formatTime(record.last_seen_at) }}</span>
                  </template>
                </template>
              </a-table>
            </div>
          </div>
        </section>

        <!-- 基础信息 -->
        <section v-if="activeTab === 'basic'">
          <a-card :bordered="false" class="!shadow-sm" :class="{ 'opacity-60': tabLoading }">
            <a-form layout="vertical">
              <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
                <a-form-item label="新郎">
                  <a-input v-model:value="cfg.groom" placeholder="新郎姓名" />
                </a-form-item>
                <a-form-item label="新娘">
                  <a-input v-model:value="cfg.bride" placeholder="新娘姓名" />
                </a-form-item>
                <a-form-item label="公历日期">
                  <a-input v-model:value="cfg.date" placeholder="如 2026-05-20" />
                </a-form-item>
                <a-form-item label="农历 / 星期">
                  <a-input v-model:value="cfg.lunar" placeholder="如 农历四月初四 星期三" />
                </a-form-item>
                <a-form-item label="日历圈选日期">
                  <a-input v-model:value="cfg.calendarDate" placeholder="如 2026-09-04" />
                  <div class="text-[11px] text-[#8A8680] mt-1">前台婚礼日历会展示该日期所在月份，并用爱心圈出这一天。</div>
                </a-form-item>
                <a-form-item label="酒店名称">
                  <a-input v-model:value="cfg.hotel" placeholder="宴会酒店" />
                </a-form-item>
                <a-form-item label="详细地址">
                  <a-textarea v-model:value="cfg.address" :rows="2" placeholder="可换行" />
                </a-form-item>
              </div>
              <div class="flex flex-wrap items-center justify-between gap-2 mb-1">
                <div class="text-[11px] text-[#8A8680]">
                  原图用于预览；展示图为等比缩放后的体积（不裁切），前台封面/揭幕/尾页优先用展示图。
                  <span v-if="resizeProgress && resizingBasic" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
                </div>
                <a-button
                  size="small"
                  type="primary"
                  class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
                  :loading="resizingBasic"
                  :disabled="uploading || (!cfg.heroImg && !cfg.endImg)"
                  @click="batchResizeBasicImages"
                >
                  <i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
                </a-button>
              </div>
              <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
                <a-form-item label="封面图片">
                  <div class="relative w-[96px]">
                    <image-field
                      v-model="cfg.heroImg"
                      label="封面"
                      @pick="openUpload(u => setBasicOriginal('heroImg', u))"
                      :uploading="uploading"
                    />
                    <div v-if="cfg.heroImgResized" class="text-center mt-1">
                      <div class="text-[9px] text-[#a88c6b]">展示已生成</div>
                      <div v-if="cfg.heroImgResizedMeta" class="resized-meta">{{ cfg.heroImgResizedMeta }}</div>
                    </div>
                  </div>
                  <div class="text-[11px] text-[#8A8680] mt-2">用于首页封面和入场揭幕背景。</div>
                </a-form-item>
                <a-form-item label="尾页图片">
                  <div class="relative w-[96px]">
                    <image-field
                      v-model="cfg.endImg"
                      label="尾页"
                      @pick="openUpload(u => setBasicOriginal('endImg', u))"
                      :uploading="uploading"
                    />
                    <div v-if="cfg.endImgResized" class="text-center mt-1">
                      <div class="text-[9px] text-[#a88c6b]">展示已生成</div>
                      <div v-if="cfg.endImgResizedMeta" class="resized-meta">{{ cfg.endImgResizedMeta }}</div>
                    </div>
                  </div>
                  <div class="text-[11px] text-[#8A8680] mt-2">用于最后的回执尾页，可与封面分开设置。</div>
                </a-form-item>
              </div>
            </a-form>
          </a-card>
        </section>

        <!-- 文案 -->
        <section v-if="activeTab === 'copy'">
          <a-card :bordered="false" class="!shadow-sm">
            <a-form layout="vertical">
              <a-form-item label="竖排文案 · 上联">
                <a-input v-model:value="cfg.formalText1" placeholder="如 两姓联姻 一堂缔约" />
              </a-form-item>
              <a-form-item label="竖排文案 · 下联">
                <a-input v-model:value="cfg.formalText2" placeholder="如 良缘永结 匹配同称" />
              </a-form-item>
              <a-form-item label="邀请文案页高度">
                <div class="flex gap-2 items-center">
                  <a-select :value="heightFieldSelectValue(cfg.formalPageHeight)" @change="(v) => onHeightFieldModeChange(cfg, 'formalPageHeight', v)" class="flex-1">
                    <a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
                  </a-select>
                  <a-input-number v-if="heightFieldSelectValue(cfg.formalPageHeight) === '__custom__'"
                                  :value="customVhValue(cfg.formalPageHeight)" @change="(v) => onHeightFieldCustomVh(cfg, 'formalPageHeight', v)"
                                  :min="20" :max="200" addon-after="vh" class="w-[150px]" />
                </div>
                <div class="text-[11px] text-[#8A8680] mt-1">控制前台竖排邀请文案页的展示高度，默认满屏。</div>
              </a-form-item>
            </a-form>
          </a-card>
        </section>

        <!-- 视觉 / 动效 -->
        <section v-if="activeTab === 'visual'">
          <a-card :bordered="false" class="!shadow-sm">
            <a-form layout="vertical">
              <a-form-item label="背景音乐">
                <div class="text-[12px] text-[#8A8680] leading-relaxed">多首背景音乐的上传、试听与「设为播放」请在 <span class="text-[#a88c6b]">背景音乐</span> 标签页管理。</div>
              </a-form-item>
              <a-form-item label="入场揭幕退场方式">
                <a-select v-model:value="cfg.introExitEffect">
                  <a-select-option v-for="opt in INTRO_EXIT_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
                </a-select>
                <div class="text-[11px] text-[#8A8680] mt-1">点击开启请柬时的退场动画，可按婚礼调性选择更轻盈或更有仪式感的方式。</div>
              </a-form-item>
              <a-form-item label="滚动方式">
                <a-radio-group v-model:value="cfg.scrollMode">
                  <a-radio value="snap">整页 / 分屏吸附滚动</a-radio>
                  <a-radio value="free">自由滚动</a-radio>
                </a-radio-group>
                <div class="text-[11px] text-[#8A8680] mt-1">整页模式会带来更有仪式感的逐屏切换效果。</div>
              </a-form-item>
              <div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
                <a-form-item label="自由滚动速度（滚动间隔）">
                  <div class="flex gap-3 items-center">
                    <a-slider v-model:value="cfg.freeScrollInterval" :min="10" :max="500" class="flex-1" />
                    <a-input-number v-model:value="cfg.freeScrollInterval" :min="10" :max="500" addon-after="ms" class="w-[120px]" />
                  </div>
                  <div class="text-[11px] text-[#8A8680] mt-1">间隔越小滚动越快，默认 50ms。仅在「自由滚动」模式下生效。</div>
                </a-form-item>
                <a-form-item label="页面切换速度（动画时长）">
                  <div class="flex gap-3 items-center">
                    <a-slider v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" class="flex-1" />
                    <a-input-number v-model:value="cfg.pageSwitchDuration" :min="200" :max="3000" :step="100" addon-after="ms" class="w-[120px]" />
                  </div>
                  <div class="text-[11px] text-[#8A8680] mt-1">整页/分屏模式下自动翻页的动画时长，默认 800ms。</div>
                </a-form-item>
                <a-form-item label="首屏停留时间">
                  <div class="flex gap-3 items-center">
                    <a-slider v-model:value="cfg.firstScreenDuration" :min="0" :max="12000" :step="100" class="flex-1" />
                    <a-input-number v-model:value="cfg.firstScreenDuration" :min="0" :max="12000" :step="100" addon-after="ms" class="w-[120px]" />
                  </div>
                  <div class="text-[11px] text-[#8A8680] mt-1">首屏效果结束后等待多久开始自动滚动，默认 4500ms。</div>
                </a-form-item>
              </div>
              <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
                <a-form-item label="飘落花瓣">
                  <a-switch v-model:checked="cfg.petalEnabled" />
                </a-form-item>
                <a-form-item label="漂浮爱心">
                  <a-switch v-model:checked="cfg.bubbleEnabled" />
                </a-form-item>
                <a-form-item label="入场揭幕">
                  <a-switch v-model:checked="cfg.introEnabled" />
                </a-form-item>
              </div>
              <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
                <a-form-item label="祝福弹幕">
                  <a-switch v-model:checked="cfg.danmakuEnabled" />
                  <div class="text-[11px] text-[#8A8680] mt-1">关闭后前台不展示弹幕，也不显示送祝福入口。</div>
                </a-form-item>
                <a-form-item label="弹幕显示时间">
                  <a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
                  <div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间（刚刚、x分钟前等）。</div>
                </a-form-item>
                <a-form-item label="九宫格入场动画">
                  <a-switch v-model:checked="cfg.nineGridAnimate" />
                  <div class="text-[11px] text-[#8A8680] mt-1">开启：进入视口后暂停滚动，每 300ms 入场一格（方向动效）；全部完成后再继续。关闭：直接展示，更稳（推荐 iOS）。</div>
                </a-form-item>
              </div>
            </a-form>
          </a-card>
        </section>

        <!-- 背景音乐 -->
        <section v-if="activeTab === 'music'">
          <div class="admin-panel">
            <div class="flex flex-wrap items-start justify-between gap-3 mb-5">
              <div>
                <div class="text-sm text-[#2c2c2c] tracking-wide">曲目列表</div>
                <p class="text-[11px] text-[#8A8680] mt-1 leading-relaxed max-w-xl">
                  开启随机后按<strong>权重</strong>抽曲，播完自动再随机下一首（不单曲循环）。
                  可开启「记忆曲目」让同一访客在设定时长内首次打开仍播同一首。
                </p>
              </div>
              <div class="flex flex-wrap items-center gap-3 justify-end">
                <label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
                  <a-switch v-model:checked="cfg.musicRandomEnabled" size="small" />
                  随机播放
                </label>
                <a-button type="primary" size="small"
                          class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
                          @click="openMusicUpload">
                  <i class="fa-solid fa-plus mr-1"></i>上传音乐
                </a-button>
              </div>
            </div>

            <div
              v-if="cfg.musicRandomEnabled"
              class="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[#e7e3db] bg-[#faf8f5] px-3 py-2.5"
            >
              <label class="inline-flex items-center gap-2 text-[12px] text-[#5c5348] cursor-pointer select-none">
                <a-switch v-model:checked="cfg.musicCacheEnabled" size="small" />
                记忆曲目
              </label>
              <div class="inline-flex items-center gap-2 text-[12px] text-[#5c5348]">
                <span :class="{ 'opacity-40': !cfg.musicCacheEnabled }">记忆时长</span>
                <a-input-number
                  v-model:value="cfg.musicCacheHours"
                  :min="0.5"
                  :max="720"
                  :step="0.5"
                  size="small"
                  class="!w-[96px]"
                  :disabled="!cfg.musicCacheEnabled"
                />
                <span :class="{ 'opacity-40': !cfg.musicCacheEnabled }" class="text-[#8A8680]">小时</span>
              </div>
              <span class="text-[10px] text-[#8A8680]">
                {{ cfg.musicCacheEnabled
                  ? `同一设备 ${cfg.musicCacheHours || 24} 小时内首次打开沿用上次抽中的歌；播完仍会换下一首。`
                  : '关闭后每次打开页面都会重新按权重抽曲。' }}
              </span>
            </div>

            <div v-if="!cfg.musicList.length" class="admin-empty">
              <div class="admin-empty-icon"><i class="fa-solid fa-music"></i></div>
              <div class="text-[13px] text-[#5c5348]">尚未上传音乐</div>
              <div class="text-[11px] text-[#8A8680] mt-1">支持 mp3 等常见音频格式</div>
              <a-button class="mt-4" @click="openMusicUpload">
                <i class="fa-solid fa-upload mr-1 text-[#a88c6b]"></i>上传第一首
              </a-button>
            </div>

            <ul v-else class="music-list">
              <li
                v-for="(t, i) in cfg.musicList"
                :key="i"
                class="music-track"
                :class="{ 'music-track--active': t.url === cfg.activeMusicUrl }"
              >
                <div class="music-track-main">
                  <button
                    type="button"
                    class="music-cover"
                    @click="openUpload(u => t.coverUrl = u)"
                    :title="t.coverUrl ? '更换封面' : '上传封面'"
                  >
                    <img v-if="t.coverUrl" :src="t.coverUrl" alt="" />
                    <span v-else class="music-cover-fallback">
                      <i class="fa-solid fa-compact-disc"></i>
                    </span>
                    <span class="music-cover-index">{{ String(i + 1).padStart(2, '0') }}</span>
                  </button>

                  <div class="music-meta min-w-0 flex-1">
                    <a-input
                      v-model:value="t.name"
                      placeholder="曲目名称"
                      class="music-name-input"
                      :bordered="false"
                    />
                    <div class="music-sub">
                      <span class="truncate">{{ shortUrl(t.url) }}</span>
                      <span
                        v-if="t.url === cfg.activeMusicUrl"
                        class="music-badge"
                      >正在播放</span>
                    </div>
                    <div class="music-chips">
                      <label
                        class="music-chip music-chip--weight"
                        :class="{ 'opacity-40': !cfg.musicRandomEnabled }"
                        :title="cfg.musicRandomEnabled ? '随机权重，越大越容易抽到' : '开启随机播放后生效'"
                      >
                        <i class="fa-solid fa-scale-balanced"></i>
                        <span>权重</span>
                        <a-input-number
                          v-model:value="t.weight"
                          :min="1"
                          :max="1000"
                          :step="1"
                          size="small"
                          class="music-weight-input"
                          :disabled="!cfg.musicRandomEnabled"
                        />
                        <span v-if="cfg.musicRandomEnabled" class="music-weight-pct">
                          {{ musicWeightPercent(t) }}%
                        </span>
                      </label>
                      <button type="button" class="music-chip" @click="openLrcUpload(t)">
                        <i class="fa-solid fa-file-lines"></i>
                        {{ t.lrcUrl ? '已有歌词' : '上传歌词' }}
                      </button>
                      <button
                        v-if="t.lrcUrl"
                        type="button"
                        class="music-chip music-chip--muted"
                        @click="t.lrcUrl = ''"
                      >清除歌词</button>
                    </div>
                    <audio v-if="t.url" :src="t.url" controls preload="none" class="music-audio"></audio>
                  </div>

                  <div class="music-actions">
                    <a-button
                      size="small"
                      :type="t.url === cfg.activeMusicUrl ? 'primary' : 'default'"
                      :class="t.url === cfg.activeMusicUrl ? '!bg-[#a88c6b] !border-[#a88c6b]' : ''"
                      @click="setActiveMusic(t.url)"
                    >
                      <i class="fa-solid fa-play mr-1 text-[10px]"></i>
                      {{ t.url === cfg.activeMusicUrl ? '当前曲' : '设为播放' }}
                    </a-button>
                    <a-button size="small" danger type="text" @click="removeMusic(i)" title="删除">
                      <i class="fa-solid fa-trash"></i>
                    </a-button>
                  </div>
                </div>
              </li>
            </ul>
          </div>
        </section>

        <!-- 上传设置 -->
        <section v-if="activeTab === 'upload'">
          <a-card :bordered="false" class="!shadow-sm">
            <a-alert
              type="info"
              show-icon
              class="mb-4"
              message="上传设置仅后台可见，OSS 密钥不会写入公开请柬配置。"
            />
            <a-form layout="vertical">
              <a-form-item label="上传方式">
                <a-radio-group v-model:value="uploadCfg.provider">
                  <a-radio value="local">本地上传</a-radio>
                  <a-radio value="oss">阿里云 OSS</a-radio>
                </a-radio-group>
              </a-form-item>
              <div v-if="uploadCfg.provider === 'oss'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
                <a-form-item label="AccessKeyId">
                  <a-input v-model:value="uploadCfg.accessKeyId" placeholder="请输入 AccessKeyId" />
                </a-form-item>
                <a-form-item label="AccessKeySecret">
                  <a-input-password v-model:value="uploadCfg.accessKeySecret" :placeholder="uploadCfg.hasSecret ? '已配置，留空则不修改' : '请输入 AccessKeySecret'" />
                </a-form-item>
                <a-form-item label="Bucket">
                  <a-input v-model:value="uploadCfg.bucket" placeholder="如 wedding-assets" />
                </a-form-item>
                <a-form-item label="文件夹">
                  <a-input v-model:value="uploadCfg.folder" placeholder="如 invitations" />
                </a-form-item>
                <a-form-item label="访问域名">
                  <a-input v-model:value="uploadCfg.domain" placeholder="如 https://cdn.example.com，可留空" />
                </a-form-item>
                <a-form-item label="Region">
                  <a-input v-model:value="uploadCfg.region" placeholder="如 cn-hangzhou" />
                </a-form-item>
                <a-form-item label="Endpoint">
                  <a-input v-model:value="uploadCfg.endpoint" placeholder="如 https://oss-cn-hangzhou.aliyuncs.com" />
                </a-form-item>
              </div>
              <div v-else class="text-[12px] text-[#8A8680] leading-relaxed mb-4">
                本地上传会继续保存到后端 uploads 目录，返回 http://localhost:8080/uploads/ 开头的地址。
              </div>
              <a-button type="primary" :loading="uploadConfigSaving" @click="onSaveUploadConfig"
                        class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
                <i class="fa-solid fa-floppy-disk mr-1"></i>保存上传设置
              </a-button>
            </a-form>
          </a-card>
        </section>

        <!-- 海报（独立 poster_configs：1 男方 /hb-n，2 女方 /hb-nv，3 新人模板 /hb-xr） -->
        <section v-if="activeTab === 'poster'">
          <a-card :bordered="false" class="!shadow-sm">
            <a-alert
              type="info"
              show-icon
              class="mb-4"
              message="每一方同时维护竖版(/1)与横版(/2)。例：男方竖版 /hb-n/1，横版 /hb-n/2；女方 /hb-nv/1|/2；新人模板 /hb-xr/1|/2。"
            />
            <a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
              <a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
            </a-tabs>
            <a-tabs v-model:activeKey="posterLayoutTab" size="small" class="mb-3">
              <a-tab-pane
                v-for="l in POSTER_LAYOUTS"
                :key="l.value"
                :tab="`${l.label} (/${l.value})`"
              />
            </a-tabs>
            <a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
              <a-form-item label="所属方 / 风格">
                <a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
                <a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
                <span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterPreviewPath }}</span>
              </a-form-item>
              <a-form-item label="版式模板">
                <a-radio-group v-model:value="posterCfg.template" class="poster-tpl-group">
                  <a-radio-button v-for="t in POSTER_TEMPLATES" :key="t.value" :value="t.value">
                    {{ t.label }}
                  </a-radio-button>
                </a-radio-group>
                <div class="text-[11px] text-[#8A8680] mt-1">
                  {{ POSTER_TEMPLATES.find(t => t.value === posterCfg.template)?.desc }}
                </div>
              </a-form-item>
              <a-form-item label="手机端布局">
                <a-radio-group v-model:value="posterCfg.mobileLayout">
                  <a-radio-button v-for="t in MOBILE_LAYOUTS" :key="t.value" :value="t.value">
                    {{ t.label }}
                  </a-radio-button>
                </a-radio-group>
                <div class="text-[11px] text-[#8A8680] mt-1">
                  {{ MOBILE_LAYOUTS.find(t => t.value === posterCfg.mobileLayout)?.desc }}（仅窄屏生效，PC 不受影响）
                </div>
              </a-form-item>
              <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
                <a-form-item label="启用海报页">
                  <a-switch v-model:checked="posterCfg.enabled" />
                </a-form-item>
                <a-form-item label="显示跳转请柬按钮">
                  <a-switch v-model:checked="posterCfg.showInviteButton" />
                </a-form-item>
                <a-form-item label="按钮文案">
                  <a-input v-model:value="posterCfg.inviteButtonText" :disabled="!posterCfg.showInviteButton" />
                </a-form-item>
              </div>
              <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
                <a-form-item label="进页 Loading">
                  <a-select v-model:value="posterCfg.loadingEffect">
                    <a-select-option v-for="opt in LOADING_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
                  </a-select>
                </a-form-item>
                <a-form-item label="打开页面过场">
                  <a-select v-model:value="posterCfg.enterEffect">
                    <a-select-option v-for="opt in ENTER_EFFECTS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
                  </a-select>
                </a-form-item>
                <a-form-item label="跳转路径">
                  <a-input v-model:value="posterCfg.invitePath" placeholder="/" />
                </a-form-item>
              </div>
              <a-form-item label="背景音乐">
                <a-select
                  v-model:value="posterCfg.musicUrl"
                  allow-clear
                  show-search
                  option-filter-prop="label"
                  placeholder="不播放（从已有曲目选择）"
                  :options="posterMusicSelectOptions"
                  :loading="posterMusicLoading"
                />
                <div class="text-[11px] text-[#8A8680] mt-1">
                  曲目来自「背景音乐」页；清空则该海报不播放。可到
                  <a class="text-[#a88c6b] cursor-pointer" @click="activeTab = 'music'">背景音乐</a>
                  上传管理。
                </div>
              </a-form-item>
              <a-form-item label="页面标题（浏览器标签 / 分享）">
                <a-input
                  v-model:value="posterCfg.pageTitle"
                  :placeholder="currentPosterSide.label + '海报页面标题'"
                />
              </a-form-item>
              <a-form-item label="页面描述 description">
                <a-textarea
                  v-model:value="posterCfg.pageDescription"
                  :rows="2"
                  placeholder="用于 meta description / 分享摘要"
                />
              </a-form-item>
              <a-form-item :label="posterCfg.template === 'split' ? '左侧照片' : '顶部照片'">
                <image-field
                  v-model="posterCfg.heroImg"
                  label="海报图片"
                  @pick="openUpload(setPosterHero)"
                  :uploading="uploading"
                />
              </a-form-item>
              <a-form-item label="照片展示焦点">
                <a-radio-group v-model:value="heroFocusDevice" size="small" class="mb-2" button-style="solid">
                  <a-radio-button value="pc">PC 端</a-radio-button>
                  <a-radio-button value="mobile">手机端</a-radio-button>
                </a-radio-group>
                <div
                  v-if="posterCfg.heroImg"
                  ref="heroFocusPreviewEl"
                  class="hero-focus-preview hero-focus-preview--drag"
                  :class="{ dragging: heroFocusDragging, 'is-mobile': heroFocusDevice === 'mobile' }"
                  @pointerdown="onHeroFocusPointerDown"
                >
                  <img
                    :src="posterCfg.heroImg"
                    alt=""
                    draggable="false"
                    :style="{ objectPosition: heroFocusPreviewCss }"
                  />
                  <span
                    class="hero-focus-crosshair"
                    :style="{ left: heroFocusActiveX + '%', top: heroFocusActiveY + '%' }"
                  />
                </div>
                <div v-else class="hero-focus-preview hero-focus-preview--empty">
                  请先上传海报图片，再拖拽设置焦点
                </div>
                <div class="hero-focus-pad mt-2" :style="{ '--hero-focus-n': HERO_FOCUS_GRID }">
                  <button
                    v-for="pt in heroFocusGrid"
                    :key="pt.key"
                    type="button"
                    class="hero-focus-cell"
                    :class="{ active: isHeroFocusGridActive(pt) }"
                    :title="`${Math.round(pt.x)}%, ${Math.round(pt.y)}%`"
                    @click="setHeroFocus(pt.x, pt.y)"
                  >
                    <span class="hero-focus-dot" />
                  </button>
                </div>
                <div class="text-[11px] text-[#8A8680] mt-1">
                  {{ heroFocusDevice === 'mobile' ? '手机端' : 'PC 端' }}焦点：拖拽十字准星或点网格；当前
                  {{ Math.round(heroFocusActiveX) }}% · {{ Math.round(heroFocusActiveY) }}%
                </div>
              </a-form-item>
              <a-form-item label="二维码中心图">
                <image-field
                  v-model="posterCfg.qrCenterImg"
                  label="中心图（可选）"
                  @pick="openUpload(setPosterQrCenter)"
                  :uploading="uploading"
                />
                <div class="text-xs text-stone-500 mt-1">不配置时，二维码中心为圆圈大「囍」字</div>
              </a-form-item>

              <!-- classic 字段 -->
              <template v-if="posterCfg.template === 'classic'">
                <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
                  <a-form-item :label="posterNameLabels.left">
                    <div class="flex gap-2">
                      <a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
                      <a-input v-model:value="posterCfg.sonName" placeholder="姓名" />
                    </div>
                  </a-form-item>
                  <a-form-item :label="posterNameLabels.right">
                    <div class="flex gap-2">
                      <a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
                      <a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" />
                    </div>
                  </a-form-item>
                </div>
                <a-form-item label="主标题">
                  <a-input v-model:value="posterCfg.title" placeholder="婚礼邀请函" />
                </a-form-item>
                <a-form-item label="正文（每行一条）">
                  <a-textarea v-model:value="posterLinesText" :rows="10" placeholder="每行一条正文" />
                </a-form-item>
                <a-form-item label="底部文案">
                  <a-input v-model:value="posterCfg.footer" />
                </a-form-item>
              </template>

              <!-- split 字段 -->
              <template v-else>
                <div class="grid grid-cols-1 md:grid-cols-3 gap-x-6">
                  <a-form-item label="竖排主标">
                    <a-input v-model:value="posterCfg.verticalSlogan" placeholder="吾家有喜" />
                  </a-form-item>
                  <a-form-item label="副标一">
                    <a-input v-model:value="posterCfg.subSlogan1" placeholder="佳偶天成" />
                  </a-form-item>
                  <a-form-item label="副标二">
                    <a-input v-model:value="posterCfg.subSlogan2" placeholder="百年好合" />
                  </a-form-item>
                </div>
                <a-form-item label="问候语">
                  <a-input v-model:value="posterCfg.greeting" placeholder="尊敬的各位亲朋好友" />
                </a-form-item>
                <a-form-item label="正文（每行一条，建议 2～4 行）">
                  <a-textarea v-model:value="posterLinesText" :rows="5" placeholder="每行一条正文" />
                </a-form-item>
                <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
                  <a-form-item :label="posterNameLabels.left">
                    <div class="flex gap-2">
                      <a-input v-model:value="posterCfg.sonLabel" class="!w-[100px]" :placeholder="posterNameLabels.leftPh" />
                      <a-input v-model:value="posterCfg.sonName" placeholder="姓名" @update:value="v => posterCfg.groomName = v" />
                    </div>
                  </a-form-item>
                  <a-form-item :label="posterNameLabels.right">
                    <div class="flex gap-2">
                      <a-input v-model:value="posterCfg.daughterInLawLabel" class="!w-[100px]" :placeholder="posterNameLabels.rightPh" />
                      <a-input v-model:value="posterCfg.daughterInLawName" placeholder="姓名" @update:value="v => posterCfg.brideName = v" />
                    </div>
                  </a-form-item>
                  <a-form-item label="喜宴时间">
                    <a-input v-model:value="posterCfg.dateText" />
                  </a-form-item>
                  <a-form-item label="农历">
                    <a-input v-model:value="posterCfg.lunarText" />
                  </a-form-item>
                </div>
                <a-form-item label="地点">
                  <a-input v-model:value="posterCfg.venueText" />
                </a-form-item>
                <a-form-item label="底部 Love 文案">
                  <a-input v-model:value="posterCfg.loveFooter" placeholder="Love you" />
                </a-form-item>
              </template>
            </a-form>
          </a-card>
          <div class="poster-action-bar-spacer" aria-hidden="true"></div>
        </section>

        <div v-if="activeTab === 'poster'" class="poster-action-bar">
          <div class="poster-action-bar-inner">
            <div class="poster-action-bar-meta">
              <span>{{ currentPosterSide.label }} · {{ currentPosterLayout.label }}</span>
              <span class="poster-action-bar-path">{{ currentPosterPreviewPath }}</span>
            </div>
            <div class="poster-action-bar-btns">
              <a-button
                v-if="posterUndoSnapshot"
                size="small"
                @click="undoPosterImport"
              >
                <i class="fa-solid fa-rotate-left mr-1"></i>撤回导入
              </a-button>
              <a-button size="small" :loading="posterCopying" @click="copyPosterTemplate">
                <i class="fa-solid fa-copy mr-1"></i>复制
              </a-button>
              <a-button size="small" :loading="posterImporting" @click="importPosterTemplateDirect">
                <i class="fa-solid fa-file-import mr-1"></i>导入
              </a-button>
              <a-button size="small" @click="openPosterPreview(posterSideTab, posterLayoutTab)">
                <i class="fa-solid fa-eye mr-1"></i>预览
              </a-button>
              <a-button
                type="primary"
                size="small"
                :loading="posterSaving"
                class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
                @click="onSavePosterConfig"
              >
                <i class="fa-solid fa-floppy-disk mr-1"></i>保存
              </a-button>
            </div>
          </div>
        </div>

        <section v-if="activeTab === 'photos'">
          <div class="admin-panel !p-4 mb-3">
            <div class="flex justify-between items-center gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">相册页</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ cfg.photoPages.length }} 页</div>
              </div>
              <a-button size="small" type="primary"
                        class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
                        @click="addPage">
                <i class="fa-solid fa-plus mr-1"></i>新增相册页
              </a-button>
            </div>
            <a-tabs
              v-if="cfg.photoPages.length"
              v-model:activeKey="activePhotoTab"
              size="small"
              type="card"
              class="photo-page-tabs mt-3"
              :destroyInactiveTabPane="true"
            >
              <a-tab-pane v-for="(page, idx) in cfg.photoPages" :key="String(idx)" :tab="`第 ${idx + 1} 页`" />
            </a-tabs>
          </div>
          <div v-if="!cfg.photoPages.length" class="admin-empty">
            <div class="admin-empty-icon"><i class="fa-regular fa-images"></i></div>
            <div class="text-[13px] text-[#5c5348]">暂无相册页</div>
          </div>
          <template v-for="(page, idx) in cfg.photoPages" :key="idx">
          <a-card v-if="idx === currentPhotoIndex" :bordered="false" class="!shadow-sm mb-4 !rounded-2xl !border !border-[#a88c6b]/10">
            <div class="flex justify-between items-center mb-3">
              <span class="inline-flex items-center gap-2 text-[#a88c6b] text-sm">
                <span class="w-7 h-7 rounded-lg bg-[#a88c6b]/10 text-[11px] inline-flex items-center justify-center tracking-wider">
                  {{ String(idx + 1).padStart(2, '0') }}
                </span>
                第 {{ idx + 1 }} 页
              </span>
              <div class="flex gap-1">
                <a-button size="small" :disabled="idx===0" @click="movePage(idx,-1)"><i class="fa-solid fa-arrow-up"></i></a-button>
                <a-button size="small" :disabled="idx===cfg.photoPages.length-1" @click="movePage(idx,1)"><i class="fa-solid fa-arrow-down"></i></a-button>
                <a-button size="small" danger type="text" @click="removePage(idx)"><i class="fa-solid fa-trash"></i></a-button>
              </div>
            </div>
            <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
              <a-form-item label="版式">
                <a-select v-model:value="page.type" @change="() => ensurePageShape(page)">
                  <a-select-option value="single">单图</a-select-option>
                  <a-select-option value="overlap">错落双图</a-select-option>
                  <a-select-option value="one-large-five-small">一大五小</a-select-option>
                  <a-select-option value="nine-grid">九宫格</a-select-option>
                  <a-select-option value="masonry">瀑布流</a-select-option>
                  <a-select-option value="carousel">轮播滑动</a-select-option>
                  <a-select-option value="polaroid">拍立得拼贴</a-select-option>
                  <a-select-option value="heart-collage">爱心拼接</a-select-option>
                </a-select>
              </a-form-item>
              <a-form-item label="页面高度">
                <div class="flex gap-2 items-center">
                  <a-select :value="heightSelectValue(page)" @change="(v) => onHeightModeChange(page, v)" class="flex-1">
                    <a-select-option v-for="opt in HEIGHT_PRESETS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
                  </a-select>
                  <a-input-number v-if="heightSelectValue(page) === '__custom__'"
                                  :value="customVh(page)" @change="(v) => onCustomVh(page, v)"
                                  :min="20" :max="200" addon-after="vh" class="w-[150px]" />
                </div>
              </a-form-item>
            </div>
            <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
              <a-form-item label="边框样式">
                <a-select v-model:value="page.borderStyle">
                  <a-select-option v-for="b in BORDER_STYLES" :key="b.value" :value="b.value">{{ b.label }}</a-select-option>
                </a-select>
              </a-form-item>
              <div class="flex items-center gap-3">
                <span class="text-[11px] text-[#a88c6b]">预览</span>
                <div class="w-[70px] h-[88px] shrink-0">
                  <div :class="borderClass(page.borderStyle)" style="width:100%;height:100%">
                    <img src="https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=200&q=80" />
                  </div>
                </div>
              </div>
            </div>
            <!-- 单图版式的宽度设置（逐页配置） -->
            <div v-if="page.type==='single'" class="grid grid-cols-1 md:grid-cols-2 gap-x-6 items-start">
              <a-form-item label="图片宽度">
                <a-radio-group v-model:value="page.singleWidth">
                  <a-radio value="default">默认（居中卡幅）</a-radio>
                  <a-radio value="full">宽度铺满</a-radio>
                </a-radio-group>
              </a-form-item>
              <a-form-item v-if="page.singleWidth==='full'" label="保持原始宽高比">
                <a-switch v-model:checked="page.singleKeepRatio" />
                <div class="text-[11px] text-[#8A8680] mt-1">开启：按图片原始比例显示；关闭：固定 4:5 比例裁切铺满。</div>
              </a-form-item>
            </div>
            <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
              <a-form-item label="小标题 (英文)">
                <a-input v-model:value="page.subtitle" />
              </a-form-item>
              <a-form-item label="标题">
                <a-input v-model:value="page.title" />
              </a-form-item>
            </div>
            <a-form-item label="描述">
              <a-textarea v-model:value="page.desc" :rows="2" />
            </a-form-item>
            <!-- 图片上传区：按版式渲染 -->
            <div class="flex flex-wrap items-center justify-between gap-2 mt-2 mb-2">
              <div class="text-[11px] text-[#8A8680]">
                原图用于预览；展示图为等比缩放后的体积（不裁切）。
                <span v-if="resizeProgress" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
              </div>
              <a-button
                size="small"
                type="primary"
                class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
                :loading="resizingPage"
                :disabled="uploading || !pageHasOriginal(page)"
                @click="batchResizeAndUpload(page)"
              >
                <i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
              </a-button>
            </div>
            <div class="flex flex-wrap gap-3 mt-2">
              <template v-if="page.type==='single'">
                <div class="relative w-[96px]">
                  <image-field v-model="page.img1" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" />
                  <div v-if="page.img1Resized" class="text-center mt-1">
                    <div class="text-[9px] text-[#a88c6b]">展示已生成</div>
                    <div v-if="page.img1ResizedMeta" class="resized-meta">{{ page.img1ResizedMeta }}</div>
                  </div>
                </div>
              </template>
              <template v-else-if="page.type==='overlap'">
                <div class="relative w-[96px]">
                  <image-field v-model="page.img1" label="图一" @pick="openUpload(u => setOriginalAndClearResized(page, 'img1', u))" :uploading="uploading" />
                  <div v-if="page.img1Resized" class="text-center mt-1">
                    <div class="text-[9px] text-[#a88c6b]">展示已生成</div>
                    <div v-if="page.img1ResizedMeta" class="resized-meta">{{ page.img1ResizedMeta }}</div>
                  </div>
                </div>
                <div class="relative w-[96px]">
                  <image-field v-model="page.img2" label="图二" @pick="openUpload(u => setOriginalAndClearResized(page, 'img2', u))" :uploading="uploading" />
                  <div v-if="page.img2Resized" class="text-center mt-1">
                    <div class="text-[9px] text-[#a88c6b]">展示已生成</div>
                    <div v-if="page.img2ResizedMeta" class="resized-meta">{{ page.img2ResizedMeta }}</div>
                  </div>
                </div>
              </template>
              <template v-else-if="page.type === 'heart-collage'">
                <div class="w-full">
                  <div class="text-[11px] text-[#8A8680] mb-2 flex flex-wrap items-center gap-x-3 gap-y-1">
                    <span>
                      <strong>单击</strong>两格对调顺序；格内<strong>拖拽</strong>调整展示焦点（同海报）。
                      空格点击上传。
                    </span>
                    <a-button
                      v-if="heartSwapSelected?.page === page"
                      size="small"
                      type="link"
                      class="!px-0 !h-auto !text-[#a88c6b]"
                      @click="heartSwapSelected = null"
                    >取消选择（已选：{{ heartSlotLabel(heartSwapSelected.idx) }}）</a-button>
                  </div>
                  <div class="heart-admin-board">
                    <div
                      v-for="(slot, iidx) in HEART_SLOTS"
                      :key="iidx"
                      class="heart-admin-slot"
                      :style="heartSlotStyle(slot)"
                    >
                      <div
                        class="heart-admin-cell"
                        :class="{
                          empty: !(page.images[iidx] || page.imagesResized?.[iidx]),
                          selected: heartSwapSelected?.page === page && heartSwapSelected?.idx === iidx,
                          'swap-target': heartSwapSelected?.page === page && heartSwapSelected?.idx !== iidx,
                          dragging: heartFocusDrag?.page === page && heartFocusDrag?.idx === iidx && heartFocusDrag?.moved,
                        }"
                        @pointerdown="onHeartSlotPointerDown($event, page, iidx)"
                      >
                        <img
                          v-if="page.images[iidx] || page.imagesResized?.[iidx]"
                          :src="page.imagesResized?.[iidx] || page.images[iidx]"
                          alt=""
                          draggable="false"
                          :style="{ objectPosition: heartFocusCssAt(page, iidx) }"
                        />
                        <div v-else class="heart-admin-empty">
                          <span class="text-[16px] leading-none">+</span>
                          <span>{{ heartSlotLabel(iidx) }}</span>
                        </div>
                        <span
                          v-if="page.images[iidx] || page.imagesResized?.[iidx]"
                          class="hero-focus-crosshair heart-admin-crosshair"
                          :style="{
                            left: heartFocusAt(page, iidx, 'x') + '%',
                            top: heartFocusAt(page, iidx, 'y') + '%',
                          }"
                        />
                        <div class="heart-admin-toolbar">
                          <span class="heart-admin-label">{{ heartSlotLabel(iidx) }}</span>
                          <button
                            type="button"
                            class="heart-admin-btn"
                            title="上传/更换"
                            @click.stop="openUpload(u => setOriginalImageAt(page, iidx, u))"
                          >
                            <i class="fa-solid fa-image"></i>
                          </button>
                        </div>
                        <div
                          v-if="page.imagesResized?.[iidx]"
                          class="heart-admin-meta"
                          :title="page.imagesResizedMeta?.[iidx] || '展示已生成'"
                        >已缩放</div>
                      </div>
                    </div>
                  </div>
                </div>
              </template>
              <template v-else>
                <div v-for="(img, iidx) in page.images" :key="iidx" class="relative w-[96px]">
                  <image-field v-model="page.images[iidx]"
                               :label="`图${iidx + 1}`"
                               @pick="openUpload(u => setOriginalImageAt(page, iidx, u))"
                               :uploading="uploading" />
                  <div v-if="page.imagesResized?.[iidx]" class="text-center mt-1">
                    <div class="text-[9px] text-[#a88c6b]">展示已生成</div>
                    <div v-if="page.imagesResizedMeta?.[iidx]" class="resized-meta">{{ page.imagesResizedMeta[iidx] }}</div>
                  </div>
                  <button v-if="isFlexible(page.type) && page.images.length > 1"
                          class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-[#a88c6b] text-white text-[10px] leading-none flex items-center justify-center shadow"
                          @click="removeImage(page, iidx)" title="移除该图">✕</button>
                  <button v-if="iidx > 0"
                          class="absolute -bottom-2 left-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
                          @click="moveImage(page, iidx, -1)" title="前移"><i class="fa-solid fa-arrow-left"></i></button>
                  <button v-if="iidx < page.images.length - 1"
                          class="absolute -bottom-2 right-1 w-5 h-5 rounded-full bg-white border border-[#a88c6b]/50 text-[#a88c6b] text-[10px] leading-none flex items-center justify-center shadow hover:bg-[#a88c6b] hover:text-white transition-colors"
                          @click="moveImage(page, iidx, 1)" title="后移"><i class="fa-solid fa-arrow-right"></i></button>
                </div>
                <button v-if="isFlexible(page.type)"
                        class="w-[96px] h-[96px] rounded-xl border border-dashed border-[#a88c6b]/40 text-[#a88c6b] text-[11px] flex items-center justify-center hover:border-[#a88c6b] transition-colors"
                        @click="addImage(page)">+ 添加图片</button>
              </template>
            </div>
          </a-card>
          </template>
        </section>

        <!-- 婚礼流程 -->
        <section v-if="activeTab === 'schedule'">
          <div class="admin-panel">
            <div class="flex justify-between items-center mb-4">
              <div>
                <div class="text-sm text-[#2c2c2c]">流程环节</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ cfg.schedule.length }} 个环节</div>
              </div>
              <a-button size="small" type="primary"
                        class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
                        @click="addSchedule">
                <i class="fa-solid fa-plus mr-1"></i>新增环节
              </a-button>
            </div>
            <div v-if="!cfg.schedule.length" class="admin-empty">
              <div class="admin-empty-icon"><i class="fa-regular fa-clock"></i></div>
              <div class="text-[13px] text-[#5c5348]">暂无流程</div>
            </div>
            <ol v-else class="schedule-list">
              <li v-for="(item, idx) in cfg.schedule" :key="idx" class="schedule-row">
                <div class="schedule-index">{{ String(idx + 1).padStart(2, '0') }}</div>
                <div class="schedule-fields">
                  <div>
                    <div class="field-label">时间</div>
                    <a-input v-model:value="item.time" placeholder="如 17:08" />
                  </div>
                  <div>
                    <div class="field-label">环节</div>
                    <a-input v-model:value="item.event" placeholder="如 仪式开始" />
                  </div>
                  <div>
                    <div class="field-label">英文副标题</div>
                    <a-input v-model:value="item.desc" placeholder="如 Ceremony" />
                  </div>
                </div>
                <a-button danger size="small" type="text" @click="removeSchedule(idx)">
                  <i class="fa-solid fa-trash"></i>
                </a-button>
              </li>
            </ol>
          </div>
        </section>

        <!-- 回执列表 -->
        <section v-if="activeTab === 'rsvp'">
          <div class="admin-panel">
            <div class="flex justify-between items-center mb-4">
              <div>
                <div class="text-sm text-[#2c2c2c]">出席回执</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ rsvpList.length }} 条</div>
              </div>
              <a-button size="small" @click="loadRsvp"><i class="fa-solid fa-rotate-right mr-1"></i>刷新</a-button>
            </div>
            <a-table
              class="admin-table"
              :columns="rsvpColumns"
              :data-source="rsvpList"
              :loading="rsvpLoading"
              row-key="id"
              size="middle"
              :pagination="{ pageSize: 8, hideOnSinglePage: true }"
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key==='guest_count'">
                  <span
                    class="admin-pill"
                    :class="record.guest_count === '0' ? 'admin-pill--muted' : 'admin-pill--rose'"
                  >{{ rsvpCountLabel(record.guest_count) }}</span>
                </template>
                <template v-else-if="column.key==='created_at'">
                  <span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
                </template>
                <template v-else-if="column.key==='actions'">
                  <a-button size="small" danger type="text" @click="onDeleteRsvp(record)">删除</a-button>
                </template>
              </template>
            </a-table>
          </div>
        </section>

        <!-- 礼金 -->
        <section v-if="activeTab === 'cash'">
          <div class="admin-panel">
            <div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">礼金记录</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">
                  共 {{ cashGiftCount }} 笔 · 合计 ¥{{ cashGiftTotal }}
                  · 前台录入 <a class="text-[#a88c6b]" href="/lijin" target="_blank">/lijin</a>
                  · 明细 <a class="text-[#a88c6b]" href="/lijin/list" target="_blank">/lijin/list</a>
                </div>
              </div>
              <div class="flex gap-2">
                <a-button size="small" @click="openCashGiftCreate">新增</a-button>
                <a-button size="small" :loading="cashGiftLoading" @click="loadCashGifts">
                  <i class="fa-solid fa-rotate-right mr-1"></i>刷新
                </a-button>
              </div>
            </div>
            <a-table
              class="admin-table"
              :columns="cashGiftColumns"
              :data-source="cashGiftList"
              :loading="cashGiftLoading"
              row-key="id"
              size="middle"
              :pagination="{ pageSize: 10, hideOnSinglePage: true }"
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key==='amount'">
                  <span class="text-[#a88c6b] font-medium">¥{{ record.amount }}</span>
                </template>
                <template v-else-if="column.key==='created_at'">
                  <span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
                </template>
                <template v-else-if="column.key==='actions'">
                  <a-space>
                    <a-button size="small" type="link" class="!text-[#a88c6b] !px-1" @click="openCashGiftEdit(record)">编辑</a-button>
                    <a-button size="small" danger type="text" @click="onDeleteCashGift(record)">删除</a-button>
                  </a-space>
                </template>
              </template>
            </a-table>
          </div>

          <div class="admin-panel mt-4">
            <div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">记账口令</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">
                  前台 <a class="text-[#a88c6b]" href="/lijin" target="_blank">/lijin</a> 登记礼金前需输入此口令
                </div>
              </div>
              <a-button
                size="small"
                type="primary"
                class="!bg-[#a88c6b] !border-[#a88c6b]"
                :loading="cashEntryPassSaving"
                @click="saveCashEntryPass"
              >保存口令</a-button>
            </div>
            <a-input-password
              v-model:value="cashEntryPass"
              placeholder="设置记账口令"
              class="!max-w-[320px]"
              :maxlength="64"
            />
          </div>

          <div class="admin-panel mt-4">
            <div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">查看全部口令</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">
                  前台 <a class="text-[#a88c6b]" href="/lijin/list" target="_blank">/lijin/list</a> 需输入口令后查看完整礼金列表
                </div>
              </div>
              <a-button
                size="small"
                type="primary"
                class="!bg-[#a88c6b] !border-[#a88c6b]"
                :loading="cashViewPassSaving"
                @click="saveCashViewPass"
              >保存口令</a-button>
            </div>
            <a-input-password
              v-model:value="cashViewPass"
              placeholder="设置查看口令"
              class="!max-w-[320px]"
              :maxlength="64"
            />
          </div>

          <div class="admin-panel mt-4">
            <div class="flex justify-between items-center mb-3 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">备注快捷选项</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">前台礼金页可一键点选；男方 / 女方分开配置</div>
              </div>
              <div class="flex gap-2">
                <a-button size="small" @click="resetCashNotePresets">恢复默认</a-button>
                <a-button
                  size="small"
                  type="primary"
                  class="!bg-[#a88c6b] !border-[#a88c6b]"
                  :loading="cashNotesSaving"
                  @click="saveCashNotePresets"
                >保存选项</a-button>
              </div>
            </div>
            <div class="flex flex-wrap gap-2 mb-3">
              <a-tag
                v-for="(n, i) in cashNotePresets"
                :key="`${n}-${i}`"
                closable
                color="gold"
                @close.prevent="removeCashNotePreset(i)"
              >{{ n }}</a-tag>
            </div>
            <div class="flex gap-2 items-center flex-wrap">
              <a-input
                v-model:value="cashNoteDraft"
                placeholder="新增快捷备注，如：男方亲戚"
                class="!max-w-[280px]"
                :maxlength="30"
                @pressEnter="addCashNotePreset"
              />
              <a-button size="small" @click="addCashNotePreset">添加</a-button>
            </div>
          </div>

          <a-modal
            v-model:open="cashGiftModalOpen"
            :title="cashGiftEditingId ? '编辑礼金' : '新增礼金'"
            ok-text="保存"
            cancel-text="取消"
            :confirm-loading="cashGiftSaving"
            @ok="saveCashGiftModal"
          >
            <a-form layout="vertical" class="mt-2">
              <a-form-item label="姓名" required>
                <a-input v-model:value="cashGiftForm.name" placeholder="宾客姓名" :maxlength="20" />
              </a-form-item>
              <a-form-item label="金额（元）" required>
                <a-input-number v-model:value="cashGiftForm.amount" :min="1" :max="10000000" class="w-full" />
              </a-form-item>
              <a-form-item label="备注">
                <div class="flex flex-wrap gap-1.5 mb-2">
                  <a-tag
                    v-for="n in cashNotePresets"
                    :key="'m-' + n"
                    class="!cursor-pointer"
                    :color="cashGiftForm.note === n ? 'gold' : undefined"
                    @click="cashGiftForm.note = n"
                  >{{ n }}</a-tag>
                </div>
                <a-input v-model:value="cashGiftForm.note" placeholder="可选" :maxlength="100" />
              </a-form-item>
            </a-form>
          </a-modal>
        </section>

        <!-- 酒店迎宾海报 -->
        <section v-if="activeTab === 'hotelWelcome'">
          <HotelWelcomePosterAdmin ref="hotelWelcomeRef" />
        </section>

        <!-- 弹幕审核 -->
        <section v-if="activeTab === 'danmaku'">
          <div class="admin-panel">
            <div class="flex items-center justify-between mb-4 gap-3 flex-wrap">
              <div>
                <div class="text-sm text-[#2c2c2c]">弹幕审核</div>
                <div class="text-[11px] text-[#8A8680] mt-0.5">共 {{ danmakuList.length }} 条</div>
              </div>
              <div class="flex items-center gap-2 flex-wrap">
                <a-radio-group v-model:value="danmakuFilter" button-style="solid" size="small" class="admin-filter">
                  <a-radio-button value="">全部</a-radio-button>
                  <a-radio-button value="pending">待审核</a-radio-button>
                  <a-radio-button value="approved">已通过</a-radio-button>
                  <a-radio-button value="rejected">已拒绝</a-radio-button>
                </a-radio-group>
                <a-button size="small" @click="loadDanmaku"><i class="fa-solid fa-rotate-right mr-1"></i>刷新</a-button>
              </div>
            </div>
            <a-table
              class="admin-table"
              :columns="danmakuColumns"
              :data-source="danmakuList"
              :loading="danmakuLoading"
              row-key="id"
              size="middle"
              :pagination="{ pageSize: 8, hideOnSinglePage: true }"
            >
              <template #bodyCell="{ column, record }">
                <template v-if="column.key==='content'">
                  <span class="inline-flex items-center gap-2 min-w-0">
                    <i class="danmaku-admin-dot" :style="{ background: resolveDanmakuSwatch(record.color) }" />
                    <span class="truncate">{{ record.content }}</span>
                  </span>
                </template>
                <template v-else-if="column.key==='source'">
                  <span class="admin-pill" :class="record.source === 'rsvp' ? 'admin-pill--sky' : 'admin-pill--gold'">
                    {{ record.source === 'rsvp' ? '回执' : '弹幕' }}
                  </span>
                </template>
                <template v-else-if="column.key==='status'">
                  <span class="admin-pill" :class="danmakuStatusPill(record.status)">
                    {{ danmakuStatusLabel(record.status) }}
                  </span>
                </template>
                <template v-else-if="column.key==='created_at'">
                  <span class="text-[12px] text-[#8A8680]">{{ formatTime(record.created_at) }}</span>
                </template>
                <template v-else-if="column.key==='actions'">
                  <a-space>
                    <a-button size="small" type="primary" class="!bg-[#a88c6b] !border-[#a88c6b]"
                              :disabled="record.status === 'approved'"
                              @click="setDanmakuStatus(record.id, 'approved')">通过</a-button>
                    <a-button size="small" danger type="text"
                              :disabled="record.status === 'rejected'"
                              @click="setDanmakuStatus(record.id, 'rejected')">拒绝</a-button>
                  </a-space>
                </template>
              </template>
            </a-table>
          </div>
        </section>
      </main>
    </div>

    <MediaPickerModal
      v-model:open="mediaPickerOpen"
      :model-value="mediaPickerCurrent"
      @select="onMediaPicked"
    />
  </div>
</template>

<script setup>
import { computed, reactive, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts/core'
import { PieChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import {
  getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
  getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitCityStats, getVisitList,
  getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
  getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
  getCashGiftNotes, saveCashGiftNotes,
  getCashGiftViewPass, saveCashGiftViewPass,
  getCashGiftEntryPass, saveCashGiftEntryPass,
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import MediaPickerModal from '@/components/MediaPickerModal.vue'
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { normalizeMusicWeight, normalizeMusicCacheHours } from '@/utils/musicPick'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import {
  getSlotResizeSpecs,
  getBasicImageResizeSpecs,
  resizeSlotToFile,
  probeResizedMeta,
  metaHasFileSize,
} from '@/composables/resizeImage'
import {
  defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout, normalizePosterConfig,
  LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
  HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
  posterSideMeta, posterLayoutMeta, posterRoute,
} from '@/composables/posterDefaults'
import {
  HEART_SLOTS, HEART_SLOT_COUNT,
  heartSlotStyle, heartSlotLabel, heartFocusAt, heartFocusCssAt,
  syncImagesFocus, setHeartFocus,
} from '@/composables/heartCollage'

echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])

const router = useRouter()
const adminStore = useAdminStore()

const TAB_KEY = 'wedding_admin_active_tab'
const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout'
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku']

/** 后台菜单分组 */
const adminMenuGroups = [
  {
    key: 'data',
    label: '数据看板',
    items: [
      { key: 'overview', label: '概览', icon: 'fa-solid fa-chart-pie' },
      { key: 'visits', label: '访客记录', icon: 'fa-solid fa-users' },
      { key: 'gifts', label: '礼物', icon: 'fa-solid fa-gift' },
    ],
  },
  {
    key: 'content',
    label: '请柬内容',
    items: [
      { key: 'basic', label: '基础信息', icon: 'fa-solid fa-heart' },
      { key: 'copy', label: '邀请文案', icon: 'fa-solid fa-pen-fancy' },
      { key: 'visual', label: '视觉动效', icon: 'fa-solid fa-wand-magic-sparkles' },
      { key: 'music', label: '背景音乐', icon: 'fa-solid fa-music' },
      { key: 'photos', label: '相册页', icon: 'fa-solid fa-images' },
      { key: 'schedule', label: '婚礼流程', icon: 'fa-solid fa-list-ol' },
    ],
  },
  {
    key: 'interact',
    label: '宾客互动',
    items: [
      { key: 'rsvp', label: '出席回执', icon: 'fa-solid fa-envelope-open-text' },
      { key: 'cash', label: '礼金', icon: 'fa-solid fa-yen-sign' },
      { key: 'danmaku', label: '弹幕审核', icon: 'fa-solid fa-comments' },
    ],
  },
  {
    key: 'system',
    label: '海报与系统',
    items: [
      { key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' },
      { key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' },
      { key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' },
    ],
  },
]

const menuLayoutOptions = [
  { value: 'top', label: '顶部菜单' },
  { value: 'side', label: '侧边菜单' },
]

const SECTION_KEYS = {
  basic: [
    'groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address',
    'heroImg', 'heroImgResized', 'heroImgResizedMeta',
    'endImg', 'endImgResized', 'endImgResizedMeta',
  ],
  copy: ['formalText1', 'formalText2', 'formalPageHeight'],
  visual: [
    'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
    'petalEnabled', 'bubbleEnabled', 'introEnabled', 'danmakuEnabled', 'danmakuShowTime', 'nineGridAnimate',
  ],
  music: ['musicList', 'activeMusicUrl', 'musicRandomEnabled', 'musicCacheEnabled', 'musicCacheHours'],
  photos: ['photoPages'],
  schedule: ['schedule'],
}

function readStoredTab() {
  try {
    const v = localStorage.getItem(TAB_KEY)
    if (v && VALID_TABS.includes(v)) return v
  } catch { /* ignore */ }
  return 'overview'
}

function readStoredMenuLayout() {
  try {
    const v = localStorage.getItem(MENU_LAYOUT_KEY)
    if (v === 'top' || v === 'side') return v
  } catch { /* ignore */ }
  return 'side'
}

const activeTab = ref(readStoredTab())
const menuLayout = ref(readStoredMenuLayout())
const hotelWelcomeRef = ref(null)
watch(menuLayout, (v) => {
  try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
})

function isMenuGroupActive(group) {
  return Array.isArray(group?.items) && group.items.some((item) => item.key === activeTab.value)
}

const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
const resizingPage = ref(false)
const resizingBasic = ref(false)
const resizeProgress = ref('')
const uploadConfigSaving = ref(false)
const posterSaving = ref(false)
const posterLoading = ref(false)
const posterMusicLoading = ref(false)
/** 海报选曲用：独立缓存请柬 musicList，避免依赖是否打开过音乐页 */
const posterMusicOptions = ref([])
const tabLoading = ref(false)
const rsvpLoading = ref(false)
const rsvpList = ref([])
const cashGiftLoading = ref(false)
const cashGiftSaving = ref(false)
const cashGiftList = ref([])
const cashGiftTotal = ref(0)
const cashGiftCount = ref(0)
const cashGiftModalOpen = ref(false)
const cashGiftEditingId = ref(0)
const cashGiftForm = reactive({ name: '', amount: 1000, note: '' })
const cashNotePresets = ref([])
const cashNoteDefaults = ref([])
const cashNoteDraft = ref('')
const cashNotesSaving = ref(false)
const cashViewPass = ref('')
const cashViewPassSaving = ref(false)
const cashEntryPass = ref('')
const cashEntryPassSaving = ref(false)
const danmakuLoading = ref(false)
const danmakuList = ref([])
const danmakuFilter = ref('pending')

const visitStatsLoading = ref(false)
const visitListLoading = ref(false)
const giftStatsLoading = ref(false)
const giftListLoading = ref(false)
const giftCostsSaving = ref(false)
const giftTypeList = GIFT_TYPES
const giftList = ref([])
const giftStats = reactive({
  total: 0,
  counts: Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0])),
})
const giftCostsEdit = reactive(emptyGiftCosts())

function applyGiftCostsFromServer(costs) {
  if (!costs || typeof costs !== 'object') return
  for (const g of GIFT_TYPES) {
    const v = Number(costs[g.key])
    if (Number.isFinite(v) && v > 0) giftCostsEdit[g.key] = v
  }
}

function resetGiftCostsEdit() {
  Object.assign(giftCostsEdit, emptyGiftCosts())
}

async function saveGiftCostsEdit() {
  giftCostsSaving.value = true
  try {
    const payload = {}
    for (const g of GIFT_TYPES) {
      payload[g.key] = Number(giftCostsEdit[g.key]) || DEFAULT_GIFT_COSTS[g.key]
    }
    const res = await saveGiftCosts(payload)
    applyGiftCostsFromServer(res.data?.costs || payload)
    message.success('礼物兑换配置已保存')
  } catch (e) {
    message.error(e?.message || '保存失败')
  } finally {
    giftCostsSaving.value = false
  }
}
const visitStats = reactive({ uv: 0, pv: 0, today_uv: 0, regions: [], devices: [] })
const visitList = ref([])
const visitPreviewList = ref([])
const visitPage = ref(1)
const visitPageSize = ref(20)
const visitTotal = ref(0)
const regionChartRef = ref(null)
const deviceChartRef = ref(null)
const cityChartRef = ref(null)
let regionChart = null
let deviceChart = null
let cityChart = null
const selectedRegion = ref('')
const cityStats = ref([])
const cityStatsLoading = ref(false)

const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
const posterImporting = ref(false)
const posterCopying = ref(false)
/** 本会话最近一次复制的模板，导入时优先/兜底使用 */
let posterTemplateCache = null
/** 导入前快照，用于撤回 { s, l, config } */
const posterUndoSnapshot = ref(null)
const posterCfg = computed(() => {
  const s = sideKey(posterSideTab.value)
  const l = layoutKey(posterLayoutTab.value)
  if (!posterBundle[s]) posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
  if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
  return posterBundle[s][l]
})
const mediaPickerOpen = ref(false)
const mediaPickerCurrent = ref('')
let mediaPickerOnUrl = null

function ensurePosterSlot(side = posterSideTab.value, layout = posterLayoutTab.value) {
  const s = sideKey(side)
  const l = layoutKey(layout)
  if (!posterBundle[s] || typeof posterBundle[s] !== 'object') {
    posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
  }
  if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
  return posterBundle[s][l]
}

const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
const heroFocusDragging = ref(false)
const heroFocusDevice = ref('pc') // pc | mobile

const heroFocusActiveX = computed(() => {
  const cfg = posterCfg.value
  if (!cfg) return 50
  if (heroFocusDevice.value === 'mobile') {
    return Number.isFinite(Number(cfg.heroFocusMobileX)) ? cfg.heroFocusMobileX : (cfg.heroFocusX ?? 50)
  }
  return cfg.heroFocusX ?? 50
})
const heroFocusActiveY = computed(() => {
  const cfg = posterCfg.value
  if (!cfg) return 50
  if (heroFocusDevice.value === 'mobile') {
    return Number.isFinite(Number(cfg.heroFocusMobileY)) ? cfg.heroFocusMobileY : (cfg.heroFocusY ?? 50)
  }
  return cfg.heroFocusY ?? 50
})
const heroFocusPreviewCss = computed(() => {
  const cfg = posterCfg.value
  if (!cfg) return '50% 50%'
  return heroFocusCss(cfg, heroFocusDevice.value === 'mobile' ? 'mobile' : undefined)
})

function setHeroFocus(x, y) {
  const cfg = posterCfg.value
  if (!cfg) return
  if (heroFocusDevice.value === 'mobile') {
    cfg.heroFocusMobileX = clampHeroFocus(x)
    cfg.heroFocusMobileY = clampHeroFocus(y)
  } else {
    cfg.heroFocusX = clampHeroFocus(x)
    cfg.heroFocusY = clampHeroFocus(y)
  }
}

function isHeroFocusGridActive(pt) {
  return Math.abs(heroFocusActiveX.value - pt.x) < 0.6
    && Math.abs(heroFocusActiveY.value - pt.y) < 0.6
}

function heroFocusFromClient(clientX, clientY) {
  const el = heroFocusPreviewEl.value
  if (!el) return
  const rect = el.getBoundingClientRect()
  if (rect.width <= 0 || rect.height <= 0) return
  const x = ((clientX - rect.left) / rect.width) * 100
  const y = ((clientY - rect.top) / rect.height) * 100
  setHeroFocus(x, y)
}

function onHeroFocusPointerMove(e) {
  if (!heroFocusDragging.value) return
  heroFocusFromClient(e.clientX, e.clientY)
}

function onHeroFocusPointerUp() {
  if (!heroFocusDragging.value) return
  heroFocusDragging.value = false
  window.removeEventListener('pointermove', onHeroFocusPointerMove)
  window.removeEventListener('pointerup', onHeroFocusPointerUp)
  window.removeEventListener('pointercancel', onHeroFocusPointerUp)
}

function onHeroFocusPointerDown(e) {
  if (!posterCfg.value?.heroImg) return
  e.preventDefault()
  heroFocusDragging.value = true
  heroFocusFromClient(e.clientX, e.clientY)
  window.addEventListener('pointermove', onHeroFocusPointerMove)
  window.addEventListener('pointerup', onHeroFocusPointerUp)
  window.addEventListener('pointercancel', onHeroFocusPointerUp)
}

const posterMusicSelectOptions = computed(() => {
  const list = Array.isArray(posterMusicOptions.value) ? posterMusicOptions.value : []
  const opts = list
    .filter((t) => t && t.url)
    .map((t, i) => ({
      value: t.url,
      label: t.name || `曲目 ${i + 1}`,
    }))
  const cur = posterCfg.value?.musicUrl
  if (cur && !opts.some((o) => o.value === cur)) {
    opts.unshift({ value: cur, label: `（原曲目）${cur.slice(0, 36)}${cur.length > 36 ? '…' : ''}` })
  }
  return opts
})
const currentPosterSide = computed(() => posterSideMeta(posterSideTab.value))
const currentPosterLayout = computed(() => posterLayoutMeta(posterLayoutTab.value))
const currentPosterPreviewPath = computed(() => posterRoute(posterSideTab.value, posterLayoutTab.value))
const posterNameLabels = computed(() => {
  const s = sideKey(posterSideTab.value)
  if (s === '2') {
    return { left: '女儿称谓 / 姓名', right: '女婿称谓 / 姓名', leftPh: '女儿：', rightPh: '女婿：' }
  }
  if (s === '3') {
    return { left: '新人左侧称谓 / 姓名', right: '新人右侧称谓 / 姓名', leftPh: '新郎：', rightPh: '新娘：' }
  }
  return { left: '儿子称谓 / 姓名', right: '儿媳称谓 / 姓名', leftPh: '儿子：', rightPh: '儿媳：' }
})
const posterLinesText = computed({
  get: () => {
    const lines = posterCfg.value?.lines
    return Array.isArray(lines) ? lines.join('\n') : ''
  },
  set: (v) => {
    const slot = ensurePosterSlot()
    slot.lines = String(v || '')
      .split('\n')
      .map((s) => s.trimEnd())
  },
})
function setPosterHero(url) {
  ensurePosterSlot().heroImg = url
}
function setPosterQrCenter(url) {
  ensurePosterSlot().qrCenterImg = url
}

watch([posterSideTab, posterLayoutTab], () => {
  const slot = ensurePosterSlot()
  const l = layoutKey(posterLayoutTab.value)
  slot.layout = Number(l)
})

const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({
  current: visitPage.value,
  pageSize: visitPageSize.value,
  total: visitTotal.value,
  showSizeChanger: true,
  pageSizeOptions: ['10', '20', '50', '100'],
  showTotal: (t) => `共 ${t} 条`,
}))

function defaultConfig() {
  return {
    groom: '李明', bride: '王华', date: '2026-05-20', lunar: '农历四月初四 星期三',
    hotel: '杭州星光万丽大酒店', address: '杭州市西湖区星光大道88号\n3楼大宴会厅',
    formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
    calendarDate: '2026-09-04',
    heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
    heroImgResized: '',
    heroImgResizedMeta: '',
    endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
    endImgResized: '',
    endImgResizedMeta: '',
    musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1', weight: 1 }],
    activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
    musicRandomEnabled: false,
    musicCacheEnabled: true,
    musicCacheHours: 24,
    danmakuEnabled: true,
    danmakuShowTime: true,
    nineGridAnimate: false,
    scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
    introExitEffect: 'wind',
    freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
    photoPages: [
      { type: 'single', title: '初遇', subtitle: 'FIRST MET', desc: '世界那么大，还是遇见了你。\n于千万人之中，没有早一步也没有晚一步。', img1: 'https://images.unsplash.com/photo-1606800052052-a08af7148866?auto=format&fit=crop&w=600&q=80', height: '100vh', borderStyle: 'mat' },
      { type: 'overlap', title: '相知', subtitle: 'FALL IN LOVE', desc: '纵然万劫不复，纵然相思入骨。\n我也待你眉眼如初，岁月如故。', img1: 'https://images.unsplash.com/photo-1532712938310-34cb3982ef74?auto=format&fit=crop&w=600&q=80', img2: 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=600&q=80', height: '100vh', borderStyle: 'mat' },
      { type: 'one-large-five-small', title: '相恋', subtitle: 'ROMANCE', desc: '星光坠入眼眸，晚风轻抚过裙摆。\n浪漫不止于此，还有无数个明天。', images: fill(6), height: '100vh', borderStyle: 'mat' },
      { type: 'nine-grid', title: '相守', subtitle: 'FOREVER', desc: '往后余生，风雪是你，平淡是你。', images: fill(9), height: '100vh', borderStyle: 'mat' },
    ],
    schedule: [
      { time: '16:00', event: '签到迎宾', desc: 'Welcome' },
      { time: '17:08', event: '仪式开始', desc: 'Ceremony' },
      { time: '18:00', event: '敬备喜宴', desc: 'Banquet' },
    ],
  }
}

function defaultUploadConfig() {
  return {
    provider: 'local',
    accessKeyId: '',
    accessKeySecret: '',
    hasSecret: false,
    bucket: '',
    folder: '',
    domain: '',
    region: '',
    endpoint: '',
  }
}

function fill(n) {
  return Array.from({ length: n }, () => 'https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=400&q=80')
}

// 页面高度预设
const HEIGHT_PRESETS = [
  { label: '满屏（整屏）', value: '100vh' },
  { label: '大屏（80%）', value: '80vh' },
  { label: '半屏（60%）', value: '60vh' },
  { label: '自适应（内容高度）', value: 'auto' },
  { label: '自定义', value: '__custom__' },
]
const INTRO_EXIT_EFFECTS = [
  { label: '风吹纸页', value: 'wind' },
  { label: '翻书开启', value: 'book' },
  { label: '上提淡出', value: 'lift' },
  { label: '双门揭幕', value: 'doors' },
]
function heightSelectValue(page) {
  return heightFieldSelectValue(page.height)
}
function onHeightModeChange(page, val) {
  if (val !== '__custom__') page.height = val
}
function customVh(page) {
  return customVhValue(page.height)
}
function onCustomVh(page, val) {
  page.height = (val || 100) + 'vh'
}
function heightFieldSelectValue(value) {
  const match = HEIGHT_PRESETS.find((p) => p.value === value)
  return match ? match.value : '__custom__'
}
function customVhValue(value) {
  const m = /^([\d.]+)vh$/.exec(value || '')
  return m ? Number(m[1]) : 100
}
function onHeightFieldModeChange(target, key, val) {
  if (val !== '__custom__') target[key] = val
}
function onHeightFieldCustomVh(target, key, val) {
  target[key] = (val || 100) + 'vh'
}

function syncImagesResized(p) {
  if (!Array.isArray(p.images)) {
    delete p.imagesResized
    delete p.imagesResizedMeta
    return
  }
  if (!Array.isArray(p.imagesResized)) p.imagesResized = []
  if (!Array.isArray(p.imagesResizedMeta)) p.imagesResizedMeta = []
  while (p.imagesResized.length < p.images.length) p.imagesResized.push('')
  while (p.imagesResizedMeta.length < p.images.length) p.imagesResizedMeta.push('')
  if (p.imagesResized.length > p.images.length) p.imagesResized.length = p.images.length
  if (p.imagesResizedMeta.length > p.images.length) p.imagesResizedMeta.length = p.images.length
}

function ensurePageShape(p) {
  if (p.type === 'single') {
    p.img1 = p.img1 || ''
    p.img1Resized = p.img1Resized || ''
    p.img1ResizedMeta = p.img1ResizedMeta || ''
    delete p.img2
    delete p.img2Resized
    delete p.img2ResizedMeta
    delete p.images
    delete p.imagesResized
    delete p.imagesResizedMeta
  } else if (p.type === 'overlap') {
    p.img1 = p.img1 || ''
    p.img2 = p.img2 || ''
    p.img1Resized = p.img1Resized || ''
    p.img2Resized = p.img2Resized || ''
    p.img1ResizedMeta = p.img1ResizedMeta || ''
    p.img2ResizedMeta = p.img2ResizedMeta || ''
    delete p.images
    delete p.imagesResized
    delete p.imagesResizedMeta
  } else {
    // 固定数量版式：一大五小(6) / 九宫格(9) / 爱心拼接(13)；柔性版式：瀑布流/轮播/拍立得，仅首次补齐
    const fixed = { 'one-large-five-small': 6, 'nine-grid': 9, 'heart-collage': HEART_SLOT_COUNT, 'masonry': 6, 'carousel': 5, 'polaroid': 6 }
    const need = fixed[p.type] || 6
    p.images = Array.isArray(p.images) ? p.images : []
    if (p.images.length === 0) while (p.images.length < need) p.images.push('')
    else if (['one-large-five-small', 'nine-grid', 'heart-collage'].includes(p.type)) {
      while (p.images.length < need) p.images.push('')
      if (p.images.length > need) p.images.length = need
    }
    syncImagesResized(p)
    syncImagesFocus(p)
    delete p.img1
    delete p.img2
    delete p.img1Resized
    delete p.img2Resized
    delete p.img1ResizedMeta
    delete p.img2ResizedMeta
  }
  // 非爱心版式清掉焦点字段
  if (p.type !== 'heart-collage') syncImagesFocus(p)
  p.title = p.title || ''; p.subtitle = p.subtitle || ''; p.desc = p.desc || ''
  p.height = p.height || '100vh'
  p.borderStyle = p.borderStyle || 'mat'
  // 单图宽度设置（仅 single 版式使用，逐页配置）
  p.singleWidth = p.singleWidth === 'full' ? 'full' : 'default'
  if (typeof p.singleKeepRatio !== 'boolean') p.singleKeepRatio = true
  return p
}

// 柔性版式（图片数量可增删）
function isFlexible(type) {
  return ['masonry', 'carousel', 'polaroid'].includes(type)
}

function addImage(page) {
  if (!Array.isArray(page.images)) page.images = []
  page.images.push('')
  syncImagesResized(page)
  syncImagesFocus(page)
}
function removeImage(page, i) {
  if (page.images.length > 1) {
    page.images.splice(i, 1)
    if (Array.isArray(page.imagesResized)) page.imagesResized.splice(i, 1)
    if (Array.isArray(page.imagesResizedMeta)) page.imagesResizedMeta.splice(i, 1)
    if (Array.isArray(page.imagesFocusX)) page.imagesFocusX.splice(i, 1)
    if (Array.isArray(page.imagesFocusY)) page.imagesFocusY.splice(i, 1)
    syncImagesResized(page)
    syncImagesFocus(page)
  }
}
// 图片排序：前移(-1) / 后移(+1)
function moveImage(page, i, dir) {
  const j = i + dir
  if (!Array.isArray(page.images) || j < 0 || j >= page.images.length) return
  const swap = (arr) => {
    if (!Array.isArray(arr) || i >= arr.length || j >= arr.length) return
    const t = arr[i]; arr[i] = arr[j]; arr[j] = t
  }
  swap(page.images)
  syncImagesResized(page)
  swap(page.imagesResized)
  swap(page.imagesResizedMeta)
  syncImagesFocus(page)
  swap(page.imagesFocusX)
  swap(page.imagesFocusY)
}

const heartSwapSelected = ref(null)
const heartFocusDrag = ref(null)
const HEART_FOCUS_DRAG_THRESHOLD = 6

function swapHeartSlots(page, i, j) {
  if (!page || !Array.isArray(page.images) || i === j) return
  if (i < 0 || j < 0 || i >= page.images.length || j >= page.images.length) return
  syncImagesResized(page)
  syncImagesFocus(page)
  const swap = (arr) => {
    if (!Array.isArray(arr)) return
    const t = arr[i]
    arr[i] = arr[j]
    arr[j] = t
  }
  swap(page.images)
  swap(page.imagesResized)
  swap(page.imagesResizedMeta)
  swap(page.imagesFocusX)
  swap(page.imagesFocusY)
}

function applyHeartFocusFromClient(clientX, clientY) {
  const drag = heartFocusDrag.value
  if (!drag?.el || !drag.page) return
  const rect = drag.el.getBoundingClientRect()
  if (!rect.width || !rect.height) return
  const x = ((clientX - rect.left) / rect.width) * 100
  const y = ((clientY - rect.top) / rect.height) * 100
  setHeartFocus(drag.page, drag.idx, x, y)
}

function onHeartSlotPointerDown(e, page, idx) {
  if (e.target?.closest?.('.heart-admin-btn')) return
  if (e.button != null && e.button !== 0) return

  const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
  // 空格：交给 pointerup 做「上传 / 对调」
  if (empty) {
    heartFocusDrag.value = {
      page, idx, el: e.currentTarget,
      startX: e.clientX, startY: e.clientY,
      moved: false, empty: true,
    }
    window.addEventListener('pointerup', onHeartSlotPointerUp)
    window.addEventListener('pointercancel', onHeartSlotPointerUp)
    return
  }

  e.preventDefault()
  heartFocusDrag.value = {
    page, idx, el: e.currentTarget,
    startX: e.clientX, startY: e.clientY,
    moved: false, empty: false,
  }
  window.addEventListener('pointermove', onHeartSlotPointerMove)
  window.addEventListener('pointerup', onHeartSlotPointerUp)
  window.addEventListener('pointercancel', onHeartSlotPointerUp)
}

function onHeartSlotPointerMove(e) {
  const drag = heartFocusDrag.value
  if (!drag || drag.empty) return
  const dx = e.clientX - drag.startX
  const dy = e.clientY - drag.startY
  if (!drag.moved && (dx * dx + dy * dy) < HEART_FOCUS_DRAG_THRESHOLD ** 2) return
  drag.moved = true
  applyHeartFocusFromClient(e.clientX, e.clientY)
}

function onHeartSlotClickAction(page, idx) {
  const sel = heartSwapSelected.value
  const empty = !(page.images?.[idx] || page.imagesResized?.[idx])
  if (empty && !(sel && sel.page === page)) {
    openUpload((u) => setOriginalImageAt(page, idx, u))
    return
  }
  if (!sel || sel.page !== page) {
    heartSwapSelected.value = { page, idx }
    return
  }
  if (sel.idx === idx) {
    heartSwapSelected.value = null
    return
  }
  swapHeartSlots(page, sel.idx, idx)
  heartSwapSelected.value = null
}

function onHeartSlotPointerUp(e) {
  const drag = heartFocusDrag.value
  window.removeEventListener('pointermove', onHeartSlotPointerMove)
  window.removeEventListener('pointerup', onHeartSlotPointerUp)
  window.removeEventListener('pointercancel', onHeartSlotPointerUp)
  heartFocusDrag.value = null
  if (!drag?.page) return
  // 拖过焦点：不对调
  if (drag.moved) return
  onHeartSlotClickAction(drag.page, drag.idx)
}

function clearHeartSlotPointer() {
  heartFocusDrag.value = null
  window.removeEventListener('pointermove', onHeartSlotPointerMove)
  window.removeEventListener('pointerup', onHeartSlotPointerUp)
  window.removeEventListener('pointercancel', onHeartSlotPointerUp)
}

function pageHasOriginal(page) {
  return getSlotResizeSpecs(page).some((s) => !!s.getOriginal())
}

function setOriginalAndClearResized(page, key, url) {
  page[key] = url
  page[`${key}Resized`] = ''
  page[`${key}ResizedMeta`] = ''
}

function setBasicOriginal(key, url) {
  cfg[key] = url
  cfg[`${key}Resized`] = ''
  cfg[`${key}ResizedMeta`] = ''
}

async function fillMissingBasicResizedMeta() {
  await Promise.all(['heroImg', 'endImg'].map(async (key) => {
    const original = cfg[key]
    const resized = cfg[`${key}Resized`]
    const metaKey = `${key}ResizedMeta`
    if (!resized || !original || metaHasFileSize(cfg[metaKey])) return
    try {
      cfg[metaKey] = await probeResizedMeta(original, resized)
    } catch { /* 跨域/失效图忽略 */ }
  }))
}

async function batchResizeBasicImages() {
  const slots = getBasicImageResizeSpecs(cfg).filter((s) => !!s.getOriginal())
  if (!slots.length) {
    message.warning('请先上传封面或尾页原图')
    return
  }
  resizingBasic.value = true
  resizeProgress.value = `0/${slots.length}`
  let ok = 0
  let fail = 0
  try {
    for (let i = 0; i < slots.length; i += 1) {
      const slot = slots[i]
      resizeProgress.value = `${i + 1}/${slots.length}`
      try {
        const { file, meta } = await resizeSlotToFile(
          slot.getOriginal(),
          slot.maxWidth,
          slot.maxHeight,
          `resized-basic-${slot.key}-${Date.now()}.jpg`,
        )
        const res = await uploadImage(file)
        if (!res?.url) throw new Error('上传无返回地址')
        slot.setResized(res.url, meta)
        ok += 1
      } catch (err) {
        fail += 1
        console.warn('[resize-basic]', slot.key, err)
      }
    }
    if (ok > 0) {
      try {
        await saveConfigSection('basic', pickSectionData('basic'))
        if (fail === 0) message.success(`已生成并保存 ${ok} 张展示图`)
        else message.warning(`成功 ${ok} 张并已保存，失败 ${fail} 张`)
      } catch (e) {
        message.warning(`已生成 ${ok} 张展示图，但自动保存失败：${e?.message || '请手动保存'}`)
      }
    } else {
      message.error('缩放上传全部失败，请确认原图可跨域读取')
    }
  } finally {
    resizingBasic.value = false
    resizeProgress.value = ''
  }
}

function setOriginalImageAt(page, iidx, url) {
  page.images[iidx] = url
  syncImagesResized(page)
  syncImagesFocus(page)
  page.imagesResized[iidx] = ''
  page.imagesResizedMeta[iidx] = ''
}

/** 缺 meta，或只有像素没有文件大小时，自动探测并补全 */
async function fillMissingResizedMeta(page) {
  if (!page) return
  if (page.type === 'single' || page.type === 'overlap') {
    const keys = page.type === 'single' ? ['img1'] : ['img1', 'img2']
    await Promise.all(keys.map(async (key) => {
      const original = page[key]
      const resized = page[`${key}Resized`]
      const metaKey = `${key}ResizedMeta`
      if (!resized || !original || metaHasFileSize(page[metaKey])) return
      try {
        page[metaKey] = await probeResizedMeta(original, resized)
      } catch { /* 跨域/失效图忽略 */ }
    }))
    return
  }
  if (!Array.isArray(page.images)) return
  syncImagesResized(page)
  await Promise.all(page.images.map(async (original, i) => {
    const resized = page.imagesResized[i]
    if (!resized || !original || metaHasFileSize(page.imagesResizedMeta[i])) return
    try {
      page.imagesResizedMeta[i] = await probeResizedMeta(original, resized)
    } catch { /* ignore */ }
  }))
}

async function batchResizeAndUpload(page) {
  const slots = getSlotResizeSpecs(page).filter((s) => !!s.getOriginal())
  if (!slots.length) {
    message.warning('请先上传原图')
    return
  }
  resizingPage.value = true
  resizeProgress.value = `0/${slots.length}`
  let ok = 0
  let fail = 0
  try {
    for (let i = 0; i < slots.length; i += 1) {
      const slot = slots[i]
      resizeProgress.value = `${i + 1}/${slots.length}`
      try {
        const { file, meta } = await resizeSlotToFile(
          slot.getOriginal(),
          slot.maxWidth,
          slot.maxHeight,
          `resized-${page.type}-${slot.key}-${Date.now()}.jpg`,
        )
        const res = await uploadImage(file)
        if (!res?.url) throw new Error('上传无返回地址')
        slot.setResized(res.url, meta)
        ok += 1
      } catch (err) {
        fail += 1
        console.warn('[resize]', slot.key, err)
      }
    }
    if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
    else if (ok > 0) message.warning(`成功 ${ok} 张，失败 ${fail} 张（常见原因：跨域无法读取原图）`)
    else message.error('缩放上传全部失败，请确认原图可跨域读取')
  } finally {
    resizingPage.value = false
    resizeProgress.value = ''
  }
}

// 相册页操作
function setActivePhotoIndex(i) {
  const max = Math.max(cfg.photoPages.length - 1, 0)
  activePhotoTab.value = String(Math.min(Math.max(i, 0), max))
}
function addPage() {
  cfg.photoPages.push(ensurePageShape({ type: 'single', title: '新页面', subtitle: 'NEW', desc: '', img1: '' }))
  setActivePhotoIndex(cfg.photoPages.length - 1)
}
function removePage(i) {
  cfg.photoPages.splice(i, 1)
  setActivePhotoIndex(Math.min(i, cfg.photoPages.length - 1))
}
function movePage(i, dir) {
  const j = i + dir
  if (j < 0 || j >= cfg.photoPages.length) return
  const t = cfg.photoPages[i]; cfg.photoPages[i] = cfg.photoPages[j]; cfg.photoPages[j] = t
  setActivePhotoIndex(j)
}
// 流程操作
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }

// 选图：弹层图库 + 新上传
function openUpload(onUrl) {
  mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
  mediaPickerCurrent.value = ''
  mediaPickerOpen.value = true
}
function onMediaPicked(url) {
  if (mediaPickerOnUrl) mediaPickerOnUrl(url)
  mediaPickerOnUrl = null
}

// 上传（背景音乐，多首）
function openMusicUpload() {
  const input = document.createElement('input')
  input.type = 'file'; input.accept = 'audio/*'
  input.onchange = async (e) => {
    const file = e.target.files[0]; if (!file) return
    uploading.value = true
    try {
      const res = await uploadImage(file) // 后端 /upload 同处理任意文件
      const name = file.name.replace(/\.[^.]+$/, '') || '背景音乐'
      const url = res.url
      if (!cfg.musicList) cfg.musicList = []
      cfg.musicList.push({ url, name, coverUrl: '', lrcUrl: '', weight: 1 })
      if (!cfg.activeMusicUrl) cfg.activeMusicUrl = url
      message.success('音乐上传成功')
    } catch (err) {
      message.error('上传失败')
    } finally {
      uploading.value = false; e.target.value = ''
    }
  }
  input.click()
}

function openLrcUpload(track) {
  const input = document.createElement('input')
  input.type = 'file'
  input.accept = '.lrc,text/plain'
  input.onchange = async (e) => {
    const file = e.target.files[0]
    if (!file) return
    uploading.value = true
    try {
      const res = await uploadImage(file)
      track.lrcUrl = res.url
      message.success('歌词上传成功')
    } catch {
      message.error('歌词上传失败')
    } finally {
      uploading.value = false
      e.target.value = ''
    }
  }
  input.click()
}

function setActiveMusic(url) { cfg.activeMusicUrl = url }
function removeMusic(i) {
  cfg.musicList.splice(i, 1)
  if (cfg.activeMusicUrl === cfg.musicList[i]?.url) {
    cfg.activeMusicUrl = cfg.musicList[0]?.url || ''
  }
}

function musicWeightPercent(track) {
  const list = (cfg.musicList || []).filter((t) => t && t.url)
  const total = list.reduce((s, t) => s + normalizeMusicWeight(t.weight), 0)
  if (!total) return 0
  return Math.round((normalizeMusicWeight(track?.weight) / total) * 100)
}
function shortUrl(url) {
  if (!url) return ''
  const parts = url.split('/')
  return parts[parts.length - 1] || url
}

async function loadUploadConfig() {
  try {
    const res = await getUploadConfig()
    Object.assign(uploadCfg, defaultUploadConfig(), res.data || {})
    uploadCfg.accessKeySecret = ''
  } catch (e) {
    // 上传设置仅后台使用，读取失败不影响内容编辑。
  }
}

async function onSaveUploadConfig() {
  uploadConfigSaving.value = true
  try {
    const payload = {
      provider: uploadCfg.provider,
      accessKeyId: uploadCfg.accessKeyId,
      accessKeySecret: uploadCfg.accessKeySecret,
      bucket: uploadCfg.bucket,
      folder: uploadCfg.folder,
      domain: uploadCfg.domain,
      region: uploadCfg.region,
      endpoint: uploadCfg.endpoint,
    }
    const res = await saveUploadConfig(payload)
    Object.assign(uploadCfg, defaultUploadConfig(), res.data || {})
    uploadCfg.accessKeySecret = ''
    message.success('上传设置已保存')
  } catch (e) {
    message.error(e?.message || '上传设置保存失败')
  } finally {
    uploadConfigSaving.value = false
  }
}

async function loadPosterMusicOptions() {
  posterMusicLoading.value = true
  try {
    const res = await getConfigSection('music')
    const list = res.data?.musicList
    if (Array.isArray(list) && list.length) {
      posterMusicOptions.value = list.filter((t) => t && t.url)
    } else if (Array.isArray(cfg.musicList) && cfg.musicList.length) {
      posterMusicOptions.value = cfg.musicList.filter((t) => t && t.url)
    } else {
      posterMusicOptions.value = []
    }
  } catch {
    posterMusicOptions.value = Array.isArray(cfg.musicList)
      ? cfg.musicList.filter((t) => t && t.url)
      : []
  } finally {
    posterMusicLoading.value = false
  }
}

async function loadPosterConfig() {
  posterLoading.value = true
  try {
    const res = await getPosterConfig()
    const bundle = normalizePosterBundle(res.data || {})
    posterBundle['1'] = bundle['1']
    posterBundle['2'] = bundle['2']
    posterBundle['3'] = bundle['3']
  } catch (e) {
    message.error(e?.message || '海报配置加载失败')
  } finally {
    posterLoading.value = false
  }
}

async function onSavePosterConfig() {
  posterSaving.value = true
  try {
    // 仅同步 layout 编号与 Tab 一致，不覆盖用户所选 template / mobileLayout
    for (const s of ['1', '2', '3']) {
      ensurePosterSlot(s, '1').layout = 1
      ensurePosterSlot(s, '2').layout = 2
    }
    const payload = normalizePosterBundle(posterBundle)
    const res = await savePosterConfig(payload)
    const bundle = normalizePosterBundle(res.data || payload)
    posterBundle['1'] = bundle['1']
    posterBundle['2'] = bundle['2']
    posterBundle['3'] = bundle['3']
    posterUndoSnapshot.value = null
    message.success('海报配置已保存')
  } catch (e) {
    message.error(e?.message || '海报配置保存失败')
  } finally {
    posterSaving.value = false
  }
}

function buildPosterTemplatePayload() {
  const s = sideKey(posterSideTab.value)
  const l = layoutKey(posterLayoutTab.value)
  const slot = ensurePosterSlot(s, l)
  return {
    version: 1,
    kind: 'poster-slot',
    side: Number(s),
    layout: Number(l),
    sideLabel: posterSideMeta(s).label,
    layoutLabel: posterLayoutMeta(l).label,
    route: posterRoute(s, l),
    config: JSON.parse(JSON.stringify(slot)),
  }
}

async function copyPosterTemplate() {
  posterCopying.value = true
  try {
    const payload = buildPosterTemplatePayload()
    posterTemplateCache = payload
    const text = JSON.stringify(payload, null, 2)
    if (navigator?.clipboard?.writeText) {
      await navigator.clipboard.writeText(text)
    } else {
      const ta = document.createElement('textarea')
      ta.value = text
      ta.style.position = 'fixed'
      ta.style.left = '-9999px'
      document.body.appendChild(ta)
      ta.select()
      document.execCommand('copy')
      document.body.removeChild(ta)
    }
    message.success(`已复制「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」`)
  } catch (e) {
    message.error(e?.message || '复制失败，请检查浏览器剪贴板权限')
  } finally {
    posterCopying.value = false
  }
}

function extractPosterImportConfig(parsed) {
  if (!parsed || typeof parsed !== 'object') return null
  if (parsed.kind === 'poster-slot' && parsed.config && typeof parsed.config === 'object') {
    return parsed.config
  }
  if (parsed.config && typeof parsed.config === 'object' && !Array.isArray(parsed.config)) {
    return parsed.config
  }
  const s = sideKey(posterSideTab.value)
  const l = layoutKey(posterLayoutTab.value)
  const sideObj = parsed[s] || parsed[Number(s)]
  if (sideObj && typeof sideObj === 'object') {
    const slot = sideObj[l] || sideObj[Number(l)]
    if (slot && typeof slot === 'object') return slot
  }
  if ('template' in parsed || 'heroImg' in parsed || 'lines' in parsed || 'sonName' in parsed || 'groomName' in parsed) {
    return parsed
  }
  return null
}

async function readPosterImportSource() {
  // 1) 剪贴板 2) 本会话刚复制的缓存
  try {
    if (navigator?.clipboard?.readText) {
      const clip = String(await navigator.clipboard.readText() || '').trim()
      if (clip) {
        try {
          const parsed = JSON.parse(clip)
          if (extractPosterImportConfig(parsed)) return parsed
        } catch { /* not json */ }
      }
    }
  } catch { /* no permission */ }
  if (posterTemplateCache && extractPosterImportConfig(posterTemplateCache)) {
    return posterTemplateCache
  }
  return null
}

function applyImportedConfig(rawCfg) {
  const s = sideKey(posterSideTab.value)
  const l = layoutKey(posterLayoutTab.value)
  posterUndoSnapshot.value = {
    s,
    l,
    config: JSON.parse(JSON.stringify(ensurePosterSlot(s, l))),
  }
  const next = normalizePosterConfig(rawCfg, s, l)
  next.side = Number(s)
  next.layout = Number(l)
  if (next.sonName && !next.groomName) next.groomName = next.sonName
  if (next.daughterInLawName && !next.brideName) next.brideName = next.daughterInLawName
  if (next.groomName && !next.sonName) next.sonName = next.groomName
  if (next.brideName && !next.daughterInLawName) next.daughterInLawName = next.brideName
  posterBundle[s][l] = next
}

async function importPosterTemplateDirect() {
  posterImporting.value = true
  try {
    const parsed = await readPosterImportSource()
    if (!parsed) {
      message.warning('请先复制模板，再点导入')
      return
    }
    const rawCfg = extractPosterImportConfig(parsed)
    if (!rawCfg) {
      message.error('无法识别的模板数据')
      return
    }
    applyImportedConfig(rawCfg)
    message.success(`已导入到「${currentPosterSide.value.label} · ${currentPosterLayout.value.label}」，可撤回或保存`)
  } catch (e) {
    message.error(e?.message || '导入失败')
  } finally {
    posterImporting.value = false
  }
}

function undoPosterImport() {
  const snap = posterUndoSnapshot.value
  if (!snap?.config) {
    message.info('没有可撤回的导入')
    return
  }
  posterSideTab.value = snap.s
  posterLayoutTab.value = snap.l
  posterBundle[snap.s][snap.l] = snap.config
  posterUndoSnapshot.value = null
  message.success('已撤回导入')
}

function pickSectionData(section) {
  const keys = SECTION_KEYS[section] || []
  const data = {}
  for (const k of keys) data[k] = cfg[k]
  const cloned = JSON.parse(JSON.stringify(data))
  if (section === 'music' && Array.isArray(cloned.musicList)) {
    cloned.musicList = cloned.musicList.map((t) => ({
      ...t,
      weight: normalizeMusicWeight(t?.weight),
    }))
    cloned.musicCacheEnabled = cloned.musicCacheEnabled !== false
    cloned.musicCacheHours = normalizeMusicCacheHours(cloned.musicCacheHours)
  }
  return cloned
}

function applySectionData(section, loaded) {
  if (!loaded || typeof loaded !== 'object') return
  if (section === 'music') {
    if (Array.isArray(loaded.musicList)) {
      loaded.musicList = loaded.musicList.map((t) => ({
        ...t,
        coverUrl: t.coverUrl || '',
        lrcUrl: t.lrcUrl || '',
        weight: normalizeMusicWeight(t.weight),
      }))
    }
    if (typeof loaded.musicRandomEnabled !== 'boolean') loaded.musicRandomEnabled = false
    if (typeof loaded.musicCacheEnabled !== 'boolean') loaded.musicCacheEnabled = true
    loaded.musicCacheHours = normalizeMusicCacheHours(loaded.musicCacheHours)
  }
  if (section === 'photos') {
    loaded.photoPages = (loaded.photoPages || []).map(ensurePageShape)
  }
  if (section === 'basic') {
    loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
    loaded.calendarDate = loaded.calendarDate || '2026-09-04'
    loaded.heroImgResized = loaded.heroImgResized || ''
    loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || ''
    loaded.endImgResized = loaded.endImgResized || ''
    loaded.endImgResizedMeta = loaded.endImgResizedMeta || ''
  }
  if (section === 'copy') {
    loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
  }
  if (section === 'visual') {
    loaded.introExitEffect = loaded.introExitEffect || 'wind'
    loaded.freeScrollInterval = Number(loaded.freeScrollInterval) || 50
    loaded.pageSwitchDuration = Number(loaded.pageSwitchDuration) || 800
    loaded.firstScreenDuration = Number.isFinite(Number(loaded.firstScreenDuration))
      ? Number(loaded.firstScreenDuration)
      : 4500
    if (typeof loaded.danmakuEnabled !== 'boolean') loaded.danmakuEnabled = true
    if (typeof loaded.danmakuShowTime !== 'boolean') loaded.danmakuShowTime = true
    if (typeof loaded.nineGridAnimate !== 'boolean') loaded.nineGridAnimate = false
  }
  Object.assign(cfg, loaded)
  if (section === 'photos') {
    setActivePhotoIndex(currentPhotoIndex.value)
    nextTick(() => {
      const page = cfg.photoPages[currentPhotoIndex.value]
      if (page) fillMissingResizedMeta(page)
    })
  }
}

async function loadConfigSection(section) {
  tabLoading.value = true
  try {
    const res = await getConfigSection(section)
    applySectionData(section, res.data || {})
  } catch (e) {
    message.error(e?.message || '加载配置失败')
  } finally {
    tabLoading.value = false
  }
}

// 保存当前配置段
async function onSave() {
  const section = activeTab.value
  if (!CONFIG_SECTIONS.includes(section)) {
    message.info('当前页无需保存配置')
    return
  }
  saving.value = true
  try {
    const res = await saveConfigSection(section, pickSectionData(section))
    applySectionData(section, res.data || pickSectionData(section))
    message.success('配置已保存')
  } catch (e) {
    const msg = e?.message || '保存失败'
    message.error(msg)
    if (String(msg).includes('未授权') || String(msg).includes('登录')) {
      router.replace({ name: 'Login', query: { redirect: '/admin' } })
    }
  } finally {
    saving.value = false
  }
}

const visitPreviewColumns = [
  { title: '指纹', key: 'fingerprint', width: 110 },
  { title: '归属地', key: 'location', ellipsis: true },
  { title: '机型', key: 'device', width: 120, ellipsis: true },
  { title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 64 },
  { title: '最近访问', key: 'last_seen_at', width: 160 },
]

const visitColumns = [
  { title: '指纹', key: 'fingerprint', width: 120 },
  { title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
  { title: '归属地', key: 'location', ellipsis: true },
  { title: '机型', key: 'device', width: 140, ellipsis: true },
  { title: '次数', dataIndex: 'visit_count', key: 'visit_count', width: 70 },
  { title: 'UA', key: 'user_agent', ellipsis: true },
  { title: '最近访问', key: 'last_seen_at', width: 170 },
]

function shortFp(fp) {
  if (!fp) return '-'
  return fp.length > 12 ? `${fp.slice(0, 8)}…${fp.slice(-4)}` : fp
}
function shortUa(ua) {
  if (!ua) return '-'
  return ua.length > 42 ? `${ua.slice(0, 42)}…` : ua
}

const PIE_COLORS = ['#a88c6b', '#c4a484', '#e8b4b8', '#7c9eb2', '#b5a4c9', '#d4a574', '#9b7e9a', '#64748b']

function disposeVisitCharts() {
  if (regionChart) {
    regionChart.dispose()
    regionChart = null
  }
  if (deviceChart) {
    deviceChart.dispose()
    deviceChart = null
  }
  if (cityChart) {
    cityChart.dispose()
    cityChart = null
  }
}

function disposeCityChart() {
  if (cityChart) {
    cityChart.dispose()
    cityChart = null
  }
}

function renderPieChart(chartRef, holder, seriesData, emptyName = '暂无数据', opts = {}) {
  if (!chartRef.value) return null
  if (holder && holder.getDom() !== chartRef.value) {
    holder.dispose()
    holder = null
  }
  if (!holder) holder = echarts.init(chartRef.value)
  const data = seriesData.length
    ? seriesData.map((r) => ({
        name: r.name || '未知',
        value: Number(r.value) || 0,
        selected: opts.selectedName ? (r.name || '未知') === opts.selectedName : false,
      }))
    : [{ name: emptyName, value: 0 }]
  holder.setOption({
    color: PIE_COLORS,
    tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
    legend: {
      type: 'scroll',
      bottom: 0,
      textStyle: { color: '#8A8680', fontSize: 11 },
    },
    series: [{
      type: 'pie',
      radius: ['38%', '62%'],
      center: ['50%', '46%'],
      selectedMode: opts.selectable ? 'single' : false,
      selectedOffset: 8,
      itemStyle: { borderRadius: 4, borderColor: '#fff', borderWidth: 2 },
      label: { color: '#5c5348', fontSize: 11 },
      data,
    }],
  }, { notMerge: true })
  return holder
}

function renderRegionChart() {
  regionChart = renderPieChart(
    regionChartRef,
    regionChart,
    Array.isArray(visitStats.regions) ? visitStats.regions : [],
    '暂无数据',
    { selectable: true, selectedName: selectedRegion.value },
  )
  if (!regionChart) return
  regionChart.off('click')
  regionChart.on('click', (params) => {
    const name = params?.name
    if (!name || name === '暂无数据') return
    if (selectedRegion.value === name) {
      clearSelectedRegion()
      return
    }
    onSelectRegion(name)
  })
}

function renderDeviceChart() {
  deviceChart = renderPieChart(
    deviceChartRef,
    deviceChart,
    Array.isArray(visitStats.devices) ? visitStats.devices : [],
  )
}

function renderCityChart() {
  cityChart = renderPieChart(
    cityChartRef,
    cityChart,
    Array.isArray(cityStats.value) ? cityStats.value : [],
    '暂无市级数据',
  )
}

function clearSelectedRegion(opts = {}) {
  selectedRegion.value = ''
  cityStats.value = []
  disposeCityChart()
  if (!opts.silent && regionChart) renderRegionChart()
}

async function onSelectRegion(name) {
  selectedRegion.value = name
  cityStatsLoading.value = true
  cityStats.value = []
  disposeCityChart()
  renderRegionChart()
  try {
    const res = await getVisitCityStats(name)
    const d = res.data || {}
    cityStats.value = Array.isArray(d.cities) ? d.cities : []
    await nextTick()
    renderCityChart()
  } catch (e) {
    message.error(e?.message || '加载市级分布失败')
    cityStats.value = []
  } finally {
    cityStatsLoading.value = false
  }
}

async function loadGiftStats() {
  giftStatsLoading.value = true
  try {
    const res = await getGiftStats()
    const d = res.data || {}
    const counts = d.counts || {}
    for (const g of GIFT_TYPES) {
      giftStats.counts[g.key] = Number(counts[g.key]) || 0
    }
    giftStats.total = Number(d.total) || Object.values(giftStats.counts).reduce((a, b) => a + b, 0)
    applyGiftCostsFromServer(d.costs)
  } catch (e) {
    message.error(e?.message || '加载礼物统计失败')
  } finally {
    giftStatsLoading.value = false
  }
}

const giftListColumns = [
  { title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
  { title: '礼物', dataIndex: 'gift_type', key: 'gift_type', width: 120 },
  { title: '消耗', dataIndex: 'cost', key: 'cost', width: 90 },
  { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
  { title: '操作', key: 'actions', width: 90 },
]
const giftTypeLabel = (key) => GIFT_LABEL_MAP[key] || key || '—'

async function loadGiftList() {
  giftListLoading.value = true
  try {
    const res = await getGiftList()
    const data = res.data || {}
    giftList.value = Array.isArray(data.list) ? data.list : (Array.isArray(data) ? data : [])
  } catch (e) {
    giftList.value = []
    message.error(e?.message || '加载礼物明细失败')
  } finally {
    giftListLoading.value = false
  }
}

async function onDeleteGift(record) {
  try {
    await deleteGift(record.id)
    message.success('已删除')
    await Promise.all([loadGiftList(), loadGiftStats()])
  } catch (e) {
    message.error(e?.message || '删除失败')
  }
}

async function loadVisitStats() {
  visitStatsLoading.value = true
  try {
    const statsRes = await getVisitStats()
    const s = statsRes.data || {}
    visitStats.uv = Number(s.uv) || 0
    visitStats.pv = Number(s.pv) || 0
    visitStats.today_uv = Number(s.today_uv) || 0
    visitStats.regions = Array.isArray(s.regions) ? s.regions : []
    visitStats.devices = Array.isArray(s.devices) ? s.devices : []
    await nextTick()
    renderRegionChart()
    renderDeviceChart()
    if (selectedRegion.value) {
      await onSelectRegion(selectedRegion.value)
    }
  } catch (e) {
    message.error(e?.message || '加载访客统计失败')
  } finally {
    visitStatsLoading.value = false
  }
}

async function loadVisitPreview() {
  visitListLoading.value = true
  try {
    const listRes = await getVisitList({ page: 1, pageSize: 10 })
    const listData = listRes.data || {}
    visitPreviewList.value = listData.list || []
    visitTotal.value = Number(listData.total) || 0
  } catch (e) {
    message.error(e?.message || '加载访客预览失败')
  } finally {
    visitListLoading.value = false
  }
}

async function loadVisitList() {
  visitListLoading.value = true
  try {
    const listRes = await getVisitList({
      page: visitPage.value,
      pageSize: visitPageSize.value,
    })
    const listData = listRes.data || {}
    visitList.value = listData.list || []
    visitTotal.value = Number(listData.total) || 0
  } catch (e) {
    message.error(e?.message || '加载访客记录失败')
  } finally {
    visitListLoading.value = false
  }
}

async function loadOverview() {
  await Promise.all([loadVisitStats(), loadVisitPreview()])
}

function onVisitTableChange(pag) {
  visitPage.value = pag?.current || 1
  visitPageSize.value = pag?.pageSize || visitPageSize.value
  loadVisitList()
}

async function onTabActivate(tab) {
  if (!adminStore.isAdmin) return
  if (tab !== 'overview') {
    clearSelectedRegion({ silent: true })
    disposeVisitCharts()
  }
  if (tab === 'overview') {
    await loadOverview()
    return
  }
  if (tab === 'gifts') {
    await Promise.all([loadGiftStats(), loadGiftList()])
    return
  }
  if (tab === 'visits') {
    await loadVisitList()
    return
  }
  if (CONFIG_SECTIONS.includes(tab)) {
    await loadConfigSection(tab)
    return
  }
  if (tab === 'upload') {
    await loadUploadConfig()
    return
  }
  if (tab === 'poster') {
    await Promise.all([loadPosterConfig(), loadPosterMusicOptions()])
    return
  }
  if (tab === 'hotelWelcome') {
    hotelWelcomeRef.value?.refresh?.()
    return
  }
  if (tab === 'rsvp') {
    await loadRsvp()
    return
  }
  if (tab === 'cash') {
    await Promise.all([loadCashGifts(), loadCashNotePresets(), loadCashViewPass(), loadCashEntryPass()])
    return
  }
  if (tab === 'danmaku') {
    await loadDanmaku()
  }
}

function openPreview() { window.open('/', '_blank') }
function openPosterPreview(side = 1, layout = 1) {
  window.open(posterRoute(side, layout), '_blank')
}

function onLogout() {
  adminStore.logout()
  router.replace({ name: 'Login', query: { redirect: '/admin' } })
}

// RSVP
const rsvpColumns = [
  { title: '姓名', dataIndex: 'name', key: 'name' },
  { title: '出席', dataIndex: 'guest_count', key: 'guest_count' },
  { title: '祝福', dataIndex: 'wishes', key: 'wishes', ellipsis: true },
  { title: '提交时间', dataIndex: 'created_at', key: 'created_at' },
  { title: '操作', key: 'actions', width: 90 },
]
const rsvpCountLabel = (v) => ({ '1': '1 人出席', '2': '2 人出席', '3': '3 人及以上', '0': '遗憾缺席' }[v] || v)
function formatTime(t) {
  if (!t) return ''
  const d = new Date(t)
  if (isNaN(d)) return t
  return d.toLocaleString('zh-CN', { hour12: false })
}
async function loadRsvp() {
  rsvpLoading.value = true
  try { const res = await getRsvpList(); rsvpList.value = res.data || [] }
  catch (e) { /* 忽略 */ }
  finally { rsvpLoading.value = false }
}
async function onDeleteRsvp(record) {
  if (!record?.id) return
  try {
    await deleteRsvp(record.id)
    message.success('已删除')
    await loadRsvp()
  } catch (e) {
    message.error(e?.message || '删除失败')
  }
}

const cashGiftColumns = [
  { title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
  { title: '金额', key: 'amount', width: 110 },
  { title: '备注', dataIndex: 'note', key: 'note', ellipsis: true },
  { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
  { title: '操作', key: 'actions', width: 140 },
]
async function loadCashGifts() {
  cashGiftLoading.value = true
  try {
    const res = await getCashGiftList()
    const data = res.data || {}
    cashGiftList.value = Array.isArray(data.list) ? data.list : []
    cashGiftTotal.value = Number(data.total) || 0
    cashGiftCount.value = Number(data.count) || cashGiftList.value.length
  } catch {
    cashGiftList.value = []
    cashGiftTotal.value = 0
    cashGiftCount.value = 0
  } finally {
    cashGiftLoading.value = false
  }
}
function openCashGiftCreate() {
  cashGiftEditingId.value = 0
  cashGiftForm.name = ''
  cashGiftForm.amount = 1000
  cashGiftForm.note = ''
  cashGiftModalOpen.value = true
}
function openCashGiftEdit(record) {
  cashGiftEditingId.value = record.id
  cashGiftForm.name = record.name || ''
  cashGiftForm.amount = Number(record.amount) || 0
  cashGiftForm.note = record.note || ''
  cashGiftModalOpen.value = true
}
async function saveCashGiftModal() {
  const name = String(cashGiftForm.name || '').trim()
  const amount = Math.round(Number(cashGiftForm.amount))
  if (!name) {
    message.error('请填写姓名')
    return Promise.reject()
  }
  if (!Number.isFinite(amount) || amount <= 0) {
    message.error('请填写正确金额')
    return Promise.reject()
  }
  cashGiftSaving.value = true
  try {
    const payload = { name, amount, note: String(cashGiftForm.note || '').trim() }
    if (cashGiftEditingId.value) await updateCashGift(cashGiftEditingId.value, payload)
    else await submitCashGift(payload)
    message.success('已保存')
    cashGiftModalOpen.value = false
    await loadCashGifts()
  } catch (e) {
    if (e) message.error(e?.message || '保存失败')
    return Promise.reject(e)
  } finally {
    cashGiftSaving.value = false
  }
}
async function onDeleteCashGift(record) {
  if (!record?.id) return
  try {
    await deleteCashGift(record.id)
    message.success('已删除')
    await loadCashGifts()
  } catch (e) {
    message.error(e?.message || '删除失败')
  }
}

async function loadCashNotePresets() {
  try {
    const res = await getCashGiftNotes()
    cashNotePresets.value = Array.isArray(res.data?.notes) ? [...res.data.notes] : []
    cashNoteDefaults.value = Array.isArray(res.data?.defaults) ? [...res.data.defaults] : []
  } catch {
    cashNotePresets.value = []
    cashNoteDefaults.value = []
  }
}

async function loadCashViewPass() {
  try {
    const res = await getCashGiftViewPass()
    cashViewPass.value = res.data?.password || ''
  } catch {
    cashViewPass.value = ''
  }
}

async function saveCashViewPass() {
  const password = String(cashViewPass.value || '').trim()
  if (!password) return message.error('请设置查看口令')
  cashViewPassSaving.value = true
  try {
    await saveCashGiftViewPass(password)
    message.success('查看口令已保存')
  } catch (e) {
    message.error(e?.message || '保存失败')
  } finally {
    cashViewPassSaving.value = false
  }
}

async function loadCashEntryPass() {
  try {
    const res = await getCashGiftEntryPass()
    cashEntryPass.value = res.data?.password || ''
  } catch {
    cashEntryPass.value = ''
  }
}

async function saveCashEntryPass() {
  const password = String(cashEntryPass.value || '').trim()
  if (!password) return message.error('请设置记账口令')
  cashEntryPassSaving.value = true
  try {
    await saveCashGiftEntryPass(password)
    message.success('记账口令已保存')
  } catch (e) {
    message.error(e?.message || '保存失败')
  } finally {
    cashEntryPassSaving.value = false
  }
}

function addCashNotePreset() {
  const v = String(cashNoteDraft.value || '').trim()
  if (!v) return
  if (cashNotePresets.value.includes(v)) {
    message.warning('已存在该选项')
    return
  }
  cashNotePresets.value.push(v)
  cashNoteDraft.value = ''
}

function removeCashNotePreset(index) {
  cashNotePresets.value.splice(index, 1)
}

function resetCashNotePresets() {
  cashNotePresets.value = cashNoteDefaults.value.length
    ? [...cashNoteDefaults.value]
    : ['男方亲戚', '女方亲戚', '男方朋友', '女方朋友', '男方同学', '女方同学', '男方同事', '女方同事', '男方邻居', '女方邻居', '其他']
}

async function saveCashNotePresets() {
  if (!cashNotePresets.value.length) return message.error('至少保留一个快捷备注')
  cashNotesSaving.value = true
  try {
    const res = await saveCashGiftNotes(cashNotePresets.value)
    cashNotePresets.value = Array.isArray(res.data?.notes) ? [...res.data.notes] : [...cashNotePresets.value]
    message.success('备注选项已保存')
  } catch (e) {
    message.error(e?.message || '保存失败')
  } finally {
    cashNotesSaving.value = false
  }
}

const danmakuColumns = [
  { title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
  { title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
  { title: '来源', key: 'source', width: 80 },
  { title: '状态', key: 'status', width: 90 },
  { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
  { title: '操作', key: 'actions', width: 160 },
]
const danmakuStatusLabel = (s) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s)
const danmakuStatusPill = (s) => ({
  pending: 'admin-pill--warn',
  approved: 'admin-pill--ok',
  rejected: 'admin-pill--muted',
}[s] || 'admin-pill--muted')

async function loadDanmaku() {
  danmakuLoading.value = true
  try {
    const params = danmakuFilter.value ? { status: danmakuFilter.value } : undefined
    const res = await getDanmakuList(params)
    danmakuList.value = res.data || []
  } catch (e) { /* 忽略 */ }
  finally { danmakuLoading.value = false }
}

async function setDanmakuStatus(id, status) {
  try {
    await updateDanmakuStatus(id, status)
    message.success(status === 'approved' ? '已通过' : '已拒绝')
    loadDanmaku()
  } catch (e) {
    message.error(e.message || '操作失败')
  }
}

watch(danmakuFilter, () => {
  if (adminStore.isAdmin && activeTab.value === 'danmaku') loadDanmaku()
})

watch(activeTab, (tab) => {
  try { localStorage.setItem(TAB_KEY, tab) } catch { /* ignore */ }
  onTabActivate(tab)
}, { immediate: true })

// 进入相册页 / 切换页签时，为缺 meta 的展示图自动探测尺寸
watch(
  () => [activeTab.value, currentPhotoIndex.value],
  ([tab]) => {
    if (tab !== 'photos') return
    const page = cfg.photoPages[currentPhotoIndex.value]
    if (page) fillMissingResizedMeta(page)
  },
)

watch(activeTab, (tab) => {
  if (tab === 'basic') fillMissingBasicResizedMeta()
})

const onResize = () => {
  regionChart?.resize()
  deviceChart?.resize()
  cityChart?.resize()
}

onMounted(() => {
  window.addEventListener('resize', onResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', onResize)
  onHeroFocusPointerUp()
  clearHeartSlotPointer()
  disposeVisitCharts()
})
</script>

<style scoped>
.resized-meta {
  margin-top: 2px;
  font-size: 9px;
  line-height: 1.35;
  color: #8A8680;
  white-space: pre-line;
  word-break: break-all;
}

.heart-admin-board {
  position: relative;
  width: 100%;
  max-width: 520px;
  aspect-ratio: 0.8 / 1;
  margin: 0 auto 8px;
  background:
    radial-gradient(ellipse at 50% 30%, rgba(201, 120, 120, 0.06), transparent 55%),
    #faf8f5;
  border: 1px solid #e7e3db;
  border-radius: 12px;
  overflow: hidden;
}
.heart-admin-slot {
  position: absolute;
  min-width: 0;
  min-height: 0;
  padding: 1px;
  box-sizing: border-box;
}
.heart-admin-cell {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 4px;
  overflow: hidden;
  background: #f0ebe3;
  border: 1px solid #e7e3db;
  user-select: none;
  touch-action: none;
  cursor: pointer;
  transition: box-shadow 0.15s, border-color 0.15s;
}
.heart-admin-cell.empty {
  border-style: dashed;
  border-color: rgba(168, 140, 107, 0.45);
  background: #f7f4ef;
}
.heart-admin-cell.selected {
  border-color: #a88c6b;
  box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.65);
  z-index: 3;
}
.heart-admin-cell.swap-target:hover {
  border-color: #c4a484;
  box-shadow: 0 0 0 2px rgba(196, 164, 132, 0.45);
  z-index: 2;
}
.heart-admin-cell.dragging {
  cursor: grabbing;
  box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.55);
  z-index: 4;
}
.heart-admin-cell img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}
.heart-admin-empty {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  color: #a88c6b;
  font-size: 10px;
  line-height: 1.2;
  padding: 2px;
  text-align: center;
  pointer-events: none;
}
.heart-admin-crosshair {
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-width: 1.5px;
}
.heart-admin-toolbar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 2px;
  padding: 2px 3px;
  background: linear-gradient(transparent, rgba(40, 30, 20, 0.55));
  z-index: 2;
  pointer-events: none;
}
.heart-admin-label {
  font-size: 9px;
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.heart-admin-btn {
  pointer-events: auto;
  width: 18px;
  height: 18px;
  border: none;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.92);
  color: #a88c6b;
  font-size: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  padding: 0;
}
.heart-admin-btn:hover {
  background: #a88c6b;
  color: #fff;
}
.heart-admin-meta {
  position: absolute;
  top: 2px;
  right: 2px;
  z-index: 2;
  font-size: 8px;
  line-height: 1;
  padding: 2px 3px;
  border-radius: 3px;
  background: rgba(168, 140, 107, 0.9);
  color: #fff;
  pointer-events: none;
}
.admin-layout-seg :deep(.ant-segmented-item-selected) {
  color: #a88c6b;
}
.admin-header {
  padding: 10px 16px 10px;
}
.admin-header-row {
  display: flex;
  align-items: center;
  gap: 12px 16px;
  flex-wrap: wrap;
}
.admin-header-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  justify-content: flex-end;
  margin-left: auto;
}
.admin-top-menu {
  display: flex;
  align-items: center;
  gap: 2px;
  flex: 1;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  padding: 2px 0;
}
.admin-top-menu::-webkit-scrollbar { display: none; }
.admin-top-menu-trigger {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: none;
  background: transparent;
  color: #5c5348;
  font-size: 13px;
  letter-spacing: 0.04em;
  padding: 8px 12px;
  border-radius: 10px;
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.15s, color 0.15s;
}
.admin-top-menu-trigger:hover {
  background: rgba(168, 140, 107, 0.08);
  color: #967b5c;
}
.admin-top-menu-trigger.active {
  color: #a88c6b;
  background: rgba(168, 140, 107, 0.12);
  font-weight: 500;
}
.admin-top-menu-caret {
  font-size: 9px;
  opacity: 0.65;
}
.admin-top-dropdown :deep(.ant-dropdown-menu-item-selected) {
  color: #a88c6b;
  background: rgba(168, 140, 107, 0.12);
}
.admin-shell {
  margin: 0 auto;
  padding: 20px 16px 40px;
  box-sizing: border-box;
}
.admin-shell--top {
  max-width: 80vw;
  width: 80vw;
}
.admin-shell--side {
  max-width: 1220px;
  display: flex;
  align-items: flex-start;
  gap: 18px;
}
.admin-nav {
  background: #fff;
  border: 1px solid rgba(168, 140, 107, 0.14);
  border-radius: 16px;
  box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
  position: sticky;
  top: 68px;
  width: 212px;
  flex-shrink: 0;
  padding: 14px 10px 16px;
  max-height: calc(100dvh - 88px);
  overflow: auto;
}
.admin-nav-group-title {
  font-size: 11px;
  letter-spacing: 0.12em;
  color: #8A8680;
  margin-bottom: 6px;
  padding: 0 6px;
}
.admin-nav-group + .admin-nav-group {
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px dashed rgba(168, 140, 107, 0.22);
}
.admin-nav-items {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.admin-nav-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  border: 1px solid transparent;
  background: transparent;
  color: #5c5348;
  font-size: 13px;
  line-height: 1.2;
  padding: 7px 10px;
  border-radius: 10px;
  cursor: pointer;
  transition: background 0.15s, color 0.15s, border-color 0.15s;
  text-align: left;
}
.admin-nav-item:hover {
  background: rgba(168, 140, 107, 0.08);
  color: #967b5c;
}
.admin-nav-item.active {
  background: rgba(168, 140, 107, 0.14);
  border-color: rgba(168, 140, 107, 0.28);
  color: #a88c6b;
  font-weight: 500;
}
.admin-nav-icon {
  width: 1em;
  text-align: center;
  font-size: 12px;
  opacity: 0.85;
}
.admin-nav-credit {
  margin-top: 14px;
  padding: 10px 8px 2px;
  border-top: 1px dashed rgba(168, 140, 107, 0.22);
  font-size: 11px;
  letter-spacing: 0.08em;
  color: #a88c6b;
  text-align: center;
}
.admin-main {
  min-width: 0;
  flex: 1;
}
@media (max-width: 960px) {
  .admin-top-menu {
    order: 3;
    flex: 1 1 100%;
    border-top: 1px solid rgba(168, 140, 107, 0.12);
    margin-top: 2px;
    padding-top: 8px;
  }
}
@media (max-width: 860px) {
  .admin-shell--side {
    flex-direction: column;
  }
  .admin-nav {
    position: static;
    width: 100%;
    max-height: none;
  }
  .admin-nav-items {
    flex-direction: row;
    flex-wrap: wrap;
  }
  .admin-nav-item {
    width: auto;
  }
}

.admin-panel {
  background: #fff;
  border: 1px solid rgba(168, 140, 107, 0.14);
  border-radius: 16px;
  padding: 20px 20px 16px;
  box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
}
.stat-card {
  border-radius: 14px;
  padding: 16px 18px;
  background: linear-gradient(145deg, #fff 0%, #f8f1e6 100%);
  border: 1px solid rgba(168, 140, 107, 0.16);
}
.stat-label {
  font-size: 11px;
  color: #8A8680;
  letter-spacing: 0.06em;
}
.stat-value {
  margin-top: 6px;
  font-size: 28px;
  font-weight: 500;
  color: #a88c6b;
  letter-spacing: 0.02em;
  line-height: 1.1;
}
.visit-preview-wrap,
.visit-list-wrap {
  border: 1px solid rgba(168, 140, 107, 0.12);
  border-radius: 12px;
  overflow: hidden;
  background: #fffdf9;
}
.visit-preview-wrap :deep(.ant-table-body),
.visit-list-wrap :deep(.ant-table-body) {
  max-height: inherit;
}
.chart-card {
  border-radius: 14px;
  padding: 14px 16px;
  background: #fdfbf7;
  border: 1px solid rgba(168, 140, 107, 0.12);
  min-height: 300px;
}

.admin-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 48px 16px;
  border-radius: 14px;
  background: linear-gradient(180deg, #fdfbf7 0%, #f7f3ec 100%);
  border: 1px dashed rgba(168, 140, 107, 0.28);
}
.admin-empty-icon {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(168, 140, 107, 0.12);
  color: #a88c6b;
  margin-bottom: 12px;
  font-size: 18px;
}

.music-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.music-track {
  border: 1px solid rgba(168, 140, 107, 0.14);
  border-radius: 14px;
  background: #fdfbf7;
  padding: 12px 14px;
  transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.music-track--active {
  border-color: rgba(168, 140, 107, 0.55);
  background: linear-gradient(135deg, #fff 0%, #f8f1e6 100%);
  box-shadow: 0 6px 18px rgba(168, 140, 107, 0.12);
}
.music-track-main {
  display: flex;
  align-items: flex-start;
  gap: 14px;
}
.music-cover {
  position: relative;
  width: 72px;
  height: 72px;
  border-radius: 12px;
  overflow: hidden;
  flex-shrink: 0;
  border: 1px solid rgba(168, 140, 107, 0.2);
  background: #f0ebe3;
  cursor: pointer;
  padding: 0;
}
.music-cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.music-cover-fallback {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #a88c6b;
  font-size: 22px;
  background: radial-gradient(circle at 30% 30%, #fff8ee, #efe4d4);
}
.music-cover-index {
  position: absolute;
  left: 4px;
  bottom: 4px;
  font-size: 9px;
  letter-spacing: 0.06em;
  color: #fff;
  background: rgba(44, 36, 28, 0.45);
  padding: 1px 5px;
  border-radius: 999px;
}
.music-meta { min-width: 0; }
.music-name-input {
  padding: 0 !important;
  font-size: 14px !important;
  font-weight: 500;
  color: #2c2c2c;
}
.music-name-input :deep(.ant-input) {
  padding: 0;
  font-size: 14px;
  font-weight: 500;
  background: transparent;
}
.music-sub {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 2px;
  font-size: 11px;
  color: #8A8680;
  min-width: 0;
}
.music-badge {
  flex-shrink: 0;
  font-size: 10px;
  letter-spacing: 0.04em;
  color: #a88c6b;
  background: rgba(168, 140, 107, 0.12);
  border: 1px solid rgba(168, 140, 107, 0.25);
  border-radius: 999px;
  padding: 1px 8px;
}
.music-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 8px;
}
.music-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  border: 1px solid rgba(168, 140, 107, 0.22);
  background: #fff;
  color: #7a6550;
  font-size: 11px;
  padding: 3px 10px;
  border-radius: 999px;
  cursor: pointer;
  transition: border-color 0.15s, color 0.15s;
}
.music-chip:hover {
  border-color: #a88c6b;
  color: #a88c6b;
}
.music-chip--muted {
  color: #9a9084;
  border-color: transparent;
  background: transparent;
}
.music-chip--weight {
  cursor: default;
  gap: 6px;
}
.music-chip--weight:hover {
  border-color: rgba(168, 140, 107, 0.22);
  color: #7a6550;
}
.music-weight-input {
  width: 72px !important;
}
.music-weight-input :deep(.ant-input-number-input) {
  height: 22px;
  font-size: 11px;
  padding: 0 4px;
  text-align: center;
}
.music-weight-pct {
  font-size: 10px;
  color: #a88c6b;
  min-width: 2.2em;
}
.music-audio {
  width: 100%;
  max-width: 420px;
  height: 32px;
  margin-top: 10px;
  border-radius: 8px;
  outline: none;
}
.music-actions {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
  flex-shrink: 0;
}
@media (max-width: 640px) {
  .music-track-main { flex-wrap: wrap; }
  .music-actions {
    flex-direction: row;
    width: 100%;
    justify-content: flex-end;
  }
}

.schedule-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.schedule-row {
  display: flex;
  align-items: flex-end;
  gap: 12px;
  padding: 14px;
  border-radius: 14px;
  background: #fdfbf7;
  border: 1px solid rgba(168, 140, 107, 0.12);
}
.schedule-index {
  width: 36px;
  height: 36px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  letter-spacing: 0.06em;
  color: #a88c6b;
  background: rgba(168, 140, 107, 0.1);
  flex-shrink: 0;
  margin-bottom: 2px;
}
.schedule-fields {
  flex: 1;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  min-width: 0;
}
@media (max-width: 720px) {
  .schedule-fields { grid-template-columns: 1fr; }
  .schedule-row { align-items: flex-start; }
}
.field-label {
  font-size: 11px;
  color: #a88c6b;
  margin-bottom: 4px;
  letter-spacing: 0.04em;
}

.admin-table :deep(.ant-table) {
  background: transparent;
}
.admin-table :deep(.ant-table-thead > tr > th) {
  background: #f7f3ec !important;
  color: #7a6550;
  font-weight: 500;
  font-size: 12px;
  border-bottom: 1px solid rgba(168, 140, 107, 0.16) !important;
}
.admin-table :deep(.ant-table-tbody > tr > td) {
  border-bottom: 1px solid rgba(168, 140, 107, 0.08) !important;
  font-size: 13px;
}
.admin-table :deep(.ant-table-tbody > tr:hover > td) {
  background: #fdfbf7 !important;
}
.admin-table :deep(.ant-pagination-item-active) {
  border-color: #a88c6b;
}
.admin-table :deep(.ant-pagination-item-active a) {
  color: #a88c6b;
}

.admin-filter :deep(.ant-radio-button-wrapper-checked) {
  background: #a88c6b !important;
  border-color: #a88c6b !important;
  color: #fff !important;
}
.admin-filter :deep(.ant-radio-button-wrapper:hover) {
  color: #a88c6b;
}

.admin-pill {
  display: inline-flex;
  align-items: center;
  padding: 1px 9px;
  border-radius: 999px;
  font-size: 11px;
  line-height: 18px;
  border: 1px solid transparent;
}
.admin-pill--rose {
  color: #b4536a;
  background: rgba(232, 165, 178, 0.22);
  border-color: rgba(232, 165, 178, 0.4);
}
.admin-pill--gold {
  color: #8a7048;
  background: rgba(168, 140, 107, 0.14);
  border-color: rgba(168, 140, 107, 0.28);
}
.admin-pill--sky {
  color: #4d6f86;
  background: rgba(124, 158, 178, 0.16);
  border-color: rgba(124, 158, 178, 0.3);
}
.admin-pill--warn {
  color: #b45309;
  background: rgba(251, 191, 36, 0.18);
  border-color: rgba(251, 191, 36, 0.35);
}
.admin-pill--ok {
  color: #3f6b4a;
  background: rgba(134, 179, 143, 0.18);
  border-color: rgba(134, 179, 143, 0.35);
}
.admin-pill--muted {
  color: #8A8680;
  background: #f3f1ec;
  border-color: #e7e3db;
}

.danmaku-admin-dot {
  width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}

.hero-focus-pad {
  display: grid;
  grid-template-columns: repeat(var(--hero-focus-n, 9), 22px);
  gap: 4px;
  width: fit-content;
  padding: 8px;
  border-radius: 10px;
  background: #f7f4ef;
  border: 1px solid #e7e3db;
}
.hero-focus-cell {
  width: 22px;
  height: 22px;
  border-radius: 5px;
  border: 1px solid #ddd6cb;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  padding: 0;
  transition: border-color 0.15s, background 0.15s;
}
.hero-focus-cell:hover { border-color: #a88c6b; }
.hero-focus-cell.active {
  border-color: #a88c6b;
  background: rgba(168, 140, 107, 0.14);
}
.hero-focus-dot {
  width: 5px;
  height: 5px;
  border-radius: 999px;
  background: #c4b8a4;
}
.hero-focus-cell.active .hero-focus-dot { background: #a88c6b; }
.hero-focus-preview {
  position: relative;
  width: min(100%, 280px);
  aspect-ratio: 16 / 11;
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid #e7e3db;
  background: #f0ebe3;
  user-select: none;
  touch-action: none;
}
.hero-focus-preview.is-mobile {
  width: min(100%, 200px);
  aspect-ratio: 3 / 4;
}
.hero-focus-preview--drag { cursor: crosshair; }
.hero-focus-preview--drag.dragging { cursor: grabbing; }
.hero-focus-preview--empty {
  display: flex;
  align-items: center;
  justify-content: center;
  color: #8A8680;
  font-size: 12px;
  padding: 12px;
  text-align: center;
}
.hero-focus-preview img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}
.hero-focus-crosshair {
  position: absolute;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid #fff;
  border-radius: 999px;
  box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.85), 0 1px 4px rgba(0, 0, 0, 0.25);
  background: rgba(168, 140, 107, 0.35);
  pointer-events: none;
  z-index: 1;
}

.poster-action-bar-spacer {
  height: 72px;
}
.poster-action-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 40;
  padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
  background: rgba(255, 255, 255, 0.94);
  backdrop-filter: blur(10px);
  border-top: 1px solid rgba(168, 140, 107, 0.2);
  box-shadow: 0 -8px 24px rgba(88, 68, 42, 0.08);
}
.poster-action-bar-inner {
  max-width: 1220px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
}
.admin-shell--top ~ .poster-action-bar .poster-action-bar-inner,
.admin-shell--top .poster-action-bar-inner {
  max-width: 80vw;
}
.poster-action-bar-meta {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  font-size: 12px;
  color: #5c5348;
}
.poster-action-bar-path {
  font-size: 11px;
  color: #8A8680;
}
.poster-action-bar-btns {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: flex-end;
  margin-left: auto;
}
@media (max-width: 720px) {
  .poster-action-bar-meta { width: 100%; }
  .poster-action-bar-btns { width: 100%; justify-content: stretch; }
  .poster-action-bar-btns :deep(.ant-btn) { flex: 1 1 auto; }
}
</style>
