feat(admin,api,player): 结算预览分页、统计图表与返水限额
完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)
|
||||
|
||||
Reference in New Issue
Block a user