feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化
- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持 - 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮 - API 新增联赛列表与子场查询,按 locale 返回队名并修复编译 - 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
"description": "统一管理后台(平台管理员 + 代理,单入口登录)",
|
"description": "统一管理后台(平台管理员 + 代理,单入口登录)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5174",
|
"dev": "vite --port 5174 --host",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -299,4 +299,17 @@ body {
|
|||||||
|
|
||||||
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
|
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
|
||||||
.el-date-editor .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>
|
</style>
|
||||||
|
|||||||
103
apps/admin/src/components/LogoUrlField.vue
Normal file
103
apps/admin/src/components/LogoUrlField.vue
Normal 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>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
BUILTIN_COUNTRIES,
|
|
||||||
countryFlagUrl,
|
countryFlagUrl,
|
||||||
|
countryDisplayName,
|
||||||
|
countryOptionLabel,
|
||||||
getBuiltinCountry,
|
getBuiltinCountry,
|
||||||
searchBuiltinCountries,
|
searchBuiltinCountries,
|
||||||
type BuiltinCountry,
|
type BuiltinCountry,
|
||||||
@@ -13,6 +14,7 @@ const props = defineProps<{
|
|||||||
modelValue: string;
|
modelValue: string;
|
||||||
size?: 'small' | 'default' | 'large';
|
size?: 'small' | 'default' | 'large';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
hidePreview?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -23,14 +25,12 @@ const emit = defineEmits<{
|
|||||||
const { t, locale } = useAdminLocale();
|
const { t, locale } = useAdminLocale();
|
||||||
|
|
||||||
const filterKeyword = ref('');
|
const filterKeyword = ref('');
|
||||||
const options = computed(() => searchBuiltinCountries(filterKeyword.value));
|
const options = computed(() => searchBuiltinCountries(filterKeyword.value, locale.value));
|
||||||
|
|
||||||
const selected = computed(() => getBuiltinCountry(props.modelValue));
|
const selected = computed(() => getBuiltinCountry(props.modelValue));
|
||||||
|
|
||||||
function optionLabel(c: BuiltinCountry) {
|
function optionLabel(c: BuiltinCountry) {
|
||||||
return locale.value === 'en-US'
|
return countryOptionLabel(c, locale.value);
|
||||||
? `${c.nameEn} (${c.code})`
|
|
||||||
: `${c.nameZh} (${c.code})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFilter(q: string) {
|
function onFilter(q: string) {
|
||||||
@@ -67,13 +67,13 @@ function onChange(code: string | undefined) {
|
|||||||
>
|
>
|
||||||
<div class="country-option">
|
<div class="country-option">
|
||||||
<img :src="countryFlagUrl(c)" alt="" class="country-option-flag" loading="lazy" />
|
<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>
|
<span class="country-option-code">{{ c.code }}</span>
|
||||||
</div>
|
</div>
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<img
|
<img
|
||||||
v-if="selected"
|
v-if="selected && !hidePreview"
|
||||||
:src="countryFlagUrl(selected)"
|
:src="countryFlagUrl(selected)"
|
||||||
alt=""
|
alt=""
|
||||||
class="country-preview"
|
class="country-preview"
|
||||||
|
|||||||
@@ -97,16 +97,79 @@ export function countryFlagUrl(country: BuiltinCountry | string): string {
|
|||||||
return `https://flagcdn.com/w40/${c.iso}.png`;
|
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();
|
const k = keyword.trim().toLowerCase();
|
||||||
if (!k) return BUILTIN_COUNTRIES;
|
if (!k) return BUILTIN_COUNTRIES;
|
||||||
return BUILTIN_COUNTRIES.filter(
|
return BUILTIN_COUNTRIES.filter((c) => {
|
||||||
(c) =>
|
const display = countryDisplayName(c, locale);
|
||||||
|
return (
|
||||||
c.code.toLowerCase().includes(k) ||
|
c.code.toLowerCase().includes(k) ||
|
||||||
c.nameZh.includes(keyword.trim()) ||
|
c.nameZh.includes(keyword.trim()) ||
|
||||||
c.nameEn.toLowerCase().includes(k) ||
|
c.nameEn.toLowerCase().includes(k) ||
|
||||||
c.iso.toLowerCase().includes(k),
|
display.toLowerCase().includes(k) ||
|
||||||
|
c.iso.toLowerCase().includes(k)
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCountryCode(
|
export function resolveCountryCode(
|
||||||
|
|||||||
@@ -123,23 +123,42 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'agent.ph.select_user': 'Cari nama pengguna pemain',
|
'agent.ph.select_user': 'Cari nama pengguna pemain',
|
||||||
'agent.hint.select_user': 'Pilih akaun pemain sedia ada untuk naik taraf ke ejen peringkat 1',
|
'agent.hint.select_user': 'Pilih akaun pemain sedia ada untuk naik taraf ke ejen peringkat 1',
|
||||||
|
|
||||||
'match.create_btn': '+ Perlawanan baharu',
|
'match.create_btn': '+ Kejohanan baharu',
|
||||||
'match.filter.keyword_ph': 'Nama perlawanan / kod pasukan',
|
'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.matchup': 'Perlawanan',
|
||||||
'match.col.kickoff': 'Masa mula',
|
'match.col.kickoff': 'Masa mula',
|
||||||
'match.dialog.create': 'Perlawanan baharu',
|
'match.dialog.create_league': 'Kejohanan baharu',
|
||||||
'match.dialog.edit': 'Edit perlawanan',
|
'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.dialog.import': 'Import perlawanan',
|
||||||
'match.field.league_en': 'Liga (EN)',
|
'match.field.league_en': 'Liga (EN)',
|
||||||
'match.field.league_zh': 'Liga (ZH)',
|
'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.kickoff': 'Masa mula',
|
||||||
'match.field.home_en': 'Tuan rumah (EN)',
|
'match.field.home_en': 'Tuan rumah (EN)',
|
||||||
'match.field.home_zh': 'Tuan rumah (ZH)',
|
'match.field.home_zh': 'Tuan rumah (ZH)',
|
||||||
|
'match.field.home_ms': 'Tuan rumah (MS)',
|
||||||
'match.field.away_en': 'Pelawat (EN)',
|
'match.field.away_en': 'Pelawat (EN)',
|
||||||
'match.field.away_zh': 'Pelawat (ZH)',
|
'match.field.away_zh': 'Pelawat (ZH)',
|
||||||
|
'match.field.away_ms': 'Pelawat (MS)',
|
||||||
'match.field.featured': 'Pilihan utama',
|
'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.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.keyword_ph': 'No. pertaruhan / nama pengguna',
|
||||||
'bet.filter.date_from': 'Tarikh pertaruhan dari',
|
'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_en': 'South Africa',
|
||||||
'match.ph.away_zh': 'Afrika Selatan',
|
'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.username_required': 'Sila isi nama pengguna',
|
||||||
'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara',
|
'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara',
|
||||||
'err.password_mismatch': 'Kata laluan tidak sepadan',
|
'err.password_mismatch': 'Kata laluan tidak sepadan',
|
||||||
@@ -256,7 +333,8 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'msg.save_failed': 'Gagal menyimpan',
|
'msg.save_failed': 'Gagal menyimpan',
|
||||||
'msg.deleted': 'Dipadam',
|
'msg.deleted': 'Dipadam',
|
||||||
'msg.delete_failed': 'Gagal memadam',
|
'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.published': 'Diterbitkan dengan pasaran',
|
||||||
'msg.closed': 'Pertaruhan ditutup',
|
'msg.closed': 'Pertaruhan ditutup',
|
||||||
'msg.invalid_json': 'JSON tidak sah',
|
'msg.invalid_json': 'JSON tidak sah',
|
||||||
|
|||||||
@@ -124,22 +124,41 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
|
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
|
||||||
|
|
||||||
'match.create_btn': '+ 新增赛事',
|
'match.create_btn': '+ 新增赛事',
|
||||||
|
'match.create_fixture_btn': '+ 新增单场',
|
||||||
|
'match.btn.markets': '盘口',
|
||||||
'match.filter.keyword_ph': '赛事名 / 球队代码',
|
'match.filter.keyword_ph': '赛事名 / 球队代码',
|
||||||
|
'match.col.league': '赛事',
|
||||||
|
'match.col.fixture_count': '单场',
|
||||||
|
'match.col.league_code': '代码',
|
||||||
'match.col.matchup': '对阵',
|
'match.col.matchup': '对阵',
|
||||||
'match.col.kickoff': '开赛时间',
|
'match.col.kickoff': '开赛时间',
|
||||||
'match.dialog.create': '新增赛事',
|
'match.dialog.create_league': '新增赛事',
|
||||||
'match.dialog.edit': '编辑赛事',
|
'match.dialog.create_fixture': '新增单场',
|
||||||
|
'match.dialog.create': '新增单场',
|
||||||
|
'match.dialog.edit': '编辑单场',
|
||||||
'match.dialog.import': '导入赛事',
|
'match.dialog.import': '导入赛事',
|
||||||
'match.field.league_en': '联赛(英)',
|
'match.field.league_en': '联赛(英)',
|
||||||
'match.field.league_zh': '联赛(中)',
|
'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.kickoff': '开赛时间',
|
||||||
'match.field.home_en': '主队(英)',
|
'match.field.home_en': '主队(英)',
|
||||||
'match.field.home_zh': '主队(中)',
|
'match.field.home_zh': '主队(中)',
|
||||||
|
'match.field.home_ms': '主队(马来)',
|
||||||
'match.field.away_en': '客队(英)',
|
'match.field.away_en': '客队(英)',
|
||||||
'match.field.away_zh': '客队(中)',
|
'match.field.away_zh': '客队(中)',
|
||||||
|
'match.field.away_ms': '客队(马来)',
|
||||||
'match.field.featured': '热门',
|
'match.field.featured': '热门',
|
||||||
'match.hint.create_draft': '创建后为草稿,请在列表点击「发布」并生成盘口。',
|
'match.hint.create_draft': '创建后为草稿,请展开赛事后在单场行点击「发布」并生成盘口。',
|
||||||
|
'match.hint.create_league': '创建赛事(联赛)后,展开该行可添加多场单场比赛。',
|
||||||
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
|
'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.keyword_ph': '流水编号 / 玩家用户名',
|
||||||
'bet.filter.date_from': '投注日起',
|
'bet.filter.date_from': '投注日起',
|
||||||
@@ -211,13 +230,71 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'match.ph.away_en': 'South Africa',
|
'match.ph.away_en': 'South Africa',
|
||||||
'match.ph.away_zh': '南非',
|
'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.username_required': '请填写用户名',
|
||||||
'err.password_min': '密码至少 8 位',
|
'err.password_min': '密码至少 8 位',
|
||||||
'err.password_mismatch': '两次密码不一致',
|
'err.password_mismatch': '两次密码不一致',
|
||||||
'err.credit_negative': '授信额度不能为负',
|
'err.credit_negative': '授信额度不能为负',
|
||||||
'err.kickoff_required': '请填写开赛时间',
|
'err.kickoff_required': '请填写开赛时间',
|
||||||
'err.teams_required': '请填写主客队名称(中文或英文至少一项)',
|
'err.teams_required': '请填写主客队名称(中文、英文或马来文至少一项)',
|
||||||
'err.league_required': '请填写联赛名称',
|
'err.league_required': '请填写联赛名称(中文、英文或马来文至少一项)',
|
||||||
'err.user_required': '请选择用户',
|
'err.user_required': '请选择用户',
|
||||||
'err.agent_no_parent': '一级代理不可设置上级玩家',
|
'err.agent_no_parent': '一级代理不可设置上级玩家',
|
||||||
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
|
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
|
||||||
@@ -256,7 +333,8 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'msg.save_failed': '保存失败',
|
'msg.save_failed': '保存失败',
|
||||||
'msg.deleted': '已删除',
|
'msg.deleted': '已删除',
|
||||||
'msg.delete_failed': '删除失败',
|
'msg.delete_failed': '删除失败',
|
||||||
'msg.match_created_draft': '赛事已创建(草稿)',
|
'msg.league_created': '赛事已创建',
|
||||||
|
'msg.match_created_draft': '单场已创建(草稿)',
|
||||||
'msg.published': '已发布并生成盘口',
|
'msg.published': '已发布并生成盘口',
|
||||||
'msg.closed': '已封盘',
|
'msg.closed': '已封盘',
|
||||||
'msg.invalid_json': 'JSON 格式无效',
|
'msg.invalid_json': 'JSON 格式无效',
|
||||||
@@ -499,23 +577,42 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'agent.ph.select_user': 'Search player username',
|
'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)',
|
'agent.hint.select_user': 'Pick an existing player account to promote to tier-1 agent (no new login)',
|
||||||
|
|
||||||
'match.create_btn': '+ New match',
|
'match.create_btn': '+ New tournament',
|
||||||
'match.filter.keyword_ph': 'Match name / team code',
|
'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.matchup': 'Matchup',
|
||||||
'match.col.kickoff': 'Kickoff',
|
'match.col.kickoff': 'Kickoff',
|
||||||
'match.dialog.create': 'New match',
|
'match.dialog.create_league': 'New tournament',
|
||||||
'match.dialog.edit': 'Edit match',
|
'match.dialog.create_fixture': 'New fixture',
|
||||||
|
'match.dialog.create': 'New fixture',
|
||||||
|
'match.dialog.edit': 'Edit fixture',
|
||||||
'match.dialog.import': 'Import matches',
|
'match.dialog.import': 'Import matches',
|
||||||
'match.field.league_en': 'League (EN)',
|
'match.field.league_en': 'League (EN)',
|
||||||
'match.field.league_zh': 'League (ZH)',
|
'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.kickoff': 'Kickoff time',
|
||||||
'match.field.home_en': 'Home (EN)',
|
'match.field.home_en': 'Home (EN)',
|
||||||
'match.field.home_zh': 'Home (ZH)',
|
'match.field.home_zh': 'Home (ZH)',
|
||||||
|
'match.field.home_ms': 'Home (MS)',
|
||||||
'match.field.away_en': 'Away (EN)',
|
'match.field.away_en': 'Away (EN)',
|
||||||
'match.field.away_zh': 'Away (ZH)',
|
'match.field.away_zh': 'Away (ZH)',
|
||||||
|
'match.field.away_ms': 'Away (MS)',
|
||||||
'match.field.featured': 'Featured',
|
'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.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.keyword_ph': 'Bet no. / username',
|
||||||
'bet.filter.date_from': 'Placed from',
|
'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_en': 'South Africa',
|
||||||
'match.ph.away_zh': '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.username_required': 'Username is required',
|
||||||
'err.password_min': 'Password must be at least 8 characters',
|
'err.password_min': 'Password must be at least 8 characters',
|
||||||
'err.password_mismatch': 'Passwords do not match',
|
'err.password_mismatch': 'Passwords do not match',
|
||||||
@@ -632,7 +787,8 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'msg.save_failed': 'Save failed',
|
'msg.save_failed': 'Save failed',
|
||||||
'msg.deleted': 'Deleted',
|
'msg.deleted': 'Deleted',
|
||||||
'msg.delete_failed': 'Delete failed',
|
'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.published': 'Published with markets',
|
||||||
'msg.closed': 'Betting closed',
|
'msg.closed': 'Betting closed',
|
||||||
'msg.invalid_json': 'Invalid JSON',
|
'msg.invalid_json': 'Invalid JSON',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const adminMenus = computed(() => [
|
|||||||
{ path: '/', label: t('nav.dashboard') },
|
{ path: '/', label: t('nav.dashboard') },
|
||||||
{ path: '/users', label: t('nav.users') },
|
{ path: '/users', label: t('nav.users') },
|
||||||
{ path: '/agents', label: t('nav.agents') },
|
{ 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: '/outrights', label: t('nav.outrights'), matchPrefix: true },
|
||||||
{ path: '/bets', label: t('nav.bets') },
|
{ path: '/bets', label: t('nav.bets') },
|
||||||
{ path: '/cashback', label: t('nav.cashback') },
|
{ path: '/cashback', label: t('nav.cashback') },
|
||||||
|
|||||||
@@ -26,6 +26,18 @@ const router = createRouter({
|
|||||||
component: () => import('../views/Matches.vue'),
|
component: () => import('../views/Matches.vue'),
|
||||||
meta: { adminOnly: true },
|
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',
|
path: 'outrights',
|
||||||
name: 'admin-outrights',
|
name: 'admin-outrights',
|
||||||
|
|||||||
22
apps/admin/src/utils/adminSelectionLabel.ts
Normal file
22
apps/admin/src/utils/adminSelectionLabel.ts
Normal 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;
|
||||||
|
}
|
||||||
51
apps/admin/src/utils/matchesListState.ts
Normal file
51
apps/admin/src/utils/matchesListState.ts
Normal 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] });
|
||||||
|
}
|
||||||
42
apps/admin/src/utils/selectionDefaults.ts
Normal file
42
apps/admin/src/utils/selectionDefaults.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,46 +1,70 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
import { resolveFormError } from '../i18n/form-validation';
|
import { resolveFormError } from '../i18n/form-validation';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
|
||||||
|
import {
|
||||||
const { t } = useAdminLocale();
|
readMatchesListUiState,
|
||||||
|
writeMatchesListUiState,
|
||||||
|
} from '../utils/matchesListState.ts';
|
||||||
import {
|
import {
|
||||||
emptyMatchForm,
|
emptyMatchForm,
|
||||||
buildPlatformPayload,
|
buildPlatformPayload,
|
||||||
formFromDetail,
|
|
||||||
type MatchCreateForm,
|
type MatchCreateForm,
|
||||||
type AdminMatchDetail,
|
|
||||||
} from './match-form.ts';
|
} from './match-form.ts';
|
||||||
|
|
||||||
const router = useRouter();
|
const { t } = useAdminLocale();
|
||||||
const matches = ref<unknown[]>([]);
|
const leagues = ref<unknown[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageSize = ref(10);
|
const pageSize = ref(10);
|
||||||
const filterStatus = ref('');
|
const filterStatus = ref('');
|
||||||
const keyword = 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 createVisible = ref(false);
|
||||||
const editVisible = ref(false);
|
|
||||||
const importVisible = ref(false);
|
const importVisible = ref(false);
|
||||||
const createLoading = ref(false);
|
const createLoading = ref(false);
|
||||||
const editLoading = ref(false);
|
|
||||||
const importLoading = ref(false);
|
const importLoading = ref(false);
|
||||||
const importJson = ref('');
|
const importJson = ref('');
|
||||||
const form = ref<MatchCreateForm>(emptyMatchForm());
|
const form = ref<MatchCreateForm>(emptyMatchForm());
|
||||||
const editingId = ref('');
|
const createUnderLeagueLabel = ref('');
|
||||||
const editingStatus = 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() {
|
function applyExpandedFromSaved(savedIds: string[]) {
|
||||||
const { data } = await api.get('/admin/matches', {
|
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: {
|
params: {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
@@ -48,24 +72,77 @@ async function load() {
|
|||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
matches.value = data.data.items;
|
leagues.value = data.data.items;
|
||||||
total.value = data.data.total;
|
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) {
|
function onPageChange(p: number) {
|
||||||
page.value = p;
|
page.value = p;
|
||||||
load();
|
load({ keepExpand: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSizeChange(size: number) {
|
function onSizeChange(size: number) {
|
||||||
pageSize.value = size;
|
pageSize.value = size;
|
||||||
page.value = 1;
|
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();
|
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;
|
createVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,24 +151,6 @@ function openImport() {
|
|||||||
importVisible.value = true;
|
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() {
|
async function submitCreate() {
|
||||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||||
try {
|
try {
|
||||||
@@ -104,8 +163,14 @@ async function submitCreate() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/admin/matches', payload);
|
await api.post('/admin/matches', payload);
|
||||||
ElMessage.success(t('msg.match_created_draft'));
|
ElMessage.success(t('msg.match_created_draft'));
|
||||||
|
createUnderLeagueLabel.value = '';
|
||||||
createVisible.value = false;
|
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) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
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() {
|
async function submitImport() {
|
||||||
let payload: unknown;
|
let payload: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -181,7 +205,7 @@ async function submitImport() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
importVisible.value = false;
|
importVisible.value = false;
|
||||||
load();
|
load({ keepExpand: true });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.import_failed'));
|
ElMessage.error(err.response?.data?.error ?? t('msg.import_failed'));
|
||||||
@@ -190,91 +214,47 @@ async function submitImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish(id: string) {
|
function onExpandChange(_row: unknown, expanded: unknown[]) {
|
||||||
await api.post(`/admin/matches/${id}/publish`);
|
expandedRowKeys.value = expanded.map((r) => leagueId(r));
|
||||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
persistListUiState();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function close(id: string) {
|
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
|
||||||
await api.post(`/admin/matches/${id}/close`);
|
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
|
||||||
ElMessage.success(t('msg.closed'));
|
const id = leagueId(row);
|
||||||
load();
|
expandedRowKeys.value = expandedRowKeys.value.includes(id) ? [] : [id];
|
||||||
|
persistListUiState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function settle(id: string) {
|
function rowClassName() {
|
||||||
router.push(`/settlement/${id}`);
|
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) {
|
function rowOf(row: unknown) {
|
||||||
return row as Record<string, unknown>;
|
return row as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
function matchStatus(row: unknown) {
|
function leagueId(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 ?? '');
|
return String(rowOf(row).id ?? '');
|
||||||
}
|
}
|
||||||
function matchTime(row: unknown) {
|
function leagueTitle(row: unknown) {
|
||||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
|
||||||
}
|
|
||||||
function matchTitle(row: unknown) {
|
|
||||||
const r = rowOf(row);
|
const r = rowOf(row);
|
||||||
if (r.matchName) return String(r.matchName);
|
const zh = String(r.leagueZh ?? '').trim();
|
||||||
const home = (r.homeTeam as { code?: string })?.code ?? '';
|
const en = String(r.leagueEn ?? '').trim();
|
||||||
const away = (r.awayTeam as { code?: string })?.code ?? '';
|
return zh || en || String(r.code ?? '—');
|
||||||
return home && away ? `${home} vs ${away}` : '—';
|
|
||||||
}
|
}
|
||||||
function canEdit(row: unknown) {
|
function leagueMatchCount(row: unknown) {
|
||||||
const r = rowOf(row);
|
return Number(rowOf(row).matchCount ?? 0);
|
||||||
if (r.isOutright) return false;
|
|
||||||
return matchStatus(row) === 'DRAFT' || matchStatus(row) === 'PUBLISHED';
|
|
||||||
}
|
}
|
||||||
function canDelete(row: unknown) {
|
function isLeagueExpanded(id: string) {
|
||||||
const r = rowOf(row);
|
return expandedRowKeys.value.includes(id);
|
||||||
if (r.isOutright) return false;
|
|
||||||
return matchStatus(row) === 'DRAFT';
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="admin-list-page">
|
<div class="admin-list-page matches-page">
|
||||||
<div class="page-toolbar">
|
<div class="page-toolbar">
|
||||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<el-card class="filter-card" shadow="never">
|
<el-card class="filter-card" shadow="never">
|
||||||
@@ -297,67 +277,54 @@ function canDelete(row: unknown) {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="data-card" shadow="never">
|
<el-card class="data-card" shadow="never">
|
||||||
|
<p class="table-hint">{{ t('match.expand_league_hint') }}</p>
|
||||||
<div class="table-wrap">
|
<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 prop="id" label="ID" width="72" />
|
||||||
<el-table-column :label="t('match.col.matchup')" min-width="200">
|
<el-table-column :label="t('match.col.league')" min-width="220">
|
||||||
<template #default="{ row }">{{ matchTitle(row) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('common.status')" width="96">
|
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('match.col.kickoff')" min-width="160">
|
<el-table-column :label="t('match.col.fixture_count')" width="100" align="center">
|
||||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.actions')" width="340" align="center" fixed="right">
|
<el-table-column :label="t('match.col.league_code')" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">{{ rowOf(row).code }}</template>
|
||||||
<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>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
@@ -375,16 +342,56 @@ function canDelete(row: unknown) {
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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 label-width="96px">
|
||||||
|
<el-form-item :label="t('match.field.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-form-item :label="t('match.field.league_en')">
|
||||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('match.field.league_zh')">
|
<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="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<el-form-item :label="t('match.field.kickoff')" required>
|
<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>
|
||||||
<el-form-item :label="t('match.field.home_en')">
|
<el-form-item :label="t('match.field.home_en')">
|
||||||
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
|
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
|
||||||
@@ -409,42 +416,6 @@ function canDelete(row: unknown) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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>
|
<el-dialog v-model="importVisible" :title="t('match.dialog.import')" width="640px" destroy-on-close>
|
||||||
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
|
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
|
||||||
<el-input
|
<el-input
|
||||||
@@ -484,4 +455,85 @@ function canDelete(row: unknown) {
|
|||||||
.edit-hint {
|
.edit-hint {
|
||||||
margin-bottom: 16px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,54 +3,133 @@
|
|||||||
import { FormValidationError } from '../i18n/form-validation';
|
import { FormValidationError } from '../i18n/form-validation';
|
||||||
|
|
||||||
export interface MatchCreateForm {
|
export interface MatchCreateForm {
|
||||||
|
leagueId: string;
|
||||||
leagueEn: string;
|
leagueEn: string;
|
||||||
leagueZh: string;
|
leagueZh: string;
|
||||||
|
leagueMs: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
homeTeamZh: string;
|
homeTeamZh: string;
|
||||||
homeTeamEn: string;
|
homeTeamEn: string;
|
||||||
|
homeTeamMs: string;
|
||||||
awayTeamZh: string;
|
awayTeamZh: string;
|
||||||
awayTeamEn: string;
|
awayTeamEn: string;
|
||||||
|
awayTeamMs: string;
|
||||||
isHot: boolean;
|
isHot: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
matchName: string;
|
||||||
|
stage: string;
|
||||||
|
groupName: string;
|
||||||
|
leagueLogoUrl: string;
|
||||||
|
homeTeamLogoUrl: string;
|
||||||
|
awayTeamLogoUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emptyMatchForm(): MatchCreateForm {
|
export function emptyMatchForm(): MatchCreateForm {
|
||||||
return {
|
return {
|
||||||
|
leagueId: '',
|
||||||
leagueEn: 'FIFA World Cup 2026',
|
leagueEn: 'FIFA World Cup 2026',
|
||||||
leagueZh: '2026 世界杯',
|
leagueZh: '2026 世界杯',
|
||||||
|
leagueMs: 'Piala Dunia 2026',
|
||||||
startTime: '',
|
startTime: '',
|
||||||
homeTeamZh: '',
|
homeTeamZh: '',
|
||||||
homeTeamEn: '',
|
homeTeamEn: '',
|
||||||
|
homeTeamMs: '',
|
||||||
awayTeamZh: '',
|
awayTeamZh: '',
|
||||||
awayTeamEn: '',
|
awayTeamEn: '',
|
||||||
|
awayTeamMs: '',
|
||||||
isHot: false,
|
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 = {
|
export type AdminMatchDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
isOutright: boolean;
|
isOutright: boolean;
|
||||||
isHot: boolean;
|
isHot: boolean;
|
||||||
|
displayOrder: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
leagueEn: string;
|
leagueEn: string;
|
||||||
leagueZh: string;
|
leagueZh: string;
|
||||||
|
leagueMs: string;
|
||||||
|
leagueLogoUrl?: string;
|
||||||
homeTeamEn: string;
|
homeTeamEn: string;
|
||||||
homeTeamZh: string;
|
homeTeamZh: string;
|
||||||
|
homeTeamMs: string;
|
||||||
|
homeTeamCode?: string;
|
||||||
|
homeTeamLogoUrl?: string;
|
||||||
awayTeamEn: string;
|
awayTeamEn: string;
|
||||||
awayTeamZh: string;
|
awayTeamZh: string;
|
||||||
|
awayTeamMs: string;
|
||||||
|
awayTeamCode?: string;
|
||||||
|
awayTeamLogoUrl?: string;
|
||||||
matchName: 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 {
|
export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||||
return {
|
return {
|
||||||
|
leagueId: '',
|
||||||
leagueEn: d.leagueEn,
|
leagueEn: d.leagueEn,
|
||||||
leagueZh: d.leagueZh,
|
leagueZh: d.leagueZh,
|
||||||
startTime: d.startTime,
|
leagueMs: d.leagueMs ?? '',
|
||||||
|
startTime: normalizeStartTimeForPicker(d.startTime),
|
||||||
homeTeamZh: d.homeTeamZh,
|
homeTeamZh: d.homeTeamZh,
|
||||||
homeTeamEn: d.homeTeamEn,
|
homeTeamEn: d.homeTeamEn,
|
||||||
|
homeTeamMs: d.homeTeamMs ?? '',
|
||||||
awayTeamZh: d.awayTeamZh,
|
awayTeamZh: d.awayTeamZh,
|
||||||
awayTeamEn: d.awayTeamEn,
|
awayTeamEn: d.awayTeamEn,
|
||||||
|
awayTeamMs: d.awayTeamMs ?? '',
|
||||||
isHot: d.isHot,
|
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()) {
|
if (!form.startTime.trim()) {
|
||||||
throw new FormValidationError('err.kickoff_required');
|
throw new FormValidationError('err.kickoff_required');
|
||||||
}
|
}
|
||||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim();
|
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim() || form.homeTeamMs.trim();
|
||||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim();
|
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim() || form.awayTeamMs.trim();
|
||||||
if (!homeOk || !awayOk) {
|
if (!homeOk || !awayOk) {
|
||||||
throw new FormValidationError('err.teams_required');
|
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');
|
throw new FormValidationError('err.league_required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
leagueId: form.leagueId.trim() || undefined,
|
||||||
leagueEn: form.leagueEn.trim(),
|
leagueEn: form.leagueEn.trim(),
|
||||||
leagueZh: form.leagueZh.trim(),
|
leagueZh: form.leagueZh.trim(),
|
||||||
|
leagueMs: form.leagueMs.trim() || undefined,
|
||||||
homeTeamEn: form.homeTeamEn.trim(),
|
homeTeamEn: form.homeTeamEn.trim(),
|
||||||
homeTeamZh: form.homeTeamZh.trim(),
|
homeTeamZh: form.homeTeamZh.trim(),
|
||||||
|
homeTeamMs: form.homeTeamMs.trim() || undefined,
|
||||||
awayTeamEn: form.awayTeamEn.trim(),
|
awayTeamEn: form.awayTeamEn.trim(),
|
||||||
awayTeamZh: form.awayTeamZh.trim(),
|
awayTeamZh: form.awayTeamZh.trim(),
|
||||||
startTime: form.startTime.trim(),
|
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||||
|
startTime: normalizeStartTimeForApi(form.startTime),
|
||||||
isHot: form.isHot,
|
isHot: form.isHot,
|
||||||
|
displayOrder: form.displayOrder,
|
||||||
|
matchName: form.matchName.trim() || undefined,
|
||||||
|
stage: form.stage.trim() || undefined,
|
||||||
|
groupName: form.groupName.trim() || undefined,
|
||||||
|
leagueLogoUrl: form.leagueLogoUrl.trim() || undefined,
|
||||||
|
homeTeamLogoUrl: form.homeTeamLogoUrl.trim() || undefined,
|
||||||
|
awayTeamLogoUrl: form.awayTeamLogoUrl.trim() || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
262
apps/admin/src/views/matches/LeagueMatchesPanel.vue
Normal file
262
apps/admin/src/views/matches/LeagueMatchesPanel.vue
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||||
|
import api from '../../api';
|
||||||
|
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
|
||||||
|
const props = defineProps<{
|
||||||
|
leagueId: string;
|
||||||
|
filterStatus: string;
|
||||||
|
keyword: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
changed: [];
|
||||||
|
'add-match': [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t, locale } = useAdminLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const matches = ref<unknown[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
|
||||||
|
params: {
|
||||||
|
status: props.filterStatus || undefined,
|
||||||
|
keyword: props.keyword.trim() || undefined,
|
||||||
|
locale: locale.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
matches.value = data.data.items;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.leagueId, props.filterStatus, props.keyword, locale.value] as const,
|
||||||
|
() => load(),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function notifyParent() {
|
||||||
|
emit('changed');
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish(id: string) {
|
||||||
|
await api.post(`/admin/matches/${id}/publish`);
|
||||||
|
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||||
|
marketTypes: [
|
||||||
|
'FT_1X2',
|
||||||
|
'FT_HANDICAP',
|
||||||
|
'FT_OVER_UNDER',
|
||||||
|
'FT_ODD_EVEN',
|
||||||
|
'HT_1X2',
|
||||||
|
'HT_HANDICAP',
|
||||||
|
'HT_OVER_UNDER',
|
||||||
|
'FT_CORRECT_SCORE',
|
||||||
|
'HT_CORRECT_SCORE',
|
||||||
|
'SH_CORRECT_SCORE',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
ElMessage.success(t('msg.published'));
|
||||||
|
notifyParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(id: string) {
|
||||||
|
await api.post(`/admin/matches/${id}/close`);
|
||||||
|
ElMessage.success(t('msg.closed'));
|
||||||
|
notifyParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeLeaveList() {
|
||||||
|
ensureLeagueExpanded(props.leagueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openManage(id: string) {
|
||||||
|
beforeLeaveList();
|
||||||
|
router.push(`/matches/${id}/edit`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMarkets(id: string) {
|
||||||
|
beforeLeaveList();
|
||||||
|
router.push(`/matches/${id}/markets`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settle(id: string) {
|
||||||
|
beforeLeaveList();
|
||||||
|
router.push(`/settlement/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
function matchStatusText(status: string) {
|
||||||
|
const key = `match.status.${status}`;
|
||||||
|
const v = t(key);
|
||||||
|
return v !== key ? v : status;
|
||||||
|
}
|
||||||
|
const statusTagTypes: Record<string, TagType> = {
|
||||||
|
DRAFT: 'info',
|
||||||
|
PUBLISHED: 'warning',
|
||||||
|
CLOSED: 'danger',
|
||||||
|
SETTLED: 'success',
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowOf(row: unknown) {
|
||||||
|
return row as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
function matchStatus(row: unknown) {
|
||||||
|
return String(rowOf(row).status ?? '');
|
||||||
|
}
|
||||||
|
function matchStatusLabel(row: unknown) {
|
||||||
|
return matchStatusText(matchStatus(row));
|
||||||
|
}
|
||||||
|
function matchStatusType(row: unknown): TagType {
|
||||||
|
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||||
|
}
|
||||||
|
function matchId(row: unknown) {
|
||||||
|
return String(rowOf(row).id ?? '');
|
||||||
|
}
|
||||||
|
function matchTime(row: unknown) {
|
||||||
|
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||||
|
}
|
||||||
|
function matchTitle(row: unknown) {
|
||||||
|
const r = rowOf(row);
|
||||||
|
const home =
|
||||||
|
String(r.homeTeamName ?? '').trim() ||
|
||||||
|
(r.homeTeam as { code?: string })?.code ||
|
||||||
|
'';
|
||||||
|
const away =
|
||||||
|
String(r.awayTeamName ?? '').trim() ||
|
||||||
|
(r.awayTeam as { code?: string })?.code ||
|
||||||
|
'';
|
||||||
|
if (home && away) return `${home} vs ${away}`;
|
||||||
|
const matchName = String(r.matchName ?? '').trim();
|
||||||
|
return matchName || '—';
|
||||||
|
}
|
||||||
|
function canManage(row: unknown) {
|
||||||
|
const s = matchStatus(row);
|
||||||
|
return s === 'DRAFT' || s === 'PUBLISHED';
|
||||||
|
}
|
||||||
|
function canDeleteRow(row: unknown) {
|
||||||
|
return matchStatus(row) === 'DRAFT';
|
||||||
|
}
|
||||||
|
function canPublishRow(row: unknown) {
|
||||||
|
return matchStatus(row) === 'DRAFT';
|
||||||
|
}
|
||||||
|
function canCloseRow(row: unknown) {
|
||||||
|
return matchStatus(row) === 'PUBLISHED';
|
||||||
|
}
|
||||||
|
function canSettleRow(row: unknown) {
|
||||||
|
return matchStatus(row) !== 'DRAFT';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(row: unknown) {
|
||||||
|
const id = matchId(row);
|
||||||
|
const title = matchTitle(row);
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: t('common.delete'),
|
||||||
|
cancelButtonText: t('common.cancel'),
|
||||||
|
});
|
||||||
|
await api.delete(`/admin/matches/${id}`);
|
||||||
|
ElMessage.success(t('msg.deleted'));
|
||||||
|
notifyParent();
|
||||||
|
} catch (e) {
|
||||||
|
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reload: load });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="league-matches-panel">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<el-button type="primary" size="small" @click="emit('add-match')">
|
||||||
|
{{ t('match.create_fixture_btn') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
||||||
|
<el-table-column prop="id" label="ID" width="64" />
|
||||||
|
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="matchup-link">{{ matchTitle(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.status')" width="88">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('match.col.kickoff')" min-width="150">
|
||||||
|
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.actions')" width="420" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-btns">
|
||||||
|
<el-button size="small" class="action-btn" :disabled="!canManage(row)" @click="openManage(matchId(row))">
|
||||||
|
{{ t('matchEditor.manage_btn') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" class="action-btn" :disabled="!canManage(row)" @click="openMarkets(matchId(row))">
|
||||||
|
{{ t('match.btn.markets') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" class="action-btn" :disabled="!canDeleteRow(row)" @click="confirmDelete(row)">
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" class="action-btn" :disabled="!canPublishRow(row)" @click="publish(matchId(row))">
|
||||||
|
{{ t('common.publish') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" class="action-btn" :disabled="!canCloseRow(row)" @click="close(matchId(row))">
|
||||||
|
{{ t('common.close_betting') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" class="action-btn" :disabled="!canSettleRow(row)" @click="settle(matchId(row))">
|
||||||
|
{{ t('common.settle') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.league-matches-panel {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
.panel-toolbar {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.matchup-link {
|
||||||
|
color: var(--green-text);
|
||||||
|
}
|
||||||
|
.action-btns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.action-btns :deep(.action-btn) {
|
||||||
|
margin: 0 !important;
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 4px 8px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
367
apps/admin/src/views/matches/MatchEventEditor.vue
Normal file
367
apps/admin/src/views/matches/MatchEventEditor.vue
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||||
|
import { resolveFormError } from '../../i18n/form-validation';
|
||||||
|
import api from '../../api';
|
||||||
|
import LogoUrlField from '../../components/LogoUrlField.vue';
|
||||||
|
import type { BuiltinCountry } from '../../data/builtinCountries';
|
||||||
|
import {
|
||||||
|
buildPlatformPayload,
|
||||||
|
emptyMatchForm,
|
||||||
|
formFromDetail,
|
||||||
|
type AdminMatchDetail,
|
||||||
|
type MatchCreateForm,
|
||||||
|
} from '../match-form.ts';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
const matchId = computed(() => String(route.params.matchId ?? ''));
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const savingMeta = ref(false);
|
||||||
|
const status = ref('DRAFT');
|
||||||
|
const form = ref<MatchCreateForm>(emptyMatchForm());
|
||||||
|
const homeTeamCode = ref('');
|
||||||
|
const awayTeamCode = ref('');
|
||||||
|
|
||||||
|
function applyTeamFromCountry(
|
||||||
|
side: 'home' | 'away',
|
||||||
|
country: BuiltinCountry,
|
||||||
|
) {
|
||||||
|
if (side === 'home') {
|
||||||
|
if (!form.value.homeTeamZh.trim()) form.value.homeTeamZh = country.nameZh;
|
||||||
|
if (!form.value.homeTeamEn.trim()) form.value.homeTeamEn = country.nameEn;
|
||||||
|
} else {
|
||||||
|
if (!form.value.awayTeamZh.trim()) form.value.awayTeamZh = country.nameZh;
|
||||||
|
if (!form.value.awayTeamEn.trim()) form.value.awayTeamEn = country.nameEn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!matchId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||||
|
const detail = data.data as AdminMatchDetail;
|
||||||
|
if (detail.isOutright) {
|
||||||
|
ElMessage.warning(t('msg.outright_no_edit'));
|
||||||
|
router.replace('/outrights');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.value = detail.status;
|
||||||
|
form.value = formFromDetail(detail);
|
||||||
|
homeTeamCode.value = detail.homeTeamCode ?? '';
|
||||||
|
awayTeamCode.value = detail.awayTeamCode ?? '';
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(matchId, load, { immediate: true });
|
||||||
|
|
||||||
|
async function saveMeta() {
|
||||||
|
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||||
|
try {
|
||||||
|
payload = buildPlatformPayload(form.value);
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.warning(resolveFormError(e, t));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingMeta.value = true;
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/matches/${matchId.value}`, payload);
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
} finally {
|
||||||
|
savingMeta.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="loading" class="match-editor-page page-scroll">
|
||||||
|
<div class="editor-topbar">
|
||||||
|
<el-button size="small" text class="back-btn" @click="router.push('/matches')">
|
||||||
|
← {{ t('matchEditor.back') }}
|
||||||
|
</el-button>
|
||||||
|
<div class="topbar-title">
|
||||||
|
<h2>{{ t('matchEditor.title') }} #{{ matchId }}</h2>
|
||||||
|
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">{{ t('matchEditor.section_info') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-width="72px" label-position="left" class="meta-form compact-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-label">{{ t('matchEditor.group.league') }}</div>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_zh')">
|
||||||
|
<el-input v-model="form.leagueZh" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_en')">
|
||||||
|
<el-input v-model="form.leagueEn" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_ms')">
|
||||||
|
<el-input v-model="form.leagueMs" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<div class="logo-inline">
|
||||||
|
<span class="logo-inline-label">{{ t('matchEditor.field.league_logo') }}</span>
|
||||||
|
<LogoUrlField v-model="form.leagueLogoUrl" />
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-label">{{ t('matchEditor.group.home') }}</div>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_zh')">
|
||||||
|
<el-input v-model="form.homeTeamZh" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_en')">
|
||||||
|
<el-input v-model="form.homeTeamEn" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_ms')">
|
||||||
|
<el-input v-model="form.homeTeamMs" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<div class="logo-inline">
|
||||||
|
<span class="logo-inline-label">{{ t('matchEditor.field.home_logo') }}</span>
|
||||||
|
<LogoUrlField
|
||||||
|
v-model="form.homeTeamLogoUrl"
|
||||||
|
:team-code="homeTeamCode"
|
||||||
|
@pick="applyTeamFromCountry('home', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-label">{{ t('matchEditor.group.away') }}</div>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_zh')">
|
||||||
|
<el-input v-model="form.awayTeamZh" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_en')">
|
||||||
|
<el-input v-model="form.awayTeamEn" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="8">
|
||||||
|
<el-form-item :label="t('match.field.lang_ms')">
|
||||||
|
<el-input v-model="form.awayTeamMs" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="24">
|
||||||
|
<div class="logo-inline">
|
||||||
|
<span class="logo-inline-label">{{ t('matchEditor.field.away_logo') }}</span>
|
||||||
|
<LogoUrlField
|
||||||
|
v-model="form.awayTeamLogoUrl"
|
||||||
|
:team-code="awayTeamCode"
|
||||||
|
@pick="applyTeamFromCountry('away', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="section-label">{{ t('matchEditor.group.schedule') }}</div>
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item :label="t('match.field.kickoff')" required>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.startTime"
|
||||||
|
type="datetime"
|
||||||
|
size="small"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
:placeholder="t('matchEditor.ph.kickoff')"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item :label="t('matchEditor.field.match_name')">
|
||||||
|
<el-input v-model="form.matchName" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('matchEditor.field.stage')">
|
||||||
|
<el-input v-model="form.stage" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('matchEditor.field.group')">
|
||||||
|
<el-input v-model="form.groupName" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('matchEditor.field.display_order')">
|
||||||
|
<el-input-number v-model="form.displayOrder" :min="0" size="small" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="12" :sm="6">
|
||||||
|
<el-form-item :label="t('match.field.featured')">
|
||||||
|
<el-switch v-model="form.isHot" size="small" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="panel-foot">
|
||||||
|
<el-button type="primary" :loading="savingMeta" @click="saveMeta">
|
||||||
|
{{ t('matchEditor.save_info') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.match-editor-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-topbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
color: var(--green-text) !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ccc;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green-text);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-inline-label {
|
||||||
|
flex: 0 0 72px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8e8e93;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-inline :deep(.logo-url-field) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-form :deep(.el-form-item__label) {
|
||||||
|
color: #8e8e93 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-form :deep(.el-input__inner),
|
||||||
|
.meta-form :deep(.el-input-number .el-input__inner) {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
apps/admin/src/views/matches/MatchMarketsPage.vue
Normal file
107
apps/admin/src/views/matches/MatchMarketsPage.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||||
|
import api from '../../api';
|
||||||
|
import MatchMarketsPanel from './MatchMarketsPanel.vue';
|
||||||
|
import type { AdminMatchDetail } from '../match-form.ts';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
const matchId = computed(() => String(route.params.matchId ?? ''));
|
||||||
|
const loading = ref(false);
|
||||||
|
const status = ref('DRAFT');
|
||||||
|
const matchLabel = ref('');
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!matchId.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||||
|
const detail = data.data as AdminMatchDetail;
|
||||||
|
if (detail.isOutright) {
|
||||||
|
ElMessage.warning(t('msg.outright_no_edit'));
|
||||||
|
router.replace('/outrights');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.value = detail.status;
|
||||||
|
const home = detail.homeTeamZh || detail.homeTeamEn || detail.homeTeamCode || '';
|
||||||
|
const away = detail.awayTeamZh || detail.awayTeamEn || detail.awayTeamCode || '';
|
||||||
|
matchLabel.value =
|
||||||
|
detail.matchName?.trim() ||
|
||||||
|
(home && away ? `${home} vs ${away}` : `#${matchId.value}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(matchId, load, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="loading" class="match-markets-page page-scroll">
|
||||||
|
<div class="editor-topbar">
|
||||||
|
<el-button size="small" text class="back-btn" @click="router.push('/matches')">
|
||||||
|
← {{ t('matchEditor.back') }}
|
||||||
|
</el-button>
|
||||||
|
<div class="topbar-title">
|
||||||
|
<h2>{{ t('matchEditor.section_markets') }}</h2>
|
||||||
|
<span class="match-subtitle">{{ matchLabel }}</span>
|
||||||
|
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="matchId" class="panel">
|
||||||
|
<MatchMarketsPanel :match-id="matchId" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.match-markets-page {
|
||||||
|
padding: 0 4px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-subtitle {
|
||||||
|
color: var(--green-text);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||||
|
import api from '../../api';
|
||||||
|
import type { AdminMatchDetail } from '../match-form.ts';
|
||||||
|
import { defaultSelectionName } from '../../utils/selectionDefaults.ts';
|
||||||
|
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
matchId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
interface SelectionRow {
|
||||||
|
id: string;
|
||||||
|
selectionCode: string;
|
||||||
|
selectionName: string;
|
||||||
|
odds: number;
|
||||||
|
status: string;
|
||||||
|
editOdds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketRow {
|
||||||
|
id: string;
|
||||||
|
marketType: string;
|
||||||
|
period: string;
|
||||||
|
lineValue: number | null;
|
||||||
|
status: string;
|
||||||
|
promoLabel: string;
|
||||||
|
editPromoLabel: string;
|
||||||
|
editLineValue: number | null;
|
||||||
|
selections: SelectionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MARKET_TYPES = [
|
||||||
|
'FT_1X2',
|
||||||
|
'FT_HANDICAP',
|
||||||
|
'FT_OVER_UNDER',
|
||||||
|
'FT_ODD_EVEN',
|
||||||
|
'HT_1X2',
|
||||||
|
'HT_HANDICAP',
|
||||||
|
'HT_OVER_UNDER',
|
||||||
|
'FT_CORRECT_SCORE',
|
||||||
|
'HT_CORRECT_SCORE',
|
||||||
|
'SH_CORRECT_SCORE',
|
||||||
|
];
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const savingMarketId = ref<string | null>(null);
|
||||||
|
const markets = ref<MarketRow[]>([]);
|
||||||
|
|
||||||
|
function mapMarkets(detail: AdminMatchDetail) {
|
||||||
|
markets.value = (detail.markets ?? []).map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
marketType: m.marketType,
|
||||||
|
period: m.period,
|
||||||
|
lineValue: m.lineValue,
|
||||||
|
status: m.status,
|
||||||
|
promoLabel: m.promoLabel,
|
||||||
|
editPromoLabel: m.promoLabel,
|
||||||
|
editLineValue: m.lineValue,
|
||||||
|
selections: m.selections.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
selectionCode: s.selectionCode,
|
||||||
|
selectionName: s.selectionName,
|
||||||
|
odds: s.odds,
|
||||||
|
status: s.status,
|
||||||
|
editOdds: s.odds,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.matchId) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/matches/${props.matchId}`);
|
||||||
|
mapMarkets(data.data as AdminMatchDetail);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.matchId,
|
||||||
|
() => {
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function marketLabel(type: string) {
|
||||||
|
const key = `matchEditor.market.${type}`;
|
||||||
|
const v = t(key);
|
||||||
|
return v !== key ? v : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionCodeLabel(code: string, market?: MarketRow) {
|
||||||
|
return adminSelectionLabel(t, code, {
|
||||||
|
lineValue: market?.editLineValue ?? market?.lineValue,
|
||||||
|
period: market?.period,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLine(market: MarketRow) {
|
||||||
|
return market.lineValue != null || market.editLineValue != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRECT_SCORE_TYPES = new Set([
|
||||||
|
'FT_CORRECT_SCORE',
|
||||||
|
'HT_CORRECT_SCORE',
|
||||||
|
'SH_CORRECT_SCORE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isMultiRowMarket(market: MarketRow) {
|
||||||
|
return CORRECT_SCORE_TYPES.has(market.marketType) || market.selections.length > 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normPromo(value: string | null | undefined) {
|
||||||
|
return (value ?? '').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normLine(value: number | null | undefined) {
|
||||||
|
return value == null ? null : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarketDirty(market: MarketRow) {
|
||||||
|
if (normPromo(market.editPromoLabel) !== normPromo(market.promoLabel)) return true;
|
||||||
|
if (normLine(market.editLineValue) !== normLine(market.lineValue)) return true;
|
||||||
|
return market.selections.some((s) => Number(s.editOdds) !== Number(s.odds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTemplates() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/matches/${props.matchId}/markets/templates`, {
|
||||||
|
marketTypes: DEFAULT_MARKET_TYPES,
|
||||||
|
});
|
||||||
|
ElMessage.success(t('matchEditor.templates_generated'));
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMarket(market: MarketRow) {
|
||||||
|
const invalid = market.selections.find((s) => !s.editOdds || s.editOdds <= 1);
|
||||||
|
if (invalid) {
|
||||||
|
ElMessage.warning(t('outright.err_odds_min'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
savingMarketId.value = market.id;
|
||||||
|
try {
|
||||||
|
await api.patch(`/admin/markets/${market.id}`, {
|
||||||
|
promoLabel: market.editPromoLabel.trim() || null,
|
||||||
|
lineValue: market.editLineValue,
|
||||||
|
});
|
||||||
|
await api.put(`/admin/matches/${props.matchId}/odds`, {
|
||||||
|
updates: market.selections.map((s) => ({
|
||||||
|
selectionId: s.id,
|
||||||
|
odds: s.editOdds,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
for (const s of market.selections) {
|
||||||
|
const name = defaultSelectionName(s.selectionCode, {
|
||||||
|
lineValue: market.editLineValue,
|
||||||
|
period: market.period,
|
||||||
|
});
|
||||||
|
if (name !== s.selectionName) {
|
||||||
|
await api.patch(`/admin/selections/${s.id}`, {
|
||||||
|
selectionName: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success(t('msg.saved'));
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
|
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||||
|
} finally {
|
||||||
|
savingMarketId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="loading" class="match-markets-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<span class="panel-title">{{ t('matchEditor.section_markets') }}</span>
|
||||||
|
<el-button size="small" type="primary" plain @click="generateTemplates">
|
||||||
|
{{ t('matchEditor.generate_templates') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
|
||||||
|
|
||||||
|
<div v-else class="market-lines">
|
||||||
|
<div
|
||||||
|
v-for="market in markets"
|
||||||
|
:key="market.id"
|
||||||
|
class="market-line"
|
||||||
|
:class="{ 'market-line--wrap': isMultiRowMarket(market) }"
|
||||||
|
>
|
||||||
|
<label class="field-promo-wrap">
|
||||||
|
<span class="promo-label">{{ t('matchEditor.field.promo_label_optional') }}</span>
|
||||||
|
<el-input
|
||||||
|
v-model="market.editPromoLabel"
|
||||||
|
size="small"
|
||||||
|
class="field-promo"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="market-line-head">
|
||||||
|
<span class="market-label" :title="market.marketType">{{ marketLabel(market.marketType) }}</span>
|
||||||
|
|
||||||
|
<el-input-number
|
||||||
|
v-if="hasLine(market)"
|
||||||
|
v-model="market.editLineValue"
|
||||||
|
size="small"
|
||||||
|
class="field-line"
|
||||||
|
:step="0.25"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="isMultiRowMarket(market)"
|
||||||
|
size="small"
|
||||||
|
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||||
|
class="btn-save"
|
||||||
|
:disabled="!isMarketDirty(market)"
|
||||||
|
:loading="savingMarketId === market.id"
|
||||||
|
@click="saveMarket(market)"
|
||||||
|
>
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="selections-wrap"
|
||||||
|
:class="isMultiRowMarket(market) ? 'selections-grid' : 'selections-inline'"
|
||||||
|
>
|
||||||
|
<div v-for="sel in market.selections" :key="sel.id" class="sel-inline">
|
||||||
|
<span class="sel-label" :title="sel.selectionCode">{{
|
||||||
|
selectionCodeLabel(sel.selectionCode, market)
|
||||||
|
}}</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="sel.editOdds"
|
||||||
|
size="small"
|
||||||
|
class="sel-odds"
|
||||||
|
:min="1.01"
|
||||||
|
:step="0.01"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="!isMultiRowMarket(market)"
|
||||||
|
size="small"
|
||||||
|
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||||
|
class="btn-save"
|
||||||
|
:disabled="!isMarketDirty(market)"
|
||||||
|
:loading="savingMarketId === market.id"
|
||||||
|
@click="saveMarket(market)"
|
||||||
|
>
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.match-markets-panel {
|
||||||
|
padding: 10px 12px 12px 16px;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ccc;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-lines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #252525;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #111;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line--wrap {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line--wrap .field-promo-wrap {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-promo-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promo-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8e8e93;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-promo {
|
||||||
|
flex: 0 0 88px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line--wrap .field-promo {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line:not(.market-line--wrap) .market-line-head {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line--wrap .market-line-head {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-line--wrap .market-line-head .btn-save {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-label {
|
||||||
|
flex: 0 0 76px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e8e8e8;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-line {
|
||||||
|
flex: 0 0 88px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-line :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selections-inline {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selections-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selections-grid .sel-inline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selections-grid .sel-label {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selections-grid .sel-odds {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selections-grid .sel-odds :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--green-text);
|
||||||
|
min-width: 14px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-odds {
|
||||||
|
width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sel-odds :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save.is-disabled,
|
||||||
|
.btn-save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "leagues" ADD COLUMN "logo_url" VARCHAR(500);
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "markets" ADD COLUMN "promo_label" VARCHAR(100);
|
||||||
@@ -219,6 +219,7 @@ model League {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||||
code String @unique @db.VarChar(64)
|
code String @unique @db.VarChar(64)
|
||||||
|
logoUrl String? @map("logo_url") @db.VarChar(500)
|
||||||
displayOrder Int @default(0) @map("display_order")
|
displayOrder Int @default(0) @map("display_order")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
@@ -330,6 +331,7 @@ model Market {
|
|||||||
allowSingle Boolean @default(true) @map("allow_single")
|
allowSingle Boolean @default(true) @map("allow_single")
|
||||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
IsIn,
|
IsIn,
|
||||||
Min,
|
Min,
|
||||||
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
||||||
|
|
||||||
@@ -207,25 +208,63 @@ class DepositDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreatePlatformMatchDto {
|
class CreatePlatformLeagueDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueEn!: string;
|
leagueEn!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
leagueZh!: string;
|
leagueZh!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
leagueMs?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
displayOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreatePlatformMatchDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
leagueId?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
|
||||||
|
@IsString()
|
||||||
|
leagueEn?: string;
|
||||||
|
|
||||||
|
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
|
||||||
|
@IsString()
|
||||||
|
leagueZh?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
leagueMs?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
homeTeamEn!: string;
|
homeTeamEn!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
homeTeamZh!: string;
|
homeTeamZh!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
homeTeamMs?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
awayTeamEn!: string;
|
awayTeamEn!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
awayTeamZh!: string;
|
awayTeamZh!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
awayTeamMs?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
startTime!: string;
|
startTime!: string;
|
||||||
|
|
||||||
@@ -236,6 +275,64 @@ class CreatePlatformMatchDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
matchName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
stage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
groupName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
leagueLogoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
homeTeamLogoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
awayTeamLogoUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BatchMatchOddsDto {
|
||||||
|
@IsArray()
|
||||||
|
updates!: OutrightOddsUpdateItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateMarketDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
promoLabel?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
lineValue?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateSelectionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
selectionName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1.01)
|
||||||
|
odds?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
|
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
|
||||||
@@ -704,10 +801,57 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('leagues')
|
@Post('leagues')
|
||||||
async createLeague(@Body() dto: { code: string; translations: Record<string, string> }) {
|
async createLeague(
|
||||||
const league = await this.matches.createLeague(dto.code, dto.translations);
|
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
|
||||||
|
) {
|
||||||
|
if ('leagueZh' in dto || 'leagueEn' in dto) {
|
||||||
|
const body = dto as CreatePlatformLeagueDto;
|
||||||
|
const league = await this.matches.createPlatformLeague({
|
||||||
|
leagueEn: body.leagueEn,
|
||||||
|
leagueZh: body.leagueZh,
|
||||||
|
leagueMs: body.leagueMs,
|
||||||
|
logoUrl: body.logoUrl,
|
||||||
|
displayOrder: body.displayOrder,
|
||||||
|
});
|
||||||
return jsonResponse(league);
|
return jsonResponse(league);
|
||||||
}
|
}
|
||||||
|
const legacy = dto as { code: string; translations: Record<string, string> };
|
||||||
|
const league = await this.matches.createLeague(legacy.code, legacy.translations);
|
||||||
|
return jsonResponse(league);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('leagues')
|
||||||
|
async listLeagues(
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
) {
|
||||||
|
const p = Math.max(1, page ? parseInt(page, 10) : 1);
|
||||||
|
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
|
||||||
|
const result = await this.matches.listAdminLeagues({
|
||||||
|
page: p,
|
||||||
|
pageSize: size,
|
||||||
|
status: status || undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
});
|
||||||
|
return jsonResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('leagues/:leagueId/matches')
|
||||||
|
async listLeagueMatches(
|
||||||
|
@Param('leagueId') leagueId: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('locale') locale?: string,
|
||||||
|
) {
|
||||||
|
const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
|
||||||
|
status: status || undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
locale: locale || undefined,
|
||||||
|
});
|
||||||
|
return jsonResponse({ items });
|
||||||
|
}
|
||||||
|
|
||||||
@Post('teams')
|
@Post('teams')
|
||||||
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||||
@@ -764,15 +908,24 @@ export class AdminController {
|
|||||||
@Body() dto: CreatePlatformMatchDto,
|
@Body() dto: CreatePlatformMatchDto,
|
||||||
) {
|
) {
|
||||||
const match = await this.matches.updatePlatformMatch(BigInt(id), {
|
const match = await this.matches.updatePlatformMatch(BigInt(id), {
|
||||||
leagueEn: dto.leagueEn,
|
leagueEn: dto.leagueEn ?? '',
|
||||||
leagueZh: dto.leagueZh,
|
leagueZh: dto.leagueZh ?? '',
|
||||||
|
leagueMs: dto.leagueMs,
|
||||||
homeTeamEn: dto.homeTeamEn,
|
homeTeamEn: dto.homeTeamEn,
|
||||||
homeTeamZh: dto.homeTeamZh,
|
homeTeamZh: dto.homeTeamZh,
|
||||||
|
homeTeamMs: dto.homeTeamMs,
|
||||||
awayTeamEn: dto.awayTeamEn,
|
awayTeamEn: dto.awayTeamEn,
|
||||||
awayTeamZh: dto.awayTeamZh,
|
awayTeamZh: dto.awayTeamZh,
|
||||||
|
awayTeamMs: dto.awayTeamMs,
|
||||||
startTime: new Date(dto.startTime),
|
startTime: new Date(dto.startTime),
|
||||||
isHot: dto.isHot,
|
isHot: dto.isHot,
|
||||||
displayOrder: dto.displayOrder,
|
displayOrder: dto.displayOrder,
|
||||||
|
matchName: dto.matchName,
|
||||||
|
stage: dto.stage,
|
||||||
|
groupName: dto.groupName,
|
||||||
|
leagueLogoUrl: dto.leagueLogoUrl,
|
||||||
|
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||||
|
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||||
updatedBy: operatorId,
|
updatedBy: operatorId,
|
||||||
});
|
});
|
||||||
return jsonResponse(match);
|
return jsonResponse(match);
|
||||||
@@ -787,15 +940,25 @@ export class AdminController {
|
|||||||
@Post('matches')
|
@Post('matches')
|
||||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
||||||
const match = await this.matches.createPlatformMatch({
|
const match = await this.matches.createPlatformMatch({
|
||||||
leagueEn: dto.leagueEn,
|
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
|
||||||
leagueZh: dto.leagueZh,
|
leagueEn: dto.leagueEn ?? '',
|
||||||
|
leagueZh: dto.leagueZh ?? '',
|
||||||
|
leagueMs: dto.leagueMs,
|
||||||
homeTeamEn: dto.homeTeamEn,
|
homeTeamEn: dto.homeTeamEn,
|
||||||
homeTeamZh: dto.homeTeamZh,
|
homeTeamZh: dto.homeTeamZh,
|
||||||
|
homeTeamMs: dto.homeTeamMs,
|
||||||
awayTeamEn: dto.awayTeamEn,
|
awayTeamEn: dto.awayTeamEn,
|
||||||
awayTeamZh: dto.awayTeamZh,
|
awayTeamZh: dto.awayTeamZh,
|
||||||
|
awayTeamMs: dto.awayTeamMs,
|
||||||
startTime: new Date(dto.startTime),
|
startTime: new Date(dto.startTime),
|
||||||
isHot: dto.isHot,
|
isHot: dto.isHot,
|
||||||
displayOrder: dto.displayOrder,
|
displayOrder: dto.displayOrder,
|
||||||
|
matchName: dto.matchName,
|
||||||
|
stage: dto.stage,
|
||||||
|
groupName: dto.groupName,
|
||||||
|
leagueLogoUrl: dto.leagueLogoUrl,
|
||||||
|
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||||
|
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||||
createdBy: operatorId,
|
createdBy: operatorId,
|
||||||
});
|
});
|
||||||
return jsonResponse(match);
|
return jsonResponse(match);
|
||||||
@@ -835,6 +998,48 @@ export class AdminController {
|
|||||||
return jsonResponse(markets);
|
return jsonResponse(markets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('matches/:id/odds')
|
||||||
|
async batchUpdateMatchOdds(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: BatchMatchOddsDto,
|
||||||
|
) {
|
||||||
|
const updates = dto.updates.map((u) => ({
|
||||||
|
selectionId: BigInt(u.selectionId),
|
||||||
|
odds: u.odds,
|
||||||
|
}));
|
||||||
|
const results = await this.markets.batchUpdateOdds(updates, operatorId);
|
||||||
|
return jsonResponse({ matchId: id, updated: results.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('markets/:id')
|
||||||
|
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
||||||
|
const market = await this.markets.updateMarket(BigInt(id), {
|
||||||
|
promoLabel: dto.promoLabel,
|
||||||
|
status: dto.status,
|
||||||
|
lineValue: dto.lineValue,
|
||||||
|
});
|
||||||
|
return jsonResponse(market);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('selections/:id')
|
||||||
|
async updateSelection(
|
||||||
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateSelectionDto,
|
||||||
|
) {
|
||||||
|
const selection = await this.markets.updateSelection(
|
||||||
|
BigInt(id),
|
||||||
|
{
|
||||||
|
selectionName: dto.selectionName,
|
||||||
|
odds: dto.odds,
|
||||||
|
status: dto.status,
|
||||||
|
},
|
||||||
|
operatorId,
|
||||||
|
);
|
||||||
|
return jsonResponse(selection);
|
||||||
|
}
|
||||||
|
|
||||||
@Put('selections/:id/odds')
|
@Put('selections/:id/odds')
|
||||||
async updateOdds(
|
async updateOdds(
|
||||||
@CurrentUser('id') operatorId: bigint,
|
@CurrentUser('id') operatorId: bigint,
|
||||||
|
|||||||
@@ -193,46 +193,284 @@ export class MatchesService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPlatformMatch(data: {
|
async createPlatformLeague(data: {
|
||||||
leagueEn: string;
|
leagueEn: string;
|
||||||
leagueZh: string;
|
leagueZh: string;
|
||||||
|
leagueMs?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
displayOrder?: number;
|
||||||
|
}) {
|
||||||
|
const leagueEn = data.leagueEn.trim();
|
||||||
|
const leagueZh = data.leagueZh.trim();
|
||||||
|
if (!leagueEn && !leagueZh) {
|
||||||
|
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
|
||||||
|
}
|
||||||
|
const league = await this.upsertLeagueFromZhiboExport({
|
||||||
|
type: 'FOOTBALL',
|
||||||
|
en: leagueEn || leagueZh,
|
||||||
|
zh: leagueZh || leagueEn,
|
||||||
|
});
|
||||||
|
if (data.leagueMs?.trim()) {
|
||||||
|
await this.upsertEntityTranslations('LEAGUE', league.id, {
|
||||||
|
'ms-MY': data.leagueMs.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updates: { logoUrl?: string; displayOrder?: number } = {};
|
||||||
|
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
|
||||||
|
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
||||||
|
if (Object.keys(updates).length) {
|
||||||
|
await this.prisma.league.update({ where: { id: league.id }, data: updates });
|
||||||
|
}
|
||||||
|
const [en, zh, ms] = await Promise.all([
|
||||||
|
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
|
||||||
|
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
|
||||||
|
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
|
||||||
|
]);
|
||||||
|
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } });
|
||||||
|
return {
|
||||||
|
id: fresh.id.toString(),
|
||||||
|
code: fresh.code,
|
||||||
|
logoUrl: fresh.logoUrl,
|
||||||
|
displayOrder: fresh.displayOrder,
|
||||||
|
leagueEn: en,
|
||||||
|
leagueZh: zh,
|
||||||
|
leagueMs: ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminLeagues(opts: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
const skip = (opts.page - 1) * opts.pageSize;
|
||||||
|
const kw = opts.keyword?.trim();
|
||||||
|
let idFilter: bigint[] | undefined;
|
||||||
|
|
||||||
|
if (kw || opts.status) {
|
||||||
|
const ids = new Set<bigint>();
|
||||||
|
if (kw) {
|
||||||
|
const trRows = await this.prisma.entityTranslation.findMany({
|
||||||
|
where: {
|
||||||
|
entityType: 'LEAGUE',
|
||||||
|
fieldName: 'name',
|
||||||
|
value: { contains: kw, mode: 'insensitive' },
|
||||||
|
},
|
||||||
|
select: { entityId: true },
|
||||||
|
});
|
||||||
|
for (const r of trRows) ids.add(r.entityId);
|
||||||
|
}
|
||||||
|
const matchWhere: Prisma.MatchWhereInput = {
|
||||||
|
deletedAt: null,
|
||||||
|
isOutright: false,
|
||||||
|
};
|
||||||
|
if (opts.status) matchWhere.status = opts.status;
|
||||||
|
if (kw) {
|
||||||
|
matchWhere.OR = [
|
||||||
|
{ matchName: { contains: kw, mode: 'insensitive' } },
|
||||||
|
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||||
|
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const matchLeagues = await this.prisma.match.findMany({
|
||||||
|
where: matchWhere,
|
||||||
|
select: { leagueId: true },
|
||||||
|
distinct: ['leagueId'],
|
||||||
|
});
|
||||||
|
for (const m of matchLeagues) ids.add(m.leagueId);
|
||||||
|
idFilter = [...ids];
|
||||||
|
if (!idFilter.length) {
|
||||||
|
return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where: Prisma.LeagueWhereInput = { deletedAt: null };
|
||||||
|
if (idFilter) where.id = { in: idFilter };
|
||||||
|
|
||||||
|
const [leagues, total] = await Promise.all([
|
||||||
|
this.prisma.league.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }],
|
||||||
|
skip,
|
||||||
|
take: opts.pageSize,
|
||||||
|
}),
|
||||||
|
this.prisma.league.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const items = await Promise.all(
|
||||||
|
leagues.map(async (league) => {
|
||||||
|
const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([
|
||||||
|
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
|
||||||
|
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
|
||||||
|
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
|
||||||
|
this.prisma.match.count({
|
||||||
|
where: {
|
||||||
|
leagueId: league.id,
|
||||||
|
deletedAt: null,
|
||||||
|
isOutright: false,
|
||||||
|
...(opts.status ? { status: opts.status } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
id: league.id.toString(),
|
||||||
|
code: league.code,
|
||||||
|
logoUrl: league.logoUrl,
|
||||||
|
displayOrder: league.displayOrder,
|
||||||
|
leagueEn,
|
||||||
|
leagueZh,
|
||||||
|
leagueMs,
|
||||||
|
matchCount,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items, total, page: opts.page, pageSize: opts.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAdminLeagueMatches(
|
||||||
|
leagueId: bigint,
|
||||||
|
opts: { status?: string; keyword?: string; locale?: string },
|
||||||
|
) {
|
||||||
|
const where: Prisma.MatchWhereInput = {
|
||||||
|
leagueId,
|
||||||
|
deletedAt: null,
|
||||||
|
isOutright: false,
|
||||||
|
};
|
||||||
|
if (opts.status) where.status = opts.status;
|
||||||
|
const kw = opts.keyword?.trim();
|
||||||
|
if (kw) {
|
||||||
|
where.OR = [
|
||||||
|
{ matchName: { contains: kw, mode: 'insensitive' } },
|
||||||
|
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||||
|
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const items = await this.prisma.match.findMany({
|
||||||
|
where,
|
||||||
|
include: { homeTeam: true, awayTeam: true },
|
||||||
|
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||||
|
});
|
||||||
|
const locale = opts.locale ?? 'zh-CN';
|
||||||
|
return Promise.all(
|
||||||
|
items.map(async (m) => {
|
||||||
|
const [homeTeamName, awayTeamName] = await Promise.all([
|
||||||
|
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||||
|
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
id: m.id.toString(),
|
||||||
|
status: m.status,
|
||||||
|
isOutright: m.isOutright,
|
||||||
|
isHot: m.isHot,
|
||||||
|
displayOrder: m.displayOrder,
|
||||||
|
startTime: m.startTime,
|
||||||
|
matchName: m.matchName,
|
||||||
|
homeTeamName,
|
||||||
|
awayTeamName,
|
||||||
|
homeTeam: { code: m.homeTeam.code },
|
||||||
|
awayTeam: { code: m.awayTeam.code },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPlatformMatch(data: {
|
||||||
|
leagueId?: bigint;
|
||||||
|
leagueEn?: string;
|
||||||
|
leagueZh?: string;
|
||||||
|
leagueMs?: string;
|
||||||
homeTeamZh: string;
|
homeTeamZh: string;
|
||||||
homeTeamEn: string;
|
homeTeamEn: string;
|
||||||
|
homeTeamMs?: string;
|
||||||
awayTeamZh: string;
|
awayTeamZh: string;
|
||||||
awayTeamEn: string;
|
awayTeamEn: string;
|
||||||
|
awayTeamMs?: string;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
isHot?: boolean;
|
isHot?: boolean;
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
matchName?: string;
|
||||||
|
stage?: string;
|
||||||
|
groupName?: string;
|
||||||
|
leagueLogoUrl?: string;
|
||||||
|
homeTeamLogoUrl?: string;
|
||||||
|
awayTeamLogoUrl?: string;
|
||||||
createdBy?: bigint;
|
createdBy?: bigint;
|
||||||
}) {
|
}) {
|
||||||
const homeEn = data.homeTeamEn.trim();
|
const homeEn = data.homeTeamEn.trim();
|
||||||
const homeZh = data.homeTeamZh.trim();
|
const homeZh = data.homeTeamZh.trim();
|
||||||
|
const homeMs = data.homeTeamMs?.trim() ?? '';
|
||||||
const awayEn = data.awayTeamEn.trim();
|
const awayEn = data.awayTeamEn.trim();
|
||||||
const awayZh = data.awayTeamZh.trim();
|
const awayZh = data.awayTeamZh.trim();
|
||||||
if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) {
|
const awayMs = data.awayTeamMs?.trim() ?? '';
|
||||||
throw new BadRequestException('请填写主客队中英文名至少各一项');
|
if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) {
|
||||||
|
throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const league = await this.upsertLeagueFromZhiboExport({
|
let league;
|
||||||
type: 'FOOTBALL',
|
if (data.leagueId) {
|
||||||
en: data.leagueEn.trim(),
|
league = await this.prisma.league.findFirst({
|
||||||
zh: data.leagueZh.trim(),
|
where: { id: data.leagueId, deletedAt: null },
|
||||||
});
|
});
|
||||||
|
if (!league) throw new NotFoundException('赛事不存在');
|
||||||
|
} else {
|
||||||
|
const leagueEn = data.leagueEn?.trim() ?? '';
|
||||||
|
const leagueZh = data.leagueZh?.trim() ?? '';
|
||||||
|
const leagueMs = data.leagueMs?.trim() ?? '';
|
||||||
|
if (!leagueEn && !leagueZh && !leagueMs) {
|
||||||
|
throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)');
|
||||||
|
}
|
||||||
|
league = await this.upsertLeagueFromZhiboExport({
|
||||||
|
type: 'FOOTBALL',
|
||||||
|
en: leagueEn || leagueZh || leagueMs,
|
||||||
|
zh: leagueZh || leagueEn || leagueMs,
|
||||||
|
});
|
||||||
|
if (leagueMs) {
|
||||||
|
await this.upsertEntityTranslations('LEAGUE', league.id, {
|
||||||
|
'ms-MY': leagueMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.leagueLogoUrl?.trim()) {
|
||||||
|
await this.prisma.league.update({
|
||||||
|
where: { id: league.id },
|
||||||
|
data: { logoUrl: data.leagueLogoUrl.trim() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const [homeTeam, awayTeam] = await Promise.all([
|
const [homeTeam, awayTeam] = await Promise.all([
|
||||||
this.upsertTeamFromZhiboExport({
|
this.upsertTeamFromZhiboExport({
|
||||||
id: null,
|
id: null,
|
||||||
name: homeEn || homeZh,
|
name: homeEn || homeZh || homeMs,
|
||||||
names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null },
|
names: {
|
||||||
image: '',
|
zh: homeZh || null,
|
||||||
|
en: homeEn || null,
|
||||||
|
zhTw: '',
|
||||||
|
vi: null,
|
||||||
|
km: null,
|
||||||
|
ms: homeMs || null,
|
||||||
|
},
|
||||||
|
image: data.homeTeamLogoUrl?.trim() || '',
|
||||||
}),
|
}),
|
||||||
this.upsertTeamFromZhiboExport({
|
this.upsertTeamFromZhiboExport({
|
||||||
id: null,
|
id: null,
|
||||||
name: awayEn || awayZh,
|
name: awayEn || awayZh || awayMs,
|
||||||
names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null },
|
names: {
|
||||||
image: '',
|
zh: awayZh || null,
|
||||||
|
en: awayEn || null,
|
||||||
|
zhTw: '',
|
||||||
|
vi: null,
|
||||||
|
km: null,
|
||||||
|
ms: awayMs || null,
|
||||||
|
},
|
||||||
|
image: data.awayTeamLogoUrl?.trim() || '',
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const matchName =
|
||||||
|
data.matchName?.trim() ||
|
||||||
|
`${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`;
|
||||||
|
|
||||||
return this.createMatch({
|
return this.createMatch({
|
||||||
leagueId: league.id,
|
leagueId: league.id,
|
||||||
homeTeamId: homeTeam.id,
|
homeTeamId: homeTeam.id,
|
||||||
@@ -243,7 +481,9 @@ export class MatchesService {
|
|||||||
createdBy: data.createdBy,
|
createdBy: data.createdBy,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
zhibo: {
|
zhibo: {
|
||||||
matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`,
|
matchName,
|
||||||
|
stage: data.stage?.trim() || undefined,
|
||||||
|
groupName: data.groupName?.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -251,7 +491,7 @@ export class MatchesService {
|
|||||||
private async requireAdminMatch(matchId: bigint) {
|
private async requireAdminMatch(matchId: bigint) {
|
||||||
const match = await this.prisma.match.findFirst({
|
const match = await this.prisma.match.findFirst({
|
||||||
where: { id: matchId, deletedAt: null },
|
where: { id: matchId, deletedAt: null },
|
||||||
include: { homeTeam: true, awayTeam: true },
|
include: { homeTeam: true, awayTeam: true, league: true },
|
||||||
});
|
});
|
||||||
if (!match) throw new NotFoundException('赛事不存在');
|
if (!match) throw new NotFoundException('赛事不存在');
|
||||||
return match;
|
return match;
|
||||||
@@ -259,27 +499,66 @@ export class MatchesService {
|
|||||||
|
|
||||||
async getAdminMatchDetail(matchId: bigint) {
|
async getAdminMatchDetail(matchId: bigint) {
|
||||||
const match = await this.requireAdminMatch(matchId);
|
const match = await this.requireAdminMatch(matchId);
|
||||||
const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([
|
const markets = await this.prisma.market.findMany({
|
||||||
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
where: { matchId },
|
||||||
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||||
this.getTranslation('TEAM', match.homeTeamId, 'en-US'),
|
orderBy: { sortOrder: 'asc' },
|
||||||
this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'),
|
});
|
||||||
this.getTranslation('TEAM', match.awayTeamId, 'en-US'),
|
const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] =
|
||||||
this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'),
|
await Promise.all([
|
||||||
|
this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'),
|
||||||
|
this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'),
|
||||||
|
this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'),
|
||||||
|
this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'),
|
||||||
|
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
|
||||||
|
this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'),
|
||||||
|
this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'),
|
||||||
|
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
|
||||||
|
this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
id: match.id.toString(),
|
id: match.id.toString(),
|
||||||
status: match.status,
|
status: match.status,
|
||||||
isOutright: match.isOutright,
|
isOutright: match.isOutright,
|
||||||
isHot: match.isHot,
|
isHot: match.isHot,
|
||||||
|
displayOrder: match.displayOrder,
|
||||||
startTime: match.startTime.toISOString(),
|
startTime: match.startTime.toISOString(),
|
||||||
|
leagueId: match.leagueId.toString(),
|
||||||
|
leagueCode: match.league.code,
|
||||||
leagueEn,
|
leagueEn,
|
||||||
leagueZh,
|
leagueZh,
|
||||||
|
leagueMs,
|
||||||
|
leagueLogoUrl: match.league.logoUrl ?? '',
|
||||||
homeTeamEn: homeEn,
|
homeTeamEn: homeEn,
|
||||||
homeTeamZh: homeZh,
|
homeTeamZh: homeZh,
|
||||||
|
homeTeamMs: homeMs,
|
||||||
|
homeTeamCode: match.homeTeam.code,
|
||||||
|
homeTeamLogoUrl: match.homeTeam.logoUrl ?? '',
|
||||||
awayTeamEn: awayEn,
|
awayTeamEn: awayEn,
|
||||||
awayTeamZh: awayZh,
|
awayTeamZh: awayZh,
|
||||||
|
awayTeamMs: awayMs,
|
||||||
|
awayTeamCode: match.awayTeam.code,
|
||||||
|
awayTeamLogoUrl: match.awayTeam.logoUrl ?? '',
|
||||||
matchName: match.matchName ?? '',
|
matchName: match.matchName ?? '',
|
||||||
|
stage: match.stage ?? '',
|
||||||
|
groupName: match.groupName ?? '',
|
||||||
|
markets: markets.map((m) => ({
|
||||||
|
id: m.id.toString(),
|
||||||
|
marketType: m.marketType,
|
||||||
|
period: m.period,
|
||||||
|
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||||||
|
status: m.status,
|
||||||
|
promoLabel: m.promoLabel ?? '',
|
||||||
|
sortOrder: m.sortOrder,
|
||||||
|
selections: m.selections.map((s) => ({
|
||||||
|
id: s.id.toString(),
|
||||||
|
selectionCode: s.selectionCode,
|
||||||
|
selectionName: s.selectionName,
|
||||||
|
odds: Number(s.odds),
|
||||||
|
status: s.status,
|
||||||
|
sortOrder: s.sortOrder,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,13 +567,22 @@ export class MatchesService {
|
|||||||
data: {
|
data: {
|
||||||
leagueEn: string;
|
leagueEn: string;
|
||||||
leagueZh: string;
|
leagueZh: string;
|
||||||
|
leagueMs?: string;
|
||||||
homeTeamZh: string;
|
homeTeamZh: string;
|
||||||
homeTeamEn: string;
|
homeTeamEn: string;
|
||||||
|
homeTeamMs?: string;
|
||||||
awayTeamZh: string;
|
awayTeamZh: string;
|
||||||
awayTeamEn: string;
|
awayTeamEn: string;
|
||||||
|
awayTeamMs?: string;
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
isHot?: boolean;
|
isHot?: boolean;
|
||||||
displayOrder?: number;
|
displayOrder?: number;
|
||||||
|
matchName?: string;
|
||||||
|
stage?: string;
|
||||||
|
groupName?: string;
|
||||||
|
leagueLogoUrl?: string;
|
||||||
|
homeTeamLogoUrl?: string;
|
||||||
|
awayTeamLogoUrl?: string;
|
||||||
updatedBy?: bigint;
|
updatedBy?: bigint;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -306,23 +594,55 @@ export class MatchesService {
|
|||||||
throw new BadRequestException('当前状态不可编辑');
|
throw new BadRequestException('当前状态不可编辑');
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`;
|
const matchName =
|
||||||
|
data.matchName?.trim() ||
|
||||||
|
`${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
|
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
|
||||||
'zh-CN': data.leagueZh.trim(),
|
'zh-CN': data.leagueZh.trim(),
|
||||||
'en-US': data.leagueEn.trim(),
|
'en-US': data.leagueEn.trim(),
|
||||||
|
'ms-MY': (data.leagueMs ?? '').trim(),
|
||||||
}),
|
}),
|
||||||
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
|
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
|
||||||
'zh-CN': data.homeTeamZh.trim(),
|
'zh-CN': data.homeTeamZh.trim(),
|
||||||
'en-US': data.homeTeamEn.trim(),
|
'en-US': data.homeTeamEn.trim(),
|
||||||
|
'ms-MY': (data.homeTeamMs ?? '').trim(),
|
||||||
}),
|
}),
|
||||||
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
|
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
|
||||||
'zh-CN': data.awayTeamZh.trim(),
|
'zh-CN': data.awayTeamZh.trim(),
|
||||||
'en-US': data.awayTeamEn.trim(),
|
'en-US': data.awayTeamEn.trim(),
|
||||||
|
'ms-MY': (data.awayTeamMs ?? '').trim(),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const logoUpdates: Promise<unknown>[] = [];
|
||||||
|
if (data.leagueLogoUrl !== undefined) {
|
||||||
|
logoUpdates.push(
|
||||||
|
this.prisma.league.update({
|
||||||
|
where: { id: match.leagueId },
|
||||||
|
data: { logoUrl: data.leagueLogoUrl.trim() || null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.homeTeamLogoUrl !== undefined) {
|
||||||
|
logoUpdates.push(
|
||||||
|
this.prisma.team.update({
|
||||||
|
where: { id: match.homeTeamId },
|
||||||
|
data: { logoUrl: data.homeTeamLogoUrl.trim() || null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.awayTeamLogoUrl !== undefined) {
|
||||||
|
logoUpdates.push(
|
||||||
|
this.prisma.team.update({
|
||||||
|
where: { id: match.awayTeamId },
|
||||||
|
data: { logoUrl: data.awayTeamLogoUrl.trim() || null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (logoUpdates.length) await Promise.all(logoUpdates);
|
||||||
|
|
||||||
return this.prisma.match.update({
|
return this.prisma.match.update({
|
||||||
where: { id: matchId },
|
where: { id: matchId },
|
||||||
data: {
|
data: {
|
||||||
@@ -330,6 +650,8 @@ export class MatchesService {
|
|||||||
isHot: data.isHot ?? match.isHot,
|
isHot: data.isHot ?? match.isHot,
|
||||||
displayOrder: data.displayOrder ?? match.displayOrder,
|
displayOrder: data.displayOrder ?? match.displayOrder,
|
||||||
matchName,
|
matchName,
|
||||||
|
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
||||||
|
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
||||||
updatedBy: data.updatedBy,
|
updatedBy: data.updatedBy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -484,6 +806,13 @@ export class MatchesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
|
||||||
|
const row = await this.prisma.entityTranslation.findFirst({
|
||||||
|
where: { entityType, entityId, locale, fieldName: 'name' },
|
||||||
|
});
|
||||||
|
return row?.value ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async getTranslation(entityType: string, entityId: bigint, locale: string) {
|
async getTranslation(entityType: string, entityId: bigint, locale: string) {
|
||||||
const translations = await this.prisma.entityTranslation.findMany({
|
const translations = await this.prisma.entityTranslation.findMany({
|
||||||
where: { entityType, entityId },
|
where: { entityType, entityId },
|
||||||
@@ -500,25 +829,63 @@ export class MatchesService {
|
|||||||
leagueId: bigint;
|
leagueId: bigint;
|
||||||
homeTeamId: bigint;
|
homeTeamId: bigint;
|
||||||
awayTeamId: bigint;
|
awayTeamId: bigint;
|
||||||
homeTeam?: { code: string };
|
startTime: Date;
|
||||||
awayTeam?: { code: string };
|
status?: string;
|
||||||
markets?: unknown[];
|
isHot?: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
matchName?: string | null;
|
||||||
|
stage?: string | null;
|
||||||
|
groupName?: string | null;
|
||||||
|
homeTeam?: { code: string; logoUrl?: string | null };
|
||||||
|
awayTeam?: { code: string; logoUrl?: string | null };
|
||||||
|
league?: { logoUrl?: string | null };
|
||||||
|
markets?: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
const [leagueName, homeName, awayName] = await Promise.all([
|
const [leagueName, homeName, awayName] = await Promise.all([
|
||||||
this.getTranslation('LEAGUE', m.leagueId, locale),
|
this.getTranslation('LEAGUE', m.leagueId, locale),
|
||||||
this.getTranslation('TEAM', m.homeTeamId, locale),
|
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||||
this.getTranslation('TEAM', m.awayTeamId, locale),
|
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||||
]);
|
]);
|
||||||
return {
|
const base = {
|
||||||
...match,
|
|
||||||
id: m.id.toString(),
|
id: m.id.toString(),
|
||||||
leagueId: m.leagueId.toString(),
|
leagueId: m.leagueId.toString(),
|
||||||
leagueName,
|
leagueName,
|
||||||
|
leagueLogoUrl: m.league?.logoUrl ?? null,
|
||||||
homeTeamName: homeName,
|
homeTeamName: homeName,
|
||||||
awayTeamName: awayName,
|
awayTeamName: awayName,
|
||||||
homeTeamCode: m.homeTeam?.code ?? '',
|
homeTeamCode: m.homeTeam?.code ?? '',
|
||||||
awayTeamCode: m.awayTeam?.code ?? '',
|
awayTeamCode: m.awayTeam?.code ?? '',
|
||||||
|
homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null,
|
||||||
|
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
||||||
|
startTime: m.startTime.toISOString(),
|
||||||
|
isHot: m.isHot ?? false,
|
||||||
|
displayOrder: m.displayOrder ?? 0,
|
||||||
|
matchName: m.matchName ?? null,
|
||||||
|
stage: m.stage ?? null,
|
||||||
|
groupName: m.groupName ?? null,
|
||||||
|
status: m.status ?? 'PUBLISHED',
|
||||||
};
|
};
|
||||||
|
if (m.markets) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
markets: m.markets.map((market) => ({
|
||||||
|
id: (market.id as bigint).toString(),
|
||||||
|
marketType: market.marketType as string,
|
||||||
|
period: market.period as string,
|
||||||
|
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||||
|
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||||
|
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
|
||||||
|
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
||||||
|
id: (s.id as bigint).toString(),
|
||||||
|
selectionCode: s.selectionCode as string,
|
||||||
|
selectionName: s.selectionName as string,
|
||||||
|
odds: Number(s.odds),
|
||||||
|
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||||
@@ -528,27 +895,37 @@ export class MatchesService {
|
|||||||
status: 'PUBLISHED',
|
status: 'PUBLISHED',
|
||||||
isOutright: false,
|
isOutright: false,
|
||||||
sportType: 'FOOTBALL',
|
sportType: 'FOOTBALL',
|
||||||
|
deletedAt: null,
|
||||||
startTime: { gt: now },
|
startTime: { gt: now },
|
||||||
...(leagueId ? { leagueId } : {}),
|
...(leagueId ? { leagueId } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
|
league: true,
|
||||||
homeTeam: true,
|
homeTeam: true,
|
||||||
awayTeam: true,
|
awayTeam: true,
|
||||||
markets: {
|
markets: {
|
||||||
where: { status: 'OPEN' },
|
where: { status: 'OPEN' },
|
||||||
include: { selections: { where: { status: 'OPEN' } } },
|
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
|
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||||
const match = await this.prisma.match.findUnique({
|
const match = await this.prisma.match.findFirst({
|
||||||
where: { id: matchId },
|
where: {
|
||||||
|
id: matchId,
|
||||||
|
deletedAt: null,
|
||||||
|
sportType: 'FOOTBALL',
|
||||||
|
isOutright: false,
|
||||||
|
status: { in: ['PUBLISHED', 'CLOSED'] },
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
|
league: true,
|
||||||
homeTeam: true,
|
homeTeam: true,
|
||||||
awayTeam: true,
|
awayTeam: true,
|
||||||
markets: {
|
markets: {
|
||||||
@@ -560,9 +937,6 @@ export class MatchesService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!match) throw new NotFoundException('Match not found');
|
if (!match) throw new NotFoundException('Match not found');
|
||||||
if (match.sportType !== 'FOOTBALL') {
|
|
||||||
throw new NotFoundException('Match not found');
|
|
||||||
}
|
|
||||||
return this.enrichMatch(match, locale);
|
return this.enrichMatch(match, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ export class MarketsService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
|
||||||
|
const sideLabel = side === 'home' ? '主队' : '客队';
|
||||||
|
const value = side === 'home' ? line : -line;
|
||||||
|
const lineText = value > 0 ? `+${value}` : `${value}`;
|
||||||
|
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatOuName(side: 'over' | 'under', line: number, half = false) {
|
||||||
|
const sideLabel = side === 'over' ? '大' : '小';
|
||||||
|
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatScoreName(code: string) {
|
||||||
|
return code.replace('SCORE_', '').replace('_', '-');
|
||||||
|
}
|
||||||
|
|
||||||
private getMarketConfig(marketType: string) {
|
private getMarketConfig(marketType: string) {
|
||||||
const configs: Record<string, {
|
const configs: Record<string, {
|
||||||
period: string;
|
period: string;
|
||||||
@@ -62,9 +78,9 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 1,
|
sortOrder: 1,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'HOME', name: 'Home', odds: 2.5 },
|
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||||
{ code: 'DRAW', name: 'Draw', odds: 3.2 },
|
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||||
{ code: 'AWAY', name: 'Away', odds: 2.8 },
|
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HT_1X2: {
|
HT_1X2: {
|
||||||
@@ -72,9 +88,9 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 5,
|
sortOrder: 5,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'HOME', name: 'HT Home', odds: 3.0 },
|
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
|
||||||
{ code: 'DRAW', name: 'HT Draw', odds: 2.0 },
|
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||||
{ code: 'AWAY', name: 'HT Away', odds: 3.5 },
|
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
FT_HANDICAP: {
|
FT_HANDICAP: {
|
||||||
@@ -83,8 +99,8 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 2,
|
sortOrder: 2,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'HOME', name: 'Home -0.5', odds: 1.9 },
|
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
|
||||||
{ code: 'AWAY', name: 'Away +0.5', odds: 1.9 },
|
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HT_HANDICAP: {
|
HT_HANDICAP: {
|
||||||
@@ -93,8 +109,8 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 6,
|
sortOrder: 6,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'HOME', name: 'HT Home -0.5', odds: 1.9 },
|
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
|
||||||
{ code: 'AWAY', name: 'HT Away +0.5', odds: 1.9 },
|
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
FT_OVER_UNDER: {
|
FT_OVER_UNDER: {
|
||||||
@@ -103,8 +119,8 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 3,
|
sortOrder: 3,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'OVER', name: 'Over 2.5', odds: 1.85 },
|
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
|
||||||
{ code: 'UNDER', name: 'Under 2.5', odds: 1.95 },
|
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
HT_OVER_UNDER: {
|
HT_OVER_UNDER: {
|
||||||
@@ -113,8 +129,8 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 7,
|
sortOrder: 7,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'OVER', name: 'HT Over 1.5', odds: 2.0 },
|
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
|
||||||
{ code: 'UNDER', name: 'HT Under 1.5', odds: 1.75 },
|
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
FT_ODD_EVEN: {
|
FT_ODD_EVEN: {
|
||||||
@@ -122,8 +138,8 @@ export class MarketsService {
|
|||||||
allowParlay: true,
|
allowParlay: true,
|
||||||
sortOrder: 4,
|
sortOrder: 4,
|
||||||
selections: [
|
selections: [
|
||||||
{ code: 'ODD', name: 'Odd', odds: 1.9 },
|
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||||
{ code: 'EVEN', name: 'Even', odds: 1.9 },
|
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
FT_CORRECT_SCORE: {
|
FT_CORRECT_SCORE: {
|
||||||
@@ -132,7 +148,7 @@ export class MarketsService {
|
|||||||
sortOrder: 8,
|
sortOrder: 8,
|
||||||
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||||
code,
|
code,
|
||||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
name: this.formatScoreName(code),
|
||||||
odds: 8.0,
|
odds: 8.0,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -142,7 +158,7 @@ export class MarketsService {
|
|||||||
sortOrder: 9,
|
sortOrder: 9,
|
||||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||||
code,
|
code,
|
||||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
name: this.formatScoreName(code),
|
||||||
odds: 6.0,
|
odds: 6.0,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -152,7 +168,7 @@ export class MarketsService {
|
|||||||
sortOrder: 10,
|
sortOrder: 10,
|
||||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||||
code,
|
code,
|
||||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
name: this.formatScoreName(code),
|
||||||
odds: 6.0,
|
odds: 6.0,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -206,4 +222,45 @@ export class MarketsService {
|
|||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMarket(
|
||||||
|
marketId: bigint,
|
||||||
|
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
|
||||||
|
) {
|
||||||
|
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
|
||||||
|
if (!market) throw new NotFoundException('Market not found');
|
||||||
|
|
||||||
|
return this.prisma.market.update({
|
||||||
|
where: { id: marketId },
|
||||||
|
data: {
|
||||||
|
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
|
||||||
|
...(data.status !== undefined ? { status: data.status } : {}),
|
||||||
|
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSelection(
|
||||||
|
selectionId: bigint,
|
||||||
|
data: { selectionName?: string; odds?: number; status?: string },
|
||||||
|
operatorId?: bigint,
|
||||||
|
) {
|
||||||
|
const selection = await this.prisma.marketSelection.findUnique({
|
||||||
|
where: { id: selectionId },
|
||||||
|
});
|
||||||
|
if (!selection) throw new NotFoundException('Selection not found');
|
||||||
|
|
||||||
|
if (data.odds != null) {
|
||||||
|
if (!operatorId) throw new BadRequestException('Operator required for odds update');
|
||||||
|
return this.updateOdds(selectionId, data.odds, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.marketSelection.update({
|
||||||
|
where: { id: selectionId },
|
||||||
|
data: {
|
||||||
|
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
|
||||||
|
...(data.status !== undefined ? { status: data.status } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 5173",
|
"dev": "vite --port 5173 --host",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
apps/player/src/assets/images/卡片.png
Normal file
BIN
apps/player/src/assets/images/卡片.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -5,6 +5,7 @@ import saishiImg from '../assets/images/saishi.png';
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
|
leagueLogoUrl?: string | null;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
matches: {
|
matches: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,6 +13,8 @@ defineProps<{
|
|||||||
awayTeamName: string;
|
awayTeamName: string;
|
||||||
homeTeamCode?: string;
|
homeTeamCode?: string;
|
||||||
awayTeamCode?: string;
|
awayTeamCode?: string;
|
||||||
|
homeTeamLogoUrl?: string | null;
|
||||||
|
awayTeamLogoUrl?: string | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
}[];
|
}[];
|
||||||
}>();
|
}>();
|
||||||
@@ -26,7 +29,11 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
|||||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="league-title">*{{ leagueName }}</span>
|
<span class="league-title">*{{ leagueName }}</span>
|
||||||
<img :src="saishiImg" alt="" class="league-saishi" />
|
<img
|
||||||
|
:src="leagueLogoUrl || saishiImg"
|
||||||
|
alt=""
|
||||||
|
class="league-saishi"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="expanded" class="match-panel">
|
<div v-show="expanded" class="match-panel">
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const props = defineProps<{
|
|||||||
awayTeamName: string;
|
awayTeamName: string;
|
||||||
homeTeamCode?: string;
|
homeTeamCode?: string;
|
||||||
awayTeamCode?: string;
|
awayTeamCode?: string;
|
||||||
|
homeTeamLogoUrl?: string | null;
|
||||||
|
awayTeamLogoUrl?: string | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
@@ -29,8 +31,12 @@ const kickoff = computed(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const homeFlag = computed(() => teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName));
|
const homeFlag = computed(() =>
|
||||||
const awayFlag = computed(() => teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName));
|
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
|
||||||
|
);
|
||||||
|
const awayFlag = computed(() =>
|
||||||
|
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const columns = computed(() => groupCorrectScoreSelections(props.selections, props.marketType));
|
const columns = computed(() =>
|
||||||
|
groupCorrectScoreSelections(props.selections, props.marketType, t),
|
||||||
|
);
|
||||||
|
|
||||||
function setStake(sel: CsSelection, raw: string) {
|
function setStake(sel: CsSelection, raw: string) {
|
||||||
const n = Math.max(0, Number(raw) || 0);
|
const n = Math.max(0, Number(raw) || 0);
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { resolveSelectionLabel } from '../../utils/selectionLabel';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
selections: {
|
selections: {
|
||||||
id: string;
|
id: string;
|
||||||
|
selectionCode?: string;
|
||||||
selectionName: string;
|
selectionName: string;
|
||||||
odds: string;
|
odds: string;
|
||||||
}[];
|
}[];
|
||||||
@@ -10,6 +14,14 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ pick: [id: string] }>();
|
const emit = defineEmits<{ pick: [id: string] }>();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
function label(sel: (typeof props.selections)[number]) {
|
||||||
|
if (sel.selectionCode) {
|
||||||
|
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName);
|
||||||
|
}
|
||||||
|
return sel.selectionName;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -23,7 +35,7 @@ const emit = defineEmits<{ pick: [id: string] }>();
|
|||||||
:class="{ selected: isSelected(sel.id) }"
|
:class="{ selected: isSelected(sel.id) }"
|
||||||
@click="emit('pick', sel.id)"
|
@click="emit('pick', sel.id)"
|
||||||
>
|
>
|
||||||
<span class="label">{{ sel.selectionName }}</span>
|
<span class="label">{{ label(sel) }}</span>
|
||||||
<span class="odds">{{ sel.odds }}</span>
|
<span class="odds">{{ sel.odds }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label: string;
|
label: string;
|
||||||
|
promoLabel?: string;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
hasMarket: boolean;
|
hasMarket: boolean;
|
||||||
}>();
|
}>();
|
||||||
@@ -20,6 +21,7 @@ const { t } = useI18n();
|
|||||||
@click="emit('toggle')"
|
@click="emit('toggle')"
|
||||||
>
|
>
|
||||||
<span class="row-label">{{ label }}</span>
|
<span class="row-label">{{ label }}</span>
|
||||||
|
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
|
||||||
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
|
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
|
||||||
<span v-else class="row-chevron" aria-hidden="true">{{ expanded ? '▾' : '▸' }}</span>
|
<span v-else class="row-chevron" aria-hidden="true">{{ expanded ? '▾' : '▸' }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -53,6 +55,17 @@ const { t } = useI18n();
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row-promo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffb800;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 184, 0, 0.12);
|
||||||
|
border: 1px solid rgba(255, 184, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.row.expanded .row-label {
|
.row.expanded .row-label {
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
|||||||
|
|
||||||
export interface PlayerHomeMatch {
|
export interface PlayerHomeMatch {
|
||||||
id: string;
|
id: string;
|
||||||
|
leagueName?: string;
|
||||||
|
leagueLogoUrl?: string | null;
|
||||||
homeTeamName: string;
|
homeTeamName: string;
|
||||||
awayTeamName: string;
|
awayTeamName: string;
|
||||||
homeTeamCode?: string;
|
homeTeamCode?: string;
|
||||||
awayTeamCode?: string;
|
awayTeamCode?: string;
|
||||||
|
homeTeamLogoUrl?: string | null;
|
||||||
|
awayTeamLogoUrl?: string | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
isHot?: boolean;
|
isHot?: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HomePayload {
|
interface HomePayload {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ const i18n = createI18n({
|
|||||||
parlay_sel_under: '小',
|
parlay_sel_under: '小',
|
||||||
parlay_sel_odd: '单',
|
parlay_sel_odd: '单',
|
||||||
parlay_sel_even: '双',
|
parlay_sel_even: '双',
|
||||||
|
cs_other_home: '主胜其它比分',
|
||||||
|
cs_other_draw: '和局其它比分',
|
||||||
|
cs_other_away: '客胜其它比分',
|
||||||
col_home: '主场',
|
col_home: '主场',
|
||||||
col_draw: '平',
|
col_draw: '平',
|
||||||
col_away: '客场',
|
col_away: '客场',
|
||||||
@@ -335,6 +338,9 @@ const i18n = createI18n({
|
|||||||
parlay_sel_under: 'U',
|
parlay_sel_under: 'U',
|
||||||
parlay_sel_odd: 'Odd',
|
parlay_sel_odd: 'Odd',
|
||||||
parlay_sel_even: 'Even',
|
parlay_sel_even: 'Even',
|
||||||
|
cs_other_home: 'Home win (other score)',
|
||||||
|
cs_other_draw: 'Draw (other score)',
|
||||||
|
cs_other_away: 'Away win (other score)',
|
||||||
col_home: 'Home',
|
col_home: 'Home',
|
||||||
col_draw: 'Draw',
|
col_draw: 'Draw',
|
||||||
col_away: 'Away',
|
col_away: 'Away',
|
||||||
@@ -550,6 +556,9 @@ const i18n = createI18n({
|
|||||||
parlay_sel_under: 'Bwh',
|
parlay_sel_under: 'Bwh',
|
||||||
parlay_sel_odd: 'G',
|
parlay_sel_odd: 'G',
|
||||||
parlay_sel_even: 'Gn',
|
parlay_sel_even: 'Gn',
|
||||||
|
cs_other_home: 'Menang rumah (skor lain)',
|
||||||
|
cs_other_draw: 'Seri (skor lain)',
|
||||||
|
cs_other_away: 'Menang pelawat (skor lain)',
|
||||||
col_home: 'Home',
|
col_home: 'Home',
|
||||||
col_draw: 'Seri',
|
col_draw: 'Seri',
|
||||||
col_away: 'Away',
|
col_away: 'Away',
|
||||||
|
|||||||
@@ -33,10 +33,39 @@ function orderForMarket(marketType: string) {
|
|||||||
return HT_CORRECT_SCORE_ORDER;
|
return HT_CORRECT_SCORE_ORDER;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseScoreCode(code: string): { display: string; column: CsColumn } | null {
|
const OTHER_SCORE_FALLBACK: Record<string, string> = {
|
||||||
if (code === 'OTHER_HOME') return { display: '其它', column: 'home' };
|
OTHER_HOME: '主胜其它比分',
|
||||||
if (code === 'OTHER_DRAW') return { display: '其它', column: 'draw' };
|
OTHER_DRAW: '和局其它比分',
|
||||||
if (code === 'OTHER_AWAY') return { display: '其它', column: 'away' };
|
OTHER_AWAY: '客胜其它比分',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OtherScoreCode = 'OTHER_HOME' | 'OTHER_DRAW' | 'OTHER_AWAY';
|
||||||
|
|
||||||
|
export function otherScoreDisplay(
|
||||||
|
code: OtherScoreCode,
|
||||||
|
t?: (key: string) => string,
|
||||||
|
): string {
|
||||||
|
if (t) {
|
||||||
|
const key = `bet.cs_${code.toLowerCase()}`;
|
||||||
|
const v = t(key);
|
||||||
|
if (v !== key) return v;
|
||||||
|
}
|
||||||
|
return OTHER_SCORE_FALLBACK[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScoreCode(
|
||||||
|
code: string,
|
||||||
|
t?: (key: string) => string,
|
||||||
|
): { display: string; column: CsColumn } | null {
|
||||||
|
if (code === 'OTHER_HOME') {
|
||||||
|
return { display: otherScoreDisplay('OTHER_HOME', t), column: 'home' };
|
||||||
|
}
|
||||||
|
if (code === 'OTHER_DRAW') {
|
||||||
|
return { display: otherScoreDisplay('OTHER_DRAW', t), column: 'draw' };
|
||||||
|
}
|
||||||
|
if (code === 'OTHER_AWAY') {
|
||||||
|
return { display: otherScoreDisplay('OTHER_AWAY', t), column: 'away' };
|
||||||
|
}
|
||||||
const m = code.match(/^SCORE_(\d+)_(\d+)$/);
|
const m = code.match(/^SCORE_(\d+)_(\d+)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const h = Number(m[1]);
|
const h = Number(m[1]);
|
||||||
@@ -63,6 +92,7 @@ export function groupCorrectScoreSelections(
|
|||||||
oddsVersion: string;
|
oddsVersion: string;
|
||||||
}>,
|
}>,
|
||||||
marketType: string,
|
marketType: string,
|
||||||
|
t?: (key: string) => string,
|
||||||
) {
|
) {
|
||||||
const template = orderForMarket(marketType);
|
const template = orderForMarket(marketType);
|
||||||
const home: CsSelection[] = [];
|
const home: CsSelection[] = [];
|
||||||
@@ -70,7 +100,7 @@ export function groupCorrectScoreSelections(
|
|||||||
const away: CsSelection[] = [];
|
const away: CsSelection[] = [];
|
||||||
|
|
||||||
for (const sel of selections) {
|
for (const sel of selections) {
|
||||||
const parsed = parseScoreCode(sel.selectionCode);
|
const parsed = parseScoreCode(sel.selectionCode, t);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
const row: CsSelection = { ...sel, scoreDisplay: parsed.display };
|
const row: CsSelection = { ...sel, scoreDisplay: parsed.display };
|
||||||
if (parsed.column === 'home') home.push(row);
|
if (parsed.column === 'home') home.push(row);
|
||||||
|
|||||||
28
apps/player/src/utils/selectionLabel.ts
Normal file
28
apps/player/src/utils/selectionLabel.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { parseScoreCode } from './correctScoreLayout';
|
||||||
|
|
||||||
|
const CODE_I18N: Record<string, string> = {
|
||||||
|
HOME: 'parlay_sel_home',
|
||||||
|
AWAY: 'parlay_sel_away',
|
||||||
|
DRAW: 'parlay_sel_draw',
|
||||||
|
OVER: 'parlay_sel_over',
|
||||||
|
UNDER: 'parlay_sel_under',
|
||||||
|
ODD: 'parlay_sel_odd',
|
||||||
|
EVEN: 'parlay_sel_even',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 标准选项按 code 显示固定文案,不依赖后台手填的 selectionName */
|
||||||
|
export function resolveSelectionLabel(
|
||||||
|
t: (key: string) => string,
|
||||||
|
code: string,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
const i18nKey = CODE_I18N[code];
|
||||||
|
if (i18nKey) {
|
||||||
|
const fullKey = `bet.${i18nKey}`;
|
||||||
|
const v = t(fullKey);
|
||||||
|
if (v !== fullKey) return v;
|
||||||
|
}
|
||||||
|
const parsed = parseScoreCode(code, t);
|
||||||
|
if (parsed) return parsed.display;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -159,7 +159,12 @@ const NAME_TO_ISO: Record<string, string> = {
|
|||||||
切尔西: 'gb',
|
切尔西: 'gb',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function teamFlagUrl(code?: string, name?: string): string {
|
export function teamFlagUrl(
|
||||||
|
code?: string,
|
||||||
|
name?: string,
|
||||||
|
logoUrl?: string | null,
|
||||||
|
): string {
|
||||||
|
if (logoUrl?.trim()) return logoUrl.trim();
|
||||||
const key = (code ?? '').toUpperCase();
|
const key = (code ?? '').toUpperCase();
|
||||||
if (key && CODE_TO_ISO[key]) {
|
if (key && CODE_TO_ISO[key]) {
|
||||||
return `https://flagcdn.com/w40/${CODE_TO_ISO[key]}.png`;
|
return `https://flagcdn.com/w40/${CODE_TO_ISO[key]}.png`;
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ interface Match {
|
|||||||
awayTeamName: string;
|
awayTeamName: string;
|
||||||
homeTeamCode?: string;
|
homeTeamCode?: string;
|
||||||
awayTeamCode?: string;
|
awayTeamCode?: string;
|
||||||
|
homeTeamLogoUrl?: string | null;
|
||||||
|
awayTeamLogoUrl?: string | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
|
leagueLogoUrl?: string | null;
|
||||||
|
displayOrder?: number;
|
||||||
|
isHot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LeagueGroup {
|
interface LeagueGroup {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
|
leagueLogoUrl?: string | null;
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +86,28 @@ const leagueGroups = computed<LeagueGroup[]>(() => {
|
|||||||
for (const m of filteredMatches.value) {
|
for (const m of filteredMatches.value) {
|
||||||
const id = m.leagueId ?? m.leagueName;
|
const id = m.leagueId ?? m.leagueName;
|
||||||
if (!map.has(id)) {
|
if (!map.has(id)) {
|
||||||
map.set(id, { leagueId: id, leagueName: m.leagueName, matches: [] });
|
map.set(id, {
|
||||||
|
leagueId: id,
|
||||||
|
leagueName: m.leagueName,
|
||||||
|
leagueLogoUrl: m.leagueLogoUrl ?? null,
|
||||||
|
matches: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
map.get(id)!.matches.push(m);
|
map.get(id)!.matches.push(m);
|
||||||
}
|
}
|
||||||
return [...map.values()];
|
const groups = [...map.values()];
|
||||||
|
for (const g of groups) {
|
||||||
|
g.matches.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
|
||||||
|
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return groups.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.matches[0]?.displayOrder ?? 0) - (b.matches[0]?.displayOrder ?? 0) ||
|
||||||
|
a.leagueName.localeCompare(b.leagueName),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(leagueGroups, (groups) => {
|
watch(leagueGroups, (groups) => {
|
||||||
@@ -176,6 +199,7 @@ function goMatch(id: string) {
|
|||||||
:key="group.leagueId"
|
:key="group.leagueId"
|
||||||
:league-id="group.leagueId"
|
:league-id="group.leagueId"
|
||||||
:league-name="group.leagueName"
|
:league-name="group.leagueName"
|
||||||
|
:league-logo-url="group.leagueLogoUrl"
|
||||||
:matches="group.matches"
|
:matches="group.matches"
|
||||||
:expanded="isLeagueExpanded(group.leagueId)"
|
:expanded="isLeagueExpanded(group.leagueId)"
|
||||||
@toggle="toggleLeague(group.leagueId)"
|
@toggle="toggleLeague(group.leagueId)"
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||||
|
import vsImg from '../assets/images/vs.png';
|
||||||
|
import cardBg from '../assets/images/卡片.png';
|
||||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||||
import { teamFlagUrl } from '../utils/teamFlag';
|
import { teamFlagUrl } from '../utils/teamFlag';
|
||||||
|
|
||||||
|
const matchCardBg = `url(${cardBg})`;
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { banners, hotMatches, loading } = usePlayerHome();
|
const { banners, hotMatches, loading } = usePlayerHome();
|
||||||
@@ -42,7 +45,7 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
<div
|
<div
|
||||||
v-for="match in hotMatches"
|
v-for="match in hotMatches"
|
||||||
:key="match.id"
|
:key="match.id"
|
||||||
class="card match-card"
|
class="match-card"
|
||||||
@click="goMatch(match.id)"
|
@click="goMatch(match.id)"
|
||||||
>
|
>
|
||||||
<div class="match-info">
|
<div class="match-info">
|
||||||
@@ -52,7 +55,31 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
<div class="match-flags" aria-hidden="true">
|
<div class="match-flags" aria-hidden="true">
|
||||||
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
|
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
|
||||||
<span v-else class="flag-ph">⚽</span>
|
<span v-else class="flag-ph">⚽</span>
|
||||||
<span class="vs">VS</span>
|
<div class="vs-arena">
|
||||||
|
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient :id="`hzBoltGrad-${match.id}`" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
||||||
|
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
||||||
|
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
||||||
|
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
||||||
|
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
class="hz-path hz-path-main"
|
||||||
|
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||||
|
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="hz-path hz-path-sub"
|
||||||
|
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||||
|
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="hz-beam" aria-hidden="true" />
|
||||||
|
<img :src="vsImg" alt="" class="vs-img" />
|
||||||
|
</div>
|
||||||
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
|
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
|
||||||
<span v-else class="flag-ph">⚽</span>
|
<span v-else class="flag-ph">⚽</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,16 +94,43 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.match-card {
|
.match-card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 72px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: none;
|
||||||
|
background: var(--bg-card);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||||
|
opacity: 0.25;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-card:active {
|
.match-card:active {
|
||||||
border-color: var(--border-gold-soft);
|
opacity: 0.92;
|
||||||
|
transform: scale(0.995);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-info,
|
||||||
|
.match-flags {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-info {
|
.match-info {
|
||||||
@@ -102,35 +156,199 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid #2a2a2a;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flag {
|
.flag {
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 22px;
|
height: 28px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flag-ph {
|
.flag-ph {
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 22px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vs {
|
.vs-arena {
|
||||||
font-size: 11px;
|
position: relative;
|
||||||
font-weight: 900;
|
flex-shrink: 0;
|
||||||
color: var(--primary-light);
|
width: 72px;
|
||||||
letter-spacing: 0.04em;
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hz-lightning {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hz-path {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2.2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hz-path-main {
|
||||||
|
animation: hz-strike-main 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hz-path-sub {
|
||||||
|
stroke-width: 1.6;
|
||||||
|
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
||||||
|
animation-delay: 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hz-beam {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 2px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(94, 184, 255, 0) 0%,
|
||||||
|
rgba(184, 236, 255, 0.95) 28%,
|
||||||
|
#fff 50%,
|
||||||
|
rgba(255, 208, 128, 0.95) 72%,
|
||||||
|
rgba(255, 144, 64, 0) 100%
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(0.4px);
|
||||||
|
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-img {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 26px;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
animation: vs-glow 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hz-strike-main {
|
||||||
|
0%,
|
||||||
|
72%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
74% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
76% {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
78% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hz-strike-sub {
|
||||||
|
0%,
|
||||||
|
74%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
76% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
77% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
78% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hz-beam-flash {
|
||||||
|
0%,
|
||||||
|
71%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) scaleX(0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
73% {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: translateY(-50%) scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
opacity: 0.15;
|
||||||
|
transform: translateY(-50%) scaleX(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
76% {
|
||||||
|
opacity: 0.75;
|
||||||
|
transform: translateY(-50%) scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
78% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50%) scaleX(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vs-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.82;
|
||||||
|
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 0 3px rgba(255, 230, 140, 0.7))
|
||||||
|
drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.vs-img {
|
||||||
|
animation: none;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hz-path,
|
||||||
|
.hz-beam {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface Market {
|
|||||||
period: string;
|
period: string;
|
||||||
lineValue?: string | number | null;
|
lineValue?: string | number | null;
|
||||||
allowParlay?: boolean;
|
allowParlay?: boolean;
|
||||||
|
promoLabel?: string | null;
|
||||||
selections: Selection[];
|
selections: Selection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,11 +41,17 @@ interface Selection {
|
|||||||
|
|
||||||
interface MatchDetail {
|
interface MatchDetail {
|
||||||
id: string;
|
id: string;
|
||||||
|
leagueName?: string;
|
||||||
|
leagueLogoUrl?: string | null;
|
||||||
homeTeamName: string;
|
homeTeamName: string;
|
||||||
awayTeamName: string;
|
awayTeamName: string;
|
||||||
homeTeamCode?: string;
|
homeTeamCode?: string;
|
||||||
awayTeamCode?: string;
|
awayTeamCode?: string;
|
||||||
|
homeTeamLogoUrl?: string | null;
|
||||||
|
awayTeamLogoUrl?: string | null;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
stage?: string | null;
|
||||||
|
groupName?: string | null;
|
||||||
markets: Market[];
|
markets: Market[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +72,17 @@ const marketsByType = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const homeFlag = computed(() =>
|
const homeFlag = computed(() =>
|
||||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName),
|
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName, match.value?.homeTeamLogoUrl),
|
||||||
);
|
);
|
||||||
const awayFlag = computed(() =>
|
const awayFlag = computed(() =>
|
||||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName),
|
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName, match.value?.awayTeamLogoUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function marketPromoLabel(marketType: string) {
|
||||||
|
const m = marketsByType.value.get(marketType);
|
||||||
|
return m?.promoLabel?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
const kickoff = computed(() => {
|
const kickoff = computed(() => {
|
||||||
if (!match.value) return '';
|
if (!match.value) return '';
|
||||||
return new Date(match.value.startTime).toLocaleString(locale.value, {
|
return new Date(match.value.startTime).toLocaleString(locale.value, {
|
||||||
@@ -123,7 +135,7 @@ const csConfirmLines = computed((): CsConfirmLine[] => {
|
|||||||
return market.selections
|
return market.selections
|
||||||
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
|
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const parsed = parseScoreCode(s.selectionCode);
|
const parsed = parseScoreCode(s.selectionCode, t);
|
||||||
return {
|
return {
|
||||||
scoreDisplay: parsed?.display ?? s.selectionName,
|
scoreDisplay: parsed?.display ?? s.selectionName,
|
||||||
odds: s.odds,
|
odds: s.odds,
|
||||||
@@ -262,6 +274,16 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||||
|
|
||||||
<template v-else-if="match">
|
<template v-else-if="match">
|
||||||
|
<section v-if="match.leagueName" class="league-banner">
|
||||||
|
<span class="league-title">*{{ match.leagueName }}</span>
|
||||||
|
<img
|
||||||
|
v-if="match.leagueLogoUrl"
|
||||||
|
:src="match.leagueLogoUrl"
|
||||||
|
alt=""
|
||||||
|
class="league-logo"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="match-hero">
|
<section class="match-hero">
|
||||||
<p class="kickoff">{{ kickoff }}</p>
|
<p class="kickoff">{{ kickoff }}</p>
|
||||||
<div class="match-line">
|
<div class="match-line">
|
||||||
@@ -298,6 +320,7 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
>
|
>
|
||||||
<MarketTypeTile
|
<MarketTypeTile
|
||||||
:label="marketLabel(marketType)"
|
:label="marketLabel(marketType)"
|
||||||
|
:promo-label="marketPromoLabel(marketType)"
|
||||||
:has-market="marketsByType.has(marketType)"
|
:has-market="marketsByType.has(marketType)"
|
||||||
:expanded="isExpanded(marketType)"
|
:expanded="isExpanded(marketType)"
|
||||||
@toggle="toggleMarket(marketType)"
|
@toggle="toggleMarket(marketType)"
|
||||||
@@ -382,6 +405,30 @@ function hasSlipPickForMarket(marketType: string) {
|
|||||||
padding: 2px 12px 10px;
|
padding: 2px 12px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.league-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 12px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-banner .league-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-light);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.league-logo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 40px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 44px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.kickoff {
|
.kickoff {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** 内容翻译 fallback:当前语言 → 英文 → 中文 */
|
/** 内容翻译 fallback:当前语言 → 英文 → 中文 → 马来文 */
|
||||||
export function resolveTranslationFallback(
|
export function resolveTranslationFallback(
|
||||||
map: Record<string, string | undefined | null>,
|
map: Record<string, string | undefined | null>,
|
||||||
locale: string,
|
locale: string,
|
||||||
): string {
|
): string {
|
||||||
const chain = [locale, 'en-US', 'zh-CN'];
|
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const loc of chain) {
|
for (const loc of chain) {
|
||||||
if (seen.has(loc)) continue;
|
if (seen.has(loc)) continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user