feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化

- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持

- 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮

- API 新增联赛列表与子场查询,按 locale 返回队名并修复编译

- 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
2026-06-04 16:25:03 +08:00
parent c68abadceb
commit cc737e2924
39 changed files with 3330 additions and 378 deletions

View File

@@ -5,7 +5,7 @@
"description": "统一管理后台(平台管理员 + 代理,单入口登录)",
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"dev": "vite --port 5174 --host",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},

View File

@@ -299,4 +299,17 @@ body {
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
.el-date-editor .el-input__wrapper { background: #0d0d0d !important; }
.el-date-editor .el-input__inner { color: #fff !important; }
.el-picker-panel {
background: #1c1c1c !important;
border-color: #333 !important;
color: #ddd !important;
}
.el-picker-panel__footer { background: #1c1c1c !important; border-top-color: #333 !important; }
.el-date-picker__header-label,
.el-date-table th,
.el-date-table td .el-date-table-cell__text { color: #ccc !important; }
.el-time-panel { background: #1c1c1c !important; border-color: #333 !important; }
.el-time-spinner__item { color: #aaa !important; }
.el-time-spinner__item.is-active:not(.is-disabled) { color: #fff !important; }
</style>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import CountryFlagSelect from './outright/CountryFlagSelect.vue';
import {
countryFlagUrl,
resolveCountryCode,
type BuiltinCountry,
} from '../data/builtinCountries';
import { useAdminLocale } from '../composables/useAdminLocale';
const props = defineProps<{
modelValue: string;
teamCode?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
pick: [country: BuiltinCountry];
}>();
const { t } = useAdminLocale();
const countryCode = ref('');
const isFlagUrl = computed(() => props.modelValue.includes('flagcdn.com'));
const previewUrl = computed(() => props.modelValue.trim() || '');
watch(
() => [props.modelValue, props.teamCode] as const,
([url, code]) => {
if (url && !url.includes('flagcdn.com')) {
countryCode.value = '';
return;
}
countryCode.value = resolveCountryCode(code, url || null);
},
{ immediate: true },
);
watch(countryCode, (code, prev) => {
if (!code && prev && isFlagUrl.value) {
emit('update:modelValue', '');
}
});
function onCountryPick(country: BuiltinCountry) {
emit('update:modelValue', countryFlagUrl(country));
emit('pick', country);
}
function onCustomUrlInput(value: string) {
emit('update:modelValue', value);
}
</script>
<template>
<div class="logo-url-field">
<CountryFlagSelect
v-model="countryCode"
hide-preview
class="flag-part"
@pick="onCountryPick"
/>
<el-input
:model-value="modelValue"
size="small"
:placeholder="t('matchEditor.ph.logo_url')"
clearable
class="url-part"
@update:model-value="onCustomUrlInput"
/>
<img v-if="previewUrl" :src="previewUrl" alt="" class="logo-preview" loading="lazy" />
</div>
</template>
<style scoped>
.logo-url-field {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.flag-part {
flex: 0 0 200px;
min-width: 0;
}
.url-part {
flex: 1;
min-width: 0;
}
.logo-preview {
flex-shrink: 0;
width: 32px;
height: 22px;
object-fit: contain;
border-radius: 3px;
background: #0d0d0d;
border: 1px solid #2a2a2a;
}
</style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
BUILTIN_COUNTRIES,
countryFlagUrl,
countryDisplayName,
countryOptionLabel,
getBuiltinCountry,
searchBuiltinCountries,
type BuiltinCountry,
@@ -13,6 +14,7 @@ const props = defineProps<{
modelValue: string;
size?: 'small' | 'default' | 'large';
disabled?: boolean;
hidePreview?: boolean;
}>();
const emit = defineEmits<{
@@ -23,14 +25,12 @@ const emit = defineEmits<{
const { t, locale } = useAdminLocale();
const filterKeyword = ref('');
const options = computed(() => searchBuiltinCountries(filterKeyword.value));
const options = computed(() => searchBuiltinCountries(filterKeyword.value, locale.value));
const selected = computed(() => getBuiltinCountry(props.modelValue));
function optionLabel(c: BuiltinCountry) {
return locale.value === 'en-US'
? `${c.nameEn} (${c.code})`
: `${c.nameZh} (${c.code})`;
return countryOptionLabel(c, locale.value);
}
function onFilter(q: string) {
@@ -67,13 +67,13 @@ function onChange(code: string | undefined) {
>
<div class="country-option">
<img :src="countryFlagUrl(c)" alt="" class="country-option-flag" loading="lazy" />
<span class="country-option-name">{{ c.nameZh }} · {{ c.nameEn }}</span>
<span class="country-option-name">{{ countryDisplayName(c, locale) }}</span>
<span class="country-option-code">{{ c.code }}</span>
</div>
</el-option>
</el-select>
<img
v-if="selected"
v-if="selected && !hidePreview"
:src="countryFlagUrl(selected)"
alt=""
class="country-preview"

View File

@@ -97,16 +97,79 @@ export function countryFlagUrl(country: BuiltinCountry | string): string {
return `https://flagcdn.com/w40/${c.iso}.png`;
}
export function searchBuiltinCountries(keyword: string): BuiltinCountry[] {
/** 按后台当前语言显示国家名(下拉只显示一种语言) */
export function countryDisplayName(c: BuiltinCountry, locale: string): string {
if (locale === 'en-US') return c.nameEn;
if (locale === 'ms-MY') return countryNameMs(c);
return c.nameZh;
}
export function countryOptionLabel(c: BuiltinCountry, locale: string): string {
return `${countryDisplayName(c, locale)} (${c.code})`;
}
/** 常用国家队马来语名(无则用英文,避免与中文混排) */
const COUNTRY_MS: Partial<Record<string, string>> = {
CAN: 'Kanada',
USA: 'Amerika Syarikat',
MEX: 'Mexico',
BRA: 'Brazil',
ARG: 'Argentina',
ENG: 'England',
FRA: 'Perancis',
GER: 'Jerman',
ESP: 'Sepanyol',
POR: 'Portugal',
NED: 'Belanda',
BEL: 'Belgium',
CRO: 'Croatia',
SUI: 'Switzerland',
POL: 'Poland',
SWE: 'Sweden',
NOR: 'Norway',
DEN: 'Denmark',
JPN: 'Jepun',
KOR: 'Korea Selatan',
AUS: 'Australia',
RSA: 'Afrika Selatan',
MAR: 'Maghribi',
SEN: 'Senegal',
GHA: 'Ghana',
EGY: 'Mesir',
TUN: 'Tunisia',
ALG: 'Algeria',
KSA: 'Arab Saudi',
QAT: 'Qatar',
IRN: 'Iran',
IRQ: 'Iraq',
CHN: 'China',
THA: 'Thailand',
VIE: 'Vietnam',
IDN: 'Indonesia',
MAS: 'Malaysia',
BIH: 'Bosnia',
SCO: 'Scotland',
WAL: 'Wales',
NZL: 'New Zealand',
};
function countryNameMs(c: BuiltinCountry): string {
return COUNTRY_MS[c.code] ?? c.nameEn;
}
export function searchBuiltinCountries(keyword: string, locale = 'zh-CN'): BuiltinCountry[] {
const k = keyword.trim().toLowerCase();
if (!k) return BUILTIN_COUNTRIES;
return BUILTIN_COUNTRIES.filter(
(c) =>
return BUILTIN_COUNTRIES.filter((c) => {
const display = countryDisplayName(c, locale);
return (
c.code.toLowerCase().includes(k) ||
c.nameZh.includes(keyword.trim()) ||
c.nameEn.toLowerCase().includes(k) ||
c.iso.toLowerCase().includes(k),
);
display.toLowerCase().includes(k) ||
c.iso.toLowerCase().includes(k)
);
});
}
export function resolveCountryCode(

View File

@@ -123,23 +123,42 @@ export const adminPagesMs: Record<string, string> = {
'agent.ph.select_user': 'Cari nama pengguna pemain',
'agent.hint.select_user': 'Pilih akaun pemain sedia ada untuk naik taraf ke ejen peringkat 1',
'match.create_btn': '+ Perlawanan baharu',
'match.filter.keyword_ph': 'Nama perlawanan / kod pasukan',
'match.create_btn': '+ Kejohanan baharu',
'match.create_fixture_btn': '+ Perlawanan tunggal',
'match.btn.markets': 'Pasaran',
'match.filter.keyword_ph': 'Nama kejohanan / kod pasukan',
'match.col.league': 'Kejohanan',
'match.col.fixture_count': 'Perlawanan',
'match.col.league_code': 'Kod',
'match.col.matchup': 'Perlawanan',
'match.col.kickoff': 'Masa mula',
'match.dialog.create': 'Perlawanan baharu',
'match.dialog.edit': 'Edit perlawanan',
'match.dialog.create_league': 'Kejohanan baharu',
'match.dialog.create_fixture': 'Perlawanan tunggal baharu',
'match.dialog.create': 'Perlawanan tunggal baharu',
'match.dialog.edit': 'Edit perlawanan tunggal',
'match.dialog.import': 'Import perlawanan',
'match.field.league_en': 'Liga (EN)',
'match.field.league_zh': 'Liga (ZH)',
'match.field.league_ms': 'Liga (MS)',
'match.field.league_logo': 'Logo kejohanan',
'match.field.lang_zh': 'ZH',
'match.field.lang_en': 'EN',
'match.field.lang_ms': 'MS',
'match.field.kickoff': 'Masa mula',
'match.field.home_en': 'Tuan rumah (EN)',
'match.field.home_zh': 'Tuan rumah (ZH)',
'match.field.home_ms': 'Tuan rumah (MS)',
'match.field.away_en': 'Pelawat (EN)',
'match.field.away_zh': 'Pelawat (ZH)',
'match.field.away_ms': 'Pelawat (MS)',
'match.field.featured': 'Pilihan utama',
'match.hint.create_draft': 'Disimpan sebagai draf; klik Terbitkan dalam senarai untuk buka pasaran.',
'match.hint.create_draft': 'Disimpan sebagai draf; kembangkan kejohanan dan terbitkan setiap perlawanan tunggal.',
'match.hint.create_league': 'Cipta kejohanan dahulu, kemudian kembangkan untuk tambah perlawanan tunggal.',
'match.hint.edit_published': 'Diterbitkan: edit masa mula, pilihan utama, nama paparan; tertutup/selesai dikunci.',
'match.expand_league_hint': 'Kembangkan kejohanan untuk senarai perlawanan; klik Pasaran untuk halaman tetapan odds (sama seperti aplikasi pemain).',
'match.expand_markets_hint': 'Klik Pasaran pada perlawanan tunggal untuk halaman pasaran berasingan.',
'match.no_fixtures': 'Tiada perlawanan tunggal di bawah kejohanan ini.',
'match.ph.league_ms': 'Piala Dunia 2027',
'bet.filter.keyword_ph': 'No. pertaruhan / nama pengguna',
'bet.filter.date_from': 'Tarikh pertaruhan dari',
@@ -211,6 +230,64 @@ export const adminPagesMs: Record<string, string> = {
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': 'Afrika Selatan',
'matchEditor.manage_btn': 'Maklumat asas',
'matchEditor.back': 'Kembali ke senarai',
'matchEditor.title': 'Edit maklumat asas',
'matchEditor.section_info': 'Maklumat asas',
'matchEditor.section_markets': 'Pasaran & odds',
'matchEditor.field.league_logo': 'Logo',
'matchEditor.field.home_logo': 'Logo',
'matchEditor.field.away_logo': 'Logo',
'matchEditor.field.pick_flag': 'Pilih bendera',
'matchEditor.field.custom_logo_url': 'URL imej tersuai',
'matchEditor.ph.logo_url': 'https://...',
'matchEditor.field.match_name': 'Nama paparan',
'matchEditor.field.stage': 'Peringkat',
'matchEditor.field.group': 'Kumpulan',
'matchEditor.field.display_order': 'Susunan',
'matchEditor.field.promo_label': 'Label promosi',
'matchEditor.field.promo_label_optional': 'Label promosi (pilihan)',
'matchEditor.field.line_value': 'Garisan',
'matchEditor.ph.kickoff': 'Pilih tarikh & masa mula',
'matchEditor.group.league': 'Liga',
'matchEditor.group.home': 'Tuan rumah',
'matchEditor.group.away': 'Pelawat',
'matchEditor.group.schedule': 'Jadual & paparan',
'matchEditor.save_info': 'Simpan maklumat',
'matchEditor.save_market': 'Simpan pasaran',
'matchEditor.save_odds': 'Simpan odds',
'matchEditor.generate_templates': 'Jana templat lalai',
'matchEditor.templates_generated': 'Templat pasaran dijana',
'matchEditor.no_markets': 'Tiada pasaran — terbitkan perlawanan atau jana templat.',
'matchEditor.market.FT_1X2': 'FT 1X2',
'matchEditor.market.FT_HANDICAP': 'FT handicap',
'matchEditor.market.FT_OVER_UNDER': 'FT O/U',
'matchEditor.market.FT_ODD_EVEN': 'FT ganjil/genap',
'matchEditor.market.HT_1X2': 'HT 1X2',
'matchEditor.market.HT_HANDICAP': 'HT handicap',
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
'matchEditor.market.FT_CORRECT_SCORE': 'FT skor tepat',
'matchEditor.market.HT_CORRECT_SCORE': 'HT skor tepat',
'matchEditor.market.SH_CORRECT_SCORE': '2H skor tepat',
'matchEditor.period.FT': 'Sepenuh masa',
'matchEditor.period.HT': 'Separuh masa',
'matchEditor.period.SH': 'Separuh masa ke-2',
'matchEditor.period.OUTRIGHT': 'Juara',
'matchEditor.selection.HOME': 'Tuan rumah',
'matchEditor.selection.DRAW': 'Seri',
'matchEditor.selection.AWAY': 'Pelawat',
'matchEditor.selection.OVER': 'Atas',
'matchEditor.selection.UNDER': 'Bawah',
'matchEditor.selection.ODD': 'Ganjil',
'matchEditor.selection.EVEN': 'Genap',
'matchEditor.selection.OTHER_DRAW': 'Seri (skor lain)',
'matchEditor.selection.OTHER_HOME': 'Menang rumah (skor lain)',
'matchEditor.selection.OTHER_AWAY': 'Menang pelawat (skor lain)',
'matchEditor.col.selection_code': 'Pilihan',
'matchEditor.col.selection_name': 'Nama paparan',
'matchEditor.col.odds': 'Odds',
'matchEditor.ph.selection_name': 'Nama dipaparkan kepada pemain',
'err.username_required': 'Sila isi nama pengguna',
'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara',
'err.password_mismatch': 'Kata laluan tidak sepadan',
@@ -256,7 +333,8 @@ export const adminPagesMs: Record<string, string> = {
'msg.save_failed': 'Gagal menyimpan',
'msg.deleted': 'Dipadam',
'msg.delete_failed': 'Gagal memadam',
'msg.match_created_draft': 'Perlawanan dicipta (draf)',
'msg.league_created': 'Kejohanan dicipta',
'msg.match_created_draft': 'Perlawanan tunggal dicipta (draf)',
'msg.published': 'Diterbitkan dengan pasaran',
'msg.closed': 'Pertaruhan ditutup',
'msg.invalid_json': 'JSON tidak sah',

View File

@@ -124,22 +124,41 @@ export const adminPagesZh: Record<string, string> = {
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
'match.create_btn': '+ 新增赛事',
'match.create_fixture_btn': '+ 新增单场',
'match.btn.markets': '盘口',
'match.filter.keyword_ph': '赛事名 / 球队代码',
'match.col.league': '赛事',
'match.col.fixture_count': '单场',
'match.col.league_code': '代码',
'match.col.matchup': '对阵',
'match.col.kickoff': '开赛时间',
'match.dialog.create': '新增赛事',
'match.dialog.edit': '编辑赛事',
'match.dialog.create_league': '新增赛事',
'match.dialog.create_fixture': '新增单场',
'match.dialog.create': '新增单场',
'match.dialog.edit': '编辑单场',
'match.dialog.import': '导入赛事',
'match.field.league_en': '联赛(英)',
'match.field.league_zh': '联赛(中)',
'match.field.league_ms': '联赛(马来)',
'match.field.league_logo': '赛事 Logo',
'match.field.lang_zh': '中',
'match.field.lang_en': 'EN',
'match.field.lang_ms': 'MS',
'match.field.kickoff': '开赛时间',
'match.field.home_en': '主队(英)',
'match.field.home_zh': '主队(中)',
'match.field.home_ms': '主队(马来)',
'match.field.away_en': '客队(英)',
'match.field.away_zh': '客队(中)',
'match.field.away_ms': '客队(马来)',
'match.field.featured': '热门',
'match.hint.create_draft': '创建后为草稿,请在列表点击「发布」并生成盘口。',
'match.hint.create_draft': '创建后为草稿,请展开赛事后在单场行点击「发布」并生成盘口。',
'match.hint.create_league': '创建赛事(联赛)后,展开该行可添加多场单场比赛。',
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
'match.expand_league_hint': '展开赛事查看单场列表;点击「盘口」进入单独页面设置盘口与赔率(与玩家端按联赛分组一致)。',
'match.expand_markets_hint': '在单场列表点击「盘口」进入单独页面设置盘口与赔率。',
'match.no_fixtures': '该赛事下暂无单场。',
'match.ph.league_ms': '2027 世界杯',
'bet.filter.keyword_ph': '流水编号 / 玩家用户名',
'bet.filter.date_from': '投注日起',
@@ -211,13 +230,71 @@ export const adminPagesZh: Record<string, string> = {
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': '南非',
'matchEditor.manage_btn': '基本信息',
'matchEditor.back': '返回列表',
'matchEditor.title': '编辑基本信息',
'matchEditor.section_info': '基本信息',
'matchEditor.section_markets': '盘口与赔率',
'matchEditor.field.league_logo': 'Logo',
'matchEditor.field.home_logo': 'Logo',
'matchEditor.field.away_logo': 'Logo',
'matchEditor.field.pick_flag': '选择国旗',
'matchEditor.field.custom_logo_url': '自定义图片 URL',
'matchEditor.ph.logo_url': 'https://...',
'matchEditor.field.match_name': '赛事显示名',
'matchEditor.field.stage': '阶段',
'matchEditor.field.group': '小组',
'matchEditor.field.display_order': '排序',
'matchEditor.field.promo_label': '促销标签',
'matchEditor.field.promo_label_optional': '促销标签(可选)',
'matchEditor.field.line_value': '盘口线',
'matchEditor.ph.kickoff': '选择开赛日期与时间',
'matchEditor.group.league': '联赛信息',
'matchEditor.group.home': '主队',
'matchEditor.group.away': '客队',
'matchEditor.group.schedule': '赛程与展示',
'matchEditor.save_info': '保存基本信息',
'matchEditor.save_market': '保存盘口设置',
'matchEditor.save_odds': '保存赔率',
'matchEditor.generate_templates': '生成默认盘口',
'matchEditor.templates_generated': '盘口模板已生成',
'matchEditor.no_markets': '暂无盘口,请先发布赛事或点击「生成默认盘口」。',
'matchEditor.market.FT_1X2': '全场 1X2',
'matchEditor.market.FT_HANDICAP': '全场让球',
'matchEditor.market.FT_OVER_UNDER': '全场大小',
'matchEditor.market.FT_ODD_EVEN': '全场单双',
'matchEditor.market.HT_1X2': '半场 1X2',
'matchEditor.market.HT_HANDICAP': '半场让球',
'matchEditor.market.HT_OVER_UNDER': '半场大小',
'matchEditor.market.FT_CORRECT_SCORE': '全场波胆',
'matchEditor.market.HT_CORRECT_SCORE': '半场波胆',
'matchEditor.market.SH_CORRECT_SCORE': '下半场波胆',
'matchEditor.period.FT': '全场',
'matchEditor.period.HT': '半场',
'matchEditor.period.SH': '下半场',
'matchEditor.period.OUTRIGHT': '冠军',
'matchEditor.selection.HOME': '主',
'matchEditor.selection.DRAW': '和',
'matchEditor.selection.AWAY': '客',
'matchEditor.selection.OVER': '大',
'matchEditor.selection.UNDER': '小',
'matchEditor.selection.ODD': '单',
'matchEditor.selection.EVEN': '双',
'matchEditor.selection.OTHER_DRAW': '和局其它比分',
'matchEditor.selection.OTHER_HOME': '主胜其它比分',
'matchEditor.selection.OTHER_AWAY': '客胜其它比分',
'matchEditor.col.selection_code': '选项',
'matchEditor.col.selection_name': '显示名',
'matchEditor.col.odds': '赔率',
'matchEditor.ph.selection_name': '玩家端显示名称',
'err.username_required': '请填写用户名',
'err.password_min': '密码至少 8 位',
'err.password_mismatch': '两次密码不一致',
'err.credit_negative': '授信额度不能为负',
'err.kickoff_required': '请填写开赛时间',
'err.teams_required': '请填写主客队名称(中文或英文至少一项)',
'err.league_required': '请填写联赛名称',
'err.teams_required': '请填写主客队名称(中文、英文或马来文至少一项)',
'err.league_required': '请填写联赛名称(中文、英文或马来文至少一项)',
'err.user_required': '请选择用户',
'err.agent_no_parent': '一级代理不可设置上级玩家',
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
@@ -256,7 +333,8 @@ export const adminPagesZh: Record<string, string> = {
'msg.save_failed': '保存失败',
'msg.deleted': '已删除',
'msg.delete_failed': '删除失败',
'msg.match_created_draft': '赛事已创建(草稿)',
'msg.league_created': '赛事已创建',
'msg.match_created_draft': '单场已创建(草稿)',
'msg.published': '已发布并生成盘口',
'msg.closed': '已封盘',
'msg.invalid_json': 'JSON 格式无效',
@@ -499,23 +577,42 @@ export const adminPagesEn: Record<string, string> = {
'agent.ph.select_user': 'Search player username',
'agent.hint.select_user': 'Pick an existing player account to promote to tier-1 agent (no new login)',
'match.create_btn': '+ New match',
'match.filter.keyword_ph': 'Match name / team code',
'match.create_btn': '+ New tournament',
'match.create_fixture_btn': '+ Add fixture',
'match.btn.markets': 'Markets',
'match.filter.keyword_ph': 'Tournament / team code',
'match.col.league': 'Tournament',
'match.col.fixture_count': 'Fixtures',
'match.col.league_code': 'Code',
'match.col.matchup': 'Matchup',
'match.col.kickoff': 'Kickoff',
'match.dialog.create': 'New match',
'match.dialog.edit': 'Edit match',
'match.dialog.create_league': 'New tournament',
'match.dialog.create_fixture': 'New fixture',
'match.dialog.create': 'New fixture',
'match.dialog.edit': 'Edit fixture',
'match.dialog.import': 'Import matches',
'match.field.league_en': 'League (EN)',
'match.field.league_zh': 'League (ZH)',
'match.field.league_ms': 'League (MS)',
'match.field.league_logo': 'Tournament logo',
'match.field.lang_zh': 'ZH',
'match.field.lang_en': 'EN',
'match.field.lang_ms': 'MS',
'match.field.kickoff': 'Kickoff time',
'match.field.home_en': 'Home (EN)',
'match.field.home_zh': 'Home (ZH)',
'match.field.home_ms': 'Home (MS)',
'match.field.away_en': 'Away (EN)',
'match.field.away_zh': 'Away (ZH)',
'match.field.away_ms': 'Away (MS)',
'match.field.featured': 'Featured',
'match.hint.create_draft': 'Saved as draft; click Publish in the list to open markets.',
'match.hint.create_draft': 'Saved as draft; expand the tournament and publish each fixture to open markets.',
'match.hint.create_league': 'Create a tournament first, then expand it to add fixtures.',
'match.hint.edit_published': 'Published: edit kickoff, featured, display names; closed/settled are locked.',
'match.expand_league_hint': 'Expand a tournament to see fixtures; use Markets for a dedicated odds page (same grouping as player app).',
'match.expand_markets_hint': 'Click Markets on a fixture to open the dedicated markets page.',
'match.no_fixtures': 'No fixtures under this tournament yet.',
'match.ph.league_ms': 'World Cup 2027',
'bet.filter.keyword_ph': 'Bet no. / username',
'bet.filter.date_from': 'Placed from',
@@ -587,6 +684,64 @@ export const adminPagesEn: Record<string, string> = {
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': 'South Africa',
'matchEditor.manage_btn': 'Basic info',
'matchEditor.back': 'Back to list',
'matchEditor.title': 'Edit basic info',
'matchEditor.section_info': 'Basic info',
'matchEditor.section_markets': 'Markets & odds',
'matchEditor.field.league_logo': 'Logo',
'matchEditor.field.home_logo': 'Logo',
'matchEditor.field.away_logo': 'Logo',
'matchEditor.field.pick_flag': 'Pick flag',
'matchEditor.field.custom_logo_url': 'Custom image URL',
'matchEditor.ph.logo_url': 'https://...',
'matchEditor.field.match_name': 'Display name',
'matchEditor.field.stage': 'Stage',
'matchEditor.field.group': 'Group',
'matchEditor.field.display_order': 'Sort order',
'matchEditor.field.promo_label': 'Promo label',
'matchEditor.field.promo_label_optional': 'Promo label (optional)',
'matchEditor.field.line_value': 'Line',
'matchEditor.ph.kickoff': 'Select kickoff date & time',
'matchEditor.group.league': 'League',
'matchEditor.group.home': 'Home team',
'matchEditor.group.away': 'Away team',
'matchEditor.group.schedule': 'Schedule & display',
'matchEditor.save_info': 'Save info',
'matchEditor.save_market': 'Save market',
'matchEditor.save_odds': 'Save odds',
'matchEditor.generate_templates': 'Generate templates',
'matchEditor.templates_generated': 'Market templates created',
'matchEditor.no_markets': 'No markets yet — publish the match or generate templates.',
'matchEditor.market.FT_1X2': 'FT 1X2',
'matchEditor.market.FT_HANDICAP': 'FT handicap',
'matchEditor.market.FT_OVER_UNDER': 'FT O/U',
'matchEditor.market.FT_ODD_EVEN': 'FT odd/even',
'matchEditor.market.HT_1X2': 'HT 1X2',
'matchEditor.market.HT_HANDICAP': 'HT handicap',
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
'matchEditor.market.FT_CORRECT_SCORE': 'FT correct score',
'matchEditor.market.HT_CORRECT_SCORE': 'HT correct score',
'matchEditor.market.SH_CORRECT_SCORE': '2H correct score',
'matchEditor.period.FT': 'Full time',
'matchEditor.period.HT': 'Half time',
'matchEditor.period.SH': 'Second half',
'matchEditor.period.OUTRIGHT': 'Outright',
'matchEditor.selection.HOME': 'Home',
'matchEditor.selection.DRAW': 'Draw',
'matchEditor.selection.AWAY': 'Away',
'matchEditor.selection.OVER': 'Over',
'matchEditor.selection.UNDER': 'Under',
'matchEditor.selection.ODD': 'Odd',
'matchEditor.selection.EVEN': 'Even',
'matchEditor.selection.OTHER_DRAW': 'Draw (other score)',
'matchEditor.selection.OTHER_HOME': 'Home win (other score)',
'matchEditor.selection.OTHER_AWAY': 'Away win (other score)',
'matchEditor.col.selection_code': 'Option',
'matchEditor.col.selection_name': 'Display name',
'matchEditor.col.odds': 'Odds',
'matchEditor.ph.selection_name': 'Name shown to players',
'err.username_required': 'Username is required',
'err.password_min': 'Password must be at least 8 characters',
'err.password_mismatch': 'Passwords do not match',
@@ -632,7 +787,8 @@ export const adminPagesEn: Record<string, string> = {
'msg.save_failed': 'Save failed',
'msg.deleted': 'Deleted',
'msg.delete_failed': 'Delete failed',
'msg.match_created_draft': 'Match created (draft)',
'msg.league_created': 'Tournament created',
'msg.match_created_draft': 'Fixture created (draft)',
'msg.published': 'Published with markets',
'msg.closed': 'Betting closed',
'msg.invalid_json': 'Invalid JSON',

View File

@@ -14,7 +14,7 @@ const adminMenus = computed(() => [
{ path: '/', label: t('nav.dashboard') },
{ path: '/users', label: t('nav.users') },
{ path: '/agents', label: t('nav.agents') },
{ path: '/matches', label: t('nav.matches') },
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
{ path: '/bets', label: t('nav.bets') },
{ path: '/cashback', label: t('nav.cashback') },

View File

@@ -26,6 +26,18 @@ const router = createRouter({
component: () => import('../views/Matches.vue'),
meta: { adminOnly: true },
},
{
path: 'matches/:matchId/edit',
name: 'admin-match-edit',
component: () => import('../views/matches/MatchEventEditor.vue'),
meta: { adminOnly: true },
},
{
path: 'matches/:matchId/markets',
name: 'admin-match-markets',
component: () => import('../views/matches/MatchMarketsPage.vue'),
meta: { adminOnly: true },
},
{
path: 'outrights',
name: 'admin-outrights',

View File

@@ -0,0 +1,22 @@
import { defaultSelectionName } from './selectionDefaults';
/** 管理后台盘口选项展示名(按 code + 当前语言) */
export function adminSelectionLabel(
t: (key: string) => string,
code: string,
opts?: { lineValue?: number | null; period?: string },
): string {
const i18nKey = `matchEditor.selection.${code}`;
const translated = t(i18nKey);
if (translated !== i18nKey) return translated;
const score = code.match(/^SCORE_(\d+)_(\d+)$/);
if (score) return `${score[1]}-${score[2]}`;
if (opts) {
const name = defaultSelectionName(code, opts);
if (name !== code) return name;
}
return code;
}

View File

@@ -0,0 +1,51 @@
/** 赛事列表 UI 状态(返回列表时恢复展开等) */
const STORAGE_KEY = 'admin_matches_list_ui';
export type MatchesListUiState = {
expandedLeagueIds: string[];
page: number;
pageSize: number;
filterStatus: string;
keyword: string;
};
function defaultState(): MatchesListUiState {
return {
expandedLeagueIds: [],
page: 1,
pageSize: 10,
filterStatus: '',
keyword: '',
};
}
export function readMatchesListUiState(): MatchesListUiState | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as MatchesListUiState;
if (!Array.isArray(parsed.expandedLeagueIds)) return null;
return parsed;
} catch {
return null;
}
}
export function writeMatchesListUiState(state: MatchesListUiState) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
export function patchMatchesListUiState(patch: Partial<MatchesListUiState>) {
const base = readMatchesListUiState() ?? defaultState();
writeMatchesListUiState({ ...base, ...patch });
}
/** 从子页返回前确保该赛事行处于展开记录中 */
export function ensureLeagueExpanded(leagueId: string) {
if (!leagueId) return;
const base = readMatchesListUiState() ?? defaultState();
const ids = new Set(base.expandedLeagueIds);
ids.add(leagueId);
writeMatchesListUiState({ ...base, expandedLeagueIds: [...ids] });
}

View File

@@ -0,0 +1,42 @@
/** 标准盘口选项的固定显示名(写入 DB玩家端也会按 code 做 i18n */
function handicapName(side: 'home' | 'away', line: number, half: boolean) {
const sideLabel = side === 'home' ? '主队' : '客队';
const value = side === 'home' ? line : -line;
const lineText = value > 0 ? `+${value}` : `${value}`;
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
}
function ouName(side: 'over' | 'under', line: number, half: boolean) {
const sideLabel = side === 'over' ? '大' : '小';
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
}
export function defaultSelectionName(
code: string,
opts: { lineValue?: number | null; period?: string },
): string {
const half = opts.period === 'HT';
const line = opts.lineValue;
if (code === 'HOME' && line != null) return handicapName('home', line, half);
if (code === 'AWAY' && line != null) return handicapName('away', line, half);
if (code === 'OVER' && line != null) return ouName('over', line, half);
if (code === 'UNDER' && line != null) return ouName('under', line, half);
const fixed: Record<string, string> = {
HOME: '主胜',
DRAW: '和',
AWAY: '客胜',
ODD: '单',
EVEN: '双',
OTHER_DRAW: '和局其它比分',
OTHER_HOME: '主胜其它比分',
OTHER_AWAY: '客胜其它比分',
};
if (fixed[code]) return fixed[code];
if (code.startsWith('SCORE_')) {
return code.replace('SCORE_', '').replace('_', '-');
}
return code;
}

View File

@@ -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>

View File

@@ -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,
};
}

View 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>

View 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>

View 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>

View 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>