1859 lines
55 KiB
Vue
1859 lines
55 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted, defineAsyncComponent, watch, nextTick } 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 { markAdminListStale } from '../utils/adminListStale';
|
||
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();
|
||
|
||
function settlementReturnTo(isOutright = false) {
|
||
const q = route.query.returnTo;
|
||
if (typeof q === 'string' && q.startsWith('/')) return q;
|
||
return isOutright ? '/matches/outrights' : '/matches';
|
||
}
|
||
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: 0,
|
||
awayCorners: 0,
|
||
homeYellowCards: 0,
|
||
awayYellowCards: 0,
|
||
homeRedCards: 0,
|
||
awayRedCards: 0,
|
||
homeCards: 0,
|
||
awayCards: 0,
|
||
};
|
||
}
|
||
|
||
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 }>
|
||
>([]);
|
||
interface ResettlePreviewItem {
|
||
betId: string;
|
||
betNo: string;
|
||
oldPayout: string;
|
||
newPayout: string;
|
||
delta: string;
|
||
oldStatus: string;
|
||
newStatus: string;
|
||
}
|
||
|
||
interface ResettlePreview {
|
||
batch: {
|
||
id: string;
|
||
batchNo: string;
|
||
};
|
||
affectedCount: number;
|
||
totalTopup: string;
|
||
totalClawback: string;
|
||
items: ResettlePreviewItem[];
|
||
}
|
||
|
||
const preview = ref<Record<string, unknown> | null>(null);
|
||
const resettlePreview = ref<ResettlePreview | 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);
|
||
const previewDialogVisible = ref(false);
|
||
const resettleDialogVisible = ref(false);
|
||
const confirmLoading = ref(false);
|
||
const resettleConfirmLoading = ref(false);
|
||
|
||
const resettlePage = ref(1);
|
||
const resettlePageSize = ref(10);
|
||
|
||
const resettleItemsPage = computed(() => {
|
||
if (!resettlePreview.value?.items) return [];
|
||
const start = (resettlePage.value - 1) * resettlePageSize.value;
|
||
const end = start + resettlePageSize.value;
|
||
return resettlePreview.value.items.slice(start, end);
|
||
});
|
||
|
||
const isProgrammaticChange = ref(false);
|
||
|
||
interface SettlementHistoryRecord {
|
||
id: string;
|
||
batchNo: string;
|
||
htHomeScore: number | null;
|
||
htAwayScore: number | null;
|
||
ftHomeScore: number | null;
|
||
ftAwayScore: number | null;
|
||
homeCorners: number | null;
|
||
awayCorners: number | null;
|
||
homeYellowCards: number | null;
|
||
awayYellowCards: number | null;
|
||
homeRedCards: number | null;
|
||
awayRedCards: number | null;
|
||
homeCards: number | null;
|
||
awayCards: number | null;
|
||
totalBets: number;
|
||
totalPayout: string;
|
||
totalRefund: string;
|
||
confirmedAt: string | null;
|
||
isResettle: boolean;
|
||
reason: string | null;
|
||
operatorUsername: string;
|
||
}
|
||
|
||
const settlementHistory = ref<SettlementHistoryRecord[]>([]);
|
||
const historyLoading = ref(false);
|
||
const activeTab = ref<'bets' | 'history'>('bets');
|
||
|
||
async function loadSettlementHistory() {
|
||
if (!matchId.value) return;
|
||
historyLoading.value = true;
|
||
try {
|
||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/history`);
|
||
settlementHistory.value = data.data as SettlementHistoryRecord[];
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||
} finally {
|
||
historyLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// 智能比分推荐已暂时关闭(后端 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 formatScorePair(h: number | null, a: number | null) {
|
||
if (h == null || a == null) return '—';
|
||
return `${h}-${a}`;
|
||
}
|
||
|
||
function formatHistoryScore(row: SettlementHistoryRecord) {
|
||
const ht = formatScorePair(row.htHomeScore, row.htAwayScore);
|
||
const ft = formatScorePair(row.ftHomeScore, row.ftAwayScore);
|
||
if (ht === '—' && ft === '—') return '—';
|
||
return `${ht} / ${ft}`;
|
||
}
|
||
|
||
function formatHomeAwayPair(h: number | null, a: number | null) {
|
||
if (h == null && a == null) return '—';
|
||
return `${h ?? '—'} / ${a ?? '—'}`;
|
||
}
|
||
|
||
function formatHistoryCards(row: SettlementHistoryRecord) {
|
||
const { homeYellowCards: yH, awayYellowCards: yA, homeRedCards: rH, awayRedCards: rA } = row;
|
||
if (yH == null && yA == null && rH == null && rA == null) return '—';
|
||
return `Y:${yH ?? '—'}/${yA ?? '—'} R:${rH ?? '—'}/${rA ?? '—'}`;
|
||
}
|
||
|
||
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;
|
||
isProgrammaticChange.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(settlementReturnTo(detail.isOutright));
|
||
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 ?? 0,
|
||
awayCorners: detail.score.awayCorners ?? 0,
|
||
homeYellowCards: detail.score.homeYellowCards ?? 0,
|
||
awayYellowCards: detail.score.awayYellowCards ?? 0,
|
||
homeRedCards: detail.score.homeRedCards ?? 0,
|
||
awayRedCards: detail.score.awayRedCards ?? 0,
|
||
homeCards: detail.score.homeCards ?? 0,
|
||
awayCards: detail.score.awayCards ?? 0,
|
||
};
|
||
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();
|
||
if (detail.status === 'PENDING_SETTLEMENT') {
|
||
try {
|
||
const previewRes = await api.get(`/admin/matches/${matchId.value}/settlement/preview`, {
|
||
params: { page: 1, pageSize: previewPageSize.value }
|
||
});
|
||
if (previewRes.data.data) {
|
||
preview.value = previewRes.data.data;
|
||
const itemsPage = previewRes.data.data.items as PreviewItemsPage;
|
||
previewPage.value = itemsPage.page;
|
||
previewPageSize.value = itemsPage.pageSize;
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load active settlement preview', e);
|
||
}
|
||
}
|
||
} 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;
|
||
await nextTick();
|
||
isProgrammaticChange.value = false;
|
||
}
|
||
}
|
||
|
||
const isSettled = computed(() => match.value?.status === 'SETTLED');
|
||
|
||
async function previewResettlement() {
|
||
const payload = buildSettlementPayload();
|
||
if (!payload) return;
|
||
try {
|
||
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
|
||
...payload,
|
||
reason: resettleReason.value.trim() || undefined,
|
||
});
|
||
resettlePage.value = 1;
|
||
resettlePreview.value = data.data;
|
||
resettleDialogVisible.value = true;
|
||
} catch (e: unknown) {
|
||
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||
}
|
||
}
|
||
|
||
async function confirmResettle() {
|
||
if (!resettlePreview.value?.batch) return;
|
||
resettleConfirmLoading.value = true;
|
||
try {
|
||
await api.post(`/admin/resettle/${(resettlePreview.value.batch as { id: string }).id}/confirm`);
|
||
ElMessage.success(t('msg.resettle_confirmed'));
|
||
resettlePreview.value = null;
|
||
resettleDialogVisible.value = false;
|
||
await loadMatch();
|
||
void loadSettlementHistory();
|
||
markAdminListStale();
|
||
} catch (e: unknown) {
|
||
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||
} finally {
|
||
resettleConfirmLoading.value = false;
|
||
}
|
||
}
|
||
|
||
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;
|
||
previewDialogVisible.value = true;
|
||
await loadMatch();
|
||
markAdminListStale();
|
||
} 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;
|
||
confirmLoading.value = true;
|
||
try {
|
||
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
|
||
ElMessage.success(t('msg.settlement_confirmed'));
|
||
preview.value = null;
|
||
previewDialogVisible.value = false;
|
||
await loadMatch();
|
||
void loadSettlementHistory();
|
||
markAdminListStale();
|
||
} catch (e: unknown) {
|
||
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||
} finally {
|
||
confirmLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function handlePreviewClick() {
|
||
if (preview.value) {
|
||
previewDialogVisible.value = true;
|
||
} else {
|
||
void previewSettlement();
|
||
}
|
||
}
|
||
|
||
watch(
|
||
[score, matchStats, winnerTeamId],
|
||
() => {
|
||
if (isProgrammaticChange.value) return;
|
||
preview.value = null;
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
onMounted(() => {
|
||
void loadMatch();
|
||
void loadSettlementHistory();
|
||
});
|
||
</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="handlePreviewClick"
|
||
>
|
||
{{ preview ? t('settlement.view_preview_btn') : 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-dialog
|
||
v-model="resettleDialogVisible"
|
||
:title="t('settlement.resettle_preview_title')"
|
||
width="850px"
|
||
destroy-on-close
|
||
>
|
||
<div v-if="resettlePreview">
|
||
<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>
|
||
|
||
<div v-if="resettlePreview.items && resettlePreview.items.length > 0" class="preview-items-wrap" style="margin-top: 20px;">
|
||
<div class="preview-items-head" style="margin-bottom: 10px;">
|
||
<span class="preview-items-title" style="font-size: 14px; font-weight: 600; color: var(--text);">
|
||
{{ t('settlement.resettle_affected_list') }} ({{ resettlePreview.items.length }})
|
||
</span>
|
||
</div>
|
||
<el-table :data="resettleItemsPage" size="small" stripe class="preview-items-table" style="max-height: 400px; overflow-y: auto;">
|
||
<el-table-column type="index" :index="(i: number) => (resettlePage - 1) * resettlePageSize + 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('settlement.resettle_col.old_result')" min-width="160">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="betStatusTagType(row.oldStatus)" style="margin-right: 6px;">{{ betStatusLabel(row.oldStatus) }}</el-tag>
|
||
<span class="old-payout" style="color: var(--text-muted); font-size: 11px;">{{ formatAmount(row.oldPayout) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.resettle_col.new_result')" min-width="160">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="betStatusTagType(row.newStatus)" style="margin-right: 6px;">{{ betStatusLabel(row.newStatus) }}</el-tag>
|
||
<span class="new-payout" style="color: var(--text); font-size: 11px;">{{ formatAmount(row.newPayout) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.resettle_col.adjust')" width="120" align="right">
|
||
<template #default="{ row }">
|
||
<span :class="Number(row.delta) > 0 ? 'pstat-green' : Number(row.delta) < 0 ? 'pstat-orange' : ''" style="font-weight: bold;">
|
||
{{ Number(row.delta) > 0 ? '+' : '' }}{{ formatAmount(row.delta) }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-pagination
|
||
class="preview-pager bet-pager"
|
||
size="small"
|
||
background
|
||
layout="total, sizes, prev, pager, next"
|
||
:total="resettlePreview.items.length"
|
||
v-model:current-page="resettlePage"
|
||
v-model:page-size="resettlePageSize"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
style="margin-top: 12px; justify-content: flex-end;"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="resettleDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button type="warning" :loading="resettleConfirmLoading" @click="confirmResettle">
|
||
{{ t('settlement.resettle_confirm') }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 结算预览弹窗 -->
|
||
<el-dialog
|
||
v-model="previewDialogVisible"
|
||
:title="t('settlement.preview_title')"
|
||
width="850px"
|
||
destroy-on-close
|
||
>
|
||
<div v-if="preview">
|
||
<div class="preview-metrics" style="margin-bottom: 16px;">
|
||
<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>
|
||
<p v-if="previewZeroHint" class="preview-zero-hint" style="margin-bottom: 16px;">{{ 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>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="previewDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button type="success" :loading="confirmLoading" @click="confirm">
|
||
{{ t('settlement.confirm_btn') }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-card v-loading="statsLoading && activeTab === 'bets'" class="stats-card" shadow="never">
|
||
<el-tabs v-model="activeTab" class="settlement-tabs">
|
||
<el-tab-pane name="bets" :label="t('settlement.history_tab_bets')">
|
||
<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>
|
||
<div class="table-wrap">
|
||
<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="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>
|
||
|
||
<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-tab-pane>
|
||
|
||
<el-tab-pane name="history" :label="t('settlement.history_tab')">
|
||
<div v-loading="historyLoading" class="history-body">
|
||
<div class="table-wrap">
|
||
<el-table
|
||
:data="settlementHistory"
|
||
size="small"
|
||
stripe
|
||
class="stats-table history-table"
|
||
:empty-text="t('settlement.history.no_records')"
|
||
>
|
||
<el-table-column prop="batchNo" :label="t('settlement.history.col.batch_no')" min-width="120" show-overflow-tooltip />
|
||
<el-table-column :label="t('settlement.history.col.type')" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="row.isResettle ? 'warning' : 'success'">
|
||
{{ row.isResettle ? t('settlement.history.type.resettle') : t('settlement.history.type.initial') }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.history.col.score')" min-width="120">
|
||
<template #default="{ row }">{{ formatHistoryScore(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.history.col.corners')" width="100">
|
||
<template #default="{ row }">{{ formatHomeAwayPair(row.homeCorners, row.awayCorners) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.history.col.cards')" min-width="120">
|
||
<template #default="{ row }">{{ formatHistoryCards(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="totalBets" :label="t('settlement.history.col.total_bets')" width="96" align="right" />
|
||
<el-table-column :label="t('settlement.history.col.total_payout')" width="100" align="right">
|
||
<template #default="{ row }">{{ formatAmount(row.totalPayout) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.history.col.total_refund')" width="100" align="right">
|
||
<template #default="{ row }">{{ formatAmount(row.totalRefund) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="operatorUsername" :label="t('settlement.history.col.operator')" width="96" show-overflow-tooltip />
|
||
<el-table-column :label="t('settlement.history.col.time')" width="120">
|
||
<template #default="{ row }">{{ row.confirmedAt ? formatTime(row.confirmedAt) : '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('settlement.history.col.reason')" min-width="120" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.reason || '—' }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</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;
|
||
}
|
||
|
||
.settlement-tabs {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tabs__header) {
|
||
margin-bottom: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tabs__nav-wrap) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tabs__item) {
|
||
height: 34px;
|
||
line-height: 34px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: #888;
|
||
padding: 0 14px;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tabs__item.is-active) {
|
||
color: #d4fde5;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tabs__active-bar) {
|
||
background-color: var(--gold-bright);
|
||
height: 2px;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tabs__content) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
padding: 0;
|
||
}
|
||
|
||
.settlement-tabs :deep(.el-tab-pane) {
|
||
height: 100%;
|
||
}
|
||
|
||
.history-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
height: 100%;
|
||
}
|
||
|
||
.history-body .table-wrap {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.history-table {
|
||
width: 100%;
|
||
}
|
||
|
||
.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 .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 .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>
|