API: - 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板 - 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数 - User 表增加 visible_menus 字段;新增 player_messages 表及迁移 - 充值审核通过/拒绝时按系统配置自动写入玩家站内消息 - 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count - 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置 - 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管) - SystemConfig 增加 inbox 功能开关及各类通知开关 Admin: - 员工管理:按角色默认菜单 + 可勾选可见菜单项 - ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询 - Contents:富文本编辑器、图片字段组件重构 - DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列 - 多页面 i18n 与权限细节调整 Player: - 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除 - 公告列表与详情页;走马灯可跳转详情 - 客服 Modal 改为 Panel,与邮箱 Hub 整合 - 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认 - HomeView 今日赛事板块;FootballView 等体验优化 Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码 Docs: 玩家端缺失功能分析文档 Chore: 移除 .agents/skills 设计类 skill 文件 Co-authored-by: Cursor <cursoragent@cursor.com>
307 lines
6.7 KiB
Vue
307 lines
6.7 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { useI18n } from 'vue-i18n';
|
||
|
||
const { t } = useI18n();
|
||
import defaultBannerImg from '../assets/images/banner.webp';
|
||
|
||
export interface BannerItem {
|
||
id?: string;
|
||
linkType?: string | null;
|
||
linkTarget?: string | null;
|
||
translation?: { title?: string; body?: string; imageUrl?: string };
|
||
}
|
||
|
||
const props = defineProps<{
|
||
banners: BannerItem[];
|
||
/** 未配置跳转链接时的默认路由 */
|
||
fallbackTo?: string;
|
||
}>();
|
||
|
||
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 || t('home.banner_fallback');
|
||
}
|
||
|
||
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.id) {
|
||
void router.push(`/announcements/${banner.id}`);
|
||
return;
|
||
}
|
||
if (props.fallbackTo) {
|
||
void router.push(props.fallbackTo);
|
||
}
|
||
}
|
||
|
||
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)"
|
||
>
|
||
<template v-if="imageUrl(banner)">
|
||
<img
|
||
:src="imageUrl(banner)"
|
||
alt=""
|
||
class="slide-backdrop"
|
||
aria-hidden="true"
|
||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||
@error="onImgError"
|
||
/>
|
||
<img
|
||
:src="imageUrl(banner)"
|
||
:alt="title(banner)"
|
||
class="slide-img"
|
||
:loading="i === 0 ? 'eager' : 'lazy'"
|
||
@error="onImgError"
|
||
/>
|
||
</template>
|
||
<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="t('home.banner_prev')" @click.stop="prev">‹</button>
|
||
<button v-if="banners.length > 1" class="nav next" type="button" :aria-label="t('home.banner_next')" @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="t('home.banner_slide', { n: i + 1 })"
|
||
@click.stop="goTo(i)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.carousel {
|
||
position: relative;
|
||
margin: -12px -16px 14px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.media {
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #05080d;
|
||
}
|
||
|
||
.viewport {
|
||
overflow: hidden;
|
||
width: 100%;
|
||
height: clamp(148px, 41.86vw, 180px);
|
||
}
|
||
|
||
.track {
|
||
display: flex;
|
||
height: 100%;
|
||
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.slide {
|
||
position: relative;
|
||
flex: 0 0 100%;
|
||
min-width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
background:
|
||
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
|
||
#070d15;
|
||
cursor: pointer;
|
||
touch-action: manipulation;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
.slide::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 1;
|
||
background:
|
||
linear-gradient(90deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.03) 48%, rgba(0, 0, 0, 0.18));
|
||
box-shadow: inset 0 -1px 0 rgba(212, 175, 55, 0.16);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.slide-backdrop {
|
||
position: absolute;
|
||
inset: -18px;
|
||
width: calc(100% + 36px);
|
||
height: calc(100% + 36px);
|
||
object-fit: cover;
|
||
filter: blur(16px);
|
||
opacity: 0.42;
|
||
transform: scale(1.04);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.slide-img {
|
||
position: relative;
|
||
z-index: 2;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
object-position: center;
|
||
display: block;
|
||
}
|
||
|
||
.slide-fallback {
|
||
position: relative;
|
||
z-index: 2;
|
||
width: 100%;
|
||
height: 100%;
|
||
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);
|
||
padding-bottom: 5px;
|
||
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>
|