重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。 Co-authored-by: Cursor <cursoragent@cursor.com>
386 lines
11 KiB
Vue
386 lines
11 KiB
Vue
<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 { countryDisplayName, type BuiltinCountry } from '../../data/builtinCountries';
|
|
import {
|
|
buildMatchUpdatePayload,
|
|
emptyMatchForm,
|
|
formFromDetail,
|
|
type AdminMatchDetail,
|
|
type MatchCreateForm,
|
|
} from '../match-form';
|
|
import AdminSubNav from '../../components/AdminSubNav.vue';
|
|
|
|
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,
|
|
) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
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('/matches');
|
|
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 buildMatchUpdatePayload>;
|
|
try {
|
|
payload = buildMatchUpdatePayload(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">
|
|
<AdminSubNav
|
|
:title="t('matchEditor.title')"
|
|
:subtitle="`#${matchId}`"
|
|
>
|
|
<template #extra>
|
|
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
|
</template>
|
|
</AdminSubNav>
|
|
|
|
<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 league-readonly-block">
|
|
<div class="section-label">{{ t('matchEditor.group.league') }}</div>
|
|
<p class="field-hint">{{ t('matchEditor.hint.league_readonly') }}</p>
|
|
<div class="league-readonly-grid">
|
|
<div v-if="form.leagueLogoUrl" class="league-readonly-logo">
|
|
<img :src="form.leagueLogoUrl" alt="" />
|
|
</div>
|
|
<div class="league-readonly-names">
|
|
<div v-if="form.leagueZh.trim()" class="league-readonly-line">
|
|
<span class="league-readonly-lang">{{ t('match.field.lang_zh') }}</span>
|
|
<span>{{ form.leagueZh }}</span>
|
|
</div>
|
|
<div v-if="form.leagueEn.trim()" class="league-readonly-line">
|
|
<span class="league-readonly-lang">{{ t('match.field.lang_en') }}</span>
|
|
<span>{{ form.leagueEn }}</span>
|
|
</div>
|
|
<div v-if="form.leagueMs.trim()" class="league-readonly-line">
|
|
<span class="league-readonly-lang">{{ t('match.field.lang_ms') }}</span>
|
|
<span>{{ form.leagueMs }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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-col :xs="12" :sm="6">
|
|
<el-form-item :label="t('matchEditor.field.correct_score_enabled')">
|
|
<el-switch v-model="form.correctScoreEnabled" 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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.field-hint {
|
|
margin: 0 0 8px;
|
|
font-size: 12px;
|
|
color: #8e8e93;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.league-readonly-grid {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.league-readonly-logo img {
|
|
width: 40px;
|
|
height: 40px;
|
|
object-fit: contain;
|
|
border-radius: 6px;
|
|
background: #1a1a1a;
|
|
}
|
|
|
|
.league-readonly-names {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.league-readonly-line {
|
|
display: flex;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: #ddd;
|
|
}
|
|
|
|
.league-readonly-lang {
|
|
flex: 0 0 28px;
|
|
color: #8e8e93;
|
|
font-size: 12px;
|
|
}
|
|
</style>
|