1. 礼物特效
This commit is contained in:
Binary file not shown.
@@ -35,6 +35,7 @@ func migrateSchema(db *gorm.DB) {
|
||||
{&models.Danmaku{}, "祝福弹幕", "danmakus"},
|
||||
{&models.SiteLike{}, "站点点赞", "site_likes"},
|
||||
{&models.SiteGift{}, "站点礼物", "site_gifts"},
|
||||
{&models.GiftConfig{}, "礼物兑换配置", "gift_configs"},
|
||||
{&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
|
||||
{&visit.SiteVisit{}, "访客记录", "site_visits"},
|
||||
}
|
||||
|
||||
@@ -71,11 +71,21 @@ type SiteGift struct {
|
||||
GiftType string `gorm:"column:gift_type;size:32;not null;index;comment:礼物类型" json:"gift_type"`
|
||||
Name string `gorm:"column:name;size:50;not null;default:'';comment:送礼人姓名" json:"name"`
|
||||
ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"`
|
||||
Cost int64 `gorm:"column:cost;not null;default:0;comment:兑换消耗的点赞数" json:"cost"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;comment:送礼时间" json:"created_at"`
|
||||
}
|
||||
|
||||
func (SiteGift) TableName() string { return "site_gifts" }
|
||||
|
||||
// GiftConfig 礼物兑换配置(各礼物所需点赞数)
|
||||
type GiftConfig struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
CostsData string `gorm:"type:json;column:costs_data;not null;comment:礼物代价JSON map" json:"costs_data"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GiftConfig) TableName() string { return "gift_configs" }
|
||||
|
||||
// AiModerationCache 缓存 AI 审核通过/失败结果
|
||||
type AiModerationCache struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
|
||||
@@ -86,39 +86,96 @@ func loadGiftCounts() map[string]int64 {
|
||||
return counts
|
||||
}
|
||||
|
||||
// defaultGiftCosts 默认三阶梯:50 / 100 / 200
|
||||
var defaultGiftCosts = map[string]int64{
|
||||
"520": 50,
|
||||
"forever": 50,
|
||||
"bald": 50,
|
||||
"concentric": 50,
|
||||
"1314": 100,
|
||||
"heaven": 100,
|
||||
"moisten": 100,
|
||||
"match": 200,
|
||||
}
|
||||
|
||||
const (
|
||||
giftBaseAllow int64 = 1
|
||||
giftLikesPerExtra int64 = 50
|
||||
giftCostMin int64 = 1
|
||||
giftCostMax int64 = 10000
|
||||
likeBatchMax = 200
|
||||
)
|
||||
|
||||
// giftQuota 每个 client 基础 1 次赠送,每点赞 50 次多 1 次
|
||||
func giftQuota(clientID string) (myLikes, used, allow, remain, likesToNext int64) {
|
||||
func defaultGiftCostsCopy() map[string]int64 {
|
||||
out := make(map[string]int64, len(defaultGiftCosts))
|
||||
for k, v := range defaultGiftCosts {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadGiftCosts() map[string]int64 {
|
||||
costs := defaultGiftCostsCopy()
|
||||
var cfg models.GiftConfig
|
||||
if err := db.First(&cfg, 1).Error; err != nil || strings.TrimSpace(cfg.CostsData) == "" {
|
||||
return costs
|
||||
}
|
||||
parsed := map[string]int64{}
|
||||
if err := json.Unmarshal([]byte(cfg.CostsData), &parsed); err != nil {
|
||||
return costs
|
||||
}
|
||||
for k := range giftTypeWhitelist {
|
||||
if v, ok := parsed[k]; ok && v >= giftCostMin && v <= giftCostMax {
|
||||
costs[k] = v
|
||||
}
|
||||
}
|
||||
return costs
|
||||
}
|
||||
|
||||
func saveGiftCosts(costs map[string]int64) error {
|
||||
normalized := defaultGiftCostsCopy()
|
||||
for k := range giftTypeWhitelist {
|
||||
if v, ok := costs[k]; ok && v >= giftCostMin && v <= giftCostMax {
|
||||
normalized[k] = v
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg models.GiftConfig
|
||||
if err := db.First(&cfg, 1).Error; err != nil {
|
||||
cfg = models.GiftConfig{ID: 1, CostsData: string(raw)}
|
||||
return db.Create(&cfg).Error
|
||||
}
|
||||
return db.Model(&cfg).Update("costs_data", string(raw)).Error
|
||||
}
|
||||
|
||||
// giftBalance 点赞余额:可用 = 我的点赞 - 已兑换消耗
|
||||
func giftBalance(clientID string) (myLikes, spent, available int64) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID != "" {
|
||||
db.Model(&models.SiteLike{}).Where("client_id = ?", clientID).Count(&myLikes)
|
||||
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Count(&used)
|
||||
if clientID == "" {
|
||||
return 0, 0, 0
|
||||
}
|
||||
allow = giftBaseAllow + myLikes/giftLikesPerExtra
|
||||
remain = allow - used
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
likesToNext = giftLikesPerExtra - (myLikes % giftLikesPerExtra)
|
||||
if likesToNext <= 0 {
|
||||
likesToNext = giftLikesPerExtra
|
||||
db.Model(&models.SiteLike{}).Where("client_id = ?", clientID).Count(&myLikes)
|
||||
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Select("COALESCE(SUM(cost), 0)").Scan(&spent)
|
||||
available = myLikes - spent
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func giftQuotaPayload(clientID string) gin.H {
|
||||
myLikes, used, allow, remain, likesToNext := giftQuota(clientID)
|
||||
myLikes, spent, available := giftBalance(clientID)
|
||||
var giftUsed int64
|
||||
if strings.TrimSpace(clientID) != "" {
|
||||
db.Model(&models.SiteGift{}).Where("client_id = ?", clientID).Count(&giftUsed)
|
||||
}
|
||||
return gin.H{
|
||||
"my_likes": myLikes,
|
||||
"gift_used": used,
|
||||
"gift_allow": allow,
|
||||
"gift_remain": remain,
|
||||
"likes_to_next": likesToNext,
|
||||
"likes_per_gift": giftLikesPerExtra,
|
||||
"my_likes": myLikes,
|
||||
"spent": spent,
|
||||
"available": available,
|
||||
"gift_used": giftUsed,
|
||||
"costs": loadGiftCosts(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +213,8 @@ func registerAPI(api *gin.RouterGroup) {
|
||||
api.GET("/gift", handleGiftStatus)
|
||||
api.POST("/gift", handleCreateGift)
|
||||
api.GET("/gift/stats", handleGiftStats)
|
||||
api.GET("/gift/costs", handleGetGiftCosts)
|
||||
api.POST("/gift/costs", handleSaveGiftCosts)
|
||||
|
||||
visit.Register(api, db, utils.RequireAdmin)
|
||||
configsection.Register(api, db, utils.RequireAdmin)
|
||||
@@ -457,6 +516,7 @@ func handleLikeCount(c *gin.Context) {
|
||||
func handleLike(c *gin.Context) {
|
||||
var body struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
@@ -467,13 +527,34 @@ func handleLike(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
||||
return
|
||||
}
|
||||
if err := db.Create(&models.SiteLike{ClientID: clientID}).Error; err != nil {
|
||||
n := body.Count
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
if n > likeBatchMax {
|
||||
c.JSON(400, gin.H{"error": fmt.Sprintf("单次最多点赞 %d 次", likeBatchMax)})
|
||||
return
|
||||
}
|
||||
rows := make([]models.SiteLike, n)
|
||||
now := time.Now()
|
||||
for i := 0; i < n; i++ {
|
||||
rows[i] = models.SiteLike{ClientID: clientID, CreatedAt: now}
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "点赞失败"})
|
||||
return
|
||||
}
|
||||
var count int64
|
||||
db.Model(&models.SiteLike{}).Count(&count)
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"})
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"count": count,
|
||||
"added": n,
|
||||
"quota": giftQuotaPayload(clientID),
|
||||
},
|
||||
"msg": "点赞成功",
|
||||
})
|
||||
}
|
||||
|
||||
func handleGiftStatus(c *gin.Context) {
|
||||
@@ -541,10 +622,16 @@ func handleCreateGift(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
||||
return
|
||||
}
|
||||
_, _, _, remain, likesToNext := giftQuota(clientID)
|
||||
if remain <= 0 {
|
||||
costs := loadGiftCosts()
|
||||
cost := costs[giftType]
|
||||
if cost < giftCostMin {
|
||||
cost = defaultGiftCosts[giftType]
|
||||
}
|
||||
_, _, available := giftBalance(clientID)
|
||||
if available < cost {
|
||||
need := cost - available
|
||||
c.JSON(400, gin.H{
|
||||
"error": fmt.Sprintf("赠送次数已用完,再点赞 %d 次可多送 1 次", likesToNext),
|
||||
"error": fmt.Sprintf("点赞不足,再点赞 %d 次可兑换「%s」", need, giftTypeWhitelist[giftType]),
|
||||
"data": giftQuotaPayload(clientID),
|
||||
})
|
||||
return
|
||||
@@ -553,6 +640,7 @@ func handleCreateGift(c *gin.Context) {
|
||||
GiftType: giftType,
|
||||
Name: name,
|
||||
ClientID: clientID,
|
||||
Cost: cost,
|
||||
}).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "送礼失败"})
|
||||
return
|
||||
@@ -568,6 +656,7 @@ func handleCreateGift(c *gin.Context) {
|
||||
"counts": counts,
|
||||
"total": total,
|
||||
"gift_type": giftType,
|
||||
"cost": cost,
|
||||
"quota": giftQuotaPayload(clientID),
|
||||
},
|
||||
"msg": "送礼成功",
|
||||
@@ -593,10 +682,60 @@ func handleGiftStats(c *gin.Context) {
|
||||
"counts": counts,
|
||||
"labels": labels,
|
||||
"total": total,
|
||||
"costs": loadGiftCosts(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetGiftCosts(c *gin.Context) {
|
||||
if !utils.RequireAdmin(c) {
|
||||
return
|
||||
}
|
||||
labels := make(map[string]string, len(giftTypeWhitelist))
|
||||
for k, v := range giftTypeWhitelist {
|
||||
labels[k] = v
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"costs": loadGiftCosts(),
|
||||
"labels": labels,
|
||||
"defaults": defaultGiftCostsCopy(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func handleSaveGiftCosts(c *gin.Context) {
|
||||
if !utils.RequireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Costs map[string]int64 `json:"costs"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Costs == nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
for k, v := range body.Costs {
|
||||
if _, ok := giftTypeWhitelist[k]; !ok {
|
||||
continue
|
||||
}
|
||||
if v < giftCostMin || v > giftCostMax {
|
||||
c.JSON(400, gin.H{"error": fmt.Sprintf("「%s」兑换点赞需在 %d–%d 之间", giftTypeWhitelist[k], giftCostMin, giftCostMax)})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := saveGiftCosts(body.Costs); err != nil {
|
||||
c.JSON(500, gin.H{"error": "保存失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{"costs": loadGiftCosts()},
|
||||
"msg": "礼物兑换配置已保存",
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeDanmakuColor(color string) string {
|
||||
c := strings.TrimSpace(color)
|
||||
if c == "rose" || c == "sage" {
|
||||
|
||||
@@ -134,8 +134,8 @@ export function getLikeStatus(clientId) {
|
||||
return service.get('/like', { params: { client_id: clientId } })
|
||||
}
|
||||
|
||||
export function submitLike(clientId) {
|
||||
return service.post('/like', { client_id: clientId })
|
||||
export function submitLike(clientId, count = 1) {
|
||||
return service.post('/like', { client_id: clientId, count })
|
||||
}
|
||||
|
||||
export function getGiftStatus(clientId) {
|
||||
@@ -154,6 +154,14 @@ export function getGiftStats() {
|
||||
return service.get('/gift/stats')
|
||||
}
|
||||
|
||||
export function getGiftCosts() {
|
||||
return service.get('/gift/costs')
|
||||
}
|
||||
|
||||
export function saveGiftCosts(costs) {
|
||||
return service.post('/gift/costs', { costs })
|
||||
}
|
||||
|
||||
export function adminLogin(credentials) {
|
||||
return service.post('/admin/login', credentials)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div v-if="enabled && (items.length || rows.length)" class="danmaku-layer" aria-hidden="true">
|
||||
<!-- 始终挂载,用 v-show 隐藏,避免关闭时 v-if 拆树与清空列表打架 -->
|
||||
<div v-show="enabled" class="danmaku-layer" aria-hidden="true">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
@@ -142,7 +143,6 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
const key = itemKey(item)
|
||||
if (!force && isCooling(key)) return null
|
||||
|
||||
// 速度区间收窄,降低同屏追尾概率;轨道占用保证同轨不叠
|
||||
const duration = 12 + Math.random() * 4
|
||||
const delay = force ? 0 : Math.random() * 0.25
|
||||
const slot = reserveTrack(duration, delay, { force })
|
||||
@@ -154,7 +154,6 @@ const buildRow = (item, { force = false } = {}) => {
|
||||
const tint = resolveDanmakuTint(item.color)
|
||||
const id = ++uid
|
||||
const totalDelay = delay + slot.extraDelay
|
||||
// 5 轨均匀分布在上半屏,轨间距约 14%
|
||||
const topPct = 7 + slot.track * 14
|
||||
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
|
||||
|
||||
@@ -191,11 +190,7 @@ const mergeItems = (incoming) => {
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!props.enabled) {
|
||||
items.value = []
|
||||
shownIds.clear()
|
||||
return
|
||||
}
|
||||
if (!props.enabled) return
|
||||
try {
|
||||
const done = isBatchDone()
|
||||
const maxId = maxItemId()
|
||||
@@ -229,9 +224,7 @@ const spawnOne = () => {
|
||||
const pushItem = (item) => {
|
||||
if (!item || !props.enabled) return
|
||||
const id = item.id
|
||||
if (id != null && items.value.some((x) => x.id === id)) {
|
||||
// already in list — still force-spawn for sender
|
||||
} else {
|
||||
if (id == null || !items.value.some((x) => x.id === id)) {
|
||||
items.value = [...items.value, item]
|
||||
}
|
||||
if (!props.active) return
|
||||
@@ -240,6 +233,7 @@ const pushItem = (item) => {
|
||||
}
|
||||
|
||||
const onEnded = (id) => {
|
||||
if (!rows.value.length) return
|
||||
rows.value = rows.value.filter((r) => r.id !== id)
|
||||
}
|
||||
|
||||
@@ -270,52 +264,70 @@ const stopPoll = () => {
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.active, items.value.length],
|
||||
() => {
|
||||
if (props.enabled && props.active && items.value.length) startSpawn()
|
||||
else {
|
||||
stopSpawn()
|
||||
if (!props.enabled) {
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
resetTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
const clearFlying = () => {
|
||||
rows.value = []
|
||||
resetTracks()
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
clearFlying()
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
cursor = 0
|
||||
}
|
||||
|
||||
const syncSpawn = () => {
|
||||
if (props.enabled && props.active && items.value.length) startSpawn()
|
||||
else stopSpawn()
|
||||
}
|
||||
|
||||
// 只监听开关本身,禁止在依赖 items.length 的 watcher 里清空 items(会递归更新)
|
||||
watch(
|
||||
() => props.enabled,
|
||||
async (on) => {
|
||||
if (on) {
|
||||
await fetchList()
|
||||
startPoll()
|
||||
if (props.active && items.value.length) startSpawn()
|
||||
syncSpawn()
|
||||
} else {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
shownIds.clear()
|
||||
resetTracks()
|
||||
clearAll()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
() => {
|
||||
if (!props.enabled) return
|
||||
if (props.active) syncSpawn()
|
||||
else {
|
||||
stopSpawn()
|
||||
clearFlying()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// items 变化时只启停 spawn,绝不在这里清空 items
|
||||
watch(
|
||||
() => items.value.length,
|
||||
() => {
|
||||
if (!props.enabled) return
|
||||
syncSpawn()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.enabled) return
|
||||
await fetchList()
|
||||
startPoll()
|
||||
if (props.active && items.value.length) startSpawn()
|
||||
syncSpawn()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
clearAll()
|
||||
})
|
||||
|
||||
defineExpose({ refresh: fetchList, pushItem })
|
||||
|
||||
@@ -206,12 +206,15 @@ function resizeCanvas() {
|
||||
H = cssH
|
||||
canvas.style.width = `${cssW}px`
|
||||
canvas.style.height = `${cssH}px`
|
||||
canvas.style.writingMode = 'horizontal-tb'
|
||||
canvas.style.direction = 'ltr'
|
||||
canvas.width = Math.max(1, Math.floor(W * dpr))
|
||||
canvas.height = Math.max(1, Math.floor(H * dpr))
|
||||
ctx = canvas.getContext('2d', { alpha: true })
|
||||
if (!ctx) return
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
if (typeof ctx.direction === 'string') ctx.direction = 'ltr'
|
||||
}
|
||||
|
||||
function drawHeartPath(context, cx, cy, size) {
|
||||
@@ -277,28 +280,80 @@ function scrolledCount(mt, now) {
|
||||
return Math.max(from, Math.round(from + (target - from) * e))
|
||||
}
|
||||
|
||||
const FONT_STACK = '"Ma Shan Zheng", "PingFang SC", "Hiragino Sans GB", sans-serif'
|
||||
|
||||
function setMilestoneFont(context, fontSize) {
|
||||
// Ma Shan Zheng 只有 400;用 700 时 iOS 会假粗并打乱字距/排版
|
||||
context.font = `400 ${fontSize}px ${FONT_STACK}`
|
||||
if (typeof context.direction === 'string') context.direction = 'ltr'
|
||||
if (typeof context.letterSpacing === 'string') context.letterSpacing = '0px'
|
||||
}
|
||||
|
||||
/** 保证艺术字单行:按字宽横排,过宽则缩小字号 */
|
||||
function fitMilestoneFontSize(context, text, desired, maxWidth) {
|
||||
let size = Math.max(12, desired)
|
||||
setMilestoneFont(context, size)
|
||||
let w = context.measureText(text).width
|
||||
while (size > 14 && w > maxWidth) {
|
||||
size -= 2
|
||||
setMilestoneFont(context, size)
|
||||
w = context.measureText(text).width
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
function drawMilestoneLine(context, text, fontSize, glowBlur, gradient, strokeColor, glowColor, y) {
|
||||
const blur = Math.max(0, glowBlur * SHADOW_MUL)
|
||||
context.shadowColor = glowColor
|
||||
context.shadowBlur = blur
|
||||
context.strokeStyle = strokeColor
|
||||
context.lineWidth = fontSize * 0.065
|
||||
context.lineJoin = 'round'
|
||||
context.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif`
|
||||
const raw = String(text || '').replace(/[\r\n\u2028\u2029]/g, '')
|
||||
if (!raw) return
|
||||
// iOS canvas shadowBlur 在中文笔画下会拖出脏光晕,艺术字直接关掉阴影
|
||||
const blur = isIOS ? 0 : Math.max(0, glowBlur * SHADOW_MUL)
|
||||
const maxW = Math.max(40, W * 0.88)
|
||||
const size = fitMilestoneFontSize(context, raw, fontSize, maxW)
|
||||
setMilestoneFont(context, size)
|
||||
context.textAlign = 'center'
|
||||
context.textBaseline = 'middle'
|
||||
context.strokeText(text, 0, y)
|
||||
const textGrad = context.createLinearGradient(0, y - fontSize * 0.55, 0, y + fontSize * 0.55)
|
||||
context.lineJoin = 'round'
|
||||
context.miterLimit = 2
|
||||
context.lineWidth = size * (isIOS ? 0.08 : 0.065)
|
||||
context.shadowColor = 'transparent'
|
||||
context.shadowBlur = 0
|
||||
context.shadowOffsetX = 0
|
||||
context.shadowOffsetY = 0
|
||||
context.strokeStyle = strokeColor
|
||||
|
||||
const chars = Array.from(raw)
|
||||
// 逐字测宽后强制横排,避免 iOS 把四字折成 2×2
|
||||
const widths = chars.map((ch) => context.measureText(ch).width)
|
||||
const total = widths.reduce((a, b) => a + b, 0)
|
||||
const textGrad = context.createLinearGradient(0, y - size * 0.55, 0, y + size * 0.55)
|
||||
textGrad.addColorStop(0, gradient[0])
|
||||
textGrad.addColorStop(0.35, gradient[1])
|
||||
textGrad.addColorStop(0.7, gradient[2])
|
||||
textGrad.addColorStop(1, gradient[3])
|
||||
context.fillStyle = textGrad
|
||||
context.shadowBlur = blur * 1.5
|
||||
context.fillText(text, 0, y)
|
||||
|
||||
let x = -total / 2
|
||||
for (let i = 0; i < chars.length; i += 1) {
|
||||
const ch = chars[i]
|
||||
const cw = widths[i]
|
||||
const cx = x + cw / 2
|
||||
if (blur > 0) {
|
||||
context.shadowColor = glowColor
|
||||
context.shadowBlur = blur
|
||||
}
|
||||
context.strokeText(ch, cx, y)
|
||||
context.fillStyle = textGrad
|
||||
if (blur > 0) context.shadowBlur = blur * 1.5
|
||||
context.fillText(ch, cx, y)
|
||||
context.shadowColor = 'transparent'
|
||||
context.shadowBlur = 0
|
||||
context.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
context.fillText(ch, cx - size * 0.018, y - size * 0.028)
|
||||
x += cw
|
||||
}
|
||||
context.shadowColor = 'transparent'
|
||||
context.shadowBlur = 0
|
||||
context.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
context.fillText(text, -fontSize * 0.018, y - fontSize * 0.028)
|
||||
context.shadowOffsetX = 0
|
||||
context.shadowOffsetY = 0
|
||||
}
|
||||
|
||||
function textProgress(t, now) {
|
||||
@@ -984,7 +1039,21 @@ onUnmounted(() => {
|
||||
rings = []
|
||||
})
|
||||
|
||||
defineExpose({ play, playSequence })
|
||||
function clearAll() {
|
||||
clearTimeout(queueTimer)
|
||||
queueTimer = null
|
||||
playQueue = []
|
||||
activeMeta = null
|
||||
clearVisuals()
|
||||
running = false
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = 0
|
||||
}
|
||||
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
defineExpose({ play, playSequence, clear: clearAll, stop: clearAll })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -997,6 +1066,11 @@ defineExpose({ play, playSequence })
|
||||
height: 100dvh;
|
||||
z-index: 46;
|
||||
pointer-events: none;
|
||||
/* 防止 iOS 继承竖排书写模式导致 canvas 文字折成两行/方阵 */
|
||||
writing-mode: horizontal-tb;
|
||||
text-orientation: mixed;
|
||||
direction: ltr;
|
||||
white-space: nowrap;
|
||||
/* iOS:独立合成层,避免和左侧点赞 canvas / transform 滚动打架 */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="gift-feed" aria-live="polite">
|
||||
<TransitionGroup name="gift-feed-item" tag="div" class="gift-feed-list">
|
||||
<TransitionGroup
|
||||
name="gift-feed-item"
|
||||
tag="div"
|
||||
class="gift-feed-list"
|
||||
:css="useTransition"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
@@ -21,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { ref, nextTick, onUnmounted } from 'vue'
|
||||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
|
||||
const MAX_ITEMS = 5
|
||||
@@ -30,6 +35,7 @@ const ROLL_MS = 900
|
||||
const items = ref([])
|
||||
const timers = new Map()
|
||||
const rollTimers = new Map()
|
||||
const useTransition = ref(true)
|
||||
let seq = 0
|
||||
|
||||
const baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
|
||||
@@ -146,14 +152,28 @@ const bumpOrPush = (name, giftType) => {
|
||||
return push(n, type, 1)
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
timers.forEach((t) => clearTimeout(t))
|
||||
timers.clear()
|
||||
rollTimers.forEach((r) => cancelAnimationFrame(r))
|
||||
rollTimers.clear()
|
||||
// 一次性清空时关掉 leave 过渡,避免 TransitionGroup 与 vnode patch 冲突
|
||||
useTransition.value = false
|
||||
items.value = []
|
||||
nextTick(() => {
|
||||
useTransition.value = true
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
timers.forEach((t) => clearTimeout(t))
|
||||
timers.clear()
|
||||
rollTimers.forEach((r) => cancelAnimationFrame(r))
|
||||
rollTimers.clear()
|
||||
items.value = []
|
||||
})
|
||||
|
||||
defineExpose({ push, revealCount, bumpOrPush })
|
||||
defineExpose({ push, revealCount, bumpOrPush, clear })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -316,6 +316,8 @@ function fireOneLikeEffect() {
|
||||
return true
|
||||
}
|
||||
|
||||
let burstGapMs = LIKE_GAP_MS
|
||||
|
||||
const scheduleNextLike = () => {
|
||||
clearLikeTimer()
|
||||
if (firedLikes >= targetLikes) return
|
||||
@@ -328,19 +330,37 @@ const scheduleNextLike = () => {
|
||||
}
|
||||
firedLikes += 1
|
||||
if (firedLikes < targetLikes) scheduleNextLike()
|
||||
}, LIKE_GAP_MS)
|
||||
}, burstGapMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
|
||||
* @param {number} count
|
||||
* @param {{ durationMs?: number, maxVisual?: number }} [opts]
|
||||
* durationMs: 把视觉节奏压缩到约几秒内播完(多次点赞用)
|
||||
* maxVisual: 最多触发几次飘心节奏
|
||||
*/
|
||||
const burst = (count = 3) => {
|
||||
const burst = (count = 3, opts = {}) => {
|
||||
clearLikeTimer()
|
||||
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
|
||||
if (!n) return
|
||||
const raw = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
|
||||
if (!raw) return
|
||||
if (!W || !H) resizeCanvas()
|
||||
targetLikes = n
|
||||
|
||||
const maxVisual = Math.max(1, Math.min(
|
||||
Number(opts.maxVisual) || raw,
|
||||
raw,
|
||||
24,
|
||||
))
|
||||
const durationMs = Number(opts.durationMs)
|
||||
const useShort = Number.isFinite(durationMs) && durationMs > 0 && raw > 1
|
||||
const visual = useShort ? Math.min(maxVisual, Math.max(5, Math.ceil(Math.min(raw, 12)))) : Math.min(raw, maxVisual)
|
||||
|
||||
targetLikes = visual
|
||||
firedLikes = 0
|
||||
burstGapMs = useShort
|
||||
? Math.max(90, Math.floor(durationMs / Math.max(1, visual)))
|
||||
: LIKE_GAP_MS
|
||||
|
||||
if (fireOneLikeEffect()) firedLikes = 1
|
||||
if (firedLikes < targetLikes) scheduleNextLike()
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
/** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */
|
||||
/** 礼物类型常量表(与后端 giftTypeWhitelist / defaultGiftCosts 一致) */
|
||||
export const GIFT_TYPES = [
|
||||
{ key: '520', label: '520', preview: 'linear-gradient(135deg, #ff8da6 0%, #ff2d55 55%, #c41e3a 100%)' },
|
||||
{ key: '1314', label: '1314', preview: 'linear-gradient(135deg, #fff3b0 0%, #ffd700 50%, #e6a000 100%)' },
|
||||
{ key: 'forever', label: '长长久久', preview: 'linear-gradient(135deg, #ffb3c1 0%, #c41e3a 55%, #6b0f1a 100%)' },
|
||||
{ key: 'bald', label: '白头偕老', preview: 'linear-gradient(135deg, #ffffff 0%, #e8e4d9 45%, #b8a878 100%)' },
|
||||
{ key: 'concentric', label: '永结同心', preview: 'linear-gradient(135deg, #f5dcc4 0%, #d4a574 50%, #b76e79 100%)' },
|
||||
{ key: 'heaven', label: '天作之合', preview: 'linear-gradient(135deg, #cce4ff 0%, #5ba3ff 50%, #1a5bb5 100%)' },
|
||||
{ key: 'moisten', label: '相濡以沫', preview: 'linear-gradient(135deg, #c5f5ef 0%, #40b4a6 50%, #165e5a 100%)' },
|
||||
{ key: 'match', label: '佳偶天成', preview: 'linear-gradient(135deg, #ffd4a8 0%, #e85d75 50%, #b5475a 100%)' },
|
||||
{ key: '520', label: '520', cost: 50, preview: 'linear-gradient(135deg, #ff8da6 0%, #ff2d55 55%, #c41e3a 100%)' },
|
||||
{ key: '1314', label: '1314', cost: 100, preview: 'linear-gradient(135deg, #fff3b0 0%, #ffd700 50%, #e6a000 100%)' },
|
||||
{ key: 'forever', label: '长长久久', cost: 50, preview: 'linear-gradient(135deg, #ffb3c1 0%, #c41e3a 55%, #6b0f1a 100%)' },
|
||||
{ key: 'bald', label: '白头偕老', cost: 50, preview: 'linear-gradient(135deg, #ffffff 0%, #e8e4d9 45%, #b8a878 100%)' },
|
||||
{ key: 'concentric', label: '永结同心', cost: 50, preview: 'linear-gradient(135deg, #f5dcc4 0%, #d4a574 50%, #b76e79 100%)' },
|
||||
{ key: 'heaven', label: '天作之合', cost: 100, preview: 'linear-gradient(135deg, #cce4ff 0%, #5ba3ff 50%, #1a5bb5 100%)' },
|
||||
{ key: 'moisten', label: '相濡以沫', cost: 100, preview: 'linear-gradient(135deg, #c5f5ef 0%, #40b4a6 50%, #165e5a 100%)' },
|
||||
{ key: 'match', label: '佳偶天成', cost: 200, preview: 'linear-gradient(135deg, #ffd4a8 0%, #e85d75 50%, #b5475a 100%)' },
|
||||
]
|
||||
|
||||
export const GIFT_LABEL_MAP = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.label]))
|
||||
|
||||
export const DEFAULT_GIFT_COSTS = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.cost]))
|
||||
|
||||
/** 快捷点赞气泡档位 */
|
||||
export const LIKE_QUICK_AMOUNTS = [10, 50, 100]
|
||||
|
||||
export function emptyGiftCounts() {
|
||||
return Object.fromEntries(GIFT_TYPES.map((g) => [g.key, 0]))
|
||||
}
|
||||
|
||||
export function emptyGiftCosts() {
|
||||
return { ...DEFAULT_GIFT_COSTS }
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续相同「姓名 + 礼物」合并为一条(带 count),用于串行特效与左下角展示
|
||||
* @param {Array<{ gift_type?: string, name?: string }>} recent
|
||||
@@ -36,3 +45,12 @@ export function aggregateGiftRecent(recent) {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 礼物分页:每页 4 个,便于横向翻页 */
|
||||
export function chunkGiftTypes(types = GIFT_TYPES, pageSize = 4) {
|
||||
const pages = []
|
||||
for (let i = 0; i < types.length; i += pageSize) {
|
||||
pages.push(types.slice(i, i + pageSize))
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
@@ -76,22 +76,6 @@
|
||||
</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">各礼物收到次数 · 合计 {{ giftStats.total }}</div>
|
||||
</div>
|
||||
<a-button size="small" :loading="giftStatsLoading" @click="loadGiftStats">
|
||||
<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-5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-5">
|
||||
<div class="chart-card">
|
||||
<div class="text-[12px] text-[#7a6550] mb-2 tracking-wide">地区分布</div>
|
||||
@@ -139,6 +123,54 @@
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 礼物 -->
|
||||
<a-tab-pane key="gifts" tab="礼物">
|
||||
<div v-if="activeTab === 'gifts'" 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" @click="loadGiftStats">
|
||||
<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>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 访客记录 -->
|
||||
<a-tab-pane key="visits" tab="访客记录">
|
||||
<div v-if="activeTab === 'visits'" class="admin-panel" :class="{ 'opacity-60 pointer-events-none': tabLoading }">
|
||||
@@ -983,13 +1015,13 @@ import { CanvasRenderer } from 'echarts/renderers'
|
||||
import {
|
||||
getConfigSection, saveConfigSection, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig,
|
||||
getDanmakuList, updateDanmakuStatus, getVisitStats, getVisitList,
|
||||
getPosterConfig, savePosterConfig, getGiftStats,
|
||||
getPosterConfig, savePosterConfig, getGiftStats, saveGiftCosts,
|
||||
} from '@/api/wedding'
|
||||
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 } from '@/utils/giftTypes'
|
||||
import { GIFT_TYPES, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
|
||||
import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
|
||||
import {
|
||||
defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
|
||||
@@ -1003,7 +1035,7 @@ const adminStore = useAdminStore()
|
||||
|
||||
const TAB_KEY = 'wedding_admin_active_tab'
|
||||
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
|
||||
const VALID_TABS = ['overview', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
|
||||
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'danmaku']
|
||||
|
||||
const SECTION_KEYS = {
|
||||
basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
|
||||
@@ -1044,11 +1076,42 @@ const danmakuFilter = ref('pending')
|
||||
const visitStatsLoading = ref(false)
|
||||
const visitListLoading = ref(false)
|
||||
const giftStatsLoading = ref(false)
|
||||
const giftCostsSaving = ref(false)
|
||||
const giftTypeList = GIFT_TYPES
|
||||
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([])
|
||||
@@ -1728,6 +1791,7 @@ async function loadGiftStats() {
|
||||
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 {
|
||||
@@ -1787,7 +1851,7 @@ async function loadVisitList() {
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
await Promise.all([loadVisitStats(), loadVisitPreview(), loadGiftStats()])
|
||||
await Promise.all([loadVisitStats(), loadVisitPreview()])
|
||||
}
|
||||
|
||||
function onVisitTableChange(pag) {
|
||||
@@ -1803,6 +1867,10 @@ async function onTabActivate(tab) {
|
||||
await loadOverview()
|
||||
return
|
||||
}
|
||||
if (tab === 'gifts') {
|
||||
await loadGiftStats()
|
||||
return
|
||||
}
|
||||
if (tab === 'visits') {
|
||||
await loadVisitList()
|
||||
return
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 右上角:自动滚动 / 弹幕 / 礼物特效 -->
|
||||
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-3">
|
||||
<div @click="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
|
||||
<div class="fixed top-6 right-5 z-[80] flex flex-col items-end gap-3 pointer-events-auto">
|
||||
<div @click.stop="toggleAutoScroll" class="control-btn cursor-pointer transition-all duration-300"
|
||||
:class="isAutoScroll ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30'"
|
||||
title="自动滚动">
|
||||
<img v-if="isAutoScroll" src="https://api.iconify.design/lucide:pause.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
@@ -66,19 +66,19 @@
|
||||
type="button"
|
||||
class="control-btn cursor-pointer transition-all duration-300"
|
||||
:class="showDanmaku ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30 opacity-55'"
|
||||
title="弹幕展示"
|
||||
@click="toggleShowDanmaku"
|
||||
:title="showDanmaku ? '关闭弹幕' : '开启弹幕'"
|
||||
@click.stop="toggleShowDanmaku"
|
||||
>
|
||||
<i class="fa-solid fa-comment-dots text-[13px]"></i>
|
||||
<i class="fa-solid fa-comment-dots text-[13px]" :class="showDanmaku ? 'text-[#a88c6b]' : 'text-[#a88c6b]/70'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="control-btn cursor-pointer transition-all duration-300"
|
||||
:class="showGiftFx ? 'bg-[#a88c6b]/15 !border-[#a88c6b]/60 shadow-sm' : 'bg-white/60 shadow-sm border-[#a88c6b]/30 opacity-55'"
|
||||
title="礼物特效"
|
||||
@click="toggleShowGiftFx"
|
||||
:title="showGiftFx ? '关闭礼物特效' : '开启礼物特效'"
|
||||
@click.stop="toggleShowGiftFx"
|
||||
>
|
||||
<i class="fa-solid fa-gift text-[13px]"></i>
|
||||
<i class="fa-solid fa-gift text-[13px]" :class="showGiftFx ? 'text-[#a88c6b]' : 'text-[#a88c6b]/70'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -95,13 +95,39 @@
|
||||
<!-- 行1:点赞 -->
|
||||
<div class="action-dock-row">
|
||||
<div class="like-hit-zone">
|
||||
<div
|
||||
v-if="showLikeBubbles"
|
||||
class="like-bubbles"
|
||||
@click.stop
|
||||
@touchstart.stop
|
||||
>
|
||||
<button
|
||||
v-for="(n, i) in likeQuickAmounts"
|
||||
:key="n"
|
||||
type="button"
|
||||
class="action-pill like-fab like-bubble-btn"
|
||||
:style="likeBubbleStyle(i)"
|
||||
:disabled="likeBusy"
|
||||
@click.stop="handleQuickLike(n)"
|
||||
>
|
||||
<i class="fa-solid fa-heart like-icon"></i>
|
||||
<span class="like-count">+{{ n }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="action-pill like-fab"
|
||||
:class="{ liked: likePulse, busy: likeBusy }"
|
||||
:disabled="likeBusy"
|
||||
title="点赞"
|
||||
@click="handleLike"
|
||||
title="点赞(长按快捷次数)"
|
||||
@click="handleLikeClick"
|
||||
@touchstart.passive="onLikePressStart"
|
||||
@touchend="onLikePressEnd"
|
||||
@touchcancel="onLikePressCancel"
|
||||
@mousedown="onLikePressStart"
|
||||
@mouseup="onLikePressEnd"
|
||||
@mouseleave="onLikePressCancel"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<i class="fa-heart like-icon" :class="likePulse ? 'fa-solid' : 'fa-regular'"></i>
|
||||
<span class="like-count">{{ likeCount }}</span>
|
||||
@@ -301,30 +327,48 @@
|
||||
<button type="button" class="p-1 text-[#c8c4bc]/80 text-sm leading-none" @click="closeGiftPanel">✕</button>
|
||||
</div>
|
||||
<div class="gift-quota mb-1.5">
|
||||
<span v-if="giftQuota.remain > 0">还可赠送 <b>{{ giftQuota.remain }}</b> 次</span>
|
||||
<span v-else>赠送次数已用完,再点赞 <b>{{ giftQuota.likes_to_next }}</b> 次可多送 1 次</span>
|
||||
<span class="gift-quota-sub">已送 {{ giftQuota.gift_used }} / {{ giftQuota.gift_allow }} · 点赞 {{ giftQuota.my_likes }}(每 {{ giftQuota.likes_per_gift }} 赞 +1 次)</span>
|
||||
<span>可用点赞 <b>{{ giftQuota.available }}</b></span>
|
||||
<span class="gift-quota-sub">总赞 {{ giftQuota.my_likes }} · 已兑 {{ giftQuota.spent }} · 已送 {{ giftQuota.gift_used }} 份</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="giftForm.name"
|
||||
placeholder="您的姓名"
|
||||
class="gift-name-input w-full rounded-lg px-3 py-2 text-[12px] mb-1.5 focus:outline-none"
|
||||
/>
|
||||
<div class="gift-grid">
|
||||
<button
|
||||
v-for="g in giftTypes"
|
||||
:key="g.key"
|
||||
type="button"
|
||||
class="gift-item"
|
||||
:disabled="giftBusy || giftQuota.remain <= 0"
|
||||
@click="handleSendGift(g.key)"
|
||||
<div
|
||||
ref="giftPagesRef"
|
||||
class="gift-pages"
|
||||
@scroll.passive="onGiftPagesScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(page, pi) in giftPages"
|
||||
:key="pi"
|
||||
class="gift-page"
|
||||
>
|
||||
<span
|
||||
class="gift-item-preview calligraphy-strong"
|
||||
:style="{ backgroundImage: g.preview }"
|
||||
>{{ g.label }}</span>
|
||||
<span class="gift-item-caption">{{ g.label }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="g in page"
|
||||
:key="g.key"
|
||||
type="button"
|
||||
class="gift-item"
|
||||
:disabled="giftBusy || giftQuota.available < giftCostOf(g.key)"
|
||||
@click="handleSendGift(g.key)"
|
||||
>
|
||||
<span
|
||||
class="gift-item-preview calligraphy-strong"
|
||||
:style="{ backgroundImage: g.preview }"
|
||||
>{{ g.label }}</span>
|
||||
<span class="gift-item-caption">{{ g.label }}</span>
|
||||
<span class="gift-item-cost">{{ giftCostOf(g.key) }} 赞</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gift-page-dots" aria-hidden="true">
|
||||
<span
|
||||
v-for="(_, pi) in giftPages"
|
||||
:key="pi"
|
||||
class="gift-page-dot"
|
||||
:class="{ active: giftPageIndex === pi }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -702,7 +746,14 @@ import { toast } from '@/composables/useToast'
|
||||
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||
import { getClientId } from '@/utils/clientId'
|
||||
import { getGuestName, setGuestName } from '@/utils/guestName'
|
||||
import { GIFT_TYPES, emptyGiftCounts, aggregateGiftRecent } from '@/utils/giftTypes'
|
||||
import {
|
||||
GIFT_TYPES,
|
||||
emptyGiftCounts,
|
||||
emptyGiftCosts,
|
||||
aggregateGiftRecent,
|
||||
chunkGiftTypes,
|
||||
LIKE_QUICK_AMOUNTS,
|
||||
} from '@/utils/giftTypes'
|
||||
import { sendVisitBeacon } from '@/utils/visitBeacon'
|
||||
import { optimizeImageUrl, isIOS } from '@/composables/useAndroidOptimize'
|
||||
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||||
@@ -826,30 +877,119 @@ const likeCount = ref(0)
|
||||
const likePulse = ref(false)
|
||||
const likeBusy = ref(false)
|
||||
let likePulseTimer = null
|
||||
const giftTypes = GIFT_TYPES
|
||||
const giftPages = chunkGiftTypes(GIFT_TYPES, 4)
|
||||
const giftPagesRef = ref(null)
|
||||
const giftPageIndex = ref(0)
|
||||
const giftCounts = reactive(emptyGiftCounts())
|
||||
const giftCosts = reactive(emptyGiftCosts())
|
||||
const giftTotal = ref(0)
|
||||
const giftBusy = ref(false)
|
||||
const showGiftPanel = ref(false)
|
||||
const giftForm = reactive({ name: '' })
|
||||
const giftQuota = reactive({
|
||||
my_likes: 0,
|
||||
spent: 0,
|
||||
available: 0,
|
||||
gift_used: 0,
|
||||
gift_allow: 1,
|
||||
gift_remain: 1,
|
||||
likes_to_next: 50,
|
||||
likes_per_gift: 50,
|
||||
})
|
||||
const pendingGiftReplay = ref([])
|
||||
const likeQuickAmounts = LIKE_QUICK_AMOUNTS
|
||||
const showLikeBubbles = ref(false)
|
||||
let likePressTimer = null
|
||||
let likePressFired = false
|
||||
let likeSkipClick = false
|
||||
let likeBubblesHideTimer = null
|
||||
|
||||
/** 气泡绕点赞左侧半圆弧分布(上 → 左 → 下) */
|
||||
const likeBubbleStyle = (index) => {
|
||||
const total = likeQuickAmounts.length || 1
|
||||
const startDeg = 132
|
||||
const endDeg = 228
|
||||
const deg = total === 1 ? 180 : startDeg + ((endDeg - startDeg) * index) / (total - 1)
|
||||
const rad = (deg * Math.PI) / 180
|
||||
const r = 78
|
||||
const x = Math.cos(rad) * r
|
||||
const y = -Math.sin(rad) * r
|
||||
return {
|
||||
'--bx': `${x.toFixed(1)}px`,
|
||||
'--by': `${y.toFixed(1)}px`,
|
||||
'--delay': `${0.04 + index * 0.05}s`,
|
||||
}
|
||||
}
|
||||
|
||||
const giftCostOf = (key) => Number(giftCosts[key]) || Number(GIFT_TYPES.find((g) => g.key === key)?.cost) || 50
|
||||
|
||||
const applyGiftQuota = (quota) => {
|
||||
if (!quota || typeof quota !== 'object') return
|
||||
giftQuota.my_likes = Number(quota.my_likes) || 0
|
||||
giftQuota.spent = Number(quota.spent) || 0
|
||||
giftQuota.available = Math.max(0, Number(quota.available) || 0)
|
||||
giftQuota.gift_used = Number(quota.gift_used) || 0
|
||||
giftQuota.gift_allow = Number(quota.gift_allow) || 1
|
||||
giftQuota.gift_remain = Math.max(0, Number(quota.gift_remain) || 0)
|
||||
giftQuota.likes_to_next = Number(quota.likes_to_next) || 50
|
||||
giftQuota.likes_per_gift = Number(quota.likes_per_gift) || 50
|
||||
if (quota.costs && typeof quota.costs === 'object') {
|
||||
for (const g of GIFT_TYPES) {
|
||||
const v = Number(quota.costs[g.key])
|
||||
if (Number.isFinite(v) && v > 0) giftCosts[g.key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onGiftPagesScroll = () => {
|
||||
const el = giftPagesRef.value
|
||||
if (!el) return
|
||||
const w = el.clientWidth || 1
|
||||
giftPageIndex.value = Math.round(el.scrollLeft / w)
|
||||
}
|
||||
|
||||
const hideLikeBubblesSoon = () => {
|
||||
clearTimeout(likeBubblesHideTimer)
|
||||
likeBubblesHideTimer = setTimeout(() => {
|
||||
showLikeBubbles.value = false
|
||||
}, 3500)
|
||||
}
|
||||
|
||||
const clearLikePressTimer = () => {
|
||||
if (likePressTimer) {
|
||||
clearTimeout(likePressTimer)
|
||||
likePressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const onLikePressStart = (e) => {
|
||||
if (likeBusy.value) return
|
||||
if (e.type === 'mousedown' && e.button !== 0) return
|
||||
likePressFired = false
|
||||
clearLikePressTimer()
|
||||
likePressTimer = setTimeout(() => {
|
||||
likePressFired = true
|
||||
likeSkipClick = true
|
||||
showLikeBubbles.value = true
|
||||
hideLikeBubblesSoon()
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) {
|
||||
try { navigator.vibrate(12) } catch { /* ignore */ }
|
||||
}
|
||||
}, 420)
|
||||
}
|
||||
|
||||
const onLikePressEnd = () => {
|
||||
clearLikePressTimer()
|
||||
}
|
||||
|
||||
const onLikePressCancel = () => {
|
||||
clearLikePressTimer()
|
||||
}
|
||||
|
||||
const handleLikeClick = () => {
|
||||
if (likeSkipClick || likePressFired) {
|
||||
likeSkipClick = false
|
||||
likePressFired = false
|
||||
return
|
||||
}
|
||||
handleLike(1)
|
||||
}
|
||||
|
||||
const handleQuickLike = (n) => {
|
||||
showLikeBubbles.value = false
|
||||
handleLike(n)
|
||||
}
|
||||
|
||||
const DANMAKU_PREF_KEY = 'wedding_show_danmaku_v1'
|
||||
@@ -869,9 +1009,21 @@ const toggleShowDanmaku = () => {
|
||||
showDanmaku.value = !showDanmaku.value
|
||||
try { localStorage.setItem(DANMAKU_PREF_KEY, showDanmaku.value ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
const toggleShowGiftFx = () => {
|
||||
showGiftFx.value = !showGiftFx.value
|
||||
try { localStorage.setItem(GIFT_FX_PREF_KEY, showGiftFx.value ? '1' : '0') } catch { /* ignore */ }
|
||||
const toggleShowGiftFx = async () => {
|
||||
const next = !showGiftFx.value
|
||||
if (!next) {
|
||||
// 先清特效/列表,再切开关,避免同帧 Transition 冲突
|
||||
giftEffectRef.value?.clear?.()
|
||||
giftFeedRef.value?.clear?.()
|
||||
showGiftFx.value = false
|
||||
try { localStorage.setItem(GIFT_FX_PREF_KEY, '0') } catch { /* ignore */ }
|
||||
return
|
||||
}
|
||||
showGiftFx.value = true
|
||||
try { localStorage.setItem(GIFT_FX_PREF_KEY, '1') } catch { /* ignore */ }
|
||||
// 重新打开时拉取最近礼物并回放
|
||||
await nextTick()
|
||||
await loadGiftStatus()
|
||||
}
|
||||
|
||||
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
||||
@@ -1384,15 +1536,13 @@ const completeIntro = () => {
|
||||
if (pendingLikeBurst > 0) {
|
||||
const n = pendingLikeBurst
|
||||
pendingLikeBurst = 0
|
||||
nextTick(() => likeBurstRef.value?.burst(n))
|
||||
nextTick(() => likeBurstRef.value?.burst(n, { durationMs: 2800, maxVisual: 10 }))
|
||||
}
|
||||
if (pendingGiftReplay.value.length) {
|
||||
const list = pendingGiftReplay.value.slice()
|
||||
pendingGiftReplay.value = []
|
||||
if (showGiftFx.value) {
|
||||
nextTick(() => giftEffectRef.value?.playSequence(list, { max: 12 }))
|
||||
} else {
|
||||
nextTick(() => replayGiftFeedOnly(list))
|
||||
}
|
||||
}
|
||||
scheduleAutoScrollStart()
|
||||
@@ -1488,7 +1638,7 @@ const handleSubmitRsvp = async () => {
|
||||
if (res.code === 200) {
|
||||
setGuestName(rsvpForm.name)
|
||||
syncGuestNameIntoForms()
|
||||
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
|
||||
if (showDanmaku.value && res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
|
||||
isSubmitted.value = true
|
||||
}
|
||||
else toast.error(res.error || '提交失败')
|
||||
@@ -1507,7 +1657,7 @@ const handleSubmitBlessing = async () => {
|
||||
if (res.code === 200) {
|
||||
setGuestName(blessingForm.name)
|
||||
syncGuestNameIntoForms()
|
||||
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
|
||||
if (showDanmaku.value && res.data) danmakuLayerRef.value?.pushItem?.(res.data)
|
||||
blessingSubmitted.value = true
|
||||
scheduleBlessingAutoClose()
|
||||
}
|
||||
@@ -1531,9 +1681,18 @@ const applyGiftCounts = (counts, total) => {
|
||||
}
|
||||
|
||||
const openGiftPanel = async () => {
|
||||
showLikeBubbles.value = false
|
||||
syncGuestNameIntoForms()
|
||||
giftForm.name = getGuestName() || giftForm.name
|
||||
showGiftPanel.value = true
|
||||
await nextTick()
|
||||
const el = giftPagesRef.value
|
||||
if (el) {
|
||||
const maxPage = Math.max(0, giftPages.length - 1)
|
||||
const page = Math.min(Math.max(0, giftPageIndex.value), maxPage)
|
||||
giftPageIndex.value = page
|
||||
el.scrollLeft = page * (el.clientWidth || 0)
|
||||
}
|
||||
try {
|
||||
const res = await getGiftStatus(getClientId())
|
||||
applyGiftCounts(res.data?.counts, res.data?.total)
|
||||
@@ -1542,6 +1701,7 @@ const openGiftPanel = async () => {
|
||||
}
|
||||
|
||||
const onGiftPlayStart = ({ giftType, name, count, phase }) => {
|
||||
if (!showGiftFx.value) return
|
||||
if (phase === 'mult') {
|
||||
giftFeedRef.value?.revealCount(giftType, count)
|
||||
return
|
||||
@@ -1549,36 +1709,16 @@ const onGiftPlayStart = ({ giftType, name, count, phase }) => {
|
||||
giftFeedRef.value?.push(name, giftType, count)
|
||||
}
|
||||
|
||||
/** 关闭特效时仍按序刷左下角记录 */
|
||||
const replayGiftFeedOnly = (list) => {
|
||||
const items = Array.isArray(list) ? list : []
|
||||
if (!items.length) return
|
||||
let i = 0
|
||||
const tick = () => {
|
||||
if (i >= items.length) return
|
||||
const it = items[i]
|
||||
i += 1
|
||||
giftFeedRef.value?.push(it.name, it.giftType, it.count)
|
||||
if (it.count > 1) {
|
||||
setTimeout(() => {
|
||||
giftFeedRef.value?.revealCount(it.giftType, it.count)
|
||||
if (i < items.length) setTimeout(tick, 500)
|
||||
}, 900)
|
||||
} else if (i < items.length) {
|
||||
setTimeout(tick, 900)
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
const closeGiftPanel = () => {
|
||||
showGiftPanel.value = false
|
||||
}
|
||||
|
||||
const handleSendGift = async (giftType) => {
|
||||
if (giftBusy.value) return
|
||||
if (giftQuota.remain <= 0) {
|
||||
return toast.error(`赠送次数已用完,再点赞 ${giftQuota.likes_to_next} 次可多送 1 次`)
|
||||
const cost = giftCostOf(giftType)
|
||||
if (giftQuota.available < cost) {
|
||||
const need = cost - giftQuota.available
|
||||
return toast.error(`点赞不足,再点赞 ${need} 次可兑换`)
|
||||
}
|
||||
const name = giftForm.name.trim()
|
||||
if (!name) return toast.error('请填写姓名')
|
||||
@@ -1596,8 +1736,6 @@ const handleSendGift = async (giftType) => {
|
||||
closeGiftPanel()
|
||||
if (showGiftFx.value) {
|
||||
giftEffectRef.value?.play({ giftType, name, count: 1 })
|
||||
} else {
|
||||
giftFeedRef.value?.bumpOrPush(name, giftType)
|
||||
}
|
||||
toast.success('心意已送达')
|
||||
} catch (e) {
|
||||
@@ -1619,7 +1757,7 @@ const loadLikeStatus = async () => {
|
||||
if (count <= 0) return
|
||||
// 揭幕中则等结束后再播,避免帘幕后白播
|
||||
if (showIntro.value) pendingLikeBurst = count
|
||||
else nextTick(() => likeBurstRef.value?.burst(count))
|
||||
else nextTick(() => likeBurstRef.value?.burst(count, { durationMs: 2800, maxVisual: 10 }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1633,26 +1771,25 @@ const loadGiftStatus = async () => {
|
||||
if (!aggregated.length) return
|
||||
if (showIntro.value) pendingGiftReplay.value = aggregated
|
||||
else if (showGiftFx.value) nextTick(() => giftEffectRef.value?.playSequence(aggregated, { max: 12 }))
|
||||
else nextTick(() => replayGiftFeedOnly(aggregated))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
const handleLike = async (count = 1) => {
|
||||
if (likeBusy.value) return
|
||||
const n = Math.max(1, Math.min(200, Number(count) || 1))
|
||||
likeBusy.value = true
|
||||
try {
|
||||
const res = await submitLike(getClientId())
|
||||
likeCount.value = Number(res.data?.count) || likeCount.value + 1
|
||||
const res = await submitLike(getClientId(), n)
|
||||
likeCount.value = Number(res.data?.count) || likeCount.value + n
|
||||
likePulse.value = true
|
||||
clearTimeout(likePulseTimer)
|
||||
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
|
||||
likeBurstRef.value?.burst(1)
|
||||
// 点赞后刷新个人礼物额度(抽屉打开时立刻可见)
|
||||
giftQuota.my_likes += 1
|
||||
giftQuota.gift_allow = 1 + Math.floor(giftQuota.my_likes / giftQuota.likes_per_gift)
|
||||
giftQuota.gift_remain = Math.max(0, giftQuota.gift_allow - giftQuota.gift_used)
|
||||
const mod = giftQuota.my_likes % giftQuota.likes_per_gift
|
||||
giftQuota.likes_to_next = mod === 0 ? giftQuota.likes_per_gift : giftQuota.likes_per_gift - mod
|
||||
likeBurstRef.value?.burst(n, n > 1 ? { durationMs: 2400, maxVisual: 9 } : undefined)
|
||||
if (res.data?.quota) applyGiftQuota(res.data.quota)
|
||||
else {
|
||||
giftQuota.my_likes += n
|
||||
giftQuota.available = Math.max(0, giftQuota.my_likes - giftQuota.spent)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message || '点赞失败')
|
||||
} finally {
|
||||
@@ -1759,6 +1896,9 @@ onUnmounted(() => {
|
||||
clearTimeout(pauseTimer)
|
||||
clearTimeout(introTimer)
|
||||
clearTimeout(scrollStartTimer)
|
||||
clearTimeout(likePressTimer)
|
||||
clearTimeout(likeBubblesHideTimer)
|
||||
clearTimeout(likePulseTimer)
|
||||
clearBottomReturn()
|
||||
clearBlessingAutoClose()
|
||||
})
|
||||
@@ -1862,6 +2002,47 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
overflow: visible;
|
||||
}
|
||||
.like-bubbles {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.like-bubble-btn {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
pointer-events: auto;
|
||||
margin: 0;
|
||||
min-width: 52px;
|
||||
border-color: rgba(244, 114, 182, 0.4);
|
||||
background: rgba(255, 241, 247, 0.72);
|
||||
color: #f472b6;
|
||||
box-shadow: 0 6px 16px rgba(92, 74, 53, 0.12);
|
||||
animation: like-bubble-in 0.28s cubic-bezier(0.22, 1.2, 0.36, 1) both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px)));
|
||||
}
|
||||
.like-bubble-btn .like-icon { color: #f472b6; }
|
||||
.like-bubble-btn .like-count { color: #e11d74; font-weight: 600; }
|
||||
.like-bubble-btn:active {
|
||||
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px))) scale(0.94);
|
||||
}
|
||||
.like-bubble-btn:disabled { opacity: 0.55; }
|
||||
@keyframes like-bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.55);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(calc(-50% + var(--bx, 0px)), calc(-50% + var(--by, 0px))) scale(1);
|
||||
}
|
||||
}
|
||||
.action-pill {
|
||||
min-width: 52px;
|
||||
@@ -1915,8 +2096,9 @@ watch(
|
||||
.gift-fab.busy { opacity: 0.7; pointer-events: none; }
|
||||
.gift-icon { font-size: 16px; color: #a88c6b; line-height: 1; }
|
||||
.gift-panel {
|
||||
max-height: min(56dvh, 420px);
|
||||
overflow-y: auto;
|
||||
height: calc(268px + env(safe-area-inset-bottom, 0px));
|
||||
max-height: calc(268px + env(safe-area-inset-bottom, 0px));
|
||||
overflow: hidden;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(55, 55, 60, 0.94);
|
||||
color: #e8e6e2;
|
||||
@@ -1927,6 +2109,7 @@ watch(
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
margin: 0 auto 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gift-quota {
|
||||
display: flex;
|
||||
@@ -1939,6 +2122,7 @@ watch(
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gift-quota b { color: #f0e6c8; font-weight: 600; }
|
||||
.gift-quota-sub {
|
||||
@@ -1950,6 +2134,7 @@ watch(
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.14);
|
||||
color: #f5f3ef;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gift-name-input::placeholder {
|
||||
color: rgba(232, 230, 226, 0.4);
|
||||
@@ -1957,10 +2142,47 @@ watch(
|
||||
.gift-name-input:focus {
|
||||
border-color: rgba(240, 230, 200, 0.45);
|
||||
}
|
||||
.gift-grid {
|
||||
.gift-pages {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.gift-pages::-webkit-scrollbar { display: none; }
|
||||
.gift-page {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
box-sizing: border-box;
|
||||
padding-right: 1px;
|
||||
}
|
||||
.gift-page-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-top: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gift-page-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.gift-page-dot.active {
|
||||
background: rgba(240, 230, 200, 0.85);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.gift-item {
|
||||
display: flex;
|
||||
@@ -1968,7 +2190,7 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1px;
|
||||
min-height: 72px;
|
||||
min-height: 88px;
|
||||
padding: 6px 2px 4px;
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -1976,17 +2198,20 @@ watch(
|
||||
color: #e8e6e2;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gift-item:active { transform: scale(0.96); }
|
||||
.gift-item:disabled { opacity: 0.45; pointer-events: none; }
|
||||
.gift-item-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
line-height: 1.1;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.04em;
|
||||
min-height: 36px;
|
||||
line-height: 1;
|
||||
font-size: clamp(15px, 4.6vw, 21px);
|
||||
letter-spacing: 0.02em;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
@@ -2004,11 +2229,13 @@ watch(
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
|
||||
.gift-item { min-height: 80px; }
|
||||
.gift-item-preview { font-size: 28px; min-height: 46px; }
|
||||
.gift-item-caption { font-size: 9px; }
|
||||
.gift-item-cost {
|
||||
margin-top: 1px;
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
color: rgba(240, 230, 200, 0.72);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-comment-row {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user