1. 优化视觉效果、后端规范化

This commit is contained in:
李琦
2026-08-02 19:04:04 +08:00
parent 8f3918198a
commit 2a4d2e0bea
18 changed files with 3710 additions and 1078 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/ma-shan-zheng": "^5.3.0",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -17,6 +18,7 @@
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"echarts": "^6.1.0",
"lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",
@@ -576,6 +578,12 @@
"node": ">=18"
}
},
"node_modules/@fingerprintjs/fingerprintjs": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz",
"integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==",
"license": "MIT"
},
"node_modules/@fontsource/great-vibes": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz",
@@ -1937,6 +1945,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.24.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz",
@@ -2926,6 +2944,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/unhead": {
"version": "1.11.20",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-1.11.20.tgz",
@@ -3109,6 +3133,15 @@
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/ma-shan-zheng": "^5.3.0",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -18,6 +19,7 @@
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"echarts": "^6.1.0",
"lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",

View File

@@ -14,6 +14,9 @@ importers:
'@ant-design/icons-vue':
specifier: ^7.0.1
version: 7.0.1(vue@3.5.40)
'@fingerprintjs/fingerprintjs':
specifier: ^5.2.0
version: 5.2.0
'@fontsource/great-vibes':
specifier: ^5.3.0
version: 5.3.0
@@ -35,6 +38,9 @@ importers:
axios:
specifier: ^1.6.2
version: 1.18.1(debug@4.4.3)
echarts:
specifier: ^6.1.0
version: 6.1.0
lucide-vue-next:
specifier: ^0.507.0
version: 0.507.0(vue@3.5.40)
@@ -275,6 +281,9 @@ packages:
cpu: [x64]
os: [win32]
'@fingerprintjs/fingerprintjs@5.2.0':
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
'@fontsource/great-vibes@5.3.0':
resolution: {integrity: sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==}
@@ -753,6 +762,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
enhanced-resolve@5.24.4:
resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==}
engines: {node: '>=10.13.0'}
@@ -1069,6 +1081,9 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
unhead@1.11.20:
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
@@ -1148,6 +1163,9 @@ packages:
zhead@2.2.4:
resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
snapshots:
'@ant-design-vue/pro-layout@3.2.5(ant-design-vue@4.2.6(vue@3.5.40))(vue@3.5.40)':
@@ -1274,6 +1292,8 @@ snapshots:
'@esbuild/win32-x64@0.25.12':
optional: true
'@fingerprintjs/fingerprintjs@5.2.0': {}
'@fontsource/great-vibes@5.3.0': {}
'@fontsource/ma-shan-zheng@5.3.0': {}
@@ -1690,6 +1710,11 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
enhanced-resolve@5.24.4:
dependencies:
graceful-fs: 4.2.11
@@ -1993,6 +2018,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
tslib@2.3.0: {}
unhead@1.11.20:
dependencies:
'@unhead/dom': 1.11.20
@@ -2041,3 +2068,7 @@ snapshots:
loose-envify: 1.4.0
zhead@2.2.4: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0

View File

@@ -48,6 +48,14 @@ export function getConfig() {
return service.get('/config')
}
export function getConfigSection(section) {
return service.get('/config/section', { params: { section } })
}
export function saveConfigSection(section, data) {
return service.post('/config/section', { section, data })
}
export function getAlbumImages() {
return service.get('/album')
}
@@ -56,6 +64,18 @@ export function saveConfig(payload) {
return service.post('/config', payload)
}
export function trackVisit(payload) {
return service.post('/visit', payload)
}
export function getVisitStats() {
return service.get('/visit/stats')
}
export function getVisitList(params) {
return service.get('/visit/list', { params })
}
export function getUploadConfig() {
return service.get('/upload/config')
}

View File

