feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import defaultBannerImg from '../assets/images/banner.png';
export interface BannerItem {
id?: string;
linkType?: string | null;
linkTarget?: string | null;
translation?: { title?: string; body?: string; imageUrl?: string };
}
const props = defineProps<{ banners: BannerItem[] }>();
const router = useRouter();
const active = ref(0);
const timer = ref<ReturnType<typeof setInterval> | null>(null);
const touchStartX = ref(0);
const touchDeltaX = ref(0);
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
function imageUrl(banner: BannerItem) {
return banner.translation?.imageUrl || defaultBannerImg || FALLBACK_IMG;
}
function onImgError(e: Event) {
const img = e.target as HTMLImageElement;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = defaultBannerImg || FALLBACK_IMG;
}
function title(banner: BannerItem) {
return banner.translation?.title || 'Banner';
}
function goTo(index: number) {
if (!props.banners.length) return;
active.value = (index + props.banners.length) % props.banners.length;
}
function next() {
goTo(active.value + 1);
}
function prev() {
goTo(active.value - 1);
}
function onBannerClick(banner: BannerItem) {
if (banner.linkType === 'ROUTE' && banner.linkTarget) {
router.push(banner.linkTarget);
return;
}
if (banner.linkType === 'URL' && banner.linkTarget) {
window.open(banner.linkTarget, '_blank');
}
}
function startAutoPlay() {
stopAutoPlay();
if (props.banners.length <= 1) return;
timer.value = setInterval(next, 4500);
}
function stopAutoPlay() {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
function onTouchStart(e: TouchEvent) {
touchStartX.value = e.touches[0].clientX;
touchDeltaX.value = 0;
stopAutoPlay();
}
function onTouchMove(e: TouchEvent) {
touchDeltaX.value = e.touches[0].clientX - touchStartX.value;
}
function onTouchEnd() {
if (touchDeltaX.value > 50) prev();
else if (touchDeltaX.value < -50) next();
touchDeltaX.value = 0;
startAutoPlay();
}
watch(
() => props.banners.length,
() => {
active.value = 0;
startAutoPlay();
},
);
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
</script>
<template>
<div
v-if="banners.length"
class="carousel"
@mouseenter="stopAutoPlay"
@mouseleave="startAutoPlay"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="media">
<div class="viewport">
<div class="track" :style="{ transform: `translateX(-${active * 100}%)` }">
<div
v-for="(banner, i) in banners"
:key="banner.id ?? i"
class="slide"
@click="onBannerClick(banner)"
>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="title(banner)"
class="slide-img"
@error="onImgError"
/>
<div v-else class="slide-fallback">{{ title(banner) }}</div>
</div>
</div>
</div>
<button v-if="banners.length > 1" class="nav prev" type="button" aria-label="上一张" @click.stop="prev"></button>
<button v-if="banners.length > 1" class="nav next" type="button" aria-label="下一张" @click.stop="next"></button>
<div v-if="banners.length > 1" class="dots">
<button
v-for="(_, i) in banners"
:key="i"
type="button"
class="dot"
:class="{ active: i === active }"
:aria-label="` ${i + 1} `"
@click.stop="goTo(i)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.carousel {
position: relative;
margin: 0 -16px 14px;
overflow: hidden;
}
.media {
position: relative;
overflow: hidden;
}
.viewport {
overflow: hidden;
width: 100%;
}
.track {
display: flex;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide {
flex: 0 0 100%;
min-width: 100%;
cursor: pointer;
}
.slide-img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.slide-fallback {
height: 180px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.25), var(--secondary));
font-size: 22px;
font-weight: 800;
color: var(--primary-light);
}
.media .nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: var(--primary-light);
border: 1px solid var(--border);
font-size: 22px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.nav.prev { left: 8px; }
.nav.next { right: 8px; }
.media .dots {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
z-index: 2;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
border: 2px solid transparent;
padding: 0;
}
.dot.active {
width: 22px;
border-radius: 6px;
background: var(--gradient-gold);
border-color: var(--primary-light);
box-shadow: none;
}
</style>