feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化
- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持 - 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮 - API 新增联赛列表与子场查询,按 locale 返回队名并修复编译 - 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
@@ -1,46 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
import { ElMessage } from 'element-plus';
|
||||
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
|
||||
import {
|
||||
readMatchesListUiState,
|
||||
writeMatchesListUiState,
|
||||
} from '../utils/matchesListState.ts';
|
||||
import {
|
||||
emptyMatchForm,
|
||||
buildPlatformPayload,
|
||||
formFromDetail,
|
||||
type MatchCreateForm,
|
||||
type AdminMatchDetail,
|
||||
} from './match-form.ts';
|
||||
|
||||
const router = useRouter();
|
||||
const matches = ref<unknown[]>([]);
|
||||
const { t } = useAdminLocale();
|
||||
const leagues = ref<unknown[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const filterStatus = ref('');
|
||||
const keyword = ref('');
|
||||
const expandedRowKeys = ref<string[]>([]);
|
||||
|
||||
const createLeagueVisible = ref(false);
|
||||
const createLeagueLoading = ref(false);
|
||||
const leagueForm = ref({ leagueEn: '', leagueZh: '', leagueMs: '', logoUrl: '' });
|
||||
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const importVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const editLoading = ref(false);
|
||||
const importLoading = ref(false);
|
||||
const importJson = ref('');
|
||||
const form = ref<MatchCreateForm>(emptyMatchForm());
|
||||
const editingId = ref('');
|
||||
const editingStatus = ref('');
|
||||
const createUnderLeagueLabel = ref('');
|
||||
|
||||
const isEditPublished = computed(() => editingStatus.value === 'PUBLISHED');
|
||||
const isFixtureCreate = computed(() => !!form.value.leagueId.trim());
|
||||
|
||||
onMounted(load);
|
||||
function persistListUiState() {
|
||||
writeMatchesListUiState({
|
||||
expandedLeagueIds: [...expandedRowKeys.value],
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
filterStatus: filterStatus.value,
|
||||
keyword: keyword.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/admin/matches', {
|
||||
function applyExpandedFromSaved(savedIds: string[]) {
|
||||
const allowed = new Set(leagues.value.map((row) => leagueId(row)));
|
||||
expandedRowKeys.value = savedIds.filter((id) => allowed.has(id));
|
||||
}
|
||||
|
||||
type LoadOptions = { restoreExpand?: boolean; keepExpand?: boolean };
|
||||
|
||||
async function load(options: LoadOptions = {}) {
|
||||
const saved = options.restoreExpand ? readMatchesListUiState() : null;
|
||||
if (saved) {
|
||||
page.value = saved.page;
|
||||
pageSize.value = saved.pageSize;
|
||||
filterStatus.value = saved.filterStatus;
|
||||
keyword.value = saved.keyword;
|
||||
}
|
||||
|
||||
const { data } = await api.get('/admin/leagues', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
@@ -48,24 +72,77 @@ async function load() {
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
},
|
||||
});
|
||||
matches.value = data.data.items;
|
||||
leagues.value = data.data.items;
|
||||
total.value = data.data.total;
|
||||
|
||||
if (options.restoreExpand && saved) {
|
||||
applyExpandedFromSaved(saved.expandedLeagueIds);
|
||||
} else if (!options.keepExpand) {
|
||||
expandedRowKeys.value = [];
|
||||
} else {
|
||||
applyExpandedFromSaved(expandedRowKeys.value);
|
||||
}
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
expandedRowKeys.value = [];
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(() => load({ restoreExpand: true }));
|
||||
onBeforeUnmount(persistListUiState);
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
load({ keepExpand: true });
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
load();
|
||||
load({ keepExpand: true });
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
function openCreateLeague() {
|
||||
leagueForm.value = { leagueEn: '', leagueZh: '', leagueMs: '', logoUrl: '' };
|
||||
createLeagueVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreateLeague() {
|
||||
const { leagueEn, leagueZh, leagueMs, logoUrl } = leagueForm.value;
|
||||
if (!leagueZh.trim() && !leagueEn.trim()) {
|
||||
ElMessage.warning(t('err.league_required'));
|
||||
return;
|
||||
}
|
||||
createLeagueLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/leagues', {
|
||||
leagueEn: leagueEn.trim(),
|
||||
leagueZh: leagueZh.trim(),
|
||||
leagueMs: leagueMs.trim() || undefined,
|
||||
logoUrl: logoUrl.trim() || undefined,
|
||||
});
|
||||
ElMessage.success(t('msg.league_created'));
|
||||
createLeagueVisible.value = false;
|
||||
load({ keepExpand: true });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
} finally {
|
||||
createLeagueLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateFixture(leagueRow: unknown) {
|
||||
const r = rowOf(leagueRow);
|
||||
form.value = emptyMatchForm();
|
||||
editingId.value = '';
|
||||
form.value.leagueId = String(r.id ?? '');
|
||||
form.value.leagueEn = String(r.leagueEn ?? '');
|
||||
form.value.leagueZh = String(r.leagueZh ?? '');
|
||||
form.value.leagueMs = String(r.leagueMs ?? '');
|
||||
createUnderLeagueLabel.value = leagueTitle(leagueRow);
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -74,24 +151,6 @@ function openImport() {
|
||||
importVisible.value = true;
|
||||
}
|
||||
|
||||
async function openEdit(id: string) {
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${id}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
return;
|
||||
}
|
||||
editingId.value = id;
|
||||
editingStatus.value = detail.status;
|
||||
form.value = formFromDetail(detail);
|
||||
editVisible.value = true;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||
try {
|
||||
@@ -104,8 +163,14 @@ async function submitCreate() {
|
||||
try {
|
||||
await api.post('/admin/matches', payload);
|
||||
ElMessage.success(t('msg.match_created_draft'));
|
||||
createUnderLeagueLabel.value = '';
|
||||
createVisible.value = false;
|
||||
load();
|
||||
const lid = form.value.leagueId.trim();
|
||||
await load({ keepExpand: true });
|
||||
if (lid && !expandedRowKeys.value.includes(lid)) {
|
||||
expandedRowKeys.value = [...expandedRowKeys.value, lid];
|
||||
persistListUiState();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
@@ -114,47 +179,6 @@ async function submitCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await api.put(`/admin/matches/${editingId.value}`, payload);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
let payload: unknown;
|
||||
try {
|
||||
@@ -181,7 +205,7 @@ async function submitImport() {
|
||||
}),
|
||||
);
|
||||
importVisible.value = false;
|
||||
load();
|
||||
load({ keepExpand: true });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.import_failed'));
|
||||
@@ -190,91 +214,47 @@ async function submitImport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(id: string) {
|
||||
await api.post(`/admin/matches/${id}/publish`);
|
||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||
marketTypes: [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
],
|
||||
});
|
||||
ElMessage.success(t('msg.published'));
|
||||
load();
|
||||
function onExpandChange(_row: unknown, expanded: unknown[]) {
|
||||
expandedRowKeys.value = expanded.map((r) => leagueId(r));
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
async function close(id: string) {
|
||||
await api.post(`/admin/matches/${id}/close`);
|
||||
ElMessage.success(t('msg.closed'));
|
||||
load();
|
||||
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
|
||||
const id = leagueId(row);
|
||||
expandedRowKeys.value = expandedRowKeys.value.includes(id) ? [] : [id];
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
function settle(id: string) {
|
||||
router.push(`/settlement/${id}`);
|
||||
function rowClassName() {
|
||||
return 'row-expandable';
|
||||
}
|
||||
|
||||
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
function matchStatusText(status: string) {
|
||||
const key = `match.status.${status}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
const statusTagTypes: Record<string, TagType> = {
|
||||
DRAFT: 'info',
|
||||
PUBLISHED: 'warning',
|
||||
CLOSED: 'danger',
|
||||
SETTLED: 'success',
|
||||
};
|
||||
|
||||
function rowOf(row: unknown) {
|
||||
return row as Record<string, unknown>;
|
||||
}
|
||||
function matchStatus(row: unknown) {
|
||||
return String(rowOf(row).status ?? '');
|
||||
}
|
||||
function matchStatusLabel(row: unknown) {
|
||||
return matchStatusText(matchStatus(row));
|
||||
}
|
||||
function matchStatusType(row: unknown): TagType {
|
||||
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||
}
|
||||
function matchId(row: unknown) {
|
||||
function leagueId(row: unknown) {
|
||||
return String(rowOf(row).id ?? '');
|
||||
}
|
||||
function matchTime(row: unknown) {
|
||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||
}
|
||||
function matchTitle(row: unknown) {
|
||||
function leagueTitle(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
if (r.matchName) return String(r.matchName);
|
||||
const home = (r.homeTeam as { code?: string })?.code ?? '';
|
||||
const away = (r.awayTeam as { code?: string })?.code ?? '';
|
||||
return home && away ? `${home} vs ${away}` : '—';
|
||||
const zh = String(r.leagueZh ?? '').trim();
|
||||
const en = String(r.leagueEn ?? '').trim();
|
||||
return zh || en || String(r.code ?? '—');
|
||||
}
|
||||
function canEdit(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
if (r.isOutright) return false;
|
||||
return matchStatus(row) === 'DRAFT' || matchStatus(row) === 'PUBLISHED';
|
||||
function leagueMatchCount(row: unknown) {
|
||||
return Number(rowOf(row).matchCount ?? 0);
|
||||
}
|
||||
function canDelete(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
if (r.isOutright) return false;
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
function isLeagueExpanded(id: string) {
|
||||
return expandedRowKeys.value.includes(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<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="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||
<el-button type="primary" @click="openCreateLeague">{{ t('match.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
@@ -297,67 +277,54 @@ function canDelete(row: unknown) {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
<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>
|
||||
<div class="table-wrap">
|
||||
<el-table :data="matches" stripe>
|
||||
<el-table
|
||||
:data="leagues"
|
||||
stripe
|
||||
row-key="id"
|
||||
:expand-row-keys="expandedRowKeys"
|
||||
:row-class-name="rowClassName"
|
||||
@expand-change="onExpandChange"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<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>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="200">
|
||||
<template #default="{ row }">{{ matchTitle(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="96">
|
||||
<el-table-column :label="t('match.col.league')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
<div class="league-cell">
|
||||
<img
|
||||
v-if="rowOf(row).logoUrl"
|
||||
:src="String(rowOf(row).logoUrl)"
|
||||
alt=""
|
||||
class="league-logo"
|
||||
/>
|
||||
<span class="matchup-link">{{ leagueTitle(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="160">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
<el-table-column :label="t('match.col.fixture_count')" width="100" align="center">
|
||||
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="340" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canEdit(row)"
|
||||
size="small"
|
||||
plain
|
||||
@click="openEdit(matchId(row))"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(row)"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'DRAFT'"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@click="publish(matchId(row))"
|
||||
>
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'PUBLISHED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="close(matchId(row))"
|
||||
>
|
||||
{{ t('common.close_betting') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" plain @click="settle(matchId(row))">
|
||||
{{ t('common.settle') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<el-table-column :label="t('match.col.league_code')" width="120">
|
||||
<template #default="{ row }">{{ rowOf(row).code }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -375,16 +342,56 @@ function canDelete(row: unknown) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="t('match.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="createLeagueVisible" :title="t('match.dialog.create_league')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
<el-input v-model="leagueForm.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="leagueForm.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_ms')">
|
||||
<el-input v-model="leagueForm.leagueMs" :placeholder="t('match.ph.league_ms')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_logo')">
|
||||
<el-input v-model="leagueForm.logoUrl" :placeholder="t('matchEditor.ph.logo_url')" />
|
||||
</el-form-item>
|
||||
<p class="field-hint">{{ t('match.hint.create_league') }}</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createLeagueVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="createLeagueLoading" @click="submitCreateLeague">
|
||||
{{ t('user.btn.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="createVisible"
|
||||
:title="t('match.dialog.create_fixture')"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item v-if="isFixtureCreate" :label="t('match.col.league')">
|
||||
<span class="league-readonly">{{ createUnderLeagueLabel }}</span>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" :placeholder="t('match.ph.kickoff')" />
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
:placeholder="t('matchEditor.ph.kickoff')"
|
||||
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')" />
|
||||
@@ -409,42 +416,6 @@ function canDelete(row: unknown) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" :title="t('match.dialog.edit')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<p v-if="isEditPublished" class="field-hint edit-hint">
|
||||
{{ t('match.hint.edit_published') }}
|
||||
</p>
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="t('match.dialog.import')" width="640px" destroy-on-close>
|
||||
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
|
||||
<el-input
|
||||
@@ -484,4 +455,85 @@ function canDelete(row: unknown) {
|
||||
.edit-hint {
|
||||
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 :deep(.el-table__expanded-cell) {
|
||||
padding: 0 !important;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.data-card :deep(.row-expandable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-card :deep(.row-no-expand .el-table__expand-icon) {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
}
|
||||
|
||||
.league-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.league-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.league-readonly {
|
||||
color: var(--green-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,54 +3,133 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface MatchCreateForm {
|
||||
leagueId: string;
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs: string;
|
||||
startTime: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamMs: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamEn: string;
|
||||
awayTeamMs: string;
|
||||
isHot: boolean;
|
||||
displayOrder: number;
|
||||
matchName: string;
|
||||
stage: string;
|
||||
groupName: string;
|
||||
leagueLogoUrl: string;
|
||||
homeTeamLogoUrl: string;
|
||||
awayTeamLogoUrl: string;
|
||||
}
|
||||
|
||||
export function emptyMatchForm(): MatchCreateForm {
|
||||
return {
|
||||
leagueId: '',
|
||||
leagueEn: 'FIFA World Cup 2026',
|
||||
leagueZh: '2026 世界杯',
|
||||
leagueMs: 'Piala Dunia 2026',
|
||||
startTime: '',
|
||||
homeTeamZh: '',
|
||||
homeTeamEn: '',
|
||||
homeTeamMs: '',
|
||||
awayTeamZh: '',
|
||||
awayTeamEn: '',
|
||||
awayTeamMs: '',
|
||||
isHot: false,
|
||||
displayOrder: 0,
|
||||
matchName: '',
|
||||
stage: '',
|
||||
groupName: '',
|
||||
leagueLogoUrl: '',
|
||||
homeTeamLogoUrl: '',
|
||||
awayTeamLogoUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminMarketSelection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminMarket {
|
||||
id: string;
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
status: string;
|
||||
promoLabel: string;
|
||||
selections: AdminMarketSelection[];
|
||||
}
|
||||
|
||||
export type AdminMatchDetail = {
|
||||
id: string;
|
||||
status: string;
|
||||
isOutright: boolean;
|
||||
isHot: boolean;
|
||||
displayOrder: number;
|
||||
startTime: string;
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs: string;
|
||||
leagueLogoUrl?: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamMs: string;
|
||||
homeTeamCode?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamEn: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamMs: string;
|
||||
awayTeamCode?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
matchName: string;
|
||||
stage?: string;
|
||||
groupName?: string;
|
||||
markets?: AdminMarket[];
|
||||
};
|
||||
|
||||
export function normalizeStartTimeForPicker(iso?: string): string {
|
||||
if (!iso?.trim()) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 19);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function normalizeStartTimeForApi(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
const d = new Date(trimmed);
|
||||
if (Number.isNaN(d.getTime())) return trimmed;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
return {
|
||||
leagueId: '',
|
||||
leagueEn: d.leagueEn,
|
||||
leagueZh: d.leagueZh,
|
||||
startTime: d.startTime,
|
||||
leagueMs: d.leagueMs ?? '',
|
||||
startTime: normalizeStartTimeForPicker(d.startTime),
|
||||
homeTeamZh: d.homeTeamZh,
|
||||
homeTeamEn: d.homeTeamEn,
|
||||
homeTeamMs: d.homeTeamMs ?? '',
|
||||
awayTeamZh: d.awayTeamZh,
|
||||
awayTeamEn: d.awayTeamEn,
|
||||
awayTeamMs: d.awayTeamMs ?? '',
|
||||
isHot: d.isHot,
|
||||
displayOrder: d.displayOrder ?? 0,
|
||||
matchName: d.matchName ?? '',
|
||||
stage: d.stage ?? '',
|
||||
groupName: d.groupName ?? '',
|
||||
leagueLogoUrl: d.leagueLogoUrl ?? '',
|
||||
homeTeamLogoUrl: d.homeTeamLogoUrl ?? '',
|
||||
awayTeamLogoUrl: d.awayTeamLogoUrl ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,23 +137,39 @@ export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
if (!form.startTime.trim()) {
|
||||
throw new FormValidationError('err.kickoff_required');
|
||||
}
|
||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim();
|
||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim();
|
||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim() || form.homeTeamMs.trim();
|
||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim() || form.awayTeamMs.trim();
|
||||
if (!homeOk || !awayOk) {
|
||||
throw new FormValidationError('err.teams_required');
|
||||
}
|
||||
if (!form.leagueZh.trim() && !form.leagueEn.trim()) {
|
||||
if (
|
||||
!form.leagueId.trim() &&
|
||||
!form.leagueZh.trim() &&
|
||||
!form.leagueEn.trim() &&
|
||||
!form.leagueMs.trim()
|
||||
) {
|
||||
throw new FormValidationError('err.league_required');
|
||||
}
|
||||
|
||||
return {
|
||||
leagueId: form.leagueId.trim() || undefined,
|
||||
leagueEn: form.leagueEn.trim(),
|
||||
leagueZh: form.leagueZh.trim(),
|
||||
leagueMs: form.leagueMs.trim() || undefined,
|
||||
homeTeamEn: form.homeTeamEn.trim(),
|
||||
homeTeamZh: form.homeTeamZh.trim(),
|
||||
homeTeamMs: form.homeTeamMs.trim() || undefined,
|
||||
awayTeamEn: form.awayTeamEn.trim(),
|
||||
awayTeamZh: form.awayTeamZh.trim(),
|
||||
startTime: form.startTime.trim(),
|
||||
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||
startTime: normalizeStartTimeForApi(form.startTime),
|
||||
isHot: form.isHot,
|
||||
displayOrder: form.displayOrder,
|
||||
matchName: form.matchName.trim() || undefined,
|
||||
stage: form.stage.trim() || undefined,
|
||||
groupName: form.groupName.trim() || undefined,
|
||||
leagueLogoUrl: form.leagueLogoUrl.trim() || undefined,
|
||||
homeTeamLogoUrl: form.homeTeamLogoUrl.trim() || undefined,
|
||||
awayTeamLogoUrl: form.awayTeamLogoUrl.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
262
apps/admin/src/views/matches/LeagueMatchesPanel.vue
Normal file
262
apps/admin/src/views/matches/LeagueMatchesPanel.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
|
||||
const props = defineProps<{
|
||||
leagueId: string;
|
||||
filterStatus: string;
|
||||
keyword: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
'add-match': [];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
const matches = ref<unknown[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
|
||||
params: {
|
||||
status: props.filterStatus || undefined,
|
||||
keyword: props.keyword.trim() || undefined,
|
||||
locale: locale.value,
|
||||
},
|
||||
});
|
||||
matches.value = data.data.items;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.leagueId, props.filterStatus, props.keyword, locale.value] as const,
|
||||
() => load(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function notifyParent() {
|
||||
emit('changed');
|
||||
load();
|
||||
}
|
||||
|
||||
async function publish(id: string) {
|
||||
await api.post(`/admin/matches/${id}/publish`);
|
||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||
marketTypes: [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
],
|
||||
});
|
||||
ElMessage.success(t('msg.published'));
|
||||
notifyParent();
|
||||
}
|
||||
|
||||
async function close(id: string) {
|
||||
await api.post(`/admin/matches/${id}/close`);
|
||||
ElMessage.success(t('msg.closed'));
|
||||
notifyParent();
|
||||
}
|
||||
|
||||
function beforeLeaveList() {
|
||||
ensureLeagueExpanded(props.leagueId);
|
||||
}
|
||||
|
||||
function openManage(id: string) {
|
||||
beforeLeaveList();
|
||||
router.push(`/matches/${id}/edit`);
|
||||
}
|
||||
|
||||
function openMarkets(id: string) {
|
||||
beforeLeaveList();
|
||||
router.push(`/matches/${id}/markets`);
|
||||
}
|
||||
|
||||
function settle(id: string) {
|
||||
beforeLeaveList();
|
||||
router.push(`/settlement/${id}`);
|
||||
}
|
||||
|
||||
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
function matchStatusText(status: string) {
|
||||
const key = `match.status.${status}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
const statusTagTypes: Record<string, TagType> = {
|
||||
DRAFT: 'info',
|
||||
PUBLISHED: 'warning',
|
||||
CLOSED: 'danger',
|
||||
SETTLED: 'success',
|
||||
};
|
||||
|
||||
function rowOf(row: unknown) {
|
||||
return row as Record<string, unknown>;
|
||||
}
|
||||
function matchStatus(row: unknown) {
|
||||
return String(rowOf(row).status ?? '');
|
||||
}
|
||||
function matchStatusLabel(row: unknown) {
|
||||
return matchStatusText(matchStatus(row));
|
||||
}
|
||||
function matchStatusType(row: unknown): TagType {
|
||||
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||
}
|
||||
function matchId(row: unknown) {
|
||||
return String(rowOf(row).id ?? '');
|
||||
}
|
||||
function matchTime(row: unknown) {
|
||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||
}
|
||||
function matchTitle(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
const home =
|
||||
String(r.homeTeamName ?? '').trim() ||
|
||||
(r.homeTeam as { code?: string })?.code ||
|
||||
'';
|
||||
const away =
|
||||
String(r.awayTeamName ?? '').trim() ||
|
||||
(r.awayTeam as { code?: string })?.code ||
|
||||
'';
|
||||
if (home && away) return `${home} vs ${away}`;
|
||||
const matchName = String(r.matchName ?? '').trim();
|
||||
return matchName || '—';
|
||||
}
|
||||
function canManage(row: unknown) {
|
||||
const s = matchStatus(row);
|
||||
return s === 'DRAFT' || s === 'PUBLISHED';
|
||||
}
|
||||
function canDeleteRow(row: unknown) {
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
}
|
||||
function canPublishRow(row: unknown) {
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
}
|
||||
function canCloseRow(row: unknown) {
|
||||
return matchStatus(row) === 'PUBLISHED';
|
||||
}
|
||||
function canSettleRow(row: unknown) {
|
||||
return matchStatus(row) !== 'DRAFT';
|
||||
}
|
||||
|
||||
async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
notifyParent();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reload: load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="league-matches-panel">
|
||||
<div class="panel-toolbar">
|
||||
<el-button type="primary" size="small" @click="emit('add-match')">
|
||||
{{ t('match.create_fixture_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
||||
<el-table-column prop="id" label="ID" width="64" />
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="matchup-link">{{ matchTitle(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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('common.actions')" width="420" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="action-btns">
|
||||
<el-button size="small" class="action-btn" :disabled="!canManage(row)" @click="openManage(matchId(row))">
|
||||
{{ t('matchEditor.manage_btn') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canManage(row)" @click="openMarkets(matchId(row))">
|
||||
{{ t('match.btn.markets') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canDeleteRow(row)" @click="confirmDelete(row)">
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canPublishRow(row)" @click="publish(matchId(row))">
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canCloseRow(row)" @click="close(matchId(row))">
|
||||
{{ t('common.close_betting') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canSettleRow(row)" @click="settle(matchId(row))">
|
||||
{{ t('common.settle') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.league-matches-panel {
|
||||
padding: 10px 12px 12px;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
.panel-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
.action-btns :deep(.action-btn) {
|
||||
margin: 0 !important;
|
||||
min-width: 52px;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
</style>
|
||||
367
apps/admin/src/views/matches/MatchEventEditor.vue
Normal file
367
apps/admin/src/views/matches/MatchEventEditor.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
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 {
|
||||
buildPlatformPayload,
|
||||
emptyMatchForm,
|
||||
formFromDetail,
|
||||
type AdminMatchDetail,
|
||||
type MatchCreateForm,
|
||||
} from '../match-form.ts';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const matchId = computed(() => String(route.params.matchId ?? ''));
|
||||
|
||||
const loading = ref(false);
|
||||
const savingMeta = ref(false);
|
||||
const status = ref('DRAFT');
|
||||
const form = ref<MatchCreateForm>(emptyMatchForm());
|
||||
const homeTeamCode = ref('');
|
||||
const awayTeamCode = ref('');
|
||||
|
||||
function applyTeamFromCountry(
|
||||
side: 'home' | 'away',
|
||||
country: BuiltinCountry,
|
||||
) {
|
||||
if (side === 'home') {
|
||||
if (!form.value.homeTeamZh.trim()) form.value.homeTeamZh = country.nameZh;
|
||||
if (!form.value.homeTeamEn.trim()) form.value.homeTeamEn = country.nameEn;
|
||||
} else {
|
||||
if (!form.value.awayTeamZh.trim()) form.value.awayTeamZh = country.nameZh;
|
||||
if (!form.value.awayTeamEn.trim()) form.value.awayTeamEn = country.nameEn;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
router.replace('/outrights');
|
||||
return;
|
||||
}
|
||||
status.value = detail.status;
|
||||
form.value = formFromDetail(detail);
|
||||
homeTeamCode.value = detail.homeTeamCode ?? '';
|
||||
awayTeamCode.value = detail.awayTeamCode ?? '';
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
watch(matchId, load, { immediate: true });
|
||||
|
||||
async function saveMeta() {
|
||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
savingMeta.value = true;
|
||||
try {
|
||||
await api.put(`/admin/matches/${matchId.value}`, payload);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
savingMeta.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-editor-page page-scroll">
|
||||
<div class="editor-topbar">
|
||||
<el-button size="small" text class="back-btn" @click="router.push('/matches')">
|
||||
← {{ t('matchEditor.back') }}
|
||||
</el-button>
|
||||
<div class="topbar-title">
|
||||
<h2>{{ t('matchEditor.title') }} #{{ matchId }}</h2>
|
||||
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">{{ t('matchEditor.section_info') }}</span>
|
||||
</div>
|
||||
|
||||
<el-form label-width="72px" label-position="left" class="meta-form compact-form">
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.league') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_zh')">
|
||||
<el-input v-model="form.leagueZh" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_en')">
|
||||
<el-input v-model="form.leagueEn" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_ms')">
|
||||
<el-input v-model="form.leagueMs" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<div class="logo-inline">
|
||||
<span class="logo-inline-label">{{ t('matchEditor.field.league_logo') }}</span>
|
||||
<LogoUrlField v-model="form.leagueLogoUrl" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.home') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_zh')">
|
||||
<el-input v-model="form.homeTeamZh" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_en')">
|
||||
<el-input v-model="form.homeTeamEn" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_ms')">
|
||||
<el-input v-model="form.homeTeamMs" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<div class="logo-inline">
|
||||
<span class="logo-inline-label">{{ t('matchEditor.field.home_logo') }}</span>
|
||||
<LogoUrlField
|
||||
v-model="form.homeTeamLogoUrl"
|
||||
:team-code="homeTeamCode"
|
||||
@pick="applyTeamFromCountry('home', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.away') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_zh')">
|
||||
<el-input v-model="form.awayTeamZh" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_en')">
|
||||
<el-input v-model="form.awayTeamEn" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_ms')">
|
||||
<el-input v-model="form.awayTeamMs" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<div class="logo-inline">
|
||||
<span class="logo-inline-label">{{ t('matchEditor.field.away_logo') }}</span>
|
||||
<LogoUrlField
|
||||
v-model="form.awayTeamLogoUrl"
|
||||
:team-code="awayTeamCode"
|
||||
@pick="applyTeamFromCountry('away', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.schedule') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
size="small"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
:placeholder="t('matchEditor.ph.kickoff')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="t('matchEditor.field.match_name')">
|
||||
<el-input v-model="form.matchName" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.stage')">
|
||||
<el-input v-model="form.stage" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.group')">
|
||||
<el-input v-model="form.groupName" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.display_order')">
|
||||
<el-input-number v-model="form.displayOrder" :min="0" size="small" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="panel-foot">
|
||||
<el-button type="primary" :loading="savingMeta" @click="saveMeta">
|
||||
{{ t('matchEditor.save_info') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-editor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: var(--green-text) !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.compact-form :deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.logo-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-inline-label {
|
||||
flex: 0 0 72px;
|
||||
font-size: 12px;
|
||||
color: #8e8e93;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-inline :deep(.logo-url-field) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-form :deep(.el-form-item__label) {
|
||||
color: #8e8e93 !important;
|
||||
}
|
||||
|
||||
.meta-form :deep(.el-input__inner),
|
||||
.meta-form :deep(.el-input-number .el-input__inner) {
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
107
apps/admin/src/views/matches/MatchMarketsPage.vue
Normal file
107
apps/admin/src/views/matches/MatchMarketsPage.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import MatchMarketsPanel from './MatchMarketsPanel.vue';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const matchId = computed(() => String(route.params.matchId ?? ''));
|
||||
const loading = ref(false);
|
||||
const status = ref('DRAFT');
|
||||
const matchLabel = ref('');
|
||||
|
||||
async function load() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
router.replace('/outrights');
|
||||
return;
|
||||
}
|
||||
status.value = detail.status;
|
||||
const home = detail.homeTeamZh || detail.homeTeamEn || detail.homeTeamCode || '';
|
||||
const away = detail.awayTeamZh || detail.awayTeamEn || detail.awayTeamCode || '';
|
||||
matchLabel.value =
|
||||
detail.matchName?.trim() ||
|
||||
(home && away ? `${home} vs ${away}` : `#${matchId.value}`);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
watch(matchId, load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-markets-page page-scroll">
|
||||
<div class="editor-topbar">
|
||||
<el-button size="small" text class="back-btn" @click="router.push('/matches')">
|
||||
← {{ t('matchEditor.back') }}
|
||||
</el-button>
|
||||
<div class="topbar-title">
|
||||
<h2>{{ t('matchEditor.section_markets') }}</h2>
|
||||
<span class="match-subtitle">{{ matchLabel }}</span>
|
||||
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="matchId" class="panel">
|
||||
<MatchMarketsPanel :match-id="matchId" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-markets-page {
|
||||
padding: 0 4px 24px;
|
||||
}
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
flex-shrink: 0;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match-subtitle {
|
||||
color: var(--green-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults.ts';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
matchId: string;
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
interface SelectionRow {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
status: string;
|
||||
editOdds: number;
|
||||
}
|
||||
|
||||
interface MarketRow {
|
||||
id: string;
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
status: string;
|
||||
promoLabel: string;
|
||||
editPromoLabel: string;
|
||||
editLineValue: number | null;
|
||||
selections: SelectionRow[];
|
||||
}
|
||||
|
||||
const DEFAULT_MARKET_TYPES = [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const savingMarketId = ref<string | null>(null);
|
||||
const markets = ref<MarketRow[]>([]);
|
||||
|
||||
function mapMarkets(detail: AdminMatchDetail) {
|
||||
markets.value = (detail.markets ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
marketType: m.marketType,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue,
|
||||
status: m.status,
|
||||
promoLabel: m.promoLabel,
|
||||
editPromoLabel: m.promoLabel,
|
||||
editLineValue: m.lineValue,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
odds: s.odds,
|
||||
status: s.status,
|
||||
editOdds: s.odds,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.matchId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${props.matchId}`);
|
||||
mapMarkets(data.data as AdminMatchDetail);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.matchId,
|
||||
() => {
|
||||
load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function marketLabel(type: string) {
|
||||
const key = `matchEditor.market.${type}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : type;
|
||||
}
|
||||
|
||||
function selectionCodeLabel(code: string, market?: MarketRow) {
|
||||
return adminSelectionLabel(t, code, {
|
||||
lineValue: market?.editLineValue ?? market?.lineValue,
|
||||
period: market?.period,
|
||||
});
|
||||
}
|
||||
|
||||
function hasLine(market: MarketRow) {
|
||||
return market.lineValue != null || market.editLineValue != null;
|
||||
}
|
||||
|
||||
const CORRECT_SCORE_TYPES = new Set([
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
]);
|
||||
|
||||
function isMultiRowMarket(market: MarketRow) {
|
||||
return CORRECT_SCORE_TYPES.has(market.marketType) || market.selections.length > 6;
|
||||
}
|
||||
|
||||
function normPromo(value: string | null | undefined) {
|
||||
return (value ?? '').trim() || null;
|
||||
}
|
||||
|
||||
function normLine(value: number | null | undefined) {
|
||||
return value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
function isMarketDirty(market: MarketRow) {
|
||||
if (normPromo(market.editPromoLabel) !== normPromo(market.promoLabel)) return true;
|
||||
if (normLine(market.editLineValue) !== normLine(market.lineValue)) return true;
|
||||
return market.selections.some((s) => Number(s.editOdds) !== Number(s.odds));
|
||||
}
|
||||
|
||||
async function generateTemplates() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await api.post(`/admin/matches/${props.matchId}/markets/templates`, {
|
||||
marketTypes: DEFAULT_MARKET_TYPES,
|
||||
});
|
||||
ElMessage.success(t('matchEditor.templates_generated'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMarket(market: MarketRow) {
|
||||
const invalid = market.selections.find((s) => !s.editOdds || s.editOdds <= 1);
|
||||
if (invalid) {
|
||||
ElMessage.warning(t('outright.err_odds_min'));
|
||||
return;
|
||||
}
|
||||
|
||||
savingMarketId.value = market.id;
|
||||
try {
|
||||
await api.patch(`/admin/markets/${market.id}`, {
|
||||
promoLabel: market.editPromoLabel.trim() || null,
|
||||
lineValue: market.editLineValue,
|
||||
});
|
||||
await api.put(`/admin/matches/${props.matchId}/odds`, {
|
||||
updates: market.selections.map((s) => ({
|
||||
selectionId: s.id,
|
||||
odds: s.editOdds,
|
||||
})),
|
||||
});
|
||||
for (const s of market.selections) {
|
||||
const name = defaultSelectionName(s.selectionCode, {
|
||||
lineValue: market.editLineValue,
|
||||
period: market.period,
|
||||
});
|
||||
if (name !== s.selectionName) {
|
||||
await api.patch(`/admin/selections/${s.id}`, {
|
||||
selectionName: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
savingMarketId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-markets-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">{{ t('matchEditor.section_markets') }}</span>
|
||||
<el-button size="small" type="primary" plain @click="generateTemplates">
|
||||
{{ t('matchEditor.generate_templates') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
|
||||
|
||||
<div v-else class="market-lines">
|
||||
<div
|
||||
v-for="market in markets"
|
||||
:key="market.id"
|
||||
class="market-line"
|
||||
:class="{ 'market-line--wrap': isMultiRowMarket(market) }"
|
||||
>
|
||||
<label class="field-promo-wrap">
|
||||
<span class="promo-label">{{ t('matchEditor.field.promo_label_optional') }}</span>
|
||||
<el-input
|
||||
v-model="market.editPromoLabel"
|
||||
size="small"
|
||||
class="field-promo"
|
||||
clearable
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="market-line-head">
|
||||
<span class="market-label" :title="market.marketType">{{ marketLabel(market.marketType) }}</span>
|
||||
|
||||
<el-input-number
|
||||
v-if="hasLine(market)"
|
||||
v-model="market.editLineValue"
|
||||
size="small"
|
||||
class="field-line"
|
||||
:step="0.25"
|
||||
controls-position="right"
|
||||
/>
|
||||
|
||||
<el-button
|
||||
v-if="isMultiRowMarket(market)"
|
||||
size="small"
|
||||
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||
class="btn-save"
|
||||
:disabled="!isMarketDirty(market)"
|
||||
:loading="savingMarketId === market.id"
|
||||
@click="saveMarket(market)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="selections-wrap"
|
||||
:class="isMultiRowMarket(market) ? 'selections-grid' : 'selections-inline'"
|
||||
>
|
||||
<div v-for="sel in market.selections" :key="sel.id" class="sel-inline">
|
||||
<span class="sel-label" :title="sel.selectionCode">{{
|
||||
selectionCodeLabel(sel.selectionCode, market)
|
||||
}}</span>
|
||||
<el-input-number
|
||||
v-model="sel.editOdds"
|
||||
size="small"
|
||||
class="sel-odds"
|
||||
:min="1.01"
|
||||
:step="0.01"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
v-if="!isMultiRowMarket(market)"
|
||||
size="small"
|
||||
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||
class="btn-save"
|
||||
:disabled="!isMarketDirty(market)"
|
||||
:loading="savingMarketId === market.id"
|
||||
@click="saveMarket(market)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-markets-panel {
|
||||
padding: 10px 12px 12px 16px;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.market-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.market-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 6px;
|
||||
background: #111;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-line--wrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.market-line--wrap .field-promo-wrap {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.field-promo-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.promo-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8e8e93;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-promo {
|
||||
flex: 0 0 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-line--wrap .field-promo {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.market-line-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.market-line:not(.market-line--wrap) .market-line-head {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.market-line--wrap .market-line-head {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-line--wrap .market-line-head .btn-save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.market-label {
|
||||
flex: 0 0 76px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-line {
|
||||
flex: 0 0 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-line :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-inline {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-grid .sel-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.selections-grid .sel-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.selections-grid .sel-odds {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-grid .sel-odds :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sel-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.sel-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sel-odds {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.sel-odds :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex-shrink: 0;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.btn-save.is-disabled,
|
||||
.btn-save:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user