feat(admin,api,player): 优胜赛配置、赛事管理重构与玩家端投注体验优化

管理端拆分赛事/优胜赛 Tab,新增联赛优胜赔率面板(批量、排序、外侧删除);统一 list-chrome 工具栏对齐与列表页布局;Dashboard 失败重试、Users 操作下拉、小屏侧栏等体验修复。

API 扩展优胜赛与赛事目录接口,完善投注与钱包查询;玩家端重构赛事卡片、串关面板、注单/钱包页,新增注单详情、下注成功动画与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 09:55:56 +08:00
parent efff7c27e6
commit 24fa1b275c
66 changed files with 6289 additions and 1426 deletions

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
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 MatchesSubNav from '../components/MatchesSubNav.vue';
import CountryFlagSelect from '../components/outright/CountryFlagSelect.vue';
import { getBuiltinCountry } from '../data/builtinCountries';
import {
readMatchesListUiState,
writeMatchesListUiState,
@@ -15,10 +17,13 @@ import { formatAmount } from '../utils/format-amount';
import {
emptyMatchForm,
buildPlatformPayload,
fillBuiltinTeam,
clearBuiltinTeam,
type MatchCreateForm,
} from './match-form.ts';
const { t } = useAdminLocale();
const route = useRoute();
const leagues = ref<unknown[]>([]);
const total = ref(0);
const page = ref(1);
@@ -94,7 +99,16 @@ function onSearch() {
load();
}
onMounted(() => load({ restoreExpand: true }));
onMounted(() => {
const qStatus = route.query.status;
if (typeof qStatus === 'string' && qStatus.trim()) {
filterStatus.value = qStatus.trim();
page.value = 1;
load();
return;
}
load({ restoreExpand: true });
});
onBeforeUnmount(persistListUiState);
function onPageChange(p: number) {
@@ -138,25 +152,13 @@ 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 onTeamCodeChange(side: 'home' | 'away', code: string) {
if (!code?.trim()) {
clearBuiltinTeam(form.value, side);
return;
}
}
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)}`;
const country = getBuiltinCountry(code);
if (country) fillBuiltinTeam(form.value, side, country);
}
function openCreateFixture(leagueRow: unknown) {
@@ -266,6 +268,14 @@ function leagueTitle(row: unknown) {
const en = String(r.leagueEn ?? '').trim();
return zh || en || String(r.code ?? '—');
}
function leagueNameZh(row: unknown) {
const zh = String(rowOf(row).leagueZh ?? '').trim();
return zh || '—';
}
function leagueNameEn(row: unknown) {
const en = String(rowOf(row).leagueEn ?? '').trim();
return en || '—';
}
function leagueMatchCount(row: unknown) {
return Number(rowOf(row).matchCount ?? 0);
}
@@ -290,42 +300,51 @@ function leaguePendingBets(row: unknown) {
function isLeagueExpanded(id: string) {
return expandedRowKeys.value.includes(id);
}
</script>
<template>
<div class="admin-list-page matches-page">
<div class="page-toolbar">
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<el-button type="primary" @click="openCreateLeague">{{ t('match.create_btn') }}</el-button>
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
<MatchesSubNav embedded />
<div class="list-chrome__filters">
<div class="list-chrome__field">
<span class="list-chrome__label">{{ t('common.keyword') }}</span>
<el-input
v-model="keyword"
:placeholder="t('match.filter.keyword_ph')"
clearable
style="width: 200px"
@keyup.enter="load"
/>
</div>
<div class="list-chrome__field">
<span class="list-chrome__label">{{ t('common.status') }}</span>
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
<el-option :label="t('match.status.PENDING_SETTLEMENT')" value="PENDING_SETTLEMENT" />
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
</el-select>
</div>
<el-button type="primary" class="list-chrome__submit" @click="onSearch">
{{ t('common.search') }}
</el-button>
</div>
</div>
<div class="list-chrome__actions">
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<el-button type="primary" @click="openCreateLeague">{{ t('match.create_btn') }}</el-button>
</div>
</div>
<p v-if="filterStatus" class="list-hint">{{ t('match.filter.status_hint') }}</p>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
:placeholder="t('match.filter.keyword_ph')"
clearable
style="width: 200px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<p class="table-hint">{{ t('match.expand_league_hint') }}</p>
<section class="list-panel">
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
<div class="table-wrap">
<el-table
:data="leagues"
@@ -338,18 +357,19 @@ function isLeagueExpanded(id: string) {
>
<el-table-column type="expand" width="40">
<template #default="{ row }">
<LeagueMatchesPanel
v-if="isLeagueExpanded(leagueId(row))"
:league-id="leagueId(row)"
:filter-status="filterStatus"
:keyword="keyword"
@changed="() => load({ keepExpand: true })"
@add-match="openCreateFixture(row)"
/>
<template v-if="isLeagueExpanded(leagueId(row))">
<LeagueMatchesPanel
:league-id="leagueId(row)"
:filter-status="filterStatus"
:keyword="keyword"
@changed="() => load({ keepExpand: true })"
@add-match="openCreateFixture(row)"
/>
</template>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column :label="t('match.col.league')" min-width="220">
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
<template #default="{ row }">
<div class="league-cell">
<img
@@ -358,10 +378,15 @@ function isLeagueExpanded(id: string) {
alt=""
class="league-logo"
/>
<span class="matchup-link">{{ leagueTitle(row) }}</span>
<span class="matchup-link">{{ leagueNameZh(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('match.col.league_en')" width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="league-en">{{ leagueNameEn(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.fixture_count')" width="88" align="center">
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
</el-table-column>
@@ -381,7 +406,7 @@ function isLeagueExpanded(id: string) {
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.league_code')" width="100">
<el-table-column :label="t('match.col.league_code')" width="140" show-overflow-tooltip>
<template #default="{ row }">{{ rowOf(row).code }}</template>
</el-table-column>
</el-table>
@@ -398,7 +423,7 @@ function isLeagueExpanded(id: string) {
@size-change="onSizeChange"
/>
</div>
</el-card>
</section>
<el-dialog v-model="createLeagueVisible" :title="t('match.dialog.create_league')" width="520px" destroy-on-close>
<el-form label-width="96px">
@@ -451,36 +476,20 @@ function isLeagueExpanded(id: string) {
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('match.field.home_en')">
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
</el-form-item>
<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 :label="t('match.field.home_team')" required>
<CountryFlagSelect
v-model="form.homeTeamCode"
size="default"
class="team-country-select"
@update:model-value="onTeamCodeChange('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 :label="t('match.field.away_team')" required>
<CountryFlagSelect
v-model="form.awayTeamCode"
size="default"
class="team-country-select"
@update:model-value="onTeamCodeChange('away', $event)"
/>
</el-form-item>
<el-form-item :label="t('match.field.featured')">
@@ -511,16 +520,16 @@ function isLeagueExpanded(id: string) {
</template>
<style scoped>
.filter-card { border-radius: 12px; }
.data-card {
border-radius: 12px;
}
.dialog-hint {
font-size: 13px;
color: #666;
margin: 0 0 12px;
line-height: 1.5;
}
.team-country-select {
width: 100%;
}
.dialog-hint code {
color: #aaa;
}
@@ -534,61 +543,25 @@ function isLeagueExpanded(id: string) {
margin-bottom: 16px;
}
.action-btns {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
align-items: center;
}
.action-btns :deep(.action-btn) {
margin: 0 !important;
min-width: 52px;
padding: 4px 8px !important;
font-size: 12px !important;
background: #1a1a1a !important;
border-color: #333 !important;
color: #bbb !important;
}
.action-btns :deep(.action-btn:not(.is-disabled):hover) {
background: #252525 !important;
border-color: #444 !important;
color: #fff !important;
}
.action-btns :deep(.action-btn.is-disabled) {
background: #121212 !important;
border-color: #252525 !important;
color: #444 !important;
opacity: 1 !important;
cursor: not-allowed !important;
}
.table-hint {
font-size: 12px;
color: #666;
margin: 0 0 10px;
line-height: 1.5;
flex-shrink: 0;
}
/* 列表表格随内容增高,滚动交给外层 table-wrap仅赛事行 */
.matches-page .table-wrap .el-table {
height: auto !important;
}
.matches-page .table-wrap :deep(.el-table__header),
.matches-page .table-wrap :deep(.el-table__body) {
width: 100% !important;
}
.matches-page :deep(.el-table__expanded-cell) {
padding: 0 !important;
background: #0a0a0a;
}
.data-card :deep(.row-expandable) {
.list-panel :deep(.row-expandable) {
cursor: pointer;
}
.data-card :deep(.row-no-expand .el-table__expand-icon) {
.list-panel :deep(.row-no-expand .el-table__expand-icon) {
visibility: hidden;
pointer-events: none;
}
@@ -617,6 +590,11 @@ function isLeagueExpanded(id: string) {
flex-shrink: 0;
}
.league-en {
color: #aaa;
font-size: 13px;
}
.league-readonly {
color: var(--green-text);
font-weight: 500;