## 财务流水 - 新增 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>
251 lines
5.5 KiB
Vue
251 lines
5.5 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.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>
|