Files
thebet365/apps/player/src/views/CashbackRecordsView.vue
Mars afb5c5437e feat: 充值订单审计与重新申请,优化赛事展示和余额刷新
- 新增 deposit_order_audit_logs 表,记录提交/审批/拒绝/撤销/重提全链路
- 管理端充值单页增加审计历史;玩家端充值历史支持时间线与重新申请
- 已拒绝订单可原单号重提;撤销入账使用 PLAYER_DEPOSIT_REVERSAL 并加强幂等
- 结算后清除热门标记,允许归档已结算赛事,完善今日赛事时区窗口
- 足球页今日/早盘独立折叠;资料与余额在进入钱包/个人页及下注后自动刷新
- 补充投注玩法、结算返水规则文档;新增 smoke/settlement CLI 脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 14:52:05 +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>