@@ -4,7 +4,11 @@
v-for="row in rows"
:key="row.id"
class="danmaku-item"
:class="{ 'danmaku-item--grad': row.textLight }"
:class="{
'danmaku-item--grad': row.isGradient,
'danmaku-item--grad-light': row.isGradient && row.textLight,
'danmaku-item--grad-dark': row.isGradient && !row.textLight,
}"
:style="row.style"
@animationend="onEnded(row.id)"
>
@@ -14,7 +18,10 @@
<span
v-if="showTime && row.timeLabel"
class="danmaku-time"
:class="{ 'danmaku-time--on-grad': row.textLight }"
:class="{
'danmaku-time--on-light': row.isGradient && row.textLight,
'danmaku-time--on-dark': row.isGradient && !row.textLight,
}"
>{{ row.timeLabel }}</span>
</div>
</div>
@@ -149,6 +156,7 @@ const buildRow = (item, { force = false } = {}) => {
const totalDelay = delay + slot.extraDelay
// 5 轨均匀分布在上半屏,轨间距约 14%
const topPct = 7 + slot.track * 14
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
const timeLabel = force
? '刚刚'
@@ -160,6 +168,7 @@ const buildRow = (item, { force = false } = {}) => {
content: item.content,
color: resolveDanmakuColor(item.color),
textLight: !!tint.textLight,
isGradient,
timeLabel,
style: {
top: `${topPct}%`,
@@ -338,10 +347,15 @@ defineExpose({ refresh: fetchList, pushItem })
max-width: none;
}
.danmaku-item--grad {
backdrop-filter: saturate(1.25) blur(8px);
text-shadow: 0 1px 2px rgba(40, 20, 30, 0.28);
backdrop-filter: saturate(1.2) blur(8px);
font-weight: 500;
}
.danmaku-item--grad-light {
text-shadow: 0 1px 2px rgba(20, 10, 20, 0.45), 0 0 1px rgba(0, 0, 0, 0.2);
}
.danmaku-item--grad-dark {
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }
.danmaku-text { opacity: 0.95; }
@@ -351,8 +365,11 @@ defineExpose({ refresh: fetchList, pushItem })
font-size: 10px;
letter-spacing: 0.04em;
}
.danmaku-time--on-grad {
color: rgba(255, 255, 255, 0.82);
.danmaku-time--on-light {
color: rgba(255, 255, 255, 0.88);
}
.danmaku-time--on-dark {
color: rgba(74, 52, 32, 0.72);
}
@keyframes danmaku-fly {
from { transform: translateX(0); }

View File

@@ -1,88 +1,472 @@
<template>
<div class="like-burst" aria-hidden="true">
<span
v-for="h in hearts"
:key="h.id"
class="like-burst-heart"
:style="h.style"
@animationend="removeHeart(h.id)"
>♥</span>
</div>
<canvas ref="canvasRef" class="like-burst-canvas" aria-hidden="true" />
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
const hearts = ref([])
let uid = 0
const canvasRef = ref(null)
/** 单次最多同时排队的爱心数,避免超高点赞拖垮渲染 */
const MAX_BURST = 80
/** 同屏上限 */
const MAX_HEARTS = 55
const MAX_SPARKS = 120
/** 异常保护 */
const MAX_TOTAL = 5000
/** 每次「点赞效果」间隔 */
const LIKE_GAP_MS = 280
/** 与手动点赞一致:每次效果飘出几颗 */
const HEARTS_PER_LIKE = 1
const burst = (count = 3) => {
const n = Math.max(0, Math.min(MAX_BURST, Math.floor(Number(count) || 0)))
if (!n) return
for (let i = 0; i < n; i += 1) {
const id = ++uid
const left = 10 + Math.random() * 200
const drift = (Math.random() - 0.5) * 90
const duration = 2.8 + Math.random() * 1.0
const delay = i * 0.1
const scale = 0.8 + Math.random() * 0.55
hearts.value.push({
id,
style: {
left: `${left}px`,
'--drift': `${drift}px`,
'--scale': scale,
animationDuration: `${duration}s`,
animationDelay: `${delay}s`,
},
})
const HEART_MIN_SIZE = 14
const HEART_MAX_SIZE = 34
const HEART_MIN_LIFE = 1400
const HEART_MAX_LIFE = 2600
const RISE_SPEED_MIN = 70
const RISE_SPEED_MAX = 150
const DRIFT_AMP_MIN = 8
const DRIFT_AMP_MAX = 45
const GLOW_BLUR = 10
const GLOW_ALPHA = 0.55
const DPR_CAP = 2
/** 多色爱心:主色 */
const HEART_COLORS = [
'#ff2442', '#ff2d55', '#ff4770', '#ff6b8a',
'#f472b6', '#ec4899', '#e879f9', '#c084fc',
'#a78bfa', '#818cf8', '#60a5fa', '#38bdf8',
'#2dd4bf', '#34d399', '#fbbf24', '#f59e0b',
'#fb923c', '#a88c6b', '#d4a574', '#e8b4b8',
'#f43f5e', '#fb7185', '#fda4af',
]
/** 高光色(更亮) */
const GLOW_COLORS = [
'#ff8da6', '#ffb0c4', '#f9a8d4', '#e9d5ff',
'#c4b5fd', '#93c5fd', '#6ee7b7', '#fde68a',
'#fdba74', '#f5d0b0', '#ffffff',
]
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]
const rand = (min, max) => min + Math.random() * (max - min)
let ctx = null
let W = 0
let H = 0
let dpr = 1
let hearts = []
let sparks = []
let rafId = 0
let running = false
let targetLikes = 0
let firedLikes = 0
let likeTimer = null
const clearLikeTimer = () => {
clearTimeout(likeTimer)
likeTimer = null
}
function resizeCanvas() {
const canvas = canvasRef.value
if (!canvas) return
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
const rect = canvas.getBoundingClientRect()
W = rect.width
H = rect.height
canvas.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
}
/**
* 标准爱心路径(以 cx,cy 为视觉中心)
* 双瓣二次曲线 + 底部尖角
*/
function drawHeartPath(context, cx, cy, size) {
// size ≈ 半宽;整体高约 1.5*size
const w = size * 2.1
const h = size * 1.95
const top = cy - h * 0.42
const midY = top + h * 0.28
const tipY = top + h
context.beginPath()
// 从顶部凹口中间出发
context.moveTo(cx, midY)
// 左上瓣
context.bezierCurveTo(
cx, top,
cx - w * 0.5, top,
cx - w * 0.5, midY,
)
// 左下到尖端
context.bezierCurveTo(
cx - w * 0.5, top + h * 0.62,
cx - w * 0.08, tipY - h * 0.12,
cx, tipY,
)
// 右下到右瓣
context.bezierCurveTo(
cx + w * 0.08, tipY - h * 0.12,
cx + w * 0.5, top + h * 0.62,
cx + w * 0.5, midY,
)
// 右上瓣回到凹口
context.bezierCurveTo(
cx + w * 0.5, top,
cx, top,
cx, midY,
)
context.closePath()
}
function drawGlowHeart(context, cx, cy, size, color, glowColor, opacity, glowAlpha) {
if (opacity <= 0.01 || size <= 1) return
const globalAlpha = Math.min(opacity, 1)
context.save()
context.globalAlpha = globalAlpha
if (glowAlpha > 0.01) {
context.save()
context.shadowColor = glowColor
context.shadowBlur = GLOW_BLUR * (size / 18)
context.fillStyle = glowColor
context.globalAlpha = glowAlpha * globalAlpha
drawHeartPath(context, cx, cy, size)
context.fill()
context.restore()
}
// 主体:从上到下的轻微立体渐变
const gradient = context.createLinearGradient(cx, cy - size * 0.6, cx, cy + size * 0.7)
gradient.addColorStop(0, glowColor)
gradient.addColorStop(0.4, color)
gradient.addColorStop(1, shadeColor(color, -0.28))
context.fillStyle = gradient
context.shadowColor = 'transparent'
context.shadowBlur = 0
drawHeartPath(context, cx, cy, size)
context.fill()
// 左上高光
const highlightX = cx - size * 0.22
const highlightY = cy - size * 0.28
const highlightR = size * 0.18
context.save()
context.globalAlpha = 0.45 * globalAlpha
const hlGrad = context.createRadialGradient(
highlightX, highlightY, 0,
highlightX, highlightY, highlightR,
)
hlGrad.addColorStop(0, 'rgba(255,255,255,0.95)')
hlGrad.addColorStop(0.55, 'rgba(255,255,255,0.25)')
hlGrad.addColorStop(1, 'rgba(255,255,255,0)')
context.fillStyle = hlGrad
context.beginPath()
context.arc(highlightX, highlightY, highlightR, 0, Math.PI * 2)
context.fill()
context.restore()
context.restore()
}
/** 简单加深/变亮 hex */
function shadeColor(hex, amount) {
const h = hex.replace('#', '')
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
const n = parseInt(full, 16)
let r = (n >> 16) & 255
let g = (n >> 8) & 255
let b = n & 255
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)))
r = clamp(r + r * amount)
g = clamp(g + g * amount)
b = clamp(b + b * amount)
return `rgb(${r},${g},${b})`
}
function createHeart(x, y, now) {
return {
startX: x,
startY: y,
birthTime: now,
maxAge: rand(HEART_MIN_LIFE, HEART_MAX_LIFE),
baseSize: rand(HEART_MIN_SIZE, HEART_MAX_SIZE),
color: pick(HEART_COLORS),
glowColor: pick(GLOW_COLORS),
peakScale: rand(0.75, 1.35),
riseSpeed: rand(RISE_SPEED_MIN, RISE_SPEED_MAX),
driftAmp: rand(DRIFT_AMP_MIN, DRIFT_AMP_MAX),
driftFreq: rand(1.2, 2.8),
driftPhase: rand(0, Math.PI * 2),
rotation: rand(-0.4, 0.4),
rotationSpeed: rand(-0.35, 0.35),
extraDrift: Math.random() < 0.25 ? rand(-25, 25) : 0,
spawnedSparks: false,
}
}
const removeHeart = (id) => {
hearts.value = hearts.value.filter((h) => h.id !== id)
function heartProgress(h, now) {
return Math.min((now - h.birthTime) / h.maxAge, 1)
}
function heartScale(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.18) {
const t = progress / 0.18
const elastic = 1 - (1 - t) ** 3.5
const overshoot = Math.sin(t * Math.PI) * 0.08 * (1 - t)
return h.peakScale * (elastic + overshoot)
}
if (progress < 0.65) {
const t = (progress - 0.18) / 0.47
return h.peakScale * (1 - t * 0.25)
}
const t = (progress - 0.65) / 0.35
return h.peakScale * 0.75 * (1 - t * t)
}
function heartOpacity(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.45) return 1
if (progress < 0.78) return 1 - ((progress - 0.45) / 0.33) * 0.35
const t = (progress - 0.78) / 0.22
return 0.65 * (1 - t * t)
}
function heartGlowAlpha(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.5) return GLOW_ALPHA
if (progress < 0.8) return GLOW_ALPHA * (1 - (progress - 0.5) / 0.3)
return 0
}
function heartY(h, now) {
const ageSec = (now - h.birthTime) / 1000
const progress = heartProgress(h, now)
const speedMultiplier = 1 + (progress > 0.7 ? ((progress - 0.7) / 0.3) * 0.4 : 0)
return h.startY - h.riseSpeed * ageSec * speedMultiplier
}
function heartX(h, now) {
const ageSec = (now - h.birthTime) / 1000
const sinDrift = Math.sin(ageSec * h.driftFreq + h.driftPhase) * h.driftAmp
const linearDrift = h.extraDrift * Math.min(ageSec / 1.5, 1)
return h.startX + sinDrift + linearDrift
}
function createSpark(x, y, color, now) {
const angle = rand(0, Math.PI * 2)
const speed = rand(25, 90)
return {
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - rand(15, 50),
birthTime: now,
life: rand(250, 550),
color,
size: rand(1.5, 4.5),
}
}
function spawnSparksForHeart(heart, now) {
if (heart.spawnedSparks) return
heart.spawnedSparks = true
const cx = heartX(heart, now)
const cy = heartY(heart, now)
const sparkCount = Math.floor(rand(3, 7))
for (let i = 0; i < sparkCount; i += 1) {
if (sparks.length >= MAX_SPARKS) sparks.shift()
sparks.push(createSpark(cx, cy, heart.glowColor, now))
}
}
function spawnHeartAt(x, y, now) {
if (hearts.length >= MAX_HEARTS) {
const oldest = hearts.shift()
if (oldest && !oldest.spawnedSparks) spawnSparksForHeart(oldest, now)
}
hearts.push(createHeart(x, y, now))
ensureLoop()
}
/** 一次点赞效果:底部偏左区域冒出多颗彩色爱心 */
function fireOneLikeEffect() {
if (hearts.length >= MAX_HEARTS) return false
const now = performance.now()
const baseX = W * (0.18 + Math.random() * 0.35)
const baseY = H * (0.78 + Math.random() * 0.12)
const room = MAX_HEARTS - hearts.length
const n = Math.min(HEARTS_PER_LIKE, room)
for (let i = 0; i < n; i += 1) {
spawnHeartAt(
baseX + rand(-28, 28),
baseY + rand(-18, 18),
now + i * 20,
)
}
return true
}
const scheduleNextLike = () => {
clearLikeTimer()
if (firedLikes >= targetLikes) return
likeTimer = setTimeout(() => {
likeTimer = null
if (firedLikes >= targetLikes) return
if (!fireOneLikeEffect()) {
scheduleNextLike()
return
}
firedLikes += 1
if (firedLikes < targetLikes) scheduleNextLike()
}, LIKE_GAP_MS)
}
/**
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
*/
const burst = (count = 3) => {
clearLikeTimer()
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!n) return
if (!W || !H) resizeCanvas()
targetLikes = n
firedLikes = 0
if (fireOneLikeEffect()) firedLikes = 1
if (firedLikes < targetLikes) scheduleNextLike()
}
function ensureLoop() {
if (running) return
running = true
rafId = requestAnimationFrame(animate)
}
function stopLoopIfIdle() {
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) return
running = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
}
function animate(timestamp) {
if (!running) return
const now = timestamp || performance.now()
if (!ctx) {
rafId = requestAnimationFrame(animate)
return
}
ctx.clearRect(0, 0, W, H)
for (let i = sparks.length - 1; i >= 0; i -= 1) {
const spark = sparks[i]
const progress = Math.min((now - spark.birthTime) / spark.life, 1)
if (progress >= 1) {
sparks.splice(i, 1)
continue
}
const age = (now - spark.birthTime) / 1000
const sx = spark.x + spark.vx * age
const sy = spark.y + spark.vy * age + 0.5 * 80 * age * age
const opacity = 1 - progress * progress
const size = spark.size * (1 - progress * 0.6)
ctx.save()
ctx.globalAlpha = opacity
ctx.fillStyle = spark.color
ctx.shadowColor = spark.color
ctx.shadowBlur = 4
ctx.beginPath()
ctx.arc(sx, sy, size, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
for (let i = hearts.length - 1; i >= 0; i -= 1) {
const heart = hearts[i]
const progress = heartProgress(heart, now)
if (!heart.spawnedSparks && progress > 0.82) {
spawnSparksForHeart(heart, now)
}
if (progress >= 1) {
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
hearts.splice(i, 1)
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
continue
}
const cx = heartX(heart, now)
const cy = heartY(heart, now)
const scale = heartScale(heart, now)
const size = heart.baseSize * scale
const opacity = heartOpacity(heart, now)
const glowAlpha = heartGlowAlpha(heart, now)
const rotation = heart.rotation + heart.rotationSpeed * ((now - heart.birthTime) / 1000)
if (cy < -size * 2 || cy > H + size * 2 || cx < -size * 2 || cx > W + size * 2) {
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
hearts.splice(i, 1)
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
continue
}
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rotation)
drawGlowHeart(ctx, 0, 0, size, heart.color, heart.glowColor, opacity, glowAlpha)
ctx.restore()
}
while (sparks.length > MAX_SPARKS) sparks.shift()
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) {
rafId = requestAnimationFrame(animate)
} else {
stopLoopIfIdle()
}
}
const onResize = () => resizeCanvas()
onMounted(() => {
resizeCanvas()
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
clearLikeTimer()
running = false
if (rafId) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
hearts = []
sparks = []
})
defineExpose({ burst })
</script>
<style scoped>
.like-burst {
.like-burst-canvas {
position: fixed;
left: 0;
bottom: 0;
width: min(46vw, 280px);
height: 58vh;
width: min(52vw, 320px);
height: 62vh;
z-index: 45;
pointer-events: none;
overflow: hidden;
}
.like-burst-heart {
position: absolute;
bottom: 28px;
color: #f472b6;
font-size: 22px;
line-height: 1;
text-shadow: 0 2px 8px rgba(244, 114, 182, 0.45);
animation-name: like-rise;
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
animation-fill-mode: forwards;
opacity: 0;
}
@keyframes like-rise {
0% {
opacity: 0;
transform: translate3d(0, 12px, 0) scale(calc(var(--scale, 1) * 0.6));
}
12% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate3d(var(--drift, 0px), -55vh, 0) scale(var(--scale, 1));
}
}
</style>

