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

1859 lines
55 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, 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>