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

@@ -179,6 +179,32 @@ async function openDetail(row: BetListRow) {
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.content')" min-width="260">
<template #default="{ row }">
<el-tooltip
v-if="row.selectionPreviews?.length"
:content="row.selectionSummary || ''"
placement="top"
:show-after="300"
>
<div class="bet-content-cell">
<div
v-for="(leg, i) in (row.selectionPreviews ?? []).slice(0, 3)"
:key="i"
class="bet-leg-line"
>
<span class="bet-match">{{ leg.matchLabel }}</span>
<span class="bet-pick">{{ leg.selectionName }}</span>
<span class="bet-leg-odds">@{{ leg.odds }}</span>
</div>
<div v-if="(row.selectionPreviews?.length ?? 0) > 3" class="bet-leg-more">
{{ t('bet.legs_more', { n: (row.selectionPreviews?.length ?? 0) - 3 }) }}
</div>
</div>
</el-tooltip>
<span v-else class="bet-content-empty"></span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.selection_count')" width="88" align="center">
<template #default="{ row }">{{ row.selectionCount }}</template>
</el-table-column>
@@ -267,6 +293,12 @@ async function openDetail(row: BetListRow) {
<div class="selections-title">{{ t('bet.selections_title', { n: detail.selections.length }) }}</div>
<el-table :data="detail.selections" size="small" stripe border>
<el-table-column type="index" label="#" width="44" />
<el-table-column :label="t('bet.col.match')" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div class="detail-match-cell">{{ row.matchLabel }}</div>
<div v-if="row.leagueName" class="detail-league">{{ row.leagueName }}</div>
</template>
</el-table-column>
<el-table-column prop="selectionName" :label="t('bet.col.selection')" min-width="120" show-overflow-tooltip />
<el-table-column prop="marketType" :label="t('bet.col.market')" width="100" />
<el-table-column prop="period" :label="t('bet.col.period')" width="72">
@@ -310,7 +342,55 @@ async function openDetail(row: BetListRow) {
}
.bets-table {
min-width: 1180px;
min-width: 1380px;
}
.bet-content-cell {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 0;
}
.bet-leg-line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
font-size: 12px;
line-height: 1.35;
}
.bet-match {
color: #ddd;
}
.bet-pick {
color: var(--green-glow);
}
.bet-leg-odds {
color: #888;
font-size: 11px;
}
.bet-leg-more {
font-size: 11px;
color: #666;
}
.bet-content-empty {
color: #666;
}
.detail-match-cell {
line-height: 1.35;
}
.detail-league {
margin-top: 2px;
font-size: 11px;
color: #888;
}
.bet-no {
font-size: 12px;

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>

View File

@@ -4,7 +4,13 @@ import { useRoute, useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { VChart } from '../components/dashboard/echarts-setup';
import { formatAmount } from '../utils/format-amount';
import {
buildBetTypePieOption,
buildSelectionStakeBarOption,
buildStatusPieOption,
} from '../utils/settlement-charts';
import {
betStatusLabel,
betStatusTagType,
@@ -32,20 +38,28 @@ interface SettlementBetStats {
singleStake: string;
parlayLegCount: number;
}>;
bets: Array<{
id: string;
betNo: string;
username: string;
betType: string;
status: string;
stake: string;
potentialReturn: string | null;
placedAt: string;
marketType: string;
period: string | null;
selectionName: string;
odds: string;
}>;
bets: {
items: Array<{
id: string;
betNo: string;
username: string;
betType: string;
status: string;
stake: string;
potentialReturn: string | null;
placedAt: string;
legCountOnMatch: number;
selections: Array<{
marketType: string;
period: string | null;
selectionName: string;
odds: string;
}>;
}>;
total: number;
page: number;
pageSize: number;
};
}
const { t, locale, localeTag } = useAdminLocale();
@@ -56,16 +70,111 @@ const loading = ref(false);
const match = ref<AdminMatchDetail | null>(null);
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const preview = ref<Record<string, unknown> | null>(null);
const resettlePreview = ref<Record<string, unknown> | null>(null);
const resettleReason = ref('');
const stats = ref<SettlementBetStats | null>(null);
const statsLoading = ref(false);
const betPage = ref(1);
const betPageSize = ref(10);
const previewPage = ref(1);
const previewPageSize = ref(10);
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score
const matchId = computed(() => String(route.params.id ?? ''));
const statusSummary = computed(() => {
const counts = stats.value?.summary.statusCounts ?? {};
return ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH'].filter((s) => (counts[s] ?? 0) > 0);
type PreviewItem = {
betNo: string;
betType: string;
result: string;
payout: string;
note?: string;
};
type PreviewItemsPage = {
items: PreviewItem[];
total: number;
page: number;
pageSize: number;
};
const previewItemsPage = computed(
() => (preview.value?.items as PreviewItemsPage | undefined) ?? { items: [], total: 0, page: 1, pageSize: 10 },
);
const previewZeroHint = computed(() => {
const p = preview.value;
if (!p) return '';
const payout = Number(p.totalPayout ?? 0);
const refund = Number(p.totalRefund ?? 0);
if (payout > 0 || refund > 0) return '';
const pending = Number(p.pendingOtherMatches ?? 0);
const wonLegs = Number(p.wonLegsOnMatch ?? 0);
if (pending > 0 && wonLegs > 0) {
return t('settlement.preview_zero_parlay_hint', { pending: String(pending), legs: String(wonLegs) });
}
if (Number(p.lostOnThisMatch ?? 0) > 0) {
return t('settlement.preview_zero_lost_hint', { count: String(p.lostOnThisMatch) });
}
return t('settlement.preview_zero_default_hint');
});
function previewResultLabel(result: string) {
const key = `settlement.preview.result.${result}`;
const label = t(key);
return label !== key ? label : betResultLabel(result);
}
const betTypeChartOption = computed(() => {
const s = stats.value?.summary;
if (!s) return null;
return buildBetTypePieOption({
title: t('settlement.chart.bet_type'),
singleBets: s.singleBets,
parlayBets: s.parlayBets,
totalBets: s.totalBets,
legCount: s.legCount,
labels: {
single: t('settlement.stats_single'),
parlay: t('settlement.stats_parlay'),
},
subtext: `${s.singleBets} ${t('settlement.stats_single')} · ${s.parlayBets} ${t('settlement.stats_parlay')} · ${s.legCount} ${t('settlement.col.legs')}`,
centerLabel: t('settlement.stats_total_bets'),
});
});
const statusChartOption = computed(() => {
const s = stats.value?.summary;
if (!s) return null;
const parts = ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH']
.filter((st) => (s.statusCounts[st] ?? 0) > 0)
.map((st) => `${betStatusLabel(st)} ${s.statusCounts[st]}`);
return buildStatusPieOption({
title: t('settlement.chart.status'),
statusCounts: s.statusCounts,
labelFor: (st) => betStatusLabel(st),
subtext: parts.join(' · ') || t('settlement.no_bets'),
});
});
const selectionStakeChartOption = computed(() => {
const s = stats.value?.summary;
const rows = stats.value?.bySelection ?? [];
if (!s) return null;
const top = [...rows]
.sort((a, b) => Number(b.singleStake) - Number(a.singleStake))
.slice(0, 6);
const labels = top.map((row) => {
const name = selectionDisplay(row);
return name.length > 8 ? `${name.slice(0, 8)}` : name;
});
return buildSelectionStakeBarOption({
title: t('settlement.chart.stake_by_selection'),
subtext: `${t('settlement.stats_total_stake')} ${formatAmount(s.totalStake)} · ${t('settlement.stats_potential')} ${formatAmount(s.totalPotentialReturn)}`,
labels: labels.length ? labels : [t('settlement.no_bets')],
stakes: top.length ? top.map((row) => Number(row.singleStake)) : [0],
seriesName: t('settlement.col.single_stake'),
});
});
const leagueLabel = computed(() => {
@@ -135,12 +244,28 @@ function formatTime(v: string) {
});
}
function matchBetSelectionSummary(
row: SettlementBetStats['bets']['items'][number],
) {
return row.selections
.map((leg) => {
const market = marketLabel(leg.marketType);
const sel = selectionDisplay(leg);
return `${market} ${sel} @${leg.odds}`;
})
.join(' · ');
}
async function loadStats() {
if (!matchId.value) return;
statsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`);
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`, {
params: { page: betPage.value, pageSize: betPageSize.value },
});
stats.value = data.data as SettlementBetStats;
betPage.value = stats.value.bets.page;
betPageSize.value = stats.value.bets.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
@@ -149,6 +274,17 @@ async function loadStats() {
}
}
function onBetPageChange(page: number) {
betPage.value = page;
void loadStats();
}
function onBetPageSizeChange(size: number) {
betPageSize.value = size;
betPage.value = 1;
void loadStats();
}
async function loadMatch() {
if (!matchId.value) return;
loading.value = true;
@@ -166,6 +302,7 @@ async function loadMatch() {
if (detail.score) {
score.value = { ...detail.score };
}
betPage.value = 1;
await loadStats();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -175,15 +312,91 @@ async function loadMatch() {
}
}
async function recordScore() {
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
ElMessage.success(t('msg.score_recorded'));
const isSettled = computed(() => match.value?.status === 'SETTLED');
async function previewResettlement() {
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
...score.value,
reason: resettleReason.value.trim() || undefined,
});
resettlePreview.value = data.data;
}
async function confirmResettle() {
if (!resettlePreview.value?.batch) return;
await api.post(`/admin/resettle/${(resettlePreview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.resettle_confirmed'));
resettlePreview.value = null;
await loadMatch();
}
function settlementApiError(e: unknown, fallback: string) {
const err = e as { response?: { data?: { error?: string; message?: string } } };
const raw = err.response?.data?.error ?? err.response?.data?.message ?? fallback;
if (raw === 'Score not recorded' || raw === 'Score not found') {
return t('settlement.err_score_not_recorded');
}
return raw;
}
async function saveScore() {
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
}
async function recordScore() {
try {
await saveScore();
ElMessage.success(t('msg.score_recorded'));
await loadMatch();
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('msg.save_failed')));
}
}
async function loadPreviewItems(page = previewPage.value, pageSize = previewPageSize.value) {
const batch = preview.value?.batch as { id: string } | undefined;
if (!batch) return;
try {
const { data } = await api.get(`/admin/settlement/${batch.id}/preview-items`, {
params: { page, pageSize },
});
if (preview.value) {
preview.value = { ...preview.value, items: data.data as PreviewItemsPage };
}
previewPage.value = (data.data as PreviewItemsPage).page;
previewPageSize.value = (data.data as PreviewItemsPage).pageSize;
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
}
}
async function previewSettlement() {
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`);
preview.value = data.data;
preview.value = null;
previewPage.value = 1;
try {
await saveScore();
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
page: 1,
pageSize: previewPageSize.value,
});
preview.value = data.data;
const itemsPage = data.data.items as PreviewItemsPage;
previewPage.value = itemsPage.page;
previewPageSize.value = itemsPage.pageSize;
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
}
}
function onPreviewPageChange(page: number) {
previewPage.value = page;
void loadPreviewItems(page, previewPageSize.value);
}
function onPreviewPageSizeChange(size: number) {
previewPageSize.value = size;
previewPage.value = 1;
void loadPreviewItems(1, size);
}
async function confirm() {
@@ -204,7 +417,7 @@ onMounted(() => {
</script>
<template>
<div v-loading="loading" class="settlement-page page-scroll">
<div v-loading="loading" class="settlement-page">
<div class="page-header">
<div class="header-left">
<el-button size="small" text @click="goBack"> {{ t('settlement.back') }}</el-button>
@@ -274,6 +487,18 @@ onMounted(() => {
<el-button type="primary" size="small" @click="previewSettlement">
{{ t('settlement.preview_btn') }}
</el-button>
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
<template v-if="isSettled">
<el-input
v-model="resettleReason"
size="small"
:placeholder="t('settlement.resettle_reason')"
style="width: 200px"
/>
<el-button type="warning" size="small" plain @click="previewResettlement">
{{ t('settlement.resettle_preview') }}
</el-button>
</template>
</div>
</div>
</div>
@@ -281,130 +506,193 @@ onMounted(() => {
<!-- 智能比分弹窗已关闭 Settlement.vue git 历史 -->
<el-card v-if="preview" class="preview-card" shadow="never">
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
<el-card v-if="resettlePreview" class="preview-card" shadow="never">
<div class="preview-title">{{ t('settlement.resettle_preview_title') }}</div>
<el-row :gutter="20">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ preview.singleBetCount }}</div>
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
<div class="pstat-value">{{ resettlePreview.affectedCount }}</div>
<div class="pstat-label">{{ t('settlement.resettle_affected') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
<div class="pstat-value pstat-green">{{ resettlePreview.totalTopup }}</div>
<div class="pstat-label">{{ t('settlement.resettle_topup') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
<div class="pstat-value pstat-orange">{{ resettlePreview.totalClawback }}</div>
<div class="pstat-label">{{ t('settlement.resettle_clawback') }}</div>
</div>
</el-col>
</el-row>
<el-button type="success" class="confirm-btn" @click="confirm">
{{ t('settlement.confirm_btn') }}
<el-button type="warning" class="confirm-btn" @click="confirmResettle">
{{ t('settlement.resettle_confirm') }}
</el-button>
</el-card>
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
<div class="section-title">{{ t('settlement.stats_title') }}</div>
<template v-if="stats">
<div class="summary-grid">
<div class="sstat">
<div class="sstat-value">{{ stats.summary.totalBets }}</div>
<div class="sstat-label">{{ t('settlement.stats_total_bets') }}</div>
<el-card v-if="preview" class="preview-card preview-card--compact" shadow="never">
<div class="preview-bar">
<span class="preview-bar-title">{{ t('settlement.preview_title') }}</span>
<div class="preview-metrics">
<div class="preview-metric">
<span class="preview-metric-value">{{ preview.pendingBetCount ?? preview.singleBetCount }}</span>
<span class="preview-metric-label">{{ t('settlement.preview_pending_bets') }}</span>
</div>
<div class="sstat">
<div class="sstat-value">{{ stats.summary.singleBets }}</div>
<div class="sstat-label">{{ t('settlement.stats_single') }}</div>
<div class="preview-metric">
<span class="preview-metric-value">{{ preview.singleBetCount }} / {{ preview.parlayBetCount }}</span>
<span class="preview-metric-label">{{ t('settlement.preview_bet_mix') }}</span>
</div>
<div class="sstat">
<div class="sstat-value">{{ stats.summary.parlayBets }}</div>
<div class="sstat-label">{{ t('settlement.stats_parlay') }}</div>
<div class="preview-metric">
<span class="preview-metric-value preview-metric-green">{{ formatAmount(String(preview.totalPayout ?? 0)) }}</span>
<span class="preview-metric-label">{{ t('settlement.est_payout') }}</span>
</div>
<div class="sstat">
<div class="sstat-value">{{ formatAmount(stats.summary.totalStake) }}</div>
<div class="sstat-label">{{ t('settlement.stats_total_stake') }}</div>
</div>
<div class="sstat">
<div class="sstat-value sstat-muted">{{ formatAmount(stats.summary.totalPotentialReturn) }}</div>
<div class="sstat-label">{{ t('settlement.stats_potential') }}</div>
<div class="preview-metric">
<span class="preview-metric-value preview-metric-orange">{{ formatAmount(String(preview.totalRefund ?? 0)) }}</span>
<span class="preview-metric-label">{{ t('settlement.refund_amount') }}</span>
</div>
</div>
<div v-if="statusSummary.length" class="status-chips">
<el-tag
v-for="st in statusSummary"
:key="st"
size="small"
:type="betStatusTagType(st)"
effect="plain"
>
{{ betStatusLabel(st) }} {{ stats.summary.statusCounts[st] }}
</el-tag>
<el-button type="success" size="small" @click="confirm">
{{ t('settlement.confirm_btn') }}
</el-button>
</div>
<p v-if="previewZeroHint" class="preview-zero-hint">{{ previewZeroHint }}</p>
<div v-if="previewItemsPage.total > 0" class="preview-items-wrap">
<div class="preview-items-head">
<span class="preview-items-title">
{{ t('settlement.preview_items_title', { n: previewItemsPage.total }) }}
</span>
</div>
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
<el-table
v-if="stats.bySelection.length"
:data="stats.bySelection"
size="small"
stripe
class="stats-table"
>
<el-table-column :label="t('settlement.col.market')" min-width="140">
<template #default="{ row }">
{{ marketLabel(row.marketType) }}
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="160">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.legs')" width="88" align="center" prop="legCount" />
<el-table-column :label="t('settlement.col.single_stake')" width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.parlay_legs')" width="100" align="center" prop="parlayLegCount" />
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
<div class="subsection-title">{{ t('settlement.bet_list') }} ({{ stats.bets.length }})</div>
<el-table
v-if="stats.bets.length"
:data="stats.bets"
size="small"
stripe
max-height="420"
class="stats-table"
>
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="150" />
<el-table-column prop="username" :label="t('bet.col.player')" width="110" />
<el-table-column :label="t('common.type')" width="72">
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
<el-table-column :label="t('common.type')" width="68">
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.market')" min-width="120">
<template #default="{ row }">{{ marketLabel(row.marketType) }}</template>
<el-table-column :label="t('settlement.preview_col.result')" width="108">
<template #default="{ row }">{{ previewResultLabel(row.result) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="120">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('bet.col.odds')" width="72" align="right" prop="odds" />
<el-table-column :label="t('bet.col.stake')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="96">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" width="130">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
<el-table-column :label="t('settlement.est_payout')" width="92" align="right">
<template #default="{ row }">{{ formatAmount(row.payout) }}</template>
</el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="160" show-overflow-tooltip prop="note" />
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</template>
<el-pagination
v-if="previewItemsPage.total > 0"
class="preview-pager bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="previewItemsPage.total"
:current-page="previewItemsPage.page"
:page-size="previewItemsPage.pageSize"
:page-sizes="[10, 20, 50]"
@current-change="onPreviewPageChange"
@size-change="onPreviewPageSizeChange"
/>
</div>
</el-card>
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
<div v-if="stats" class="stats-body">
<div class="stats-charts">
<div v-if="betTypeChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="betTypeChartOption" autoresize />
</div>
<div v-if="statusChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="statusChartOption" autoresize />
</div>
<div v-if="selectionStakeChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="selectionStakeChartOption" autoresize />
</div>
</div>
<div class="stats-tables">
<div class="stats-table-block">
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
<el-table
v-if="stats.bySelection.length"
:data="stats.bySelection"
size="small"
stripe
class="stats-table"
height="100%"
>
<el-table-column :label="t('settlement.col.market')" min-width="120">
<template #default="{ row }">
{{ marketLabel(row.marketType) }}
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="120">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.legs')" width="72" align="center" prop="legCount" />
<el-table-column :label="t('settlement.col.single_stake')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.parlay_legs')" width="88" align="center" prop="parlayLegCount" />
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</div>
<div class="stats-table-block stats-table-block--bets">
<div class="subsection-title">
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
</div>
<div class="table-wrap">
<el-table
v-if="stats.bets.items.length"
:data="stats.bets.items"
size="small"
stripe
class="stats-table"
>
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
<el-table-column :label="t('common.type')" width="72">
<template #default="{ row }">
{{ betTypeLabel(row.betType) }}
<span v-if="row.legCountOnMatch > 1" class="leg-badge">×{{ row.legCountOnMatch }}</span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.content')" min-width="200">
<template #default="{ row }">
<span class="bet-content-cell">{{ matchBetSelectionSummary(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.stake')" width="88" align="right">
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" width="120">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</div>
<el-pagination
v-if="stats.bets.total > 0"
class="bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="stats.bets.total"
:current-page="stats.bets.page"
:page-size="stats.bets.pageSize"
:page-sizes="[10, 20, 50]"
@current-change="onBetPageChange"
@size-change="onBetPageSizeChange"
/>
</div>
</div>
</div>
</el-card>
</div>
</template>
@@ -413,11 +701,11 @@ onMounted(() => {
.settlement-page {
display: flex;
flex-direction: column;
gap: 16px;
flex: none !important;
min-height: auto !important;
align-self: flex-start;
gap: 12px;
height: 100%;
min-height: 0;
width: 100%;
overflow: hidden;
}
.page-header {
@@ -426,6 +714,7 @@ onMounted(() => {
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
}
.header-left {
@@ -449,11 +738,41 @@ onMounted(() => {
}
.settle-top-card,
.stats-card,
.preview-card {
flex-shrink: 0;
border-radius: 12px;
}
.settle-top-card :deep(.el-card__body) {
padding: 14px 16px;
}
.stats-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
}
.stats-card :deep(.el-card__body) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 10px 12px 12px;
}
.stats-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.settle-top-row {
display: flex;
align-items: center;
@@ -528,54 +847,88 @@ onMounted(() => {
}
.section-title {
font-size: 15px;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 14px;
margin-bottom: 10px;
flex-shrink: 0;
}
.subsection-title {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #aaa;
margin: 18px 0 10px;
margin: 0 0 8px;
flex-shrink: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
.subsection-hint {
margin-left: 8px;
font-size: 10px;
font-weight: 400;
color: #666;
}
.sstat {
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 8px;
text-align: center;
}
.sstat-value {
font-size: 22px;
font-weight: 700;
color: #eee;
}
.sstat-muted {
.leg-badge {
margin-left: 4px;
font-size: 10px;
color: var(--green-text, #2fb56a);
}
.sstat-label {
font-size: 11px;
color: #888;
margin-top: 4px;
.bet-content-cell {
font-size: 12px;
color: #bbb;
line-height: 1.4;
}
.status-chips {
display: flex;
flex-wrap: wrap;
.stats-charts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
flex-shrink: 0;
}
.mini-chart {
border: 1px solid #2a2a2a;
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
padding: 4px 6px 0;
min-height: 0;
overflow: hidden;
}
.mini-chart-canvas {
height: 148px;
width: 100%;
}
.stats-tables {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 12px;
overflow: hidden;
}
.stats-table-block {
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.stats-table-block--bets .table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.bet-pager {
flex-shrink: 0;
margin-top: 8px;
display: flex;
justify-content: flex-end;
}
.stats-table {
@@ -635,15 +988,109 @@ onMounted(() => {
align-items: center;
}
.preview-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.preview-title {
font-size: 15px;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 16px;
margin-bottom: 12px;
}
.preview-card--compact :deep(.el-card__body) {
padding: 12px 16px;
}
.preview-bar {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.preview-bar-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
white-space: nowrap;
}
.preview-metrics {
display: flex;
align-items: center;
gap: 20px;
flex: 1;
flex-wrap: wrap;
}
.preview-metric {
display: flex;
align-items: baseline;
gap: 8px;
}
.preview-metric-value {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
}
.preview-metric-green {
color: var(--green-glow);
}
.preview-metric-orange {
color: #e8a040;
}
.preview-metric-label {
font-size: 12px;
color: #888;
}
.preview-zero-hint {
margin: 10px 0 0;
padding: 8px 10px;
border-radius: 6px;
background: rgba(232, 160, 64, 0.12);
border: 1px solid rgba(232, 160, 64, 0.25);
color: #e8c080;
font-size: 12px;
line-height: 1.5;
}
.preview-items-wrap {
margin-top: 10px;
}
.preview-items-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.preview-items-title {
font-size: 12px;
font-weight: 600;
color: #aaa;
}
.preview-items-table {
margin-top: 0;
}
.preview-pager {
margin-top: 8px;
justify-content: flex-end;
}
.pstat {
padding: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 10px;
@@ -651,7 +1098,7 @@ onMounted(() => {
}
.pstat-value {
font-size: 26px;
font-size: 22px;
font-weight: 700;
color: #e0e0e0;
}
@@ -672,7 +1119,23 @@ onMounted(() => {
}
.confirm-btn {
margin-top: 24px;
margin-top: 16px;
}
@media (max-width: 1100px) {
.stats-charts {
grid-template-columns: 1fr;
}
.stats-tables {
grid-template-columns: 1fr;
overflow-y: auto;
}
.stats-table-block {
min-height: 200px;
}
}
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)

View File

@@ -47,14 +47,48 @@ const editingId = ref('');
const depositForm = ref({ userId: '', amount: 100, remark: '' });
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const bettingLimits = ref({
minStake: 1,
maxStakeSingle: 50000,
maxStakeParlay: 20000,
maxPayoutSingle: 500000,
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
onMounted(() => {
loadAgentOptions();
loadPlayerSettings();
loadBettingLimits();
load();
});
async function loadBettingLimits() {
try {
const { data } = await api.get('/admin/settings/betting-limits');
bettingLimits.value = data.data;
} catch {
/* 使用默认值 */
}
}
async function saveBettingLimits() {
limitsSaving.value = true;
try {
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
bettingLimits.value = data.data;
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
loadBettingLimits();
} finally {
limitsSaving.value = false;
}
}
async function loadPlayerSettings() {
try {
const { data } = await api.get('/admin/users/settings/account');
@@ -312,6 +346,38 @@ function statusLabel(s: string) {
</div>
</el-card>
<el-card class="settings-card" shadow="never">
<div class="global-settings">
<span class="settings-title">{{ t('user.betting_limits') }}</span>
<span class="settings-desc">{{ t('user.betting_limits_hint') }}</span>
<el-form inline size="small" class="settings-form limits-form">
<el-form-item :label="t('user.limit.min_stake')">
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_single')">
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_parlay')">
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_single')">
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_parlay')">
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.daily_stake')">
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('common.keyword')">

View File

@@ -1,3 +1,13 @@
export interface BetSelectionPreview {
matchId: string | null;
matchLabel: string;
leagueName: string;
marketType: string;
period: string | null;
selectionName: string;
odds: string;
}
export interface BetListRow {
id: string;
betNo: string;
@@ -16,11 +26,15 @@ export interface BetListRow {
placedAt: string;
settledAt: string | null;
selectionCount: number;
selectionSummary: string;
selectionPreviews: BetSelectionPreview[];
}
export interface BetSelectionDetail {
id: string;
matchId: string | null;
matchLabel: string;
leagueName: string;
marketType: string;
period: string | null;
selectionName: string;