管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。 API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。 Co-authored-by: Cursor <cursoragent@cursor.com>
194 lines
4.5 KiB
Vue
194 lines
4.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onUnmounted, nextTick } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
const props = defineProps<{ show: boolean }>();
|
|
const emit = defineEmits<{ done: [] }>();
|
|
|
|
const { t } = useI18n();
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
|
let raf = 0;
|
|
let particles: Particle[] = [];
|
|
|
|
interface Particle {
|
|
x: number; y: number; vx: number; vy: number;
|
|
life: number; maxLife: number; size: number;
|
|
color: string; shape: number;
|
|
}
|
|
|
|
const COLORS = ['#D4AF37', '#FFD700', '#F0C040', '#C8A02E', '#E6C84C', '#B8960C'];
|
|
let w = 0;
|
|
let h = 0;
|
|
|
|
function spawnBurst() {
|
|
const cx = w / 2;
|
|
const cy = h * 0.38;
|
|
for (let i = 0; i < 80; i++) {
|
|
const angle = Math.random() * Math.PI * 2;
|
|
const speed = 1.5 + Math.random() * 5;
|
|
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
|
particles.push({
|
|
x: cx + (Math.random() - 0.5) * 60,
|
|
y: cy + (Math.random() - 0.5) * 20,
|
|
vx: Math.cos(angle) * speed,
|
|
vy: Math.sin(angle) * speed - 2,
|
|
life: 0,
|
|
maxLife: 60 + Math.random() * 80,
|
|
size: 3 + Math.random() * 5,
|
|
color,
|
|
shape: Math.floor(Math.random() * 3),
|
|
});
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
if (!canvasRef.value) return;
|
|
const ctx = canvasRef.value.getContext('2d');
|
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
for (const p of particles) {
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.vy += 0.06;
|
|
p.life++;
|
|
const alpha = 1 - p.life / p.maxLife;
|
|
if (alpha <= 0) continue;
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = p.color;
|
|
if (p.shape === 0) {
|
|
ctx.beginPath();
|
|
ctx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} else if (p.shape === 1) {
|
|
ctx.fillRect(p.x - p.size * 0.35, p.y - p.size * 0.35, p.size * 0.7, p.size * 0.7);
|
|
} else {
|
|
ctx.beginPath();
|
|
ctx.moveTo(p.x, p.y - p.size * 0.5);
|
|
ctx.lineTo(p.x + p.size * 0.4, p.y + p.size * 0.3);
|
|
ctx.lineTo(p.x - p.size * 0.4, p.y + p.size * 0.3);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
particles = particles.filter((p) => p.life < p.maxLife);
|
|
|
|
raf = requestAnimationFrame(draw);
|
|
}
|
|
|
|
function start() {
|
|
setTimeout(() => { emit('done'); }, 2200);
|
|
void nextTick(() => {
|
|
if (!canvasRef.value) return;
|
|
const rect = canvasRef.value.getBoundingClientRect();
|
|
w = rect.width;
|
|
h = rect.height;
|
|
particles = [];
|
|
spawnBurst();
|
|
cancelAnimationFrame(raf);
|
|
raf = requestAnimationFrame(draw);
|
|
});
|
|
}
|
|
|
|
function stop() {
|
|
cancelAnimationFrame(raf);
|
|
particles = [];
|
|
}
|
|
|
|
watch(() => props.show, (v) => {
|
|
if (v) start();
|
|
else stop();
|
|
});
|
|
|
|
onUnmounted(stop);
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<div v-if="show" class="bet-success-overlay" @click="emit('done')">
|
|
<canvas ref="canvasRef" class="confetti-canvas" />
|
|
<div class="success-card">
|
|
<svg class="check" viewBox="0 0 52 52" xmlns="http://www.w3.org/2000/svg">
|
|
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="#D4AF37" stroke-width="3" />
|
|
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#D4AF37" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
<p class="success-text">{{ t('bet.place_success') }}</p>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.bet-success-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 300;
|
|
background: rgba(0, 0, 0, 0.82);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.confetti-canvas {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.success-card {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 18px;
|
|
animation: card-pop 0.4s cubic-bezier(0.18, 0.89, 0.32, 1.2);
|
|
}
|
|
|
|
.check {
|
|
width: 72px;
|
|
height: 72px;
|
|
}
|
|
|
|
.check-circle {
|
|
stroke-dasharray: 151;
|
|
stroke-dashoffset: 151;
|
|
animation: circle-draw 0.5s 0.15s ease forwards;
|
|
}
|
|
|
|
.check-mark {
|
|
stroke-dasharray: 42;
|
|
stroke-dashoffset: 42;
|
|
animation: check-draw 0.35s 0.45s ease forwards;
|
|
}
|
|
|
|
.success-text {
|
|
color: var(--primary-light);
|
|
font-size: 18px;
|
|
font-weight: 800;
|
|
letter-spacing: 0.04em;
|
|
animation: text-up 0.4s 0.4s ease both;
|
|
}
|
|
|
|
@keyframes card-pop {
|
|
0% { transform: scale(0.6); opacity: 0; }
|
|
100% { transform: scale(1); opacity: 1; }
|
|
}
|
|
|
|
@keyframes circle-draw {
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
|
|
@keyframes check-draw {
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
|
|
@keyframes text-up {
|
|
0% { opacity: 0; transform: translateY(8px); }
|
|
100% { opacity: 1; transform: translateY(0); }
|
|
}
|
|
</style>
|