Files
thebet365/apps/player/src/views/MobileCashbackRecordsView.vue
Mars 8a1ba0fd95 feat(player): 新增桌面端 UI 与响应式双端路由架构
- 将原移动端页面重命名为 Mobile*,新增 22 个 Desktop* 视图与 DesktopShell 布局体系
- 路由入口改为 Wrapper 视图,通过 useViewport(≥1024px)自动切换移动/桌面端
- 新增投注单面板、盘口弹层、联赛侧栏、数据表格等桌面组件及独立样式令牌
- 扩展 betSlip store、串关筛选、冠军盘、Toast/悬浮信箱等交互与三语 i18n
- 公告/消息 Hub 拆分移动与桌面实现;API 投注/钱包/充值记录支持 pageSize 分页
2026-06-29 18:01:39 +08:00

412 lines
9.1 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, computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
import { parseCashbackApiData, type CashbackRecord } from '../utils/cashback';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const highlightBatchNo = computed(() => {
const q = route.query.batchNo;
return typeof q === 'string' ? q.trim() : '';
});
const items = ref<CashbackRecord[]>([]);
const loading = ref(false);
const initialLoading = ref(true);
const page = ref(1);
const hasMore = ref(true);
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const totalAmount = computed(() =>
items.value.reduce((sum, row) => sum + Math.abs(parseFloat(row.amount) || 0), 0),
);
function formatPeriod(start: string, end: string) {
const opts: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit' };
const s = new Date(start).toLocaleDateString(locale.value, opts);
const e = new Date(end).toLocaleDateString(locale.value, opts);
return `${s} ${e}`;
}
function formatRate(rate: string) {
const n = parseFloat(rate);
if (!Number.isFinite(n)) return rate;
return `${(n * 100).toFixed(2)}%`;
}
function formatTime(v: string | null) {
if (!v) return '—';
return new Date(v).toLocaleString(locale.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
async function fetchRecords(p = 1) {
if (loading.value) return;
loading.value = true;
try {
const { data } = await api.get('/player/cashbacks', { params: { page: p } });
const newItems = parseCashbackApiData(data.data);
if (p === 1) {
items.value = newItems;
} else {
items.value = [...items.value, ...newItems];
}
const pageSize = 20;
hasMore.value = newItems.length >= pageSize;
page.value = p;
} catch {
if (p === 1) items.value = [];
} finally {
loading.value = false;
initialLoading.value = false;
}
}
useOnLocaleChange(() => {
items.value = [];
page.value = 1;
hasMore.value = true;
initialLoading.value = true;
fetchRecords(1);
});
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await fetchRecords(1); },
});
onMounted(() => {
fetchRecords(1);
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
fetchRecords(page.value + 1);
}
},
{ rootMargin: '200px' },
);
if (sentinel.value) observer.observe(sentinel.value);
});
onUnmounted(() => {
observer?.disconnect();
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
</script>
<template>
<div class="cashback-page">
<div class="top-bar">
<button type="button" class="back-btn" @click="router.back()">
&#x2039; {{ t('history.back') }}
</button>
</div>
<div class="pull-indicator" :style="pullIndicatorStyle()">
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
<span class="loading-text">{{ t('bet.loading') }}</span>
</div>
<template v-else>
<div class="hero">
<span class="hero-amount">{{ formatMoney(totalAmount, locale) }}</span>
<span class="hero-sub">
{{ t('cashback.total_received') }}
<template v-if="items.length">
· {{ t('cashback.record_count', { n: items.length }) }}
</template>
</span>
</div>
<p class="ledger-hint">{{ t('cashback.ledger_hint') }}</p>
<p v-if="items.length" class="section-title">{{ t('cashback.list_title') }}</p>
<div v-if="items.length" class="list-card">
<article
v-for="row in items"
:key="row.id"
class="record-row"
:class="{ 'record-row--highlight': highlightBatchNo && row.batchNo === highlightBatchNo }"
>
<div class="row-main">
<div class="row-left">
<span class="row-amount">{{ formatMoney(row.amount, locale) }}</span>
<span class="row-period">{{ formatPeriod(row.periodStart, row.periodEnd) }}</span>
</div>
<span class="row-rate">{{ formatRate(row.rate) }}</span>
</div>
<div class="row-detail">
<span>{{ t('cashback.effective_stake') }} {{ formatMoneyCompact(row.effectiveStake, locale) }}</span>
<span>{{ t('cashback.bet_count', { n: row.betCount }) }}</span>
</div>
<div class="row-foot">
<span class="row-batch">{{ row.batchNo }}</span>
<span class="row-time">{{ formatTime(row.confirmedAt ?? row.createdAt) }}</span>
</div>
</article>
</div>
<div ref="sentinel" class="sentinel" />
<div v-if="loading && items.length > 0" class="load-more-spinner">
<GoldSpinner :size="24" />
</div>
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
{{ t('common.no_more') }}
</div>
<div v-else class="empty">
<p class="empty-title">{{ t('cashback.empty') }}</p>
<p class="empty-hint">{{ t('cashback.empty_hint') }}</p>
</div>
</template>
</div>
</template>
<style scoped>
.cashback-page {
padding-bottom: 24px;
}
.top-bar {
margin-bottom: 4px;
}
.back-btn {
background: none;
border: none;
color: var(--primary-light, #d4af37);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
padding: 56px 20px;
font-weight: 600;
font-size: 14px;
}
.loading-text {
font-size: 13px;
}
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 18px 16px 16px;
margin-bottom: 14px;
border-radius: var(--radius, 12px);
background: linear-gradient(135deg, rgba(212, 175, 55, 0.14), rgba(26, 26, 26, 0.92));
border: 1px solid rgba(212, 175, 55, 0.28);
text-align: center;
}
.hero-amount {
font-size: 28px;
font-weight: 800;
color: #f0b90b;
line-height: 1.2;
text-shadow: 0 0 20px rgba(240, 185, 11, 0.2);
}
.hero-sub {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.section-title {
font-size: 10.5px;
color: #555;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0 0 10px;
}
.list-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
backdrop-filter: blur(10px);
}
.record-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.record-row:last-child {
border-bottom: none;
}
.record-row--highlight {
background: rgba(212, 175, 55, 0.08);
box-shadow: inset 3px 0 0 #d4af37;
}
.ledger-hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: #666;
}
.row-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.row-left {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.row-amount {
font-size: 17px;
font-weight: 800;
color: var(--primary-light);
}
.row-period {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.row-rate {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border: 1px solid rgba(240, 185, 11, 0.22);
border-radius: 999px;
padding: 3px 10px;
}
.row-detail {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.row-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.06);
font-size: 11px;
color: #666;
}
.row-batch {
font-family: ui-monospace, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 58%;
}
.row-time {
flex-shrink: 0;
}
.empty {
text-align: center;
padding: 48px 20px 32px;
}
.empty-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 700;
color: var(--text-muted);
}
.empty-hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #666;
}
.sentinel {
height: 1px;
}
.load-more-spinner {
display: flex;
justify-content: center;
padding: 20px 0 8px;
}
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;
}
</style>