feat(admin,api,player): 结算预览分页、统计图表与返水限额

完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 13:54:33 +08:00
parent 6264b8806c
commit efff7c27e6
40 changed files with 3560 additions and 578 deletions

View File

@@ -1,28 +1,90 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import type { TableColumnCtx } from 'element-plus';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import api from '../api';
interface CashbackBatch {
id: string;
batchNo: string;
periodStart: string;
periodEnd: string;
playerCount: number;
totalAmount: string;
}
interface CashbackPreviewItem {
userId: string;
username: string;
agentUsername: string | null;
effectiveStake: string;
rate: string;
amount: string;
}
interface CashbackPreview {
batch: CashbackBatch;
items: CashbackPreviewItem[];
totalAmount: string;
}
const { t } = useAdminLocale();
const preview = ref<Record<string, unknown> | null>(null);
const preview = ref<CashbackPreview | null>(null);
const rulesVisible = ref(false);
const loading = ref(false);
const period = ref({
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
end: new Date().toISOString().slice(0, 10),
});
async function generatePreview() {
const { data } = await api.post('/admin/cashbacks/preview', {
periodStart: period.value.start,
periodEnd: period.value.end,
const previewItems = computed(() => preview.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 tableSummary(param: {
columns: TableColumnCtx<CashbackPreviewItem>[];
data: CashbackPreviewItem[];
}) {
const { columns, data } = param;
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 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 === 'effectiveStake') return formatAmount(stakeSum);
if (col.property === 'amount') return formatAmount(amountSum);
return '';
});
preview.value = data.data;
}
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;
} finally {
loading.value = false;
}
}
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
await api.post(`/admin/cashbacks/${preview.value.batch.id}/confirm`);
ElMessage.success(t('msg.cashback_issued'));
preview.value = null;
}
@@ -30,49 +92,261 @@ async function confirm() {
<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" @click="generatePreview">{{ t('cashback.preview_btn') }}</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<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-card v-if="preview" class="preview-card" shadow="never">
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
<el-row :gutter="20" class="preview-stats">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ (preview.batch as { playerCount: number })?.playerCount ?? 0 }}</div>
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
<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>
{{ formatPeriodDate(preview.batch.periodStart) }}
{{ formatPeriodDate(preview.batch.periodEnd) }}
</span>
</div>
</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ preview.totalAmount }}</div>
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
<el-row :gutter="20" class="preview-stats">
<el-col :span="8">
<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 :span="8">
<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 :span="8">
<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="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>
</el-col>
</el-row>
<el-button type="success" @click="confirm" style="margin-top: 20px">{{ t('cashback.confirm_issue') }}</el-button>
</el-card>
<el-button type="success" class="confirm-btn" :disabled="previewItems.length === 0" @click="confirm">
{{ t('cashback.confirm_issue') }}
</el-button>
</el-card>
</template>
</div>
</template>
<style scoped>
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.preview-card { border-radius: 12px; }
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
.preview-stats { }
.pstat { padding: 16px; background: #f9f9fb; border-radius: 10px; text-align: center; }
.pstat-value { font-size: 26px; font-weight: 700; color: var(--green-glow); }
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
.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;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.preview-title {
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
}
.preview-meta {
margin-top: 6px;
font-size: 12px;
color: #888;
}
.meta-sep {
margin: 0 6px;
}
.preview-stats {
margin-bottom: 0;
}
.pstat {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 10px;
text-align: center;
}
.pstat-value {
font-size: 26px;
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-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 12px;
}
.table-wrap {
overflow-x: auto;
}
.amount-cell {
color: var(--green-glow);
font-weight: 600;
}
.confirm-btn {
margin-top: 20px;
}
</style>