Files
thebet365/apps/admin/src/views/Cashback.vue
Mars 414998ce36 feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情
新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:34:12 +08:00

726 lines
23 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, ref } from 'vue';
import type { TableColumnCtx } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
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;
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 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) {
const n = Number(value);
if (!Number.isFinite(n)) return '—';
return `${(n * 100).toFixed(2)}%`;
}
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() {
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 {
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(loadHistory);
</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 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="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'" 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 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'">
<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="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'" 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: var(--green-glow);
border-color: rgba(47, 181, 106, 0.45);
background: rgba(47, 181, 106, 0.08);
}
.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: 0 0 20px rgba(47, 181, 106, 0.35);
}
.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;
}
</style>