1. 后台看礼物记录

This commit is contained in:
李琦
2026-08-03 15:15:12 +08:00
parent e1997bafa8
commit 98acccef52
4 changed files with 466 additions and 9 deletions

View File

@@ -215,6 +215,8 @@ func registerAPI(api *gin.RouterGroup) {
api.GET("/gift", handleGiftStatus)
api.POST("/gift", handleCreateGift)
api.GET("/gift/stats", handleGiftStats)
api.GET("/gift/list", handleGiftList)
api.DELETE("/gift/:id", handleDeleteGift)
api.GET("/gift/costs", handleGetGiftCosts)
api.POST("/gift/costs", handleSaveGiftCosts)
api.POST("/cash-gift", handleCreateCashGift)
@@ -724,6 +726,42 @@ func handleGiftStats(c *gin.Context) {
})
}
func handleGiftList(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
var list []models.SiteGift
db.Order("created_at desc, id desc").Find(&list)
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"list": list,
"count": len(list),
},
})
}
func handleDeleteGift(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(400, gin.H{"error": "缺少 ID"})
return
}
res := db.Delete(&models.SiteGift{}, id)
if res.Error != nil {
c.JSON(500, gin.H{"error": "删除失败"})
return
}
if res.RowsAffected == 0 {
c.JSON(404, gin.H{"error": "记录不存在"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "已删除"})
}
func handleGetGiftCosts(c *gin.Context) {
if !utils.RequireAdmin(c) {
return

View File

@@ -154,6 +154,14 @@ export function getGiftStats() {
return service.get('/gift/stats')
}
export function getGiftList() {
return service.get('/gift/list')
}
export function deleteGift(id) {
return service.delete(`/gift/${id}`)
}
export function getGiftCosts() {
return service.get('/gift/costs')
}

View File

@@ -197,7 +197,7 @@
<div class="text-sm text-[#2c2c2c]">礼物统计</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
</div>
<a-button size="small" :loading="giftStatsLoading" @click="loadGiftStats">
<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>
@@ -234,6 +234,41 @@
/>
</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>
@@ -1301,7 +1336,7 @@ import { CanvasRenderer } from 'echarts/renderers'
import {
getConfigSection, saveConfigSection, uploadImage, getRsvpList, deleteRsvp, getUploadConfig, saveUploadConfig,
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
getPosterConfig, savePosterConfig, getGiftStats, saveGiftCosts,
getPosterConfig, savePosterConfig, getGiftStats, getGiftList, deleteGift, saveGiftCosts,
getCashGiftList, updateCashGift, deleteCashGift, submitCashGift,
getCashGiftNotes, saveCashGiftNotes,
getCashGiftViewPass, saveCashGiftViewPass,
@@ -1311,7 +1346,7 @@ import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { GIFT_TYPES, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
@@ -1451,8 +1486,10 @@ 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])),
@@ -2261,6 +2298,39 @@ async function loadGiftStats() {
}
}
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 {
@@ -2330,7 +2400,7 @@ async function onTabActivate(tab) {
return
}
if (tab === 'gifts') {
await loadGiftStats()
await Promise.all([loadGiftStats(), loadGiftList()])
return
}
if (tab === 'visits') {

View File

@@ -1,6 +1,48 @@
<template>
<div class="wrapper relative w-full min-h-[100dvh] bg-[#Fdfbf7] overflow-x-hidden text-[#2c2c2c]">
<!-- 揭幕前 Loading主题色囍字 + 雅致装饰 -->
<div
v-if="showBootLoading"
class="boot-loading"
:class="{ 'is-leaving': bootLeaving }"
aria-busy="true"
aria-label="加载中"
>
<div class="boot-decor" aria-hidden="true">
<i class="boot-glow boot-glow-1"></i>
<i class="boot-glow boot-glow-2"></i>
<i class="boot-glow boot-glow-3"></i>
<span class="boot-petal boot-petal-1"></span>
<span class="boot-petal boot-petal-2"></span>
<span class="boot-petal boot-petal-3"></span>
<span class="boot-petal boot-petal-4"></span>
<span class="boot-spark boot-spark-1"></span>
<span class="boot-spark boot-spark-2"></span>
<span class="boot-spark boot-spark-3"></span>
<span class="boot-spark boot-spark-4"></span>
<span class="boot-spark boot-spark-5"></span>
</div>
<div class="boot-frame boot-frame-tl" aria-hidden="true"></div>
<div class="boot-frame boot-frame-tr" aria-hidden="true"></div>
<div class="boot-frame boot-frame-bl" aria-hidden="true"></div>
<div class="boot-frame boot-frame-br" aria-hidden="true"></div>
<div class="boot-stage">
<span class="boot-kicker calligraphy">WEDDING INVITATION</span>
<div class="boot-rule" aria-hidden="true"><span></span><i></i><span></span></div>
<div class="boot-seal-wrap">
<i class="boot-seal-ring boot-seal-ring-a" aria-hidden="true"></i>
<i class="boot-seal-ring boot-seal-ring-b" aria-hidden="true"></i>
<div class="boot-seal calligraphy"></div>
</div>
<div class="boot-rule boot-rule-flip" aria-hidden="true"><span></span><i></i><span></span></div>
<p class="boot-names calligraphy-strong">{{ data.groom }}<span>&amp;</span>{{ data.bride }}</p>
<p class="boot-hint calligraphy">请柬装载中</p>
<div class="boot-dots" aria-hidden="true"><i></i><i></i><i></i></div>
</div>
</div>
<!-- 入场揭幕帘幕请柬封面 -->
<div v-show="showIntro" class="curtain-root" :class="[{ 'is-opening': introOpening }, introExitClass]" @click="openInvitation">
<div class="curtain-bg" :style="curtainBg"></div>
@@ -852,6 +894,7 @@ const audio = useAudio()
const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(false)
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0)
const isSnapping = ref(false)
const showBootLoading = ref(true), bootLeaving = ref(false)
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
const showOpenInBrowser = ref(false)
const showBlessingDrawer = ref(false)
@@ -1480,9 +1523,29 @@ const openImagePreview = async (src) => {
}
const effectsActive = computed(() =>
!isDrawerOpen.value && !showBlessingDrawer.value && !showGiftPanel.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
!showBootLoading.value
&& !isDrawerOpen.value && !showBlessingDrawer.value && !showGiftPanel.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
)
const preloadImage = (url) => new Promise((resolve) => {
const src = String(url || '').trim()
if (!src) {
resolve()
return
}
const img = new Image()
img.onload = img.onerror = () => resolve()
img.src = optimizeImageUrl(src)
})
const finishBootLoading = async () => {
if (!showBootLoading.value || bootLeaving.value) return
bootLeaving.value = true
await new Promise((r) => setTimeout(r, 480))
showBootLoading.value = false
bootLeaving.value = false
}
const closeDrawer = () => { isDrawerOpen.value = false }
const pageScrollTop = () => {
if (iosTransformActive && freeScrollOffset >= 0) return freeScrollOffset
@@ -1733,6 +1796,14 @@ const completeIntro = () => {
scrollPageToTop()
showIntro.value = false
introOpening.value = false
flushPendingEntranceFx()
scheduleAutoScrollStart()
}
/** Loading / 揭幕期间先积压,结束后再播 */
const shouldDeferEntranceFx = () => showBootLoading.value || showIntro.value
const flushPendingEntranceFx = () => {
if (pendingLikeBurst > 0) {
const n = pendingLikeBurst
pendingLikeBurst = 0
@@ -1745,7 +1816,6 @@ const completeIntro = () => {
nextTick(() => giftEffectRef.value?.playSequence(list, { max: 12 }))
}
}
scheduleAutoScrollStart()
}
const openInvitation = () => {
@@ -2014,8 +2084,8 @@ const loadLikeStatus = async () => {
const count = Number(res.data?.count) || 0
likeCount.value = count
if (count <= 0) return
// 揭幕中则等结束后再播,避免帘幕后白播
if (showIntro.value) pendingLikeBurst = count
// Loading / 揭幕中则等结束后再播,避免帘幕后白播
if (shouldDeferEntranceFx()) pendingLikeBurst = count
else nextTick(() => likeBurstRef.value?.burst(count, { durationMs: 2800, maxVisual: 10 }))
} catch { /* ignore */ }
}
@@ -2028,7 +2098,7 @@ const loadGiftStatus = async () => {
const recent = Array.isArray(res.data?.recent) ? res.data.recent : []
const aggregated = aggregateGiftRecent(recent)
if (!aggregated.length) return
if (showIntro.value) pendingGiftReplay.value = aggregated
if (shouldDeferEntranceFx()) pendingGiftReplay.value = aggregated
else if (showGiftFx.value) nextTick(() => giftEffectRef.value?.playSequence(aggregated, { max: 12 }))
} catch { /* ignore */ }
}
@@ -2059,6 +2129,8 @@ const handleLike = async (count = 1) => {
onMounted(async () => {
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
window.scrollTo(0, 0)
const bootStartedAt = Date.now()
const BOOT_MIN_MS = 1100
try {
const res = await getConfig()
@@ -2106,7 +2178,18 @@ onMounted(async () => {
loadGiftStatus()
sendVisitBeacon()
// Loading 期间预热封面,避免揭幕后闪空图
await preloadImage(data.value.heroImg)
const remain = BOOT_MIN_MS - (Date.now() - bootStartedAt)
if (remain > 0) await new Promise((r) => setTimeout(r, remain))
// 先挂上揭幕帘,再淡出 Loading避免中间一帧露出正文
if (data.value.introEnabled) showIntro.value = true
await nextTick()
await finishBootLoading()
// 未开启揭幕时Loading 结束后再播积压特效
if (!data.value.introEnabled) flushPendingEntranceFx()
const pickedUrl = pickMusicUrl(
data.value.musicList,
@@ -3385,6 +3468,264 @@ watch(
width: 20px; height: 1px; background: rgba(168,140,107,0.45);
}
/* ---------- 揭幕前 Loading象牙金主题 ---------- */
.boot-loading {
--boot-gold: #a88c6b;
--boot-gold-soft: #b89a78;
--boot-ink: #5b4a36;
--boot-cream: #fbf6ef;
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background:
radial-gradient(ellipse at 50% 42%, rgba(255, 252, 246, 0.96), transparent 55%),
radial-gradient(ellipse at 18% 78%, rgba(184, 154, 120, 0.14), transparent 42%),
radial-gradient(ellipse at 82% 18%, rgba(168, 140, 107, 0.12), transparent 40%),
linear-gradient(165deg, #fdfbf7 0%, #f7efe4 46%, #eddcc6 100%);
transition: opacity 0.48s ease, visibility 0.48s ease, transform 0.48s ease;
}
.boot-loading.is-leaving {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: scale(1.02);
}
.boot-decor {
position: absolute;
inset: 0;
pointer-events: none;
}
.boot-glow {
position: absolute;
border-radius: 50%;
filter: blur(28px);
opacity: 0.55;
animation: boot-glow-drift 5.5s ease-in-out infinite;
}
.boot-glow-1 {
width: 180px; height: 180px; left: 8%; top: 18%;
background: rgba(184, 154, 120, 0.28);
}
.boot-glow-2 {
width: 220px; height: 220px; right: 4%; bottom: 16%;
background: rgba(168, 140, 107, 0.22);
animation-delay: -2s;
}
.boot-glow-3 {
width: 140px; height: 140px; left: 42%; top: 8%;
background: rgba(255, 248, 238, 0.7);
animation-delay: -3.4s;
}
.boot-petal {
position: absolute;
color: var(--boot-gold);
opacity: 0.28;
font-size: 18px;
animation: boot-petal-float 4.8s ease-in-out infinite;
}
.boot-petal-1 { left: 14%; top: 28%; font-size: 22px; animation-delay: 0s; }
.boot-petal-2 { right: 16%; top: 34%; font-size: 16px; animation-delay: -1.2s; }
.boot-petal-3 { left: 20%; bottom: 22%; font-size: 14px; opacity: 0.22; animation-delay: -2.1s; }
.boot-petal-4 { right: 18%; bottom: 26%; font-size: 13px; opacity: 0.2; animation-delay: -3s; }
.boot-spark {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--boot-gold-soft);
box-shadow: 0 0 8px rgba(184, 154, 120, 0.55);
animation: boot-twinkle 2.4s ease-in-out infinite;
}
.boot-spark-1 { left: 22%; top: 18%; animation-delay: 0s; }
.boot-spark-2 { right: 24%; top: 22%; animation-delay: 0.4s; }
.boot-spark-3 { left: 28%; bottom: 18%; animation-delay: 0.9s; }
.boot-spark-4 { right: 26%; bottom: 20%; animation-delay: 1.3s; }
.boot-spark-5 { left: 50%; top: 14%; margin-left: -2px; animation-delay: 1.7s; }
.boot-frame {
position: absolute;
width: 52px;
height: 52px;
border-color: rgba(168, 140, 107, 0.38);
border-style: solid;
border-width: 0;
opacity: 0;
animation: boot-frame-in 0.7s ease 0.15s forwards;
}
.boot-frame-tl { top: 22px; left: 22px; border-top-width: 1px; border-left-width: 1px; }
.boot-frame-tr { top: 22px; right: 22px; border-top-width: 1px; border-right-width: 1px; }
.boot-frame-bl { bottom: 22px; left: 22px; border-bottom-width: 1px; border-left-width: 1px; }
.boot-frame-br { bottom: 22px; right: 22px; border-bottom-width: 1px; border-right-width: 1px; }
.boot-stage {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 28px;
text-align: center;
animation: boot-stage-in 0.75s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.boot-kicker {
display: block;
font-size: 11px;
letter-spacing: 0.48em;
padding-left: 0.48em;
color: var(--boot-gold);
font-weight: 700;
margin-bottom: 18px;
opacity: 0.92;
}
.boot-rule {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
color: var(--boot-gold-soft);
font-size: 10px;
opacity: 0.75;
}
.boot-rule span {
display: block;
width: 42px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(168, 140, 107, 0.55));
}
.boot-rule span:last-child {
background: linear-gradient(90deg, rgba(168, 140, 107, 0.55), transparent);
}
.boot-rule-flip { margin-top: 22px; margin-bottom: 0; }
.boot-seal-wrap {
position: relative;
width: 108px;
height: 108px;
display: flex;
align-items: center;
justify-content: center;
}
.boot-seal-ring {
position: absolute;
border-radius: 50%;
border-style: solid;
border-color: transparent;
box-sizing: border-box;
}
.boot-seal-ring-a {
inset: -10px;
border-width: 1.5px;
border-top-color: rgba(168, 140, 107, 0.72);
border-right-color: rgba(184, 154, 120, 0.18);
animation: boot-ring-spin 1.8s linear infinite;
}
.boot-seal-ring-b {
inset: -2px;
border-width: 1px;
border-bottom-color: rgba(184, 154, 120, 0.55);
border-left-color: rgba(168, 140, 107, 0.12);
animation: boot-ring-spin-rev 2.6s linear infinite;
}
.boot-seal {
width: 88px;
height: 88px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
font-weight: 700;
line-height: 1;
color: var(--boot-gold);
background:
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.65), transparent 46%),
linear-gradient(160deg, #fffaf3, #f3e6d6);
border: 1.5px solid rgba(168, 140, 107, 0.55);
box-shadow:
inset 0 0 0 5px rgba(168, 140, 107, 0.08),
0 12px 32px rgba(120, 90, 55, 0.14);
animation:
boot-seal-in 0.85s cubic-bezier(0.22, 1, 0.36, 1) both,
boot-seal-breathe 2.2s ease-in-out 0.85s infinite;
}
.boot-names {
margin: 20px 0 0;
font-size: 18px;
letter-spacing: 0.18em;
color: var(--boot-ink);
font-weight: 700;
}
.boot-names span {
display: inline-block;
margin: 0 8px;
font-style: italic;
color: var(--boot-gold-soft);
font-weight: 600;
}
.boot-hint {
margin: 12px 0 0;
font-size: 12px;
letter-spacing: 0.38em;
padding-left: 0.38em;
color: rgba(128, 106, 77, 0.78);
}
.boot-dots {
display: flex;
gap: 7px;
margin-top: 18px;
}
.boot-dots i {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--boot-gold);
opacity: 0.28;
animation: boot-dot-pulse 1.15s ease-in-out infinite;
}
.boot-dots i:nth-child(2) { animation-delay: 0.18s; }
.boot-dots i:nth-child(3) { animation-delay: 0.36s; }
@keyframes boot-stage-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@keyframes boot-frame-in {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: none; }
}
@keyframes boot-seal-in {
0% { opacity: 0; transform: scale(0.55) rotate(-8deg); }
65% { opacity: 1; transform: scale(1.06) rotate(1.5deg); }
100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes boot-seal-breathe {
0%, 100% { transform: scale(1); box-shadow: inset 0 0 0 5px rgba(168, 140, 107, 0.08), 0 12px 32px rgba(120, 90, 55, 0.14); }
50% { transform: scale(1.035); box-shadow: inset 0 0 0 5px rgba(168, 140, 107, 0.12), 0 14px 36px rgba(120, 90, 55, 0.18); }
}
@keyframes boot-ring-spin {
to { transform: rotate(360deg); }
}
@keyframes boot-ring-spin-rev {
to { transform: rotate(-360deg); }
}
@keyframes boot-glow-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(10px, -12px, 0) scale(1.08); }
}
@keyframes boot-petal-float {
0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.22; }
50% { transform: translateY(-10px) rotate(8deg); opacity: 0.4; }
}
@keyframes boot-twinkle {
0%, 100% { opacity: 0.15; transform: scale(0.7); }
50% { opacity: 0.9; transform: scale(1.25); }
}
@keyframes boot-dot-pulse {
0%, 100% { opacity: 0.22; transform: translateY(0); }
50% { opacity: 0.95; transform: translateY(-3px); }
}
/* ---------- 入场揭幕(雅致象牙金) ---------- */
.curtain-root { position: fixed; inset: 0; z-index: 100; overflow: hidden; cursor: pointer; font-family: var(--font-sans); background: #fbf6ef; perspective: 1200px; }
.curtain-bg {