View File

@@ -13,61 +13,65 @@ export const DANMAKU_COLORS = [
{ key: 'ink', hex: '#5c4a35', label: '墨褐' },
]
/** 渐变配色:高饱和多色阶 + 弹幕高显色底 + 光晕 */
/**
* 渐变配色:
* - textLight=true → 浅色字(需整体偏深,保证对比度)
* - textLight=false → 深色字(浅色/金色底)
*/
export const DANMAKU_GRADIENT_COLORS = [
{
key: 'gradSunset',
hex: '#fff8f4',
hex: '#fff6f2',
label: '晚霞',
textLight: true,
gradient: 'linear-gradient(125deg, #ff9f43 0%, #ff5e7a 42%, #d946ef 78%, #8b5cf6 100%)',
gradientBg: 'linear-gradient(125deg, rgba(255,159,67,0.92) 0%, rgba(255,94,122,0.9) 42%, rgba(217,70,239,0.9) 78%, rgba(139,92,246,0.92) 100%)',
glow: '0 6px 22px rgba(255,94,122,0.48), 0 2px 8px rgba(139,92,246,0.28)',
gradient: 'linear-gradient(125deg, #f97316 0%, #e11d48 42%, #c026d3 78%, #7c3aed 100%)',
gradientBg: 'linear-gradient(125deg, rgba(234,88,12,0.94) 0%, rgba(225,29,72,0.93) 42%, rgba(192,38,211,0.93) 78%, rgba(124,58,237,0.94) 100%)',
glow: '0 6px 22px rgba(225,29,72,0.42), 0 2px 8px rgba(124,58,237,0.28)',
},
{
key: 'gradChampagne',
hex: '#fffaf0',
hex: '#4a3420',
label: '流金',
textLight: true,
textLight: false,
gradient: 'linear-gradient(120deg, #fff1c9 0%, #f0c27a 28%, #e8a54b 55%, #c9832f 78%, #8b5a2b 100%)',
gradientBg: 'linear-gradient(120deg, rgba(255,241,201,0.95) 0%, rgba(240,194,122,0.92) 28%, rgba(232,165,75,0.9) 55%, rgba(201,131,47,0.92) 78%, rgba(139,90,43,0.94) 100%)',
glow: '0 6px 22px rgba(232,165,75,0.5), 0 2px 8px rgba(201,131,47,0.3)',
gradientBg: 'linear-gradient(120deg, rgba(255,241,201,0.96) 0%, rgba(240,194,122,0.94) 28%, rgba(232,165,75,0.92) 55%, rgba(201,131,47,0.9) 78%, rgba(180,140,80,0.88) 100%)',
glow: '0 6px 20px rgba(201,131,47,0.35), 0 2px 8px rgba(139,90,43,0.22)',
},
{
key: 'gradBlush',
hex: '#fff7fb',
hex: '#fff5fa',
label: '桃雾',
textLight: true,
gradient: 'linear-gradient(130deg, #ff8fab 0%, #ff4d8d 35%, #e879f9 68%, #a78bfa 100%)',
gradientBg: 'linear-gradient(130deg, rgba(255,143,171,0.92) 0%, rgba(255,77,141,0.9) 35%, rgba(232,121,249,0.9) 68%, rgba(167,139,250,0.92) 100%)',
glow: '0 6px 22px rgba(255,77,141,0.45), 0 2px 8px rgba(167,139,250,0.3)',
gradient: 'linear-gradient(130deg, #fb7185 0%, #e11d74 35%, #c026d3 68%, #7c3aed 100%)',
gradientBg: 'linear-gradient(130deg, rgba(244,63,94,0.93) 0%, rgba(219,39,119,0.92) 35%, rgba(192,38,211,0.92) 68%, rgba(124,58,237,0.93) 100%)',
glow: '0 6px 22px rgba(219,39,119,0.4), 0 2px 8px rgba(124,58,237,0.28)',
},
{
key: 'gradOcean',
hex: '#f4fbff',
hex: '#f0f9ff',
label: '海暮',
textLight: true,
gradient: 'linear-gradient(125deg, #38bdf8 0%, #2dd4bf 32%, #6366f1 68%, #a855f7 100%)',
gradientBg: 'linear-gradient(125deg, rgba(56,189,248,0.92) 0%, rgba(45,212,191,0.88) 32%, rgba(99,102,241,0.9) 68%, rgba(168,85,247,0.92) 100%)',
glow: '0 6px 22px rgba(56,189,248,0.42), 0 2px 8px rgba(168,85,247,0.28)',
gradient: 'linear-gradient(125deg, #0284c7 0%, #0d9488 32%, #4f46e5 68%, #9333ea 100%)',
gradientBg: 'linear-gradient(125deg, rgba(2,132,199,0.93) 0%, rgba(13,148,136,0.92) 32%, rgba(79,70,229,0.93) 68%, rgba(147,51,234,0.93) 100%)',
glow: '0 6px 22px rgba(2,132,199,0.38), 0 2px 8px rgba(147,51,234,0.26)',
},
{
key: 'gradAurora',
hex: '#f8fffc',
hex: '#f4fffb',
label: '极光',
textLight: true,
gradient: 'linear-gradient(120deg, #34d399 0%, #22d3ee 28%, #818cf8 58%, #f472b6 100%)',
gradientBg: 'linear-gradient(120deg, rgba(52,211,153,0.9) 0%, rgba(34,211,238,0.88) 28%, rgba(129,140,248,0.9) 58%, rgba(244,114,182,0.92) 100%)',
glow: '0 6px 22px rgba(34,211,238,0.4), 0 2px 10px rgba(244,114,182,0.32)',
gradient: 'linear-gradient(120deg, #059669 0%, #0891b2 28%, #6366f1 58%, #db2777 100%)',
gradientBg: 'linear-gradient(120deg, rgba(5,150,105,0.93) 0%, rgba(8,145,178,0.92) 28%, rgba(99,102,241,0.93) 58%, rgba(219,39,119,0.93) 100%)',
glow: '0 6px 22px rgba(8,145,178,0.36), 0 2px 10px rgba(219,39,119,0.28)',
},
{
key: 'gradEmber',
hex: '#fff8f2',
hex: '#fff7ed',
label: '暖焰',
textLight: true,
gradient: 'linear-gradient(125deg, #fde047 0%, #fb923c 35%, #f43f5e 70%, #e11d48 100%)',
gradientBg: 'linear-gradient(125deg, rgba(253,224,71,0.92) 0%, rgba(251,146,60,0.9) 35%, rgba(244,63,94,0.9) 70%, rgba(225,29,72,0.94) 100%)',
glow: '0 6px 22px rgba(251,146,60,0.48), 0 2px 8px rgba(244,63,94,0.32)',
gradient: 'linear-gradient(125deg, #ea580c 0%, #dc2626 40%, #be123c 70%, #9f1239 100%)',
gradientBg: 'linear-gradient(125deg, rgba(234,88,12,0.94) 0%, rgba(220,38,38,0.93) 40%, rgba(190,18,60,0.93) 70%, rgba(159,18,57,0.94) 100%)',
glow: '0 6px 22px rgba(220,38,38,0.42), 0 2px 8px rgba(190,18,60,0.3)',
},
]
@@ -97,7 +101,7 @@ export function resolveDanmakuTint(key) {
if (entry.gradientBg) {
return {
background: entry.gradientBg,
borderColor: 'rgba(255, 255, 255, 0.55)',
borderColor: entry.textLight ? 'rgba(255, 255, 255, 0.5)' : 'rgba(74, 52, 32, 0.22)',
boxShadow: entry.glow || '0 6px 20px rgba(0,0,0,0.12)',
textLight: !!entry.textLight,
}

View File

@@ -0,0 +1,39 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs'
import { trackVisit } from '@/api/wedding'
import { getClientId } from '@/utils/clientId'
const SESSION_KEY = 'wedding_visit_beacon_v1'
let fpPromise = null
function getFpAgent() {
if (!fpPromise) fpPromise = FingerprintJS.load()
return fpPromise
}
/** 同会话只上报一次;失败静默 */
export async function sendVisitBeacon() {
try {
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(SESSION_KEY)) return
} catch { /* ignore */ }
try {
const agent = await getFpAgent()
const result = await agent.get()
const fingerprint = result?.visitorId
if (!fingerprint) return
await trackVisit({
fingerprint,
client_id: getClientId(),
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
referer: typeof document !== 'undefined' ? document.referrer : '',
})
try {
sessionStorage.setItem(SESSION_KEY, '1')
} catch { /* ignore */ }
} catch {
/* silent */
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long