feat(player): 完善 H5 投注端与 API 演示数据
- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑 - 固定顶栏、公告与底栏,仅内容区滚动 - 底部导航与站点 favicon 使用 logo,登录页精简 - API 种子、冠军盘与历史注单增强 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
245
apps/player/src/components/BannerCarousel.vue
Normal file
245
apps/player/src/components/BannerCarousel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user