feat(admin,api,player): 结算预览分页、统计图表与返水限额
完善结算计算与预览 API(含后端分页),加强管理端结算/返水/权限,并优化玩家端投注单与队徽展示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -179,6 +179,32 @@ async function openDetail(row: BetListRow) {
|
||||
<el-tag type="info" size="small" effect="plain">{{ betTypeLabel(row.betType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.content')" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip
|
||||
v-if="row.selectionPreviews?.length"
|
||||
:content="row.selectionSummary || ''"
|
||||
placement="top"
|
||||
:show-after="300"
|
||||
>
|
||||
<div class="bet-content-cell">
|
||||
<div
|
||||
v-for="(leg, i) in (row.selectionPreviews ?? []).slice(0, 3)"
|
||||
:key="i"
|
||||
class="bet-leg-line"
|
||||
>
|
||||
<span class="bet-match">{{ leg.matchLabel }}</span>
|
||||
<span class="bet-pick">{{ leg.selectionName }}</span>
|
||||
<span class="bet-leg-odds">@{{ leg.odds }}</span>
|
||||
</div>
|
||||
<div v-if="(row.selectionPreviews?.length ?? 0) > 3" class="bet-leg-more">
|
||||
{{ t('bet.legs_more', { n: (row.selectionPreviews?.length ?? 0) - 3 }) }}
|
||||
</div>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<span v-else class="bet-content-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.selection_count')" width="88" align="center">
|
||||
<template #default="{ row }">{{ row.selectionCount }}</template>
|
||||
</el-table-column>
|
||||
@@ -267,6 +293,12 @@ async function openDetail(row: BetListRow) {
|
||||
<div class="selections-title">{{ t('bet.selections_title', { n: detail.selections.length }) }}</div>
|
||||
<el-table :data="detail.selections" size="small" stripe border>
|
||||
<el-table-column type="index" label="#" width="44" />
|
||||
<el-table-column :label="t('bet.col.match')" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<div class="detail-match-cell">{{ row.matchLabel }}</div>
|
||||
<div v-if="row.leagueName" class="detail-league">{{ row.leagueName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="selectionName" :label="t('bet.col.selection')" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="marketType" :label="t('bet.col.market')" width="100" />
|
||||
<el-table-column prop="period" :label="t('bet.col.period')" width="72">
|
||||
@@ -310,7 +342,55 @@ async function openDetail(row: BetListRow) {
|
||||
}
|
||||
|
||||
.bets-table {
|
||||
min-width: 1180px;
|
||||
min-width: 1380px;
|
||||
}
|
||||
|
||||
.bet-content-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.bet-leg-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bet-match {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.bet-pick {
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.bet-leg-odds {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bet-leg-more {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.bet-content-empty {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-match-cell {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.detail-league {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
.bet-no {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,28 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { TableColumnCtx } from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount, formatAmountFull } from '../utils/format-amount';
|
||||
import api from '../api';
|
||||
|
||||
interface CashbackBatch {
|
||||
id: string;
|
||||
batchNo: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
playerCount: number;
|
||||
totalAmount: string;
|
||||
}
|
||||
|
||||
interface CashbackPreviewItem {
|
||||
userId: string;
|
||||
username: string;
|
||||
agentUsername: string | null;
|
||||
effectiveStake: string;
|
||||
rate: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
interface CashbackPreview {
|
||||
batch: CashbackBatch;
|
||||
items: CashbackPreviewItem[];
|
||||
totalAmount: string;
|
||||
}
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const preview = ref<CashbackPreview | null>(null);
|
||||
const rulesVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const period = ref({
|
||||
start: new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10),
|
||||
end: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
async function generatePreview() {
|
||||
const { data } = await api.post('/admin/cashbacks/preview', {
|
||||
periodStart: period.value.start,
|
||||
periodEnd: period.value.end,
|
||||
const previewItems = computed(() => preview.value?.items ?? []);
|
||||
|
||||
function formatRate(value: string | number | null | undefined) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return `${(n * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatPeriodDate(value: string) {
|
||||
if (!value) return '—';
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function tableSummary(param: {
|
||||
columns: TableColumnCtx<CashbackPreviewItem>[];
|
||||
data: CashbackPreviewItem[];
|
||||
}) {
|
||||
const { columns, data } = param;
|
||||
const stakeSum = data.reduce((s, row) => s + Number(row.effectiveStake), 0);
|
||||
const amountSum = data.reduce((s, row) => s + Number(row.amount), 0);
|
||||
|
||||
return columns.map((col, index) => {
|
||||
if (index === 0) return t('cashback.table_total');
|
||||
if (col.property === 'effectiveStake') return formatAmount(stakeSum);
|
||||
if (col.property === 'amount') return formatAmount(amountSum);
|
||||
return '';
|
||||
});
|
||||
preview.value = data.data;
|
||||
}
|
||||
|
||||
async function generatePreview() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.post('/admin/cashbacks/preview', {
|
||||
periodStart: period.value.start,
|
||||
periodEnd: period.value.end,
|
||||
});
|
||||
preview.value = data.data as CashbackPreview;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preview.value?.batch) return;
|
||||
await api.post(`/admin/cashbacks/${(preview.value.batch as { id: string }).id}/confirm`);
|
||||
await api.post(`/admin/cashbacks/${preview.value.batch.id}/confirm`);
|
||||
ElMessage.success(t('msg.cashback_issued'));
|
||||
preview.value = null;
|
||||
}
|
||||
@@ -30,49 +92,261 @@ async function confirm() {
|
||||
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('cashback.start_date')">
|
||||
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('cashback.end_date')">
|
||||
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="generatePreview">{{ t('cashback.preview_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('cashback.start_date')">
|
||||
<el-date-picker v-model="period.start" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('cashback.end_date')">
|
||||
<el-date-picker v-model="period.end" type="date" value-format="YYYY-MM-DD" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="generatePreview">
|
||||
{{ t('cashback.preview_btn') }}
|
||||
</el-button>
|
||||
<button
|
||||
type="button"
|
||||
class="rules-help-btn"
|
||||
:aria-label="t('cashback.rules_title')"
|
||||
:title="t('cashback.rules_title')"
|
||||
@click="rulesVisible = true"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
|
||||
<el-row :gutter="20" class="preview-stats">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ (preview.batch as { playerCount: number })?.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
<el-dialog
|
||||
v-model="rulesVisible"
|
||||
:title="t('cashback.rules_title')"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<ul class="rules-list">
|
||||
<li>{{ t('cashback.rule_period') }}</li>
|
||||
<li>{{ t('cashback.rule_eligible') }}</li>
|
||||
<li>{{ t('cashback.rule_formula') }}</li>
|
||||
<li>{{ t('cashback.rule_rate') }}</li>
|
||||
<li>{{ t('cashback.rule_flow') }}</li>
|
||||
<li class="rules-note">{{ t('cashback.rule_note_zero') }}</li>
|
||||
</ul>
|
||||
</el-dialog>
|
||||
|
||||
<template v-if="preview">
|
||||
<el-card class="preview-card" shadow="never">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<div class="preview-title">{{ t('cashback.preview_title') }}</div>
|
||||
<div class="preview-meta">
|
||||
<span>{{ t('cashback.batch_no') }}:{{ preview.batch.batchNo }}</span>
|
||||
<span class="meta-sep">·</span>
|
||||
<span>
|
||||
{{ formatPeriodDate(preview.batch.periodStart) }}
|
||||
—
|
||||
{{ formatPeriodDate(preview.batch.periodEnd) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.totalAmount }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
|
||||
<el-row :gutter="20" class="preview-stats">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.batch.playerCount ?? 0 }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.players') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-green">{{ formatAmount(preview.totalAmount) }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.total') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ previewItems.length }}</div>
|
||||
<div class="pstat-label">{{ t('cashback.stat.lines') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-title">{{ t('cashback.table_title') }}</div>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
:data="previewItems"
|
||||
stripe
|
||||
class="cashback-table"
|
||||
show-summary
|
||||
:summary-method="tableSummary"
|
||||
:empty-text="t('cashback.empty_items')"
|
||||
>
|
||||
<el-table-column type="index" :label="t('cashback.col.index')" width="56" align="center" />
|
||||
<el-table-column
|
||||
prop="username"
|
||||
:label="t('cashback.col.player')"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
prop="agentUsername"
|
||||
:label="t('cashback.col.agent')"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
>
|
||||
<template #default="{ row }">{{ row.agentUsername || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="effectiveStake"
|
||||
:label="t('cashback.col.effective_stake')"
|
||||
min-width="120"
|
||||
align="right"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.effectiveStake)" placement="top">
|
||||
<span>{{ formatAmount(row.effectiveStake) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rate" :label="t('cashback.col.rate')" width="96" align="right">
|
||||
<template #default="{ row }">{{ formatRate(row.rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="amount" :label="t('cashback.col.amount')" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
|
||||
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 20px">{{ t('cashback.confirm_issue') }}</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-button type="success" class="confirm-btn" :disabled="previewItems.length === 0" @click="confirm">
|
||||
{{ t('cashback.confirm_issue') }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.preview-card { border-radius: 12px; }
|
||||
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
|
||||
.preview-stats { }
|
||||
.pstat { padding: 16px; background: #f9f9fb; border-radius: 10px; text-align: center; }
|
||||
.pstat-value { font-size: 26px; font-weight: 700; color: var(--green-glow); }
|
||||
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
|
||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
|
||||
.rules-help-btn {
|
||||
margin-left: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #3a3a3a;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.rules-help-btn:hover {
|
||||
color: var(--green-glow);
|
||||
border-color: rgba(47, 181, 106, 0.45);
|
||||
background: rgba(47, 181, 106, 0.08);
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.rules-note {
|
||||
color: #888;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.preview-card,
|
||||
.data-card {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.preview-stats {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pstat {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pstat-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.pstat-green {
|
||||
color: var(--green-glow);
|
||||
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
|
||||
}
|
||||
|
||||
.pstat-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
color: var(--green-glow);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,13 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { VChart } from '../components/dashboard/echarts-setup';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
buildBetTypePieOption,
|
||||
buildSelectionStakeBarOption,
|
||||
buildStatusPieOption,
|
||||
} from '../utils/settlement-charts';
|
||||
import {
|
||||
betStatusLabel,
|
||||
betStatusTagType,
|
||||
@@ -32,20 +38,28 @@ interface SettlementBetStats {
|
||||
singleStake: string;
|
||||
parlayLegCount: number;
|
||||
}>;
|
||||
bets: Array<{
|
||||
id: string;
|
||||
betNo: string;
|
||||
username: string;
|
||||
betType: string;
|
||||
status: string;
|
||||
stake: string;
|
||||
potentialReturn: string | null;
|
||||
placedAt: string;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
}>;
|
||||
bets: {
|
||||
items: Array<{
|
||||
id: string;
|
||||
betNo: string;
|
||||
username: string;
|
||||
betType: string;
|
||||
status: string;
|
||||
stake: string;
|
||||
potentialReturn: string | null;
|
||||
placedAt: string;
|
||||
legCountOnMatch: number;
|
||||
selections: Array<{
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
}>;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
@@ -56,16 +70,111 @@ const loading = ref(false);
|
||||
const match = ref<AdminMatchDetail | null>(null);
|
||||
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
|
||||
const preview = ref<Record<string, unknown> | null>(null);
|
||||
const resettlePreview = ref<Record<string, unknown> | null>(null);
|
||||
const resettleReason = ref('');
|
||||
const stats = ref<SettlementBetStats | null>(null);
|
||||
const statsLoading = ref(false);
|
||||
const betPage = ref(1);
|
||||
const betPageSize = ref(10);
|
||||
const previewPage = ref(1);
|
||||
const previewPageSize = ref(10);
|
||||
|
||||
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score)
|
||||
|
||||
const matchId = computed(() => String(route.params.id ?? ''));
|
||||
|
||||
const statusSummary = computed(() => {
|
||||
const counts = stats.value?.summary.statusCounts ?? {};
|
||||
return ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH'].filter((s) => (counts[s] ?? 0) > 0);
|
||||
type PreviewItem = {
|
||||
betNo: string;
|
||||
betType: string;
|
||||
result: string;
|
||||
payout: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type PreviewItemsPage = {
|
||||
items: PreviewItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
const previewItemsPage = computed(
|
||||
() => (preview.value?.items as PreviewItemsPage | undefined) ?? { items: [], total: 0, page: 1, pageSize: 10 },
|
||||
);
|
||||
|
||||
const previewZeroHint = computed(() => {
|
||||
const p = preview.value;
|
||||
if (!p) return '';
|
||||
const payout = Number(p.totalPayout ?? 0);
|
||||
const refund = Number(p.totalRefund ?? 0);
|
||||
if (payout > 0 || refund > 0) return '';
|
||||
const pending = Number(p.pendingOtherMatches ?? 0);
|
||||
const wonLegs = Number(p.wonLegsOnMatch ?? 0);
|
||||
if (pending > 0 && wonLegs > 0) {
|
||||
return t('settlement.preview_zero_parlay_hint', { pending: String(pending), legs: String(wonLegs) });
|
||||
}
|
||||
if (Number(p.lostOnThisMatch ?? 0) > 0) {
|
||||
return t('settlement.preview_zero_lost_hint', { count: String(p.lostOnThisMatch) });
|
||||
}
|
||||
return t('settlement.preview_zero_default_hint');
|
||||
});
|
||||
|
||||
function previewResultLabel(result: string) {
|
||||
const key = `settlement.preview.result.${result}`;
|
||||
const label = t(key);
|
||||
return label !== key ? label : betResultLabel(result);
|
||||
}
|
||||
|
||||
const betTypeChartOption = computed(() => {
|
||||
const s = stats.value?.summary;
|
||||
if (!s) return null;
|
||||
return buildBetTypePieOption({
|
||||
title: t('settlement.chart.bet_type'),
|
||||
singleBets: s.singleBets,
|
||||
parlayBets: s.parlayBets,
|
||||
totalBets: s.totalBets,
|
||||
legCount: s.legCount,
|
||||
labels: {
|
||||
single: t('settlement.stats_single'),
|
||||
parlay: t('settlement.stats_parlay'),
|
||||
},
|
||||
subtext: `${s.singleBets} ${t('settlement.stats_single')} · ${s.parlayBets} ${t('settlement.stats_parlay')} · ${s.legCount} ${t('settlement.col.legs')}`,
|
||||
centerLabel: t('settlement.stats_total_bets'),
|
||||
});
|
||||
});
|
||||
|
||||
const statusChartOption = computed(() => {
|
||||
const s = stats.value?.summary;
|
||||
if (!s) return null;
|
||||
const parts = ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH']
|
||||
.filter((st) => (s.statusCounts[st] ?? 0) > 0)
|
||||
.map((st) => `${betStatusLabel(st)} ${s.statusCounts[st]}`);
|
||||
return buildStatusPieOption({
|
||||
title: t('settlement.chart.status'),
|
||||
statusCounts: s.statusCounts,
|
||||
labelFor: (st) => betStatusLabel(st),
|
||||
subtext: parts.join(' · ') || t('settlement.no_bets'),
|
||||
});
|
||||
});
|
||||
|
||||
const selectionStakeChartOption = computed(() => {
|
||||
const s = stats.value?.summary;
|
||||
const rows = stats.value?.bySelection ?? [];
|
||||
if (!s) return null;
|
||||
const top = [...rows]
|
||||
.sort((a, b) => Number(b.singleStake) - Number(a.singleStake))
|
||||
.slice(0, 6);
|
||||
const labels = top.map((row) => {
|
||||
const name = selectionDisplay(row);
|
||||
return name.length > 8 ? `${name.slice(0, 8)}…` : name;
|
||||
});
|
||||
return buildSelectionStakeBarOption({
|
||||
title: t('settlement.chart.stake_by_selection'),
|
||||
subtext: `${t('settlement.stats_total_stake')} ${formatAmount(s.totalStake)} · ${t('settlement.stats_potential')} ${formatAmount(s.totalPotentialReturn)}`,
|
||||
labels: labels.length ? labels : [t('settlement.no_bets')],
|
||||
stakes: top.length ? top.map((row) => Number(row.singleStake)) : [0],
|
||||
seriesName: t('settlement.col.single_stake'),
|
||||
});
|
||||
});
|
||||
|
||||
const leagueLabel = computed(() => {
|
||||
@@ -135,12 +244,28 @@ function formatTime(v: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function matchBetSelectionSummary(
|
||||
row: SettlementBetStats['bets']['items'][number],
|
||||
) {
|
||||
return row.selections
|
||||
.map((leg) => {
|
||||
const market = marketLabel(leg.marketType);
|
||||
const sel = selectionDisplay(leg);
|
||||
return `${market} ${sel} @${leg.odds}`;
|
||||
})
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (!matchId.value) return;
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`);
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`, {
|
||||
params: { page: betPage.value, pageSize: betPageSize.value },
|
||||
});
|
||||
stats.value = data.data as SettlementBetStats;
|
||||
betPage.value = stats.value.bets.page;
|
||||
betPageSize.value = stats.value.bets.pageSize;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
@@ -149,6 +274,17 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
function onBetPageChange(page: number) {
|
||||
betPage.value = page;
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
function onBetPageSizeChange(size: number) {
|
||||
betPageSize.value = size;
|
||||
betPage.value = 1;
|
||||
void loadStats();
|
||||
}
|
||||
|
||||
async function loadMatch() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
@@ -166,6 +302,7 @@ async function loadMatch() {
|
||||
if (detail.score) {
|
||||
score.value = { ...detail.score };
|
||||
}
|
||||
betPage.value = 1;
|
||||
await loadStats();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
@@ -175,15 +312,91 @@ async function loadMatch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function recordScore() {
|
||||
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
|
||||
ElMessage.success(t('msg.score_recorded'));
|
||||
const isSettled = computed(() => match.value?.status === 'SETTLED');
|
||||
|
||||
async function previewResettlement() {
|
||||
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
|
||||
...score.value,
|
||||
reason: resettleReason.value.trim() || undefined,
|
||||
});
|
||||
resettlePreview.value = data.data;
|
||||
}
|
||||
|
||||
async function confirmResettle() {
|
||||
if (!resettlePreview.value?.batch) return;
|
||||
await api.post(`/admin/resettle/${(resettlePreview.value.batch as { id: string }).id}/confirm`);
|
||||
ElMessage.success(t('msg.resettle_confirmed'));
|
||||
resettlePreview.value = null;
|
||||
await loadMatch();
|
||||
}
|
||||
|
||||
function settlementApiError(e: unknown, fallback: string) {
|
||||
const err = e as { response?: { data?: { error?: string; message?: string } } };
|
||||
const raw = err.response?.data?.error ?? err.response?.data?.message ?? fallback;
|
||||
if (raw === 'Score not recorded' || raw === 'Score not found') {
|
||||
return t('settlement.err_score_not_recorded');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function saveScore() {
|
||||
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
|
||||
}
|
||||
|
||||
async function recordScore() {
|
||||
try {
|
||||
await saveScore();
|
||||
ElMessage.success(t('msg.score_recorded'));
|
||||
await loadMatch();
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error(settlementApiError(e, t('msg.save_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreviewItems(page = previewPage.value, pageSize = previewPageSize.value) {
|
||||
const batch = preview.value?.batch as { id: string } | undefined;
|
||||
if (!batch) return;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/settlement/${batch.id}/preview-items`, {
|
||||
params: { page, pageSize },
|
||||
});
|
||||
if (preview.value) {
|
||||
preview.value = { ...preview.value, items: data.data as PreviewItemsPage };
|
||||
}
|
||||
previewPage.value = (data.data as PreviewItemsPage).page;
|
||||
previewPageSize.value = (data.data as PreviewItemsPage).pageSize;
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
async function previewSettlement() {
|
||||
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`);
|
||||
preview.value = data.data;
|
||||
preview.value = null;
|
||||
previewPage.value = 1;
|
||||
try {
|
||||
await saveScore();
|
||||
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
|
||||
page: 1,
|
||||
pageSize: previewPageSize.value,
|
||||
});
|
||||
preview.value = data.data;
|
||||
const itemsPage = data.data.items as PreviewItemsPage;
|
||||
previewPage.value = itemsPage.page;
|
||||
previewPageSize.value = itemsPage.pageSize;
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
function onPreviewPageChange(page: number) {
|
||||
previewPage.value = page;
|
||||
void loadPreviewItems(page, previewPageSize.value);
|
||||
}
|
||||
|
||||
function onPreviewPageSizeChange(size: number) {
|
||||
previewPageSize.value = size;
|
||||
previewPage.value = 1;
|
||||
void loadPreviewItems(1, size);
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
@@ -204,7 +417,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="settlement-page page-scroll">
|
||||
<div v-loading="loading" class="settlement-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button size="small" text @click="goBack">← {{ t('settlement.back') }}</el-button>
|
||||
@@ -274,6 +487,18 @@ onMounted(() => {
|
||||
<el-button type="primary" size="small" @click="previewSettlement">
|
||||
{{ t('settlement.preview_btn') }}
|
||||
</el-button>
|
||||
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
|
||||
<template v-if="isSettled">
|
||||
<el-input
|
||||
v-model="resettleReason"
|
||||
size="small"
|
||||
:placeholder="t('settlement.resettle_reason')"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<el-button type="warning" size="small" plain @click="previewResettlement">
|
||||
{{ t('settlement.resettle_preview') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,130 +506,193 @@ onMounted(() => {
|
||||
|
||||
<!-- 智能比分弹窗已关闭(见 Settlement.vue git 历史) -->
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
|
||||
<el-card v-if="resettlePreview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">{{ t('settlement.resettle_preview_title') }}</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value">{{ preview.singleBetCount }}</div>
|
||||
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
|
||||
<div class="pstat-value">{{ resettlePreview.affectedCount }}</div>
|
||||
<div class="pstat-label">{{ t('settlement.resettle_affected') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
|
||||
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
|
||||
<div class="pstat-value pstat-green">{{ resettlePreview.totalTopup }}</div>
|
||||
<div class="pstat-label">{{ t('settlement.resettle_topup') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="pstat">
|
||||
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
|
||||
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
|
||||
<div class="pstat-value pstat-orange">{{ resettlePreview.totalClawback }}</div>
|
||||
<div class="pstat-label">{{ t('settlement.resettle_clawback') }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" class="confirm-btn" @click="confirm">
|
||||
{{ t('settlement.confirm_btn') }}
|
||||
<el-button type="warning" class="confirm-btn" @click="confirmResettle">
|
||||
{{ t('settlement.resettle_confirm') }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
|
||||
<div class="section-title">{{ t('settlement.stats_title') }}</div>
|
||||
<template v-if="stats">
|
||||
<div class="summary-grid">
|
||||
<div class="sstat">
|
||||
<div class="sstat-value">{{ stats.summary.totalBets }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_total_bets') }}</div>
|
||||
<el-card v-if="preview" class="preview-card preview-card--compact" shadow="never">
|
||||
<div class="preview-bar">
|
||||
<span class="preview-bar-title">{{ t('settlement.preview_title') }}</span>
|
||||
<div class="preview-metrics">
|
||||
<div class="preview-metric">
|
||||
<span class="preview-metric-value">{{ preview.pendingBetCount ?? preview.singleBetCount }}</span>
|
||||
<span class="preview-metric-label">{{ t('settlement.preview_pending_bets') }}</span>
|
||||
</div>
|
||||
<div class="sstat">
|
||||
<div class="sstat-value">{{ stats.summary.singleBets }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_single') }}</div>
|
||||
<div class="preview-metric">
|
||||
<span class="preview-metric-value">{{ preview.singleBetCount }} / {{ preview.parlayBetCount }}</span>
|
||||
<span class="preview-metric-label">{{ t('settlement.preview_bet_mix') }}</span>
|
||||
</div>
|
||||
<div class="sstat">
|
||||
<div class="sstat-value">{{ stats.summary.parlayBets }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_parlay') }}</div>
|
||||
<div class="preview-metric">
|
||||
<span class="preview-metric-value preview-metric-green">{{ formatAmount(String(preview.totalPayout ?? 0)) }}</span>
|
||||
<span class="preview-metric-label">{{ t('settlement.est_payout') }}</span>
|
||||
</div>
|
||||
<div class="sstat">
|
||||
<div class="sstat-value">{{ formatAmount(stats.summary.totalStake) }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_total_stake') }}</div>
|
||||
</div>
|
||||
<div class="sstat">
|
||||
<div class="sstat-value sstat-muted">{{ formatAmount(stats.summary.totalPotentialReturn) }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_potential') }}</div>
|
||||
<div class="preview-metric">
|
||||
<span class="preview-metric-value preview-metric-orange">{{ formatAmount(String(preview.totalRefund ?? 0)) }}</span>
|
||||
<span class="preview-metric-label">{{ t('settlement.refund_amount') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="statusSummary.length" class="status-chips">
|
||||
<el-tag
|
||||
v-for="st in statusSummary"
|
||||
:key="st"
|
||||
size="small"
|
||||
:type="betStatusTagType(st)"
|
||||
effect="plain"
|
||||
>
|
||||
{{ betStatusLabel(st) }} {{ stats.summary.statusCounts[st] }}
|
||||
</el-tag>
|
||||
<el-button type="success" size="small" @click="confirm">
|
||||
{{ t('settlement.confirm_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p v-if="previewZeroHint" class="preview-zero-hint">{{ previewZeroHint }}</p>
|
||||
<div v-if="previewItemsPage.total > 0" class="preview-items-wrap">
|
||||
<div class="preview-items-head">
|
||||
<span class="preview-items-title">
|
||||
{{ t('settlement.preview_items_title', { n: previewItemsPage.total }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
|
||||
<el-table
|
||||
v-if="stats.bySelection.length"
|
||||
:data="stats.bySelection"
|
||||
size="small"
|
||||
stripe
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column :label="t('settlement.col.market')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ marketLabel(row.marketType) }}
|
||||
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.selection')" min-width="160">
|
||||
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.legs')" width="88" align="center" prop="legCount" />
|
||||
<el-table-column :label="t('settlement.col.single_stake')" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.parlay_legs')" width="100" align="center" prop="parlayLegCount" />
|
||||
</el-table>
|
||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||
|
||||
<div class="subsection-title">{{ t('settlement.bet_list') }} ({{ stats.bets.length }})</div>
|
||||
<el-table
|
||||
v-if="stats.bets.length"
|
||||
:data="stats.bets"
|
||||
size="small"
|
||||
stripe
|
||||
max-height="420"
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="150" />
|
||||
<el-table-column prop="username" :label="t('bet.col.player')" width="110" />
|
||||
<el-table-column :label="t('common.type')" width="72">
|
||||
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
|
||||
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('common.type')" width="68">
|
||||
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.market')" min-width="120">
|
||||
<template #default="{ row }">{{ marketLabel(row.marketType) }}</template>
|
||||
<el-table-column :label="t('settlement.preview_col.result')" width="108">
|
||||
<template #default="{ row }">{{ previewResultLabel(row.result) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.selection')" min-width="120">
|
||||
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.odds')" width="72" align="right" prop="odds" />
|
||||
<el-table-column :label="t('bet.col.stake')" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.placed_at')" width="130">
|
||||
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||
<el-table-column :label="t('settlement.est_payout')" width="92" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.payout) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('user.field.remark')" min-width="160" show-overflow-tooltip prop="note" />
|
||||
</el-table>
|
||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||
</template>
|
||||
<el-pagination
|
||||
v-if="previewItemsPage.total > 0"
|
||||
class="preview-pager bet-pager"
|
||||
size="small"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="previewItemsPage.total"
|
||||
:current-page="previewItemsPage.page"
|
||||
:page-size="previewItemsPage.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
@current-change="onPreviewPageChange"
|
||||
@size-change="onPreviewPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
|
||||
<div v-if="stats" class="stats-body">
|
||||
<div class="stats-charts">
|
||||
<div v-if="betTypeChartOption" class="mini-chart">
|
||||
<VChart class="mini-chart-canvas" :option="betTypeChartOption" autoresize />
|
||||
</div>
|
||||
<div v-if="statusChartOption" class="mini-chart">
|
||||
<VChart class="mini-chart-canvas" :option="statusChartOption" autoresize />
|
||||
</div>
|
||||
<div v-if="selectionStakeChartOption" class="mini-chart">
|
||||
<VChart class="mini-chart-canvas" :option="selectionStakeChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-tables">
|
||||
<div class="stats-table-block">
|
||||
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
|
||||
<el-table
|
||||
v-if="stats.bySelection.length"
|
||||
:data="stats.bySelection"
|
||||
size="small"
|
||||
stripe
|
||||
class="stats-table"
|
||||
height="100%"
|
||||
>
|
||||
<el-table-column :label="t('settlement.col.market')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ marketLabel(row.marketType) }}
|
||||
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.selection')" min-width="120">
|
||||
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.legs')" width="72" align="center" prop="legCount" />
|
||||
<el-table-column :label="t('settlement.col.single_stake')" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('settlement.col.parlay_legs')" width="88" align="center" prop="parlayLegCount" />
|
||||
</el-table>
|
||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-table-block stats-table-block--bets">
|
||||
<div class="subsection-title">
|
||||
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
|
||||
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
v-if="stats.bets.items.length"
|
||||
:data="stats.bets.items"
|
||||
size="small"
|
||||
stripe
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
|
||||
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
|
||||
<el-table-column :label="t('common.type')" width="72">
|
||||
<template #default="{ row }">
|
||||
{{ betTypeLabel(row.betType) }}
|
||||
<span v-if="row.legCountOnMatch > 1" class="leg-badge">×{{ row.legCountOnMatch }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.content')" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="bet-content-cell">{{ matchBetSelectionSummary(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.stake')" width="88" align="right">
|
||||
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('bet.col.placed_at')" width="120">
|
||||
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-if="stats.bets.total > 0"
|
||||
class="bet-pager"
|
||||
size="small"
|
||||
background
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="stats.bets.total"
|
||||
:current-page="stats.bets.page"
|
||||
:page-size="stats.bets.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
@current-change="onBetPageChange"
|
||||
@size-change="onBetPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -413,11 +701,11 @@ onMounted(() => {
|
||||
.settlement-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: none !important;
|
||||
min-height: auto !important;
|
||||
align-self: flex-start;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -426,6 +714,7 @@ onMounted(() => {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -449,11 +738,41 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.settle-top-card,
|
||||
.stats-card,
|
||||
.preview-card {
|
||||
flex-shrink: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.settle-top-card :deep(.el-card__body) {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.stats-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settle-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -528,54 +847,88 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
margin: 18px 0 10px;
|
||||
margin: 0 0 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
.subsection-hint {
|
||||
margin-left: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sstat {
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sstat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.sstat-muted {
|
||||
.leg-badge {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--green-text, #2fb56a);
|
||||
}
|
||||
|
||||
.sstat-label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
.bet-content-cell {
|
||||
font-size: 12px;
|
||||
color: #bbb;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.stats-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 4px 6px 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-chart-canvas {
|
||||
height: 148px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-tables {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-table-block {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-table-block--bets .table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bet-pager {
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
@@ -635,15 +988,109 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.preview-card--compact :deep(.el-card__body) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-bar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-metric {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-metric-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.preview-metric-green {
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.preview-metric-orange {
|
||||
color: #e8a040;
|
||||
}
|
||||
|
||||
.preview-metric-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.preview-zero-hint {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(232, 160, 64, 0.12);
|
||||
border: 1px solid rgba(232, 160, 64, 0.25);
|
||||
color: #e8c080;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-items-wrap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.preview-items-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.preview-items-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.preview-items-table {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.preview-pager {
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pstat {
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
@@ -651,7 +1098,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.pstat-value {
|
||||
font-size: 26px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
@@ -672,7 +1119,23 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
margin-top: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stats-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-tables {
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-table-block {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 智能比分弹窗样式(功能已关闭,保留便于恢复)
|
||||
|
||||
@@ -47,14 +47,48 @@ const editingId = ref('');
|
||||
|
||||
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
||||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||
const bettingLimits = ref({
|
||||
minStake: 1,
|
||||
maxStakeSingle: 50000,
|
||||
maxStakeParlay: 20000,
|
||||
maxPayoutSingle: 500000,
|
||||
maxPayoutParlay: 1000000,
|
||||
dailyStakeLimit: 200000,
|
||||
});
|
||||
const settingsSaving = ref(false);
|
||||
const limitsSaving = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
loadAgentOptions();
|
||||
loadPlayerSettings();
|
||||
loadBettingLimits();
|
||||
load();
|
||||
});
|
||||
|
||||
async function loadBettingLimits() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/settings/betting-limits');
|
||||
bettingLimits.value = data.data;
|
||||
} catch {
|
||||
/* 使用默认值 */
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBettingLimits() {
|
||||
limitsSaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
|
||||
bettingLimits.value = data.data;
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
loadBettingLimits();
|
||||
} finally {
|
||||
limitsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlayerSettings() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/users/settings/account');
|
||||
@@ -312,6 +346,38 @@ function statusLabel(s: string) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="settings-card" shadow="never">
|
||||
<div class="global-settings">
|
||||
<span class="settings-title">{{ t('user.betting_limits') }}</span>
|
||||
<span class="settings-desc">{{ t('user.betting_limits_hint') }}</span>
|
||||
<el-form inline size="small" class="settings-form limits-form">
|
||||
<el-form-item :label="t('user.limit.min_stake')">
|
||||
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_stake_single')">
|
||||
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_stake_parlay')">
|
||||
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_payout_single')">
|
||||
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.max_payout_parlay')">
|
||||
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.limit.daily_stake')">
|
||||
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
export interface BetSelectionPreview {
|
||||
matchId: string | null;
|
||||
matchLabel: string;
|
||||
leagueName: string;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
}
|
||||
|
||||
export interface BetListRow {
|
||||
id: string;
|
||||
betNo: string;
|
||||
@@ -16,11 +26,15 @@ export interface BetListRow {
|
||||
placedAt: string;
|
||||
settledAt: string | null;
|
||||
selectionCount: number;
|
||||
selectionSummary: string;
|
||||
selectionPreviews: BetSelectionPreview[];
|
||||
}
|
||||
|
||||
export interface BetSelectionDetail {
|
||||
id: string;
|
||||
matchId: string | null;
|
||||
matchLabel: string;
|
||||
leagueName: string;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionName: string;
|
||||
|
||||
Reference in New Issue
Block a user