Files
thebet365/apps/admin/src/views/matches/MatchEventEditor.vue
Mars 27dc9eb3b0 sync(theme-2): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-2 样式。
2026-06-22 14:10:54 +08:00

504 lines
13 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 props = withDefaults(
defineProps<{
matchIdProp?: string;
embedded?: boolean;
}>(),
{ embedded: false },
);
const emit = defineEmits<{
saved: [];
}>();
const route = useRoute();
const router = useRouter();
const { t } = useAdminLocale();
const matchId = computed(() => props.matchIdProp ?? 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'));
if (!props.embedded) 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'));
emit('saved');
if (!props.embedded) 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"
:class="{ 'match-editor-page--embedded': embedded, 'page-scroll': !embedded }"
>
<AdminSubNav
v-if="!embedded"
: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 form-section--schedule">
<div class="section-label">{{ t('matchEditor.group.schedule') }}</div>
<el-row :gutter="12" class="schedule-fields">
<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%"
/>
<p class="field-hint schedule-timezone-hint">{{ t('match.timezone.platform_hint') }}</p>
</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;
color: var(--text);
}
.match-editor-page--embedded {
padding-bottom: 0;
max-height: min(78vh, 880px);
overflow-y: auto;
}
.panel {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
box-shadow: var(--shadow);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-soft);
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
letter-spacing: 0;
}
.panel-foot {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--border-soft);
}
.form-section {
margin-bottom: 10px;
}
.form-section:last-child {
margin-bottom: 0;
}
.section-label {
font-size: 11px;
font-weight: 700;
color: var(--success-text);
letter-spacing: 0;
text-transform: none;
margin-bottom: 6px;
}
.compact-form :deep(.el-form-item) {
margin-bottom: 8px;
}
.form-section--schedule {
padding-top: 2px;
}
.schedule-fields {
row-gap: 8px;
}
.schedule-fields :deep(.el-form-item) {
align-items: center;
min-width: 0;
}
.schedule-fields :deep(.el-form-item__label) {
flex: 0 0 auto;
width: auto !important;
min-width: 64px;
padding-right: 10px;
white-space: nowrap;
word-break: keep-all;
line-height: 32px;
}
.schedule-fields :deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
.schedule-fields :deep(.el-date-editor),
.schedule-fields :deep(.el-input),
.schedule-fields :deep(.el-input-number) {
width: 100%;
}
.schedule-fields :deep(.el-switch) {
flex-shrink: 0;
}
.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: var(--text) !important;
}
.field-hint {
margin: 0 0 8px;
font-size: 12px;
color: #8e8e93;
line-height: 1.4;
}
.schedule-timezone-hint {
margin: 4px 0 0;
}
.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: #f4f0e8;
}
.league-readonly-names {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.league-readonly-line {
display: flex;
gap: 8px;
font-size: 13px;
color: var(--text);
}
.league-readonly-lang {
flex: 0 0 28px;
color: #8e8e93;
font-size: 12px;
}
.panel-head,
.panel-foot {
border-color: var(--border-soft);
}
.panel-title,
.preview-title,
.preview-bar-title,
.league-readonly-line {
color: var(--text);
}
.section-label {
color: var(--success-text);
letter-spacing: 0;
}
.logo-inline-label,
.meta-form :deep(.el-form-item__label),
.field-hint,
.league-readonly-lang {
color: var(--text-muted) !important;
}
.meta-form :deep(.el-input__inner),
.meta-form :deep(.el-input-number .el-input__inner) {
color: var(--text) !important;
}
.league-readonly-logo img {
background: #f4f0e8;
}
@media (max-width: 760px) {
.panel-head,
.panel-foot,
.league-readonly-grid {
align-items: flex-start;
flex-direction: column;
}
.schedule-fields :deep(.el-form-item) {
align-items: stretch;
flex-direction: column;
}
.schedule-fields :deep(.el-form-item__label) {
width: auto !important;
min-width: 0;
padding-right: 0;
line-height: 1.4;
}
}
</style>