Files
thebet365/apps/admin/src/views/Cashback.vue
Mars c5a5e66bbd perf(admin): 实现切页即时导航与列表 loading 壳
非阻塞 session 刷新、侧边栏 chunk 预取、KeepAlive 对齐,以及按 tab 延迟加载重型列表页。
2026-06-18 16:46:57 +08:00

810 lines
26 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 { computed, onMounted, onActivated, ref } from 'vue';
defineOptions({ name: 'AdminCashback' });
import type { TableColumnCtx } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { formatRatePercent } from '../utils/rate-percent';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { resolveApiError } from '../i18n/form-validation';
import api from '../api';
interface CashbackBatch {
id: string;
batchNo: string;
periodStart: string;
periodEnd: string;
playerCount: number;
totalAmount: string;
totalEffectiveStake?: string;
totalBetCount?: number;
status: string;
operatorUsername?: string | null;
confirmedAt?: string | null;
createdAt: string;
}
interface CashbackPreviewItem {
userId: string;
username: string;
agentUsername: string | null;
availableBalance: string;
effectiveStake: string;
betCount: number;
rate: string;
amount: string;
}
interface CashbackPreview {
batch: CashbackBatch;
items: CashbackPreviewItem[];
totalAmount: string;
totalEffectiveStake?: string;
totalBetCount?: number;
avgRate?: string;
replacedPreviewCount?: number;
}
const { t, localeTag } = useAdminLocale();
const { hasPermission } = usePermissions();
const canConfirmCashback = computed(() => hasPermission(AdminPerm.cashback));
const preview = ref<CashbackPreview | null>(null);
const rulesVisible = ref(false);
const loading = ref(false);
const historyLoading = ref(false);
const detailLoading = ref(false);
const detailVisible = ref(false);
const detail = ref<CashbackPreview | null>(null);
const history = ref<CashbackBatch[]>([]);
const historyTotal = ref(0);
const historyPage = ref(1);
const historyPageSize = ref(10);
const historyStatus = ref('');
const period = ref({
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
end: new Date().toISOString().slice(0, 10),
});
const previewItems = computed(() => preview.value?.items ?? []);
const detailItems = computed(() => detail.value?.items ?? []);
function formatRate(value: string | number | null | undefined) {
return formatRatePercent(value);
}
function formatPeriodDate(value: string) {
if (!value) return '—';
return value.slice(0, 10);
}
function formatPeriodRange(row: Pick<CashbackBatch, 'periodStart' | 'periodEnd'>) {
return `${formatPeriodDate(row.periodStart)}${formatPeriodDate(row.periodEnd)}`;
}
function formatTime(value: string | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function statusLabel(status: string) {
const key = `cashback.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(status: string) {
if (status === 'CONFIRMED') return 'success';
if (status === 'CANCELLED') return 'info';
return 'warning';
}
function tableSummary(param: {
columns: TableColumnCtx<CashbackPreviewItem>[];
data: CashbackPreviewItem[];
}) {
const { columns, data } = param;
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 0);
const betSum = data.reduce((s, row) => s + Number(row.betCount ?? 0), 0);
const amountSum = data.reduce((s, row) => s + Number(row.amount), 0);
return columns.map((col, index) => {
if (index === 0) return t('cashback.table_total');
if (col.property === 'betCount') return String(betSum);
if (col.property === 'effectiveStake') return formatAmount(stakeSum);
if (col.property === 'amount') return formatAmount(amountSum);
return '';
});
}
async function loadHistory(opts?: { silent?: boolean }) {
if (!opts?.silent) historyLoading.value = true;
try {
const { data } = await api.get('/admin/cashbacks', {
params: {
page: historyPage.value,
pageSize: historyPageSize.value,
status: historyStatus.value || undefined,
},
});
history.value = (data.data.items ?? []) as CashbackBatch[];
historyTotal.value = data.data.total ?? 0;
} catch (err) {
ElMessage.error(resolveApiError(err, t, 'msg.load_failed'));
} finally {
if (!opts?.silent) historyLoading.value = false;
}
}
async function generatePreview() {
loading.value = true;
try {
const { data } = await api.post('/admin/cashbacks/preview', {
periodStart: period.value.start,
periodEnd: period.value.end,
});
preview.value = data.data as CashbackPreview;
const replaced = preview.value.replacedPreviewCount ?? 0;
if (replaced > 0) {
ElMessage.warning(t('msg.cashback_preview_replaced', { n: replaced }));
} else {
ElMessage.success(t('msg.cashback_preview_ready'));
}
await loadHistory();
} catch (err) {
ElMessage.error(resolveApiError(err, t, 'msg.save_failed'));
} finally {
loading.value = false;
}
}
async function confirmBatchId(batchId: string) {
try {
await ElMessageBox.confirm(t('cashback.confirm_prompt'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('cashback.confirm_issue'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
try {
await api.post(`/admin/cashbacks/${batchId}/confirm`);
ElMessage.success(t('msg.cashback_issued'));
if (preview.value?.batch.id === batchId) preview.value = null;
if (detail.value?.batch.id === batchId) detailVisible.value = false;
await loadHistory();
} catch (err) {
ElMessage.error(resolveApiError(err, t, 'msg.save_failed'));
}
}
async function cancelBatchId(batchId: string) {
try {
await ElMessageBox.confirm(t('cashback.cancel_prompt'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('cashback.cancel_issue'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
try {
await api.post(`/admin/cashbacks/${batchId}/cancel`);
ElMessage.success(t('msg.cashback_cancelled'));
if (preview.value?.batch.id === batchId) preview.value = null;
if (detail.value?.batch.id === batchId) detailVisible.value = false;
await loadHistory();
} catch (err) {
ElMessage.error(resolveApiError(err, t, 'msg.save_failed'));
}
}
async function confirm() {
if (!preview.value?.batch) return;
await confirmBatchId(preview.value.batch.id);
}
async function openDetail(batchId: string) {
detailLoading.value = true;
detailVisible.value = true;
detail.value = null;
try {
const { data } = await api.get(`/admin/cashbacks/${batchId}`);
detail.value = data.data as CashbackPreview;
} catch (err) {
detailVisible.value = false;
ElMessage.error(resolveApiError(err, t, 'msg.load_failed'));
} finally {
detailLoading.value = false;
}
}
function onHistoryPageChange(p: number) {
historyPage.value = p;
loadHistory();
}
function onHistorySizeChange(size: number) {
historyPageSize.value = size;
historyPage.value = 1;
loadHistory();
}
function onHistoryStatusChange() {
historyPage.value = 1;
loadHistory();
}
onMounted(() => void loadHistory());
onActivated(() => {
if (history.value.length > 0) void loadHistory({ silent: true });
});
</script>
<template>
<div class="page-scroll">
<el-card class="tool-card" shadow="never">
<div class="filter-row">
<el-form inline>
<el-form-item :label="t('cashback.start_date')">
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
</el-form-item>
<el-form-item :label="t('cashback.end_date')">
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="generatePreview">
{{ t('cashback.preview_btn') }}
</el-button>
<button
type="button"
class="rules-help-btn"
:aria-label="t('cashback.rules_title')"
:title="t('cashback.rules_title')"
@click="rulesVisible = true"
>
?
</button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-dialog
v-model="rulesVisible"
:title="t('cashback.rules_title')"
width="560px"
destroy-on-close
>
<ul class="rules-list">
<li>{{ t('cashback.rule_period') }}</li>
<li>{{ t('cashback.rule_eligible') }}</li>
<li>{{ t('cashback.rule_formula') }}</li>
<li>{{ t('cashback.rule_rate') }}</li>
<li>{{ t('cashback.rule_flow') }}</li>
<li>{{ t('cashback.rule_platform') }}</li>
<li class="rules-note">{{ t('cashback.rule_note_zero') }}</li>
</ul>
</el-dialog>
<template v-if="preview">
<el-card class="preview-card" shadow="never">
<div class="preview-head">
<div>
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
<div class="preview-meta">
<span>{{ t('cashback.batch_no') }}{{ preview.batch.batchNo }}</span>
<span class="meta-sep">·</span>
<span>{{ formatPeriodRange(preview.batch) }}</span>
</div>
</div>
<el-tag :type="statusTagType(preview.batch.status)" size="small">
{{ statusLabel(preview.batch.status) }}
</el-tag>
</div>
<el-row :gutter="12" class="preview-stats">
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ preview.batch.playerCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value pstat-green">{{ formatAmount(preview.totalAmount) }}</div>
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ formatAmount(preview.totalEffectiveStake ?? 0) }}</div>
<div class="pstat-label">{{ t('cashback.stat.effective_stake') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ preview.totalBetCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ formatRate(preview.avgRate) }}</div>
<div class="pstat-label">{{ t('cashback.stat.avg_rate') }}</div>
</div>
</el-col>
<el-col :xs="12" :sm="8" :md="4">
<div class="pstat">
<div class="pstat-value">{{ previewItems.length }}</div>
<div class="pstat-label">{{ t('cashback.stat.lines') }}</div>
</div>
</el-col>
</el-row>
</el-card>
<el-card class="data-card" shadow="never">
<div class="table-title">{{ t('cashback.table_title') }}</div>
<div class="table-wrap">
<el-table
:data="previewItems"
stripe
class="cashback-table"
show-summary
:summary-method="tableSummary"
:empty-text="t('cashback.empty_items')"
>
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
<el-table-column
prop="username"
:label="t('cashback.col.player')"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
prop="agentUsername"
:label="t('cashback.col.agent')"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
</el-table-column>
<el-table-column
prop="availableBalance"
:label="t('cashback.col.balance')"
min-width="110"
align="right"
>
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.availableBalance)" placement="top">
<span>{{ formatAmount(row.availableBalance) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column
prop="betCount"
:label="t('cashback.col.bet_count')"
width="88"
align="right"
/>
<el-table-column
prop="effectiveStake"
:label="t('cashback.col.effective_stake')"
min-width="120"
align="right"
>
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.effectiveStake)" placement="top">
<span>{{ formatAmount(row.effectiveStake) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
</el-table-column>
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="120" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="preview.batch.status === 'PREVIEW' && canConfirmCashback" class="preview-actions">
<el-button type="success" :disabled="previewItems.length === 0" @click="confirm">
{{ t('cashback.confirm_issue') }}
</el-button>
<el-button type="danger" plain @click="cancelBatchId(preview.batch.id)">
{{ t('cashback.cancel_issue') }}
</el-button>
</div>
</el-card>
</template>
<el-card class="data-card history-card" shadow="never">
<div class="history-head">
<div class="table-title">{{ t('cashback.history_title') }}</div>
<el-select
v-model="historyStatus"
clearable
:placeholder="t('common.all')"
style="width: 140px"
@change="onHistoryStatusChange"
>
<el-option :label="t('cashback.status.PREVIEW')" value="PREVIEW" />
<el-option :label="t('cashback.status.CONFIRMED')" value="CONFIRMED" />
<el-option :label="t('cashback.status.CANCELLED')" value="CANCELLED" />
</el-select>
</div>
<div class="table-wrap">
<el-table v-loading="historyLoading" :data="history" stripe>
<template #empty>
<AdminTableEmpty :text="t('cashback.history_empty')" />
</template>
<el-table-column type="index" :index="(i: number) => (historyPage - 1) * historyPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
<el-table-column :label="t('cashback.col.period')" min-width="190">
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.col.status')" width="96" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="playerCount" :label="t('cashback.stat.players')" width="96" align="right" />
<el-table-column prop="totalBetCount" :label="t('cashback.col.bet_count')" width="88" align="right">
<template #default="{ row }">{{ row.totalBetCount ?? 0 }}</template>
</el-table-column>
<el-table-column :label="t('cashback.stat.effective_stake')" min-width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.totalEffectiveStake ?? 0) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.stat.total')" min-width="110" align="right">
<template #default="{ row }">
<span class="amount-cell">{{ formatAmount(row.totalAmount) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('cashback.col.created_at')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.col.confirmed_at')" min-width="150">
<template #default="{ row }">{{ formatTime(row.confirmedAt) }}</template>
</el-table-column>
<el-table-column :label="t('cashback.col.operator')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.operatorUsername || '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)">
{{ t('cashback.view_detail') }}
</el-button>
<template v-if="row.status === 'PREVIEW' && canConfirmCashback">
<el-button link type="success" @click="confirmBatchId(row.id)">
{{ t('cashback.confirm_issue') }}
</el-button>
<el-button link type="danger" @click="cancelBatchId(row.id)">
{{ t('cashback.cancel_issue') }}
</el-button>
</template>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="historyPage"
v-model:page-size="historyPageSize"
:total="historyTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
@current-change="onHistoryPageChange"
@size-change="onHistorySizeChange"
/>
</div>
</el-card>
<el-dialog
v-model="detailVisible"
:title="t('cashback.detail_title')"
width="920px"
destroy-on-close
>
<div v-loading="detailLoading">
<template v-if="detail">
<div class="detail-meta">
<span>{{ t('cashback.batch_no') }}{{ detail.batch.batchNo }}</span>
<span class="meta-sep">·</span>
<span>{{ formatPeriodRange(detail.batch) }}</span>
<el-tag :type="statusTagType(detail.batch.status)" size="small" class="detail-tag">
{{ statusLabel(detail.batch.status) }}
</el-tag>
</div>
<div class="detail-summary">{{ t('cashback.detail_summary') }}</div>
<el-row :gutter="12" class="preview-stats detail-stats">
<el-col :span="8">
<div class="pstat pstat-compact">
<div class="pstat-value">{{ detail.batch.playerCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat pstat-compact">
<div class="pstat-value pstat-green">{{ formatAmount(detail.totalAmount) }}</div>
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat pstat-compact">
<div class="pstat-value">{{ detail.totalBetCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.bet_count') }}</div>
</div>
</el-col>
</el-row>
<div class="table-wrap">
<el-table
:data="detailItems"
stripe
max-height="360"
show-summary
:summary-method="tableSummary"
>
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
<el-table-column prop="username" :label="t('cashback.col.player')" min-width="110" />
<el-table-column prop="agentUsername" :label="t('cashback.col.agent')" min-width="100">
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
</el-table-column>
<el-table-column prop="availableBalance" :label="t('cashback.col.balance')" min-width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.availableBalance) }}</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('cashback.col.bet_count')" width="88" align="right" />
<el-table-column prop="effectiveStake" :label="t('cashback.col.effective_stake')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmount(row.effectiveStake) }}</template>
</el-table-column>
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
</el-table-column>
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="110" align="right">
<template #default="{ row }">
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="detail?.batch.status === 'PREVIEW' && canConfirmCashback" class="detail-actions">
<el-button type="success" :disabled="detailItems.length === 0" @click="confirmBatchId(detail.batch.id)">
{{ t('cashback.confirm_issue') }}
</el-button>
<el-button type="danger" plain @click="cancelBatchId(detail.batch.id)">
{{ t('cashback.cancel_issue') }}
</el-button>
</div>
</template>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.rules-help-btn {
margin-left: 10px;
width: 28px;
height: 28px;
padding: 0;
border-radius: 50%;
border: 1px solid #3a3a3a;
background: rgba(255, 255, 255, 0.04);
color: #aaa;
font-size: 14px;
font-weight: 600;
line-height: 1;
cursor: pointer;
vertical-align: middle;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.rules-help-btn:hover {
color: #f5f5f5;
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
}
.rules-list {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.75;
color: #ccc;
}
.rules-note {
color: #888;
margin-top: 6px;
}
.preview-card,
.data-card {
border-radius: 12px;
margin-bottom: 16px;
}
.history-card {
margin-bottom: 0;
}
.preview-head,
.history-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.preview-title,
.table-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.preview-meta,
.detail-meta {
margin-top: 6px;
font-size: 12px;
color: #888;
}
.detail-tag {
margin-left: 8px;
vertical-align: middle;
}
.detail-summary {
margin: 12px 0 8px;
font-size: 13px;
color: #aaa;
}
.meta-sep {
margin: 0 6px;
}
.preview-stats {
margin-bottom: 0;
}
.detail-stats {
margin-bottom: 12px;
}
.pstat {
padding: 14px 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 10px;
text-align: center;
margin-bottom: 8px;
}
.pstat-compact {
padding: 12px 10px;
}
.pstat-value {
font-size: 22px;
font-weight: 700;
color: #e0e0e0;
}
.pstat-green {
color: var(--green-glow);
text-shadow: none;
}
.pstat-label {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.table-wrap {
overflow-x: auto;
}
.amount-cell {
color: var(--green-glow);
font-weight: 600;
}
.preview-actions,
.detail-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.tool-card,
.preview-card,
.data-card {
border-radius: 8px;
}
.rules-help-btn {
border-color: var(--border);
background: #ffffff;
color: var(--text-muted);
}
.rules-help-btn:hover {
border-color: #d5cfc3;
background: var(--accent-hover);
color: var(--text);
}
.rules-list,
.detail-summary,
.preview-title,
.table-title,
.pstat-value {
color: var(--text);
}
.rules-note,
.preview-meta,
.detail-meta,
.pstat-label {
color: var(--text-muted);
}
.pstat {
border-color: var(--border);
background: #fbfaf7;
}
.amount-cell,
.pstat-green {
color: var(--success-text);
}
@media (max-width: 760px) {
.preview-head,
.history-head {
align-items: stretch;
flex-direction: column;
}
.preview-actions,
.detail-actions,
.pager {
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>