Files
thebet365/apps/player/src/components/BannerCarousel.vue
Mars 6124313369 feat: add finance logs page, banner upload, and admin withdraw fix
## 财务流水
- 新增 FinanceLogs.vue(/finance-logs):额度流水 + 上下分流水双 Tab,支持时间/代理/玩家/操作人筛选与分页
- 管理员与代理共用页面,API 按角色自动切换(/admin/* 或 /agent/*)
- 侧栏「财务流水」替代原「额度流水」;代理侧栏同步新增入口
- /agent-credit-transactions 重定向至 /finance-logs?tab=credit,旧链接仍可用
- 后端:新增 GET /admin/wallet/transfer-transactions;增强额度/上下分列表筛选
- 代理端:新增 GET /agent/credit-transactions;GET /agent/wallet-transactions 支持分页与筛选
- 修复:管理员下分改为 adminWithdrawFromPlayer(),下分后重算上级代理 usedCredit

## 内容管理 Banner
- Contents.vue:各语言 Banner 支持本地上传、媒体库选择、手动填 URL(≤5MB)
- vite 开发代理 /uploads;生产 nginx 反代 /uploads/ 至 API

## 玩家端 Banner
- BannerCarousel:外链无协议时自动补 https://
- defaultBanner:API 加载中不闪默认图,仅空列表时展示默认 Banner

## 其他
- AgentManager:查看额度流水链接改为 /finance-logs
- i18n:finance.*、nav.finance_logs、content.upload.*(中/英/马来)

未纳入本次提交:.pnpm-store/、release/ 部署包、uploads/banners/ 下测试上传图片

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 10:10:11 +08:00

251 lines
5.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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.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 || 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.linkType === 'ROUTE' && banner.linkTarget) {
router.push(banner.linkTarget);
return;
}
if (banner.linkType === 'URL' && banner.linkTarget) {
let url = banner.linkTarget.trim();
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
window.open(url, '_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="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: 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>