Files
thebet365/apps/admin/src/views/Settlement.vue

1508 lines
42 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 { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { formatApiErrorMessage, isApiErrorCode } from '@thebet365/shared';
import api from '../api';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
const VChart = defineAsyncComponent(() =>
import('../components/dashboard/echarts-setup').then((m) => m.VChart),
);
import { formatAmount } from '../utils/format-amount';
import {
buildBetTypePieOption,
buildSelectionStakeBarOption,
buildStatusPieOption,
} from '../utils/settlement-charts';
import {
betStatusLabel,
betStatusTagType,
betTypeLabel,
betResultLabel,
} from '../utils/bet-labels';
import { adminSelectionLabel } from '../utils/adminSelectionLabel';
import type { AdminMatchDetail } from './match-form';
import AdminSubNav from '../components/AdminSubNav.vue';
interface SettlementBetStats {
summary: {
totalBets: number;
singleBets: number;
parlayBets: number;
totalStake: string;
totalPotentialReturn: string;
statusCounts: Record<string, number>;
legCount: number;
};
bySelection: Array<{
marketType: string;
period: string | null;
selectionName: string;
selectionId: string;
legCount: number;
singleStake: string;
parlayLegCount: number;
}>;
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();
const { hasPermission } = usePermissions();
const canResettle = computed(() => hasPermission(AdminPerm.resettle));
const route = useRoute();
const router = useRouter();
const STAT_FACT_LABELS = {
homeCorners: {
'zh-CN': '主队角球',
'en-US': 'Home corners',
'ms-MY': 'Sepakan sudut tuan rumah',
},
awayCorners: {
'zh-CN': '客队角球',
'en-US': 'Away corners',
'ms-MY': 'Sepakan sudut pelawat',
},
homeCards: {
'zh-CN': '主队黄牌/红牌',
'en-US': 'Home yellow/red cards',
'ms-MY': 'Kad kuning/merah tuan rumah',
},
awayCards: {
'zh-CN': '客队黄牌/红牌',
'en-US': 'Away yellow/red cards',
'ms-MY': 'Kad kuning/merah pelawat',
},
} as const;
const loading = ref(false);
const previewing = ref(false);
const match = ref<AdminMatchDetail | null>(null);
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const matchStats = ref<{
homeCorners: number | null;
awayCorners: number | null;
homeYellowCards: number | null;
awayYellowCards: number | null;
homeRedCards: number | null;
awayRedCards: number | null;
homeCards: number | null;
awayCards: number | null;
}>(emptyMatchStats());
function emptyMatchStats() {
return {
homeCorners: null,
awayCorners: null,
homeYellowCards: null,
awayYellowCards: null,
homeRedCards: null,
awayRedCards: null,
homeCards: null,
awayCards: null,
};
}
function cardTotalFromBreakdown(
yellow: number | null,
red: number | null,
fallback: number | null,
) {
const hasBreakdown = yellow != null || red != null;
if (!hasBreakdown) return fallback ?? null;
if (yellow == null || red == null) return null;
return yellow + red;
}
function settlementStatsPayload() {
const s = matchStats.value;
return {
...s,
homeCards: cardTotalFromBreakdown(s.homeYellowCards, s.homeRedCards, s.homeCards),
awayCards: cardTotalFromBreakdown(s.awayYellowCards, s.awayRedCards, s.awayCards),
};
}
function resetSettlementInputs() {
score.value = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
matchStats.value = emptyMatchStats();
}
const winnerTeamId = ref('');
const outrightSelections = ref<
Array<{ teamId: string; teamCode: string; teamZh: string; teamEn: string }>
>([]);
const preview = ref<Record<string, unknown> | null>(null);
const resettlePreview = ref<Record<string, unknown> | null>(null);
const resettleReason = ref('');
const statsSummary = ref<Pick<SettlementBetStats, 'summary' | 'bySelection'> | null>(null);
const betsList = ref<SettlementBetStats['bets'] | null>(null);
const statsLoading = ref(false);
const betsLoading = ref(false);
const stats = computed((): SettlementBetStats | null => {
if (!statsSummary.value || !betsList.value) return null;
return { ...statsSummary.value, bets: betsList.value };
});
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 isOutright = computed(() => match.value?.isOutright === true);
const outrightTitle = computed(() => {
const m = match.value;
if (!m) return '';
const name = m.matchName?.trim();
if (name) return name;
if (locale.value === 'en-US') return m.leagueEn || m.leagueZh;
if (locale.value === 'ms-MY') return m.leagueMs || m.leagueEn || m.leagueZh;
return m.leagueZh || m.leagueEn;
});
const settlementPageTitle = computed(() =>
isOutright.value ? t('settlement.outright.page_title') : t('page.settlement.title'),
);
function outrightTeamLabel(row: { teamZh: string; teamEn: string; teamCode: string }) {
const name =
locale.value === 'en-US'
? row.teamEn || row.teamZh
: locale.value === 'ms-MY'
? row.teamEn || row.teamZh
: row.teamZh || row.teamEn;
return `${name} (${row.teamCode})`;
}
function buildSettlementPayload(): Record<string, unknown> | null {
if (isOutright.value) {
if (!winnerTeamId.value) {
ElMessage.warning(t('settlement.outright.winner_required'));
return null;
}
return { winnerTeamId: Number(winnerTeamId.value) };
}
return { ...score.value, ...settlementStatsPayload() };
}
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) {
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(() => {
const m = match.value;
if (!m) return '';
if (locale.value === 'en-US') return m.leagueEn || m.leagueZh;
if (locale.value === 'ms-MY') return m.leagueMs || m.leagueEn || m.leagueZh;
return m.leagueZh || m.leagueEn;
});
const homeLabel = computed(() => {
const m = match.value;
if (!m) return '';
if (locale.value === 'en-US') return m.homeTeamEn || m.homeTeamZh;
if (locale.value === 'ms-MY') return m.homeTeamMs || m.homeTeamEn || m.homeTeamZh;
return m.homeTeamZh || m.homeTeamEn;
});
const awayLabel = computed(() => {
const m = match.value;
if (!m) return '';
if (locale.value === 'en-US') return m.awayTeamEn || m.awayTeamZh;
if (locale.value === 'ms-MY') return m.awayTeamMs || m.awayTeamEn || m.awayTeamZh;
return m.awayTeamZh || m.awayTeamEn;
});
const kickoffLabel = computed(() => {
const iso = match.value?.startTime;
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(locale.value === 'en-US' ? 'en-GB' : 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
});
const statusLabel = computed(() => {
const s = match.value?.status;
if (!s) return '—';
const key = `match.status.${s}`;
const translated = t(key);
return translated === key ? s : translated;
});
function marketLabel(marketType: string) {
const key = `matchEditor.market.${marketType}`;
const label = t(key);
return label === key ? marketType : label;
}
function selectionDisplay(row: { selectionName: string; period?: string | null }) {
return adminSelectionLabel(t, row.selectionName, {
period: row.period ?? undefined,
});
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
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 loadStatsSummary() {
if (!matchId.value) return;
statsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/summary`);
statsSummary.value = data.data as Pick<SettlementBetStats, 'summary' | 'bySelection'>;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
statsLoading.value = false;
}
}
async function loadBets() {
if (!matchId.value) return;
betsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/bets`, {
params: { page: betPage.value, pageSize: betPageSize.value },
});
const payload = data.data as SettlementBetStats['bets'];
betsList.value = payload;
betPage.value = payload.page;
betPageSize.value = payload.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
betsLoading.value = false;
}
}
async function loadStats() {
betPage.value = 1;
await Promise.all([loadStatsSummary(), loadBets()]);
}
function onBetPageChange(page: number) {
betPage.value = page;
void loadBets();
}
function onBetPageSizeChange(size: number) {
betPageSize.value = size;
betPage.value = 1;
void loadBets();
}
async function loadMatch() {
if (!matchId.value) return;
loading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}`);
const detail = data.data as AdminMatchDetail;
const settleable =
detail.status === 'CLOSED' ||
detail.status === 'PENDING_SETTLEMENT' ||
detail.status === 'SETTLED';
if (!settleable) {
ElMessage.warning(t('settlement.must_close_first'));
router.replace(detail.isOutright ? '/matches/outrights' : '/matches');
return;
}
match.value = detail;
if (detail.score) {
score.value = {
htHome: detail.score.htHome,
htAway: detail.score.htAway,
ftHome: detail.score.ftHome,
ftAway: detail.score.ftAway,
};
matchStats.value = {
homeCorners: detail.score.homeCorners ?? null,
awayCorners: detail.score.awayCorners ?? null,
homeYellowCards: detail.score.homeYellowCards ?? null,
awayYellowCards: detail.score.awayYellowCards ?? null,
homeRedCards: detail.score.homeRedCards ?? null,
awayRedCards: detail.score.awayRedCards ?? null,
homeCards: detail.score.homeCards ?? null,
awayCards: detail.score.awayCards ?? null,
};
winnerTeamId.value = detail.score.winnerTeamId ?? '';
} else {
resetSettlementInputs();
winnerTeamId.value = '';
}
if (detail.isOutright) {
const outrightRes = await api.get(`/admin/outrights/${matchId.value}`);
const payload = outrightRes.data.data as {
selections: Array<{
teamId: string | null;
teamCode: string;
teamZh: string;
teamEn: string;
status: string;
}>;
};
outrightSelections.value = (payload.selections ?? [])
.filter((s) => s.teamId)
.map((s) => ({
teamId: s.teamId as string,
teamCode: s.teamCode,
teamZh: s.teamZh,
teamEn: s.teamEn,
}));
} else {
outrightSelections.value = [];
}
betPage.value = 1;
await loadStats();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
const isSettled = computed(() => match.value?.status === 'SETTLED');
async function previewResettlement() {
const payload = buildSettlementPayload();
if (!payload) return;
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
...payload,
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?: { code?: unknown; params?: Record<string, string | number>; error?: string; message?: string } } };
const code = err.response?.data?.code;
if (isApiErrorCode(code)) {
const params = { ...(err.response?.data?.params ?? {}) };
if (code === 'SETTLEMENT_FACTS_REQUIRED' && typeof params.fields === 'string') {
params.fields = params.fields
.split(',')
.map((field) => {
const labels = STAT_FACT_LABELS[field as keyof typeof STAT_FACT_LABELS];
return labels?.[locale.value as keyof typeof labels] ?? field;
})
.join(locale.value === 'zh-CN' ? '、' : ', ');
}
return formatApiErrorMessage(code, locale.value, params);
}
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');
}
if (raw === 'SETTLEMENT_WINNER_REQUIRED') {
return t('settlement.outright.winner_required');
}
return raw;
}
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 payload = buildSettlementPayload();
if (!payload) return;
preview.value = null;
previewPage.value = 1;
previewing.value = true;
try {
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
...payload,
page: 1,
pageSize: previewPageSize.value,
});
preview.value = data.data;
const itemsPage = data.data.items as PreviewItemsPage;
previewPage.value = itemsPage.page;
previewPageSize.value = itemsPage.pageSize;
await loadMatch();
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally {
previewing.value = false;
}
}
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() {
if (!preview.value?.batch) return;
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.settlement_confirmed'));
preview.value = null;
await loadMatch();
}
onMounted(() => {
void loadMatch();
});
</script>
<template>
<div v-loading="loading" class="settlement-page">
<AdminSubNav
:title="settlementPageTitle"
:subtitle="`#${matchId}`"
>
<template #extra>
<el-tag v-if="match" size="small" type="info">{{ statusLabel }}</el-tag>
</template>
</AdminSubNav>
<el-card v-if="match" class="settle-top-card" shadow="never">
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
<div v-if="isOutright" class="outright-settle-head">
<p class="outright-settle-title">{{ outrightTitle }}</p>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.outright.title') }}</span>
</span>
</div>
<div v-else class="match-inline">
<div class="team-chip">
<img
v-if="match.homeTeamLogoUrl"
:src="match.homeTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ homeLabel }}</span>
</div>
<span class="vs">VS</span>
<div class="team-chip">
<img
v-if="match.awayTeamLogoUrl"
:src="match.awayTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ awayLabel }}</span>
</div>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.kickoff') }}</span>
{{ kickoffLabel }}
</span>
</div>
<div class="settle-score-row">
<div v-if="isOutright" class="outright-winner-row">
<span class="score-title">{{ t('settlement.outright.winner') }}</span>
<el-select
v-model="winnerTeamId"
filterable
clearable
class="outright-winner-select"
:placeholder="t('settlement.outright.winner_ph')"
>
<el-option
v-for="row in outrightSelections"
:key="row.teamId"
:label="outrightTeamLabel(row)"
:value="row.teamId"
/>
</el-select>
</div>
<div v-else class="score-inline-group">
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ht_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ft_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
<div class="score-block compact stat-block">
<span class="score-title">{{ t('settlement.corners') }}</span>
<div class="stat-inputs">
<span class="stat-side">{{ t('settlement.home_stat') }}</span>
<el-input-number
v-model="matchStats.homeCorners"
:min="0"
:precision="0"
controls-position="right"
style="width: 80px"
/>
<span class="stat-side">{{ t('settlement.away_stat') }}</span>
<el-input-number
v-model="matchStats.awayCorners"
:min="0"
:precision="0"
controls-position="right"
style="width: 80px"
/>
</div>
</div>
<div class="score-block compact stat-block">
<span class="score-title">{{ t('settlement.yellow_cards') }}</span>
<div class="stat-inputs">
<span class="stat-side">{{ t('settlement.home_stat') }}</span>
<el-input-number
v-model="matchStats.homeYellowCards"
:min="0"
:precision="0"
controls-position="right"
style="width: 80px"
/>
<span class="stat-side">{{ t('settlement.away_stat') }}</span>
<el-input-number
v-model="matchStats.awayYellowCards"
:min="0"
:precision="0"
controls-position="right"
style="width: 80px"
/>
</div>
</div>
<div class="score-block compact stat-block">
<span class="score-title">{{ t('settlement.red_cards') }}</span>
<div class="stat-inputs">
<span class="stat-side">{{ t('settlement.home_stat') }}</span>
<el-input-number
v-model="matchStats.homeRedCards"
:min="0"
:precision="0"
controls-position="right"
style="width: 80px"
/>
<span class="stat-side">{{ t('settlement.away_stat') }}</span>
<el-input-number
v-model="matchStats.awayRedCards"
:min="0"
:precision="0"
controls-position="right"
style="width: 80px"
/>
</div>
</div>
</div>
<div class="settle-actions">
<template v-if="!isSettled">
<el-button
type="primary"
:loading="previewing"
@click="previewSettlement"
>
{{ t('settlement.preview_btn') }}
</el-button>
<span class="preview-hint">{{
isOutright ? t('settlement.outright.preview_hint') : t('settlement.preview_hint')
}}</span>
</template>
<template v-else-if="canResettle">
<el-input
v-model="resettleReason"
size="small"
:placeholder="t('settlement.resettle_reason')"
class="settle-resettle-reason"
/>
<el-button type="warning" plain @click="previewResettlement">
{{ t('settlement.resettle_preview') }}
</el-button>
</template>
</div>
</div>
</el-card>
<!-- 智能比分弹窗已关闭 Settlement.vue git 历史 -->
<el-card v-if="canResettle && 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">{{ 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">{{ 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">{{ resettlePreview.totalClawback }}</div>
<div class="pstat-label">{{ t('settlement.resettle_clawback') }}</div>
</div>
</el-col>
</el-row>
<el-button type="warning" class="confirm-btn" @click="confirmResettle">
{{ t('settlement.resettle_confirm') }}
</el-button>
</el-card>
<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="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="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="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>
<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>
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
<el-table-column type="index" :index="(i: number) => (previewItemsPage.page - 1) * previewItemsPage.pageSize + i + 1" :label="t('common.seq')" width="55" align="center" />
<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.preview_col.result')" width="108">
<template #default="{ row }">{{ previewResultLabel(row.result) }}</template>
</el-table-column>
<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>
<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 v-loading="betsLoading" class="table-wrap">
<el-table
v-if="stats.bets.items.length"
:data="stats.bets.items"
size="small"
stripe
class="stats-table"
>
<el-table-column type="index" :index="(i: number) => ((stats?.bets.page ?? 1) - 1) * (stats?.bets.pageSize ?? 10) + i + 1" :label="t('common.seq')" width="55" align="center" />
<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>
<style scoped>
.settlement-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
width: 100%;
overflow: hidden;
}
.settle-top-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-score-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px 24px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.outright-settle-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
}
.outright-settle-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--success-text);
}
.outright-winner-row {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 240px;
flex: 1;
}
.outright-winner-select {
width: min(100%, 360px);
}
.settle-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
}
.settle-resettle-reason {
width: 220px;
}
.match-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
flex: 1;
min-width: 280px;
}
.team-chip {
display: inline-flex;
align-items: center;
gap: 8px;
}
.team-logo-sm {
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
}
.team-name-inline {
font-size: 16px;
font-weight: 750;
color: var(--text);
white-space: nowrap;
}
.kickoff-inline {
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
.kickoff-inline .meta-k {
color: #aaa49a;
margin-right: 4px;
}
.score-inline-group {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 16px 20px;
}
.score-block.compact {
display: flex;
flex-direction: column;
gap: 6px;
}
.score-block.compact .score-title {
margin-bottom: 0;
}
.stat-block {
padding-left: 2px;
}
.stat-inputs {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.stat-side {
font-size: 11px;
color: var(--warning-text);
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 10px;
flex-shrink: 0;
}
.subsection-title {
font-size: 12px;
font-weight: 600;
color: #aaa;
margin: 0 0 8px;
flex-shrink: 0;
}
.subsection-hint {
margin-left: 8px;
font-size: 10px;
font-weight: 400;
color: #666;
}
.leg-badge {
margin-left: 4px;
font-size: 10px;
color: var(--warning-text);
}
.bet-content-cell {
font-size: 12px;
color: #bbb;
line-height: 1.4;
}
.stats-charts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
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 {
width: 100%;
}
.period-tag {
margin-left: 6px;
font-size: 11px;
color: #666;
}
.empty-hint {
margin: 0;
font-size: 13px;
color: #666;
text-align: center;
padding: 16px 0;
}
.match-league {
margin: 0 0 10px;
font-size: 13px;
color: #888;
}
.vs {
font-size: 13px;
font-weight: 700;
color: var(--warning-text);
flex-shrink: 0;
}
.score-title {
font-size: 13px;
color: #999;
margin-bottom: 8px;
font-weight: 500;
}
.score-inputs {
display: flex;
align-items: center;
gap: 10px;
}
.score-sep {
font-size: 18px;
color: #666;
font-weight: 300;
}
.preview-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.preview-title {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
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: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 10px;
text-align: center;
}
.pstat-value {
font-size: 22px;
font-weight: 700;
color: #e0e0e0;
}
.pstat-green {
color: var(--green-glow);
text-shadow: none;
}
.pstat-orange {
color: #e8a040;
}
.pstat-label {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.confirm-btn {
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;
}
}
.section-title,
.preview-title,
.preview-bar-title,
.pstat-value {
color: var(--text);
}
.subsection-title,
.bet-content-cell,
.match-league,
.score-title,
.preview-metric-label,
.preview-items-title,
.period-tag,
.empty-hint,
.subsection-hint {
color: var(--text-muted);
}
.stat-side,
.leg-badge,
.vs,
.preview-metric-orange,
.pstat-orange {
color: var(--warning-text);
}
.preview-metric-green,
.pstat-green {
color: var(--success-text);
}
.mini-chart,
.pstat {
border-color: var(--border);
background: #ffffff;
box-shadow: var(--shadow);
}
.preview-zero-hint {
border-color: var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
}
.score-sep {
color: var(--text-muted);
}
.stats-table-block--bets .table-wrap {
border-color: var(--border);
background: #ffffff;
}
@media (max-width: 760px) {
.score-row,
.preview-bar,
.preview-metrics {
align-items: stretch;
}
.preview-metric {
justify-content: space-between;
width: 100%;
}
}
</style>