feat(admin,api,player): settlement stats, team crests, MS fields and list bet summary
This commit is contained in:
@@ -5,10 +5,13 @@ import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
|
||||
import LogoUrlField from '../components/LogoUrlField.vue';
|
||||
import { countryDisplayName, type BuiltinCountry } from '../data/builtinCountries';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
} from '../utils/matchesListState.ts';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
emptyMatchForm,
|
||||
buildPlatformPayload,
|
||||
@@ -135,6 +138,27 @@ async function submitCreateLeague() {
|
||||
}
|
||||
}
|
||||
|
||||
function applyTeamFromCountry(side: 'home' | 'away', country: BuiltinCountry) {
|
||||
const msName = countryDisplayName(country, 'ms-MY');
|
||||
if (side === 'home') {
|
||||
if (!form.value.homeTeamZh.trim()) form.value.homeTeamZh = country.nameZh;
|
||||
if (!form.value.homeTeamEn.trim()) form.value.homeTeamEn = country.nameEn;
|
||||
if (!form.value.homeTeamMs.trim()) form.value.homeTeamMs = msName;
|
||||
} else {
|
||||
if (!form.value.awayTeamZh.trim()) form.value.awayTeamZh = country.nameZh;
|
||||
if (!form.value.awayTeamEn.trim()) form.value.awayTeamEn = country.nameEn;
|
||||
if (!form.value.awayTeamMs.trim()) form.value.awayTeamMs = msName;
|
||||
}
|
||||
}
|
||||
|
||||
function draftTeamCode(side: 'home' | 'away') {
|
||||
const en = side === 'home' ? form.value.homeTeamEn : form.value.awayTeamEn;
|
||||
const zh = side === 'home' ? form.value.homeTeamZh : form.value.awayTeamZh;
|
||||
const name = (en || zh).trim();
|
||||
if (!name) return '';
|
||||
return `NAME_${name.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '').toUpperCase().slice(0, 48)}`;
|
||||
}
|
||||
|
||||
function openCreateFixture(leagueRow: unknown) {
|
||||
const r = rowOf(leagueRow);
|
||||
form.value = emptyMatchForm();
|
||||
@@ -245,6 +269,24 @@ function leagueTitle(row: unknown) {
|
||||
function leagueMatchCount(row: unknown) {
|
||||
return Number(rowOf(row).matchCount ?? 0);
|
||||
}
|
||||
|
||||
function leagueBetStats(row: unknown) {
|
||||
return rowOf(row).betStats as
|
||||
| { betCount?: number; totalStake?: string; pendingCount?: number }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function leagueBetCount(row: unknown) {
|
||||
return Number(leagueBetStats(row)?.betCount ?? 0);
|
||||
}
|
||||
|
||||
function leagueTotalStake(row: unknown) {
|
||||
return formatAmount(String(leagueBetStats(row)?.totalStake ?? '0'));
|
||||
}
|
||||
|
||||
function leaguePendingBets(row: unknown) {
|
||||
return Number(leagueBetStats(row)?.pendingCount ?? 0);
|
||||
}
|
||||
function isLeagueExpanded(id: string) {
|
||||
return expandedRowKeys.value.includes(id);
|
||||
}
|
||||
@@ -320,10 +362,26 @@ function isLeagueExpanded(id: string) {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.fixture_count')" width="100" align="center">
|
||||
<el-table-column :label="t('match.col.fixture_count')" width="88" align="center">
|
||||
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.league_code')" width="120">
|
||||
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'bet-stat-active': leagueBetCount(row) > 0 }">{{ leagueBetCount(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.total_stake')" width="108" align="right">
|
||||
<template #default="{ row }">{{ leagueTotalStake(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="leaguePendingBets(row) > 0" type="warning" size="small" effect="plain">
|
||||
{{ leaguePendingBets(row) }}
|
||||
</el-tag>
|
||||
<span v-else class="bet-stat-zero">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.league_code')" width="100">
|
||||
<template #default="{ row }">{{ rowOf(row).code }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -369,7 +427,7 @@ function isLeagueExpanded(id: string) {
|
||||
<el-dialog
|
||||
v-model="createVisible"
|
||||
:title="t('match.dialog.create_fixture')"
|
||||
width="520px"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="96px">
|
||||
@@ -399,12 +457,32 @@ function isLeagueExpanded(id: string) {
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :placeholder="t('match.ph.home_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.home_ms')">
|
||||
<el-input v-model="form.homeTeamMs" :placeholder="t('match.ph.home_ms')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('matchEditor.field.home_logo')">
|
||||
<LogoUrlField
|
||||
v-model="form.homeTeamLogoUrl"
|
||||
:team-code="draftTeamCode('home')"
|
||||
@pick="applyTeamFromCountry('home', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :placeholder="t('match.ph.away_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :placeholder="t('match.ph.away_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_ms')">
|
||||
<el-input v-model="form.awayTeamMs" :placeholder="t('match.ph.away_ms')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('matchEditor.field.away_logo')">
|
||||
<LogoUrlField
|
||||
v-model="form.awayTeamLogoUrl"
|
||||
:team-code="draftTeamCode('away')"
|
||||
@pick="applyTeamFromCountry('away', $event)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</el-form-item>
|
||||
@@ -518,6 +596,13 @@ function isLeagueExpanded(id: string) {
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
}
|
||||
.bet-stat-active {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bet-stat-zero {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.league-cell {
|
||||
display: flex;
|
||||
@@ -536,4 +621,8 @@ function isLeagueExpanded(id: string) {
|
||||
color: var(--green-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.logo-url-field) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { formatAmount } from '../utils/format-amount';
|
||||
import {
|
||||
betStatusLabel,
|
||||
betStatusTagType,
|
||||
betTypeLabel,
|
||||
} from '../utils/bet-labels';
|
||||
import { adminSelectionLabel } from '../utils/adminSelectionLabel.ts';
|
||||
import type { AdminMatchDetail } from './match-form.ts';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
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: 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;
|
||||
}>;
|
||||
}
|
||||
|
||||
const { t, locale, localeTag } = useAdminLocale();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
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 stats = ref<SettlementBetStats | null>(null);
|
||||
const statsLoading = ref(false);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
if (!matchId.value) return;
|
||||
statsLoading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`);
|
||||
stats.value = data.data as SettlementBetStats;
|
||||
} 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 loadMatch() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||
const detail = data.data as AdminMatchDetail & {
|
||||
score?: { htHome: number; htAway: number; ftHome: number; ftAway: number } | null;
|
||||
};
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
router.replace('/outrights');
|
||||
return;
|
||||
}
|
||||
match.value = detail;
|
||||
if (detail.score) {
|
||||
score.value = { ...detail.score };
|
||||
}
|
||||
await loadStats();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordScore() {
|
||||
await api.post(`/admin/matches/${route.params.id}/settlement/score`, score.value);
|
||||
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
|
||||
ElMessage.success(t('msg.score_recorded'));
|
||||
await loadMatch();
|
||||
}
|
||||
|
||||
async function previewSettlement() {
|
||||
const { data } = await api.post(`/admin/matches/${route.params.id}/settlement/preview`);
|
||||
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`);
|
||||
preview.value = data.data;
|
||||
}
|
||||
|
||||
@@ -25,104 +189,480 @@ async function confirm() {
|
||||
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
|
||||
ElMessage.success(t('msg.settlement_confirmed'));
|
||||
preview.value = null;
|
||||
await loadMatch();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/matches');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadMatch();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.settlement.title') }}</h2>
|
||||
<span class="page-id"># {{ route.params.id }}</span>
|
||||
<div v-loading="loading" class="settlement-page page-scroll">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button size="small" text @click="goBack">← {{ t('settlement.back') }}</el-button>
|
||||
<h2 class="page-title">{{ t('page.settlement.title') }}</h2>
|
||||
<span class="page-id">#{{ matchId }}</span>
|
||||
</div>
|
||||
<el-tag v-if="match" size="small" type="info">{{ statusLabel }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-card v-if="match" class="settle-top-card" shadow="never">
|
||||
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
|
||||
<div class="settle-top-row">
|
||||
<div 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="score-panel">
|
||||
<div 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>
|
||||
<div class="action-row">
|
||||
<el-button size="small" @click="recordScore">{{ t('settlement.record_score') }}</el-button>
|
||||
<el-button type="primary" size="small" @click="previewSettlement">
|
||||
{{ t('settlement.preview_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">{{ t('settlement.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>
|
||||
</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>
|
||||
</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>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" class="confirm-btn" @click="confirm">
|
||||
{{ t('settlement.confirm_btn') }}
|
||||
</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>
|
||||
</div>
|
||||
<div class="sstat">
|
||||
<div class="sstat-value">{{ stats.summary.singleBets }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_single') }}</div>
|
||||
</div>
|
||||
<div class="sstat">
|
||||
<div class="sstat-value">{{ stats.summary.parlayBets }}</div>
|
||||
<div class="sstat-label">{{ t('settlement.stats_parlay') }}</div>
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
</el-table>
|
||||
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="score-section">
|
||||
<div class="score-block">
|
||||
<div class="score-title">{{ t('settlement.ht_score') }}</div>
|
||||
<div class="score-inputs">
|
||||
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 100px" />
|
||||
<span class="score-sep">—</span>
|
||||
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 100px" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-block">
|
||||
<div class="score-title">{{ t('settlement.ft_score') }}</div>
|
||||
<div class="score-inputs">
|
||||
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 100px" />
|
||||
<span class="score-sep">—</span>
|
||||
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 100px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<el-button @click="recordScore">{{ t('settlement.record_score') }}</el-button>
|
||||
<el-button type="primary" @click="previewSettlement">{{ t('settlement.preview_btn') }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="preview" class="preview-card" shadow="never">
|
||||
<div class="preview-title">{{ t('settlement.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>
|
||||
</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>
|
||||
</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>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-button type="success" @click="confirm" style="margin-top: 24px">{{ t('settlement.confirm_btn') }}</el-button>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-id { font-size: 14px; color: #3a3a3a; font-family: monospace; }
|
||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.preview-card { border-radius: 12px; }
|
||||
|
||||
.score-section {
|
||||
.settlement-page {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
margin-bottom: 20px;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex: none !important;
|
||||
min-height: auto !important;
|
||||
align-self: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
.score-block { }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-id {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.settle-top-card,
|
||||
.stats-card,
|
||||
.preview-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.settle-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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: 600;
|
||||
color: #eee;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kickoff-inline {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kickoff-inline .meta-k {
|
||||
color: #777;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.score-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: var(--green-text, #2fb56a);
|
||||
}
|
||||
|
||||
.sstat-label {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.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(--green-text, #2fb56a);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-title {
|
||||
font-size: 13px;
|
||||
color: #3a3a3a;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.score-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.score-sep {
|
||||
font-size: 18px;
|
||||
color: #ccc;
|
||||
color: #666;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
|
||||
.pstat { padding: 16px; background: #f9f9fb; 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-orange { color: #c85a00; }
|
||||
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
|
||||
.preview-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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-orange {
|
||||
color: #e8a040;
|
||||
}
|
||||
|
||||
.pstat-label {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,6 +90,12 @@ export type AdminMatchDetail = {
|
||||
matchName: string;
|
||||
stage?: string;
|
||||
groupName?: string;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
} | null;
|
||||
markets?: AdminMarket[];
|
||||
};
|
||||
|
||||
@@ -142,6 +148,11 @@ export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
if (!homeOk || !awayOk) {
|
||||
throw new FormValidationError('err.teams_required');
|
||||
}
|
||||
const homeKey = `${form.homeTeamZh.trim()}|${form.homeTeamEn.trim()}|${form.homeTeamMs.trim()}`.toLowerCase();
|
||||
const awayKey = `${form.awayTeamZh.trim()}|${form.awayTeamEn.trim()}|${form.awayTeamMs.trim()}`.toLowerCase();
|
||||
if (homeKey === awayKey) {
|
||||
throw new FormValidationError('err.teams_same');
|
||||
}
|
||||
if (
|
||||
!form.leagueId.trim() &&
|
||||
!form.leagueZh.trim() &&
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
const props = defineProps<{
|
||||
leagueId: string;
|
||||
filterStatus: string;
|
||||
@@ -127,6 +128,16 @@ function matchId(row: unknown) {
|
||||
function matchTime(row: unknown) {
|
||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||
}
|
||||
function betCount(row: unknown) {
|
||||
return Number(rowOf(row).betCount ?? 0);
|
||||
}
|
||||
function totalStake(row: unknown) {
|
||||
return formatAmount(String(rowOf(row).totalStake ?? '0'));
|
||||
}
|
||||
function pendingBets(row: unknown) {
|
||||
return Number(rowOf(row).pendingBets ?? 0);
|
||||
}
|
||||
|
||||
function matchTitle(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
const home =
|
||||
@@ -202,6 +213,22 @@ defineExpose({ reload: load });
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="150">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'bet-stat-active': betCount(row) > 0 }">{{ betCount(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.total_stake')" width="100" align="right">
|
||||
<template #default="{ row }">{{ totalStake(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="pendingBets(row) > 0" type="warning" size="small" effect="plain">
|
||||
{{ pendingBets(row) }}
|
||||
</el-tag>
|
||||
<span v-else class="bet-stat-zero">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="420" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
@@ -247,6 +274,13 @@ defineExpose({ reload: load });
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
}
|
||||
.bet-stat-active {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bet-stat-zero {
|
||||
color: #555;
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../../i18n/form-validation';
|
||||
import api from '../../api';
|
||||
import LogoUrlField from '../../components/LogoUrlField.vue';
|
||||
import type { BuiltinCountry } from '../../data/builtinCountries';
|
||||
import { countryDisplayName, type BuiltinCountry } from '../../data/builtinCountries';
|
||||
import {
|
||||
buildPlatformPayload,
|
||||
emptyMatchForm,
|
||||
@@ -32,12 +32,15 @@ function applyTeamFromCountry(
|
||||
side: 'home' | 'away',
|
||||
country: BuiltinCountry,
|
||||
) {
|
||||
const msName = countryDisplayName(country, 'ms-MY');
|
||||
if (side === 'home') {
|
||||
if (!form.value.homeTeamZh.trim()) form.value.homeTeamZh = country.nameZh;
|
||||
if (!form.value.homeTeamEn.trim()) form.value.homeTeamEn = country.nameEn;
|
||||
if (!form.value.homeTeamMs.trim()) form.value.homeTeamMs = msName;
|
||||
} else {
|
||||
if (!form.value.awayTeamZh.trim()) form.value.awayTeamZh = country.nameZh;
|
||||
if (!form.value.awayTeamEn.trim()) form.value.awayTeamEn = country.nameEn;
|
||||
if (!form.value.awayTeamMs.trim()) form.value.awayTeamMs = msName;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import CountryFlagSelect from '../../components/outright/CountryFlagSelect.vue';
|
||||
import LogoUrlField from '../../components/LogoUrlField.vue';
|
||||
import {
|
||||
countryFlagUrl,
|
||||
getBuiltinCountry,
|
||||
resolveCountryCode,
|
||||
type BuiltinCountry,
|
||||
@@ -30,6 +29,7 @@ interface SelectionRow {
|
||||
logoUrl: string | null;
|
||||
editOdds: number;
|
||||
editCountryCode: string;
|
||||
editLogoUrl: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -39,7 +39,9 @@ const meta = ref({
|
||||
leagueZh: '',
|
||||
leagueEn: '',
|
||||
leagueCode: '',
|
||||
matchName: '',
|
||||
titleZh: '',
|
||||
titleEn: '',
|
||||
titleMs: '',
|
||||
status: 'DRAFT',
|
||||
expectedCanonicalCount: null as number | null,
|
||||
playerVisible: true,
|
||||
@@ -53,6 +55,7 @@ const addForm = ref({
|
||||
teamCode: '',
|
||||
teamZh: '',
|
||||
teamEn: '',
|
||||
logoUrl: '',
|
||||
odds: 10,
|
||||
});
|
||||
|
||||
@@ -81,7 +84,9 @@ async function load() {
|
||||
leagueZh: string;
|
||||
leagueEn: string;
|
||||
leagueCode: string;
|
||||
matchName: string;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
titleMs: string;
|
||||
status: string;
|
||||
expectedCanonicalCount: number | null;
|
||||
playerVisible: boolean;
|
||||
@@ -92,7 +97,9 @@ async function load() {
|
||||
leagueZh: payload.leagueZh,
|
||||
leagueEn: payload.leagueEn,
|
||||
leagueCode: payload.leagueCode,
|
||||
matchName: payload.matchName,
|
||||
titleZh: payload.titleZh ?? '',
|
||||
titleEn: payload.titleEn ?? '',
|
||||
titleMs: payload.titleMs ?? '',
|
||||
status: payload.status,
|
||||
expectedCanonicalCount: payload.expectedCanonicalCount,
|
||||
playerVisible: payload.playerVisible,
|
||||
@@ -103,6 +110,7 @@ async function load() {
|
||||
logoUrl: s.logoUrl ?? null,
|
||||
editOdds: Number(s.odds),
|
||||
editCountryCode: resolveCountryCode(s.teamCode, s.logoUrl ?? null),
|
||||
editLogoUrl: s.logoUrl ?? '',
|
||||
}));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
@@ -119,7 +127,9 @@ async function saveMeta() {
|
||||
try {
|
||||
await api.put(`/admin/outrights/${matchId.value}`, {
|
||||
status: meta.value.status,
|
||||
matchName: meta.value.matchName,
|
||||
titleZh: meta.value.titleZh,
|
||||
titleEn: meta.value.titleEn,
|
||||
titleMs: meta.value.titleMs,
|
||||
});
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
@@ -171,12 +181,12 @@ async function submitAdd() {
|
||||
teamCode: country.code,
|
||||
teamZh: country.nameZh,
|
||||
teamEn: country.nameEn,
|
||||
logoUrl: countryFlagUrl(country),
|
||||
logoUrl: addForm.value.logoUrl.trim() || undefined,
|
||||
odds: addForm.value.odds,
|
||||
});
|
||||
ElMessage.success(t('msg.saved'));
|
||||
addVisible.value = false;
|
||||
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', odds: 10 };
|
||||
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', logoUrl: '', odds: 10 };
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
@@ -255,8 +265,29 @@ function rowDisplayCode(row: SelectionRow) {
|
||||
function isRowDirty(row: SelectionRow) {
|
||||
const countryDirty =
|
||||
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
|
||||
const logoDirty = (row.editLogoUrl || '').trim() !== (row.logoUrl || '').trim();
|
||||
const oddsDirty = row.editOdds !== Number(row.odds);
|
||||
return countryDirty || oddsDirty;
|
||||
return countryDirty || logoDirty || oddsDirty;
|
||||
}
|
||||
|
||||
function onRowCountryPick(row: SelectionRow, country: BuiltinCountry) {
|
||||
row.editCountryCode = country.code;
|
||||
}
|
||||
|
||||
function onRowLogoChange(row: SelectionRow, url: string) {
|
||||
row.editLogoUrl = url;
|
||||
const code = resolveCountryCode(row.editCountryCode || row.teamCode, url);
|
||||
if (code) row.editCountryCode = code;
|
||||
}
|
||||
|
||||
function onAddLogoChange(url: string) {
|
||||
addForm.value.logoUrl = url;
|
||||
const code = resolveCountryCode(addForm.value.countryCode, url);
|
||||
if (code) addForm.value.countryCode = code;
|
||||
}
|
||||
|
||||
function onAddLogoPick(country: BuiltinCountry) {
|
||||
onAddCountryPick(country);
|
||||
}
|
||||
|
||||
async function saveRow(row: SelectionRow) {
|
||||
@@ -271,8 +302,9 @@ async function saveRow(row: SelectionRow) {
|
||||
const country = getBuiltinCountry(row.editCountryCode);
|
||||
const countryDirty =
|
||||
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
|
||||
const logoDirty = (row.editLogoUrl || '').trim() !== (row.logoUrl || '').trim();
|
||||
|
||||
if (countryDirty) {
|
||||
if (countryDirty || logoDirty) {
|
||||
if (!country) {
|
||||
ElMessage.warning(t('outright.err_country'));
|
||||
return;
|
||||
@@ -281,7 +313,7 @@ async function saveRow(row: SelectionRow) {
|
||||
teamCode: country.code,
|
||||
teamZh: country.nameZh,
|
||||
teamEn: country.nameEn,
|
||||
logoUrl: countryFlagUrl(country),
|
||||
logoUrl: row.editLogoUrl.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,13 +354,19 @@ async function saveRow(row: SelectionRow) {
|
||||
|
||||
<section class="panel settings-panel">
|
||||
<div class="settings-top">
|
||||
<el-input
|
||||
v-model="meta.matchName"
|
||||
size="small"
|
||||
class="title-input"
|
||||
:placeholder="t('outright.field.title_placeholder')"
|
||||
@keyup.enter="saveMeta"
|
||||
/>
|
||||
<div class="title-fields">
|
||||
<el-form label-width="88px" size="small" @submit.prevent="saveMeta">
|
||||
<el-form-item :label="t('outright.field.title_zh')">
|
||||
<el-input v-model="meta.titleZh" @keyup.enter="saveMeta" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('outright.field.title_en')">
|
||||
<el-input v-model="meta.titleEn" @keyup.enter="saveMeta" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('outright.field.title_ms')">
|
||||
<el-input v-model="meta.titleMs" @keyup.enter="saveMeta" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<el-button size="small" :loading="saving" @click="saveMeta">
|
||||
{{ t('common.save') }}
|
||||
@@ -369,11 +407,14 @@ async function saveRow(row: SelectionRow) {
|
||||
<div class="table-wrap">
|
||||
<el-table :data="selections" stripe size="small" empty-text="—">
|
||||
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
|
||||
<el-table-column :label="t('outright.col.country')" min-width="220">
|
||||
<el-table-column :label="t('outright.col.country')" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<CountryFlagSelect
|
||||
v-model="row.editCountryCode"
|
||||
:disabled="!!savingRowId"
|
||||
<LogoUrlField
|
||||
:model-value="row.editLogoUrl"
|
||||
compact
|
||||
:team-code="row.editCountryCode || row.teamCode"
|
||||
@update:model-value="onRowLogoChange(row, $event)"
|
||||
@pick="onRowCountryPick(row, $event)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -431,13 +472,14 @@ async function saveRow(row: SelectionRow) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="440px">
|
||||
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="520px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('outright.col.country')" required>
|
||||
<CountryFlagSelect
|
||||
v-model="addForm.countryCode"
|
||||
size="default"
|
||||
@pick="onAddCountryPick"
|
||||
<LogoUrlField
|
||||
:model-value="addForm.logoUrl"
|
||||
:team-code="addForm.countryCode"
|
||||
@update:model-value="onAddLogoChange"
|
||||
@pick="onAddLogoPick"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="addForm.teamCode" :label="t('outright.col.code')">
|
||||
@@ -505,9 +547,14 @@ async function saveRow(row: SelectionRow) {
|
||||
|
||||
.settings-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-fields {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.league-meta {
|
||||
@@ -528,11 +575,6 @@ async function saveRow(row: SelectionRow) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-head.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -59,6 +59,7 @@ const createForm = ref({
|
||||
leagueId: '',
|
||||
titleZh: '',
|
||||
titleEn: '',
|
||||
titleMs: '',
|
||||
status: 'PUBLISHED',
|
||||
});
|
||||
|
||||
@@ -166,7 +167,7 @@ async function submitCreate() {
|
||||
const { data } = await api.post('/admin/outrights', createForm.value);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
createVisible.value = false;
|
||||
createForm.value = { leagueId: '', titleZh: '', titleEn: '', status: 'PUBLISHED' };
|
||||
createForm.value = { leagueId: '', titleZh: '', titleEn: '', titleMs: '', status: 'PUBLISHED' };
|
||||
listReady.value = false;
|
||||
rowDetails.value = {};
|
||||
await loadEvents(false);
|
||||
@@ -292,7 +293,7 @@ onMounted(() => {
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="t('outright.btn.create_event')" width="440px">
|
||||
<el-dialog v-model="createVisible" :title="t('outright.btn.create_event')" width="480px">
|
||||
<el-form label-width="100px" size="small">
|
||||
<el-form-item :label="t('outright.field.league')">
|
||||
<el-select v-model="createForm.leagueId" filterable style="width: 100%">
|
||||
@@ -310,6 +311,9 @@ onMounted(() => {
|
||||
<el-form-item :label="t('outright.field.title_en')">
|
||||
<el-input v-model="createForm.titleEn" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('outright.field.title_ms')">
|
||||
<el-input v-model="createForm.titleMs" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button size="small" @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
|
||||
Reference in New Issue
Block a user