feat(admin,api,player): settlement stats, team crests, MS fields and list bet summary

This commit is contained in:
2026-06-04 17:30:48 +08:00
parent cc737e2924
commit 9fcee31a9a
27 changed files with 2296 additions and 427 deletions

View File

@@ -2,15 +2,21 @@
import { ref, watch, computed } from 'vue';
import CountryFlagSelect from './outright/CountryFlagSelect.vue';
import {
countryFlagUrl,
countryLogoUrl,
detectCountryLogoKind,
getBuiltinCountry,
hasCountryCrest,
resolveCountryCode,
type BuiltinCountry,
type CountryLogoKind,
} from '../data/builtinCountries';
import { useAdminLocale } from '../composables/useAdminLocale';
const props = defineProps<{
modelValue: string;
teamCode?: string;
/** 表格等窄布局:隐藏 URL 输入框,仅国家 + 国旗/队徽 */
compact?: boolean;
}>();
const emit = defineEmits<{
@@ -20,47 +26,87 @@ const emit = defineEmits<{
const { t } = useAdminLocale();
const countryCode = ref('');
const logoKind = ref<CountryLogoKind>('crest');
const canPickCrest = computed(() => {
const code = countryCode.value;
return code ? hasCountryCrest(code) : false;
});
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);
if (url) {
logoKind.value = detectCountryLogoKind(url, countryCode.value);
}
},
{ immediate: true },
);
watch(countryCode, (code, prev) => {
if (!code && prev && isFlagUrl.value) {
if (!code && prev && (logoKind.value === 'flag' || logoKind.value === 'crest')) {
emit('update:modelValue', '');
}
});
function applyLogoForCountry(country: BuiltinCountry) {
const kind =
logoKind.value === 'crest' && hasCountryCrest(country) ? 'crest' : 'flag';
logoKind.value = kind;
emit('update:modelValue', countryLogoUrl(country, kind));
}
function onCountryPick(country: BuiltinCountry) {
emit('update:modelValue', countryFlagUrl(country));
countryCode.value = country.code;
if (!props.modelValue.trim()) {
logoKind.value = hasCountryCrest(country) ? 'crest' : 'flag';
}
applyLogoForCountry(country);
emit('pick', country);
}
function onLogoKindChange(kind: CountryLogoKind) {
if (kind === 'custom') return;
const country = getBuiltinCountry(countryCode.value);
if (!country) return;
logoKind.value = kind === 'crest' && !hasCountryCrest(country) ? 'flag' : kind;
emit('update:modelValue', countryLogoUrl(country, logoKind.value as 'flag' | 'crest'));
}
function onCustomUrlInput(value: string) {
logoKind.value = detectCountryLogoKind(value, countryCode.value);
emit('update:modelValue', value);
}
</script>
<template>
<div class="logo-url-field">
<CountryFlagSelect
v-model="countryCode"
hide-preview
class="flag-part"
@pick="onCountryPick"
/>
<div class="logo-url-field" :class="{ compact }">
<div class="pick-row">
<CountryFlagSelect
v-model="countryCode"
hide-preview
class="flag-part"
@pick="onCountryPick"
/>
<el-radio-group
v-if="countryCode"
:model-value="logoKind === 'custom' ? 'flag' : logoKind"
size="small"
class="kind-group"
@update:model-value="onLogoKindChange($event as CountryLogoKind)"
>
<el-radio-button value="flag">{{ t('teamLogo.kind.flag') }}</el-radio-button>
<el-radio-button value="crest" :disabled="!canPickCrest">
{{ t('teamLogo.kind.crest') }}
</el-radio-button>
</el-radio-group>
<img v-if="previewUrl" :src="previewUrl" alt="" class="logo-preview" loading="lazy" />
</div>
<el-input
v-if="!compact"
:model-value="modelValue"
size="small"
:placeholder="t('matchEditor.ph.logo_url')"
@@ -68,12 +114,19 @@ function onCustomUrlInput(value: string) {
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;
flex-direction: column;
gap: 6px;
width: 100%;
min-width: 0;
}
.pick-row {
display: flex;
align-items: center;
gap: 8px;
@@ -82,22 +135,30 @@ function onCustomUrlInput(value: string) {
}
.flag-part {
flex: 0 0 200px;
min-width: 0;
flex: 1;
min-width: 120px;
}
.url-part {
flex: 1;
min-width: 0;
.kind-group {
flex-shrink: 0;
}
.logo-preview {
flex-shrink: 0;
width: 32px;
height: 22px;
height: 32px;
object-fit: contain;
border-radius: 3px;
border-radius: 4px;
background: #0d0d0d;
border: 1px solid #2a2a2a;
}
.url-part {
width: 100%;
}
.logo-url-field.compact .flag-part {
flex: 1;
min-width: 0;
}
</style>

View File

@@ -2,9 +2,11 @@
import { computed, ref } from 'vue';
import {
countryFlagUrl,
countryCrestUrl,
countryDisplayName,
countryOptionLabel,
getBuiltinCountry,
hasCountryCrest,
searchBuiltinCountries,
type BuiltinCountry,
} from '../../data/builtinCountries';
@@ -67,6 +69,13 @@ function onChange(code: string | undefined) {
>
<div class="country-option">
<img :src="countryFlagUrl(c)" alt="" class="country-option-flag" loading="lazy" />
<img
v-if="hasCountryCrest(c)"
:src="countryCrestUrl(c)"
alt=""
class="country-option-crest"
loading="lazy"
/>
<span class="country-option-name">{{ countryDisplayName(c, locale) }}</span>
<span class="country-option-code">{{ c.code }}</span>
</div>
@@ -120,6 +129,13 @@ function onChange(code: string | undefined) {
flex-shrink: 0;
}
.country-option-crest {
width: 22px;
height: 22px;
object-fit: contain;
flex-shrink: 0;
}
.country-option-name {
flex: 1;
min-width: 0;

View File

@@ -1,4 +1,6 @@
/** 内置国家队(世界杯 48 强 + 常用队),供管理端选择国旗与自动填充队名 */
import { NATIONAL_TEAM_CREST, NATIONAL_TEAM_MS } from './nationalTeamCrests';
/** 内置国家队(世界杯 48 强 + 常用队),供管理端选择国旗/队徽与自动填充队名 */
export type BuiltinCountry = {
code: string;
nameZh: string;
@@ -6,6 +8,8 @@ export type BuiltinCountry = {
iso: string;
};
export type CountryLogoKind = 'flag' | 'crest' | 'custom';
export const BUILTIN_COUNTRIES: BuiltinCountry[] = [
{ code: 'FRA', nameZh: '法国', nameEn: 'France', iso: 'fr' },
{ code: 'ESP', nameZh: '西班牙', nameEn: 'Spain', iso: 'es' },
@@ -20,7 +24,7 @@ export const BUILTIN_COUNTRIES: BuiltinCountry[] = [
{ code: 'COL', nameZh: '哥伦比亚', nameEn: 'Colombia', iso: 'co' },
{ code: 'JPN', nameZh: '日本', nameEn: 'Japan', iso: 'jp' },
{ code: 'URU', nameZh: '乌拉圭', nameEn: 'Uruguay', iso: 'uy' },
{ code: 'USA', nameZh: '美国', nameEn: 'USA', iso: 'us' },
{ code: 'USA', nameZh: '美国', nameEn: 'United States', iso: 'us' },
{ code: 'MAR', nameZh: '摩洛哥', nameEn: 'Morocco', iso: 'ma' },
{ code: 'CRO', nameZh: '克罗地亚', nameEn: 'Croatia', iso: 'hr' },
{ code: 'MEX', nameZh: '墨西哥', nameEn: 'Mexico', iso: 'mx' },
@@ -34,20 +38,20 @@ export const BUILTIN_COUNTRIES: BuiltinCountry[] = [
{ code: 'IRN', nameZh: '伊朗', nameEn: 'Iran', iso: 'ir' },
{ code: 'GHA', nameZh: '加纳', nameEn: 'Ghana', iso: 'gh' },
{ code: 'ALG', nameZh: '阿尔及利亚', nameEn: 'Algeria', iso: 'dz' },
{ code: 'BIH', nameZh: '波黑', nameEn: 'Bosnia', iso: 'ba' },
{ code: 'BIH', nameZh: '波黑', nameEn: 'Bosnia & Herzegovina', iso: 'ba' },
{ code: 'EGY', nameZh: '埃及', nameEn: 'Egypt', iso: 'eg' },
{ code: 'TUN', nameZh: '突尼斯', nameEn: 'Tunisia', iso: 'tn' },
{ code: 'CAN', nameZh: '加拿大', nameEn: 'Canada', iso: 'ca' },
{ code: 'PAN', nameZh: '巴拿马', nameEn: 'Panama', iso: 'pa' },
{ code: 'AUS', nameZh: '澳大利亚', nameEn: 'Australia', iso: 'au' },
{ code: 'CZE', nameZh: '捷克', nameEn: 'Czech Republic', iso: 'cz' },
{ code: 'CZE', nameZh: '捷克', nameEn: 'Czechia', iso: 'cz' },
{ code: 'KSA', nameZh: '沙特阿拉伯', nameEn: 'Saudi Arabia', iso: 'sa' },
{ code: 'NZL', nameZh: '新西兰', nameEn: 'New Zealand', iso: 'nz' },
{ code: 'COD', nameZh: '刚果(金)', nameEn: 'DR Congo', iso: 'cd' },
{ code: 'COD', nameZh: '刚果(金)', nameEn: 'DR Congo', iso: 'cd' },
{ code: 'UZB', nameZh: '乌兹别克斯坦', nameEn: 'Uzbekistan', iso: 'uz' },
{ code: 'IRQ', nameZh: '伊拉克', nameEn: 'Iraq', iso: 'iq' },
{ code: 'RSA', nameZh: '南非', nameEn: 'South Africa', iso: 'za' },
{ code: 'CIV', nameZh: '科特迪瓦', nameEn: 'Ivory Coast', iso: 'ci' },
{ code: 'CIV', nameZh: '象牙海岸', nameEn: "Côte d'Ivoire", iso: 'ci' },
{ code: 'JOR', nameZh: '约旦', nameEn: 'Jordan', iso: 'jo' },
{ code: 'PAR', nameZh: '巴拉圭', nameEn: 'Paraguay', iso: 'py' },
{ code: 'HAI', nameZh: '海地', nameEn: 'Haiti', iso: 'ht' },
@@ -97,6 +101,49 @@ export function countryFlagUrl(country: BuiltinCountry | string): string {
return `https://flagcdn.com/w40/${c.iso}.png`;
}
export function countryCrestUrl(country: BuiltinCountry | string): string {
const c = typeof country === 'string' ? getBuiltinCountry(country) : country;
if (!c) return '';
return NATIONAL_TEAM_CREST[c.code] ?? '';
}
export function hasCountryCrest(country: BuiltinCountry | string): boolean {
return !!countryCrestUrl(country);
}
export function countryLogoUrl(
country: BuiltinCountry | string,
kind: 'flag' | 'crest',
): string {
if (kind === 'crest') {
const crest = countryCrestUrl(country);
if (crest) return crest;
}
return countryFlagUrl(country);
}
export function detectCountryLogoKind(
logoUrl?: string | null,
teamCode?: string | null,
): CountryLogoKind {
const url = (logoUrl ?? '').trim();
if (!url) return 'flag';
const code = (teamCode ?? '').trim().toUpperCase();
const fromCode = code ? getBuiltinCountry(code) : undefined;
if (fromCode) {
if (url === countryCrestUrl(fromCode)) return 'crest';
if (url.includes('flagcdn.com')) return 'flag';
}
for (const c of BUILTIN_COUNTRIES) {
const crest = countryCrestUrl(c);
if (crest && url === crest) return 'crest';
}
if (url.includes('flagcdn.com')) return 'flag';
if (url.includes('website-files.com') && url.includes('footylogos')) return 'crest';
if (url.includes('footballlogos-org')) return 'crest';
return 'custom';
}
/** 按后台当前语言显示国家名(下拉只显示一种语言) */
export function countryDisplayName(c: BuiltinCountry, locale: string): string {
if (locale === 'en-US') return c.nameEn;
@@ -108,53 +155,8 @@ 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;
return NATIONAL_TEAM_MS[c.code] ?? c.nameEn;
}
export function searchBuiltinCountries(keyword: string, locale = 'zh-CN'): BuiltinCountry[] {
@@ -179,7 +181,12 @@ export function resolveCountryCode(
const fromCode = getBuiltinCountry(teamCode);
if (fromCode) return fromCode.code;
if (logoUrl) {
const m = logoUrl.match(/flagcdn\.com\/w\d+\/([a-z0-9-]+)\.png/i);
const trimmed = logoUrl.trim();
const byCrest = BUILTIN_COUNTRIES.find(
(c) => countryCrestUrl(c) && countryCrestUrl(c) === trimmed,
);
if (byCrest) return byCrest.code;
const m = trimmed.match(/flagcdn\.com\/w\d+\/([a-z0-9-]+)\.png/i);
if (m) {
const iso = m[1].toLowerCase();
const hit = BUILTIN_COUNTRIES.find((c) => c.iso === iso);

View File

@@ -0,0 +1,103 @@
/** 世界杯 48 强国家队徽footylogos CDN */
export const NATIONAL_TEAM_CREST: Record<string, string> = {
ARG: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a1048bb2071e9deee459c12_6a104445459be40714bd9774_argentina-national-team-footylogos.png',
AUS: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a10e35263d68e6f439e_69fa8abfc42edca0f730781a_australia-national-team-footylogos.png',
AUT: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68f9fc172630205d3271b9f1_austria-national-team-footballlogos-org.svg',
BEL: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a0fe2c051dd81874aac_68f9fc44d95277458c187c4f_belgium-national-team-footballlogos-org.png',
BIH: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68fd1a0bb96a229b28eff9ca_bosnia-and-herzegovina-footballlogos-org.svg',
BRA: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a07dd70012891100817_68f9fc74eac3dc471d42c1f1_brazil-national-team-footballlogos-org.png',
CPV: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a04f8e4dbd2d7560e3f_68fd1a7a7cceddc21192dd2a_cabo-verde-footballlogos-org.png',
CAN: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a09ff1d0f7cf9fc6892_68f9fcae7c3a768d2112f7cc_canada-national-team-footballlogos-org.png',
COL: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68f9fd84ab47946a360b482d_colombia-national-team-footballlogos-org.svg',
CRO: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68f9fe732030ba1891c2c1e7_croatia-national-team-footballlogos-org.svg',
CUW: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a0c450ecf35a44ec35a_690b58788ae9e26e532abfdf_curacao-national-team-footballlogos-org.png',
CZE: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a124f9b12d3899cdf35_68f9fefa92aa92766cfafa43_czechia-national-team-footballlogos-org.png',
COD: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68fd1b5eda26ecde0ae4f1eb_dr-congo-footballlogos-org.svg',
ECU: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a03e8cfc8f978c35242_68f9ffb9e5f5a02e2ddd6c89_ecuador-national-team-footballlogos-org.png',
EGY: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39fd094c95a818967d17_68f9ffff9f0a363d783fca07_egypt-national-team-footballlogos-org.png',
ENG: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f4f81c2d9713bb1958_68fa004f9bad274585f92fd4_england-national-team-footballlogos-org.png',
FRA: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39fa921e692c7885a3a5_68fa00af9b52a99bf3ce88b8_france-national-team-footballlogos-org.png',
GER: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a134ff2b16d2ff2c420_68fa00ee54de0cacbdff9b16_germany-national-team-footballlogos-org.png',
GHA: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a176f4197f7299d29c76a61_6a121b9d1a9da04ec878bb21_ghana-national-team-footylogos.png',
HAI: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0f4c6c910c48d9bace182a_692869cd3f30b984d69b7f75_haiti-national-team-footylogos.png',
IRN: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f9be2380ec23ca8496_68fa01dc9c4cd3255b45967d_iran-national-team-footballlogos-org.png',
IRQ: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a08c18986f46d9cbb5a_68fd1ce7c48c6f7c6b9f2d9f_iraq-footballlogos-org.png',
CIV: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a0a63e7e11f6ecf95f4_68f9fe0f5e31af81b7ceb973_cote-d-ivoire-national-team-footballlogos-org.png',
JPN: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a179bc2e28bf73ecd09_68fa02b609c96c8ed3c2cdf6_japan-national-team-footballlogos-org.png',
JOR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39fac9c45208a31ce544_68fd1d515ce3de4f3d2e23b0_jordan-footballlogos-org.png',
MEX: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a067e47ced126acdd02_68fa02ee37f495f860481404_mexico-national-team-footballlogos-org.png',
MAR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f3ff709486e36ba903_68fa032284d7ebc8adde9b0e_morocco-national-team-footballlogos-org.png',
NED: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a15786b121c0b7b60cc_68f9ff890403a59958f26579_netherlands-dutch-national-team-footballlogos-org.png',
NZL: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39ff967f6d274aeb4e8b_68fa67ce6d8bf137ee675702_new-zealand-national-team-footballlogos-org.png',
NOR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68fa03b401a24ac6badeaa5c_norway-national-team-footballlogos-org.svg',
PAN: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68fa03e45d0722bd9f321589_panama-national-team-footballlogos-org.svg',
PAR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a15c8ff2f7f7f02602c_68fa042c2750779cccf807b4_paraguay-national-team-footballlogos-org.png',
POR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a0095b66650d7655aba_68fa6b30ffde0dbd282357ab_portugal-national-team-footballlogos-org.png',
QAT: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/68fd1f8e1cc7aebc6fcfff34_qatar-national-team-footballlogos-org.svg',
KSA: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f8751bc84c4be421cf_68fd1fc871c2e4977d6345f9_saudi-arabia-national-team-footballlogos-org.png',
SEN: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a052fcac044e973b7e1_68fa07554408d744a38f143f_senegal-national-team-footballlogos-org.png',
KOR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a18adae7bad8cb6e423_68fa0855071867dd20c531f3_south-korea-national-team-footballlogos-org.png',
RSA: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f717d2d9b8868d47d7_68fd1ff449702964723a7890_south-africa-national-team-footballlogos-org.png',
ESP: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a1aa6db0dbe288b67f3_68fa08931b5e1697f8930e74_spain-national-team-footballlogos-org.png',
SWE: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f2940ca8a6d33ef41f_68fa08ce61f58c56e735629c_sweden-national-team-footballlogos-org.png',
SUI: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a0d052b7e092df739cf_68fa0904f28db91037150be9_swiss-national-team-footballlogos-org.png',
TUN: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39fc9b5ae9ed29ad6646_68fa093b578f3b5329f80833_tunisia-national-team-footballlogos-org.png',
TUR: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a199844733813bb5b41_68fa09923df7f921ed9a86ef_turkey-national-team-footballlogos-org.png',
UZB: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39fc12418491a747e862_68fd208e9bfd4ed3a9b30b6f_uzbekistan-national-team-footballlogos-org.png',
URU: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a06d17821a2db9019ea_68fa0a1577c6612bb1154a07_uruguay-national-team-footballlogos-org.png',
USA: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39f54269adc0c7a000c3_68fa0a65e8dbfe6ba33cb5e6_usa-national-team-footballlogos-org.png',
ALG: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b39fec61707555515b17e_68f9faa3abd65d5f6209b2cd_algeria-national-team-footballlogos-org.png',
SCO: 'https://cdn.prod.website-files.com/68f550992570ca0322737dc2/6a0b3a0e29b605309fb7f47b_68fa07134a7663c774887b1a_scotland-national-team-footballlogos-org.png',
};
/** 48 强马来语队名(与队徽表一致) */
export const NATIONAL_TEAM_MS: Record<string, string> = {
ARG: 'Argentina',
AUS: 'Australia',
AUT: 'Austria',
BEL: 'Belgium',
BIH: 'Bosnia dan Herzegovina',
BRA: 'Brazil',
CPV: 'Tanjung Verde',
CAN: 'Kanada',
COL: 'Colombia',
CRO: 'Croatia',
CUW: 'Curaçao',
CZE: 'Republik Czech',
COD: 'Republik Demokratik Congo',
ECU: 'Ecuador',
EGY: 'Mesir',
ENG: 'England',
FRA: 'Perancis',
GER: 'Jerman',
GHA: 'Ghana',
HAI: 'Haiti',
IRN: 'Iran',
IRQ: 'Iraq',
CIV: "Côte d'Ivoire",
JPN: 'Jepun',
JOR: 'Jordan',
MEX: 'Mexico',
MAR: 'Maghribi',
NED: 'Belanda',
NZL: 'New Zealand',
NOR: 'Norway',
PAN: 'Panama',
PAR: 'Paraguay',
POR: 'Portugal',
QAT: 'Qatar',
KSA: 'Arab Saudi',
SEN: 'Senegal',
KOR: 'Korea Selatan',
RSA: 'Afrika Selatan',
ESP: 'Sepanyol',
SWE: 'Sweden',
SUI: 'Switzerland',
TUN: 'Tunisia',
TUR: 'Turki',
UZB: 'Uzbekistan',
URU: 'Uruguay',
USA: 'Amerika Syarikat',
ALG: 'Algeria',
SCO: 'Scotland',
};

View File

@@ -162,6 +162,7 @@ const zh: Record<string, string> = {
'match.status.PUBLISHED': '已发布',
'match.status.CLOSED': '已封盘',
'match.status.SETTLED': '已结算',
'match.status.PENDING_SETTLEMENT': '待结算',
...adminPagesZh,
};
@@ -314,6 +315,7 @@ const en: Record<string, string> = {
'match.status.PUBLISHED': 'Published',
'match.status.CLOSED': 'Closed',
'match.status.SETTLED': 'Settled',
'match.status.PENDING_SETTLEMENT': 'Pending settlement',
...adminPagesEn,
};
@@ -466,6 +468,7 @@ const ms: Record<string, string> = {
'match.status.PUBLISHED': 'Diterbitkan',
'match.status.CLOSED': 'Ditutup',
'match.status.SETTLED': 'Diselesaikan',
'match.status.PENDING_SETTLEMENT': 'Menunggu penyelesaian',
...adminPagesMs,
};

View File

@@ -129,6 +129,9 @@ export const adminPagesMs: Record<string, string> = {
'match.filter.keyword_ph': 'Nama kejohanan / kod pasukan',
'match.col.league': 'Kejohanan',
'match.col.fixture_count': 'Perlawanan',
'match.col.bet_count': 'Pertaruhan',
'match.col.total_stake': 'Jumlah stake',
'match.col.pending_bets': 'Menunggu',
'match.col.league_code': 'Kod',
'match.col.matchup': 'Perlawanan',
'match.col.kickoff': 'Masa mula',
@@ -227,8 +230,10 @@ export const adminPagesMs: Record<string, string> = {
'match.ph.kickoff': '2026-06-11T19:00:00Z',
'match.ph.home_en': 'Mexico',
'match.ph.home_zh': 'Mexico',
'match.ph.home_ms': 'Mexico',
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': 'Afrika Selatan',
'match.ph.away_ms': 'Afrika Selatan',
'matchEditor.manage_btn': 'Maklumat asas',
'matchEditor.back': 'Kembali ke senarai',
@@ -294,11 +299,28 @@ export const adminPagesMs: Record<string, string> = {
'err.credit_negative': 'Had kredit tidak boleh negatif',
'err.kickoff_required': 'Sila isi masa mula',
'err.teams_required': 'Isi nama pasukan tuan rumah dan pelawat (ZH atau EN)',
'err.teams_same': 'Pasukan tuan rumah dan pelawat mesti berbeza',
'err.league_required': 'Sila isi nama liga',
'err.user_required': 'Sila pilih pengguna',
'err.agent_no_parent': 'Ejen peringkat 1 tidak boleh ada pemain induk',
'err.agent_no_initial_deposit': 'Jangan isi baki permulaan pemain apabila cipta ejen',
'settlement.back': 'Kembali ke senarai',
'settlement.kickoff': 'Masa kick-off',
'settlement.stats_title': 'Statistik pertaruhan',
'settlement.stats_total_bets': 'Bil. pertaruhan',
'settlement.stats_single': 'Tunggal',
'settlement.stats_parlay': 'Parlay',
'settlement.stats_total_stake': 'Jumlah stake',
'settlement.stats_potential': 'Menang maksimum',
'settlement.stats_by_market': 'Ikut pasaran / pilihan',
'settlement.bet_list': 'Semua pertaruhan',
'settlement.no_bets': 'Tiada pertaruhan untuk perlawanan ini',
'settlement.col.market': 'Pasaran',
'settlement.col.selection': 'Pilihan',
'settlement.col.legs': 'Kaki',
'settlement.col.single_stake': 'Stake tunggal',
'settlement.col.parlay_legs': 'Kaki parlay',
'settlement.ht_score': 'Skor separuh masa',
'settlement.ft_score': 'Skor penuh masa',
'settlement.record_score': 'Simpan skor',
@@ -405,12 +427,18 @@ export const adminPagesMs: Record<string, string> = {
'outright.col.country': 'Negara',
'outright.col.odds': 'Odds juara',
'outright.country_ph': 'Cari atau pilih negara',
'teamLogo.kind.flag': 'Bendera',
'teamLogo.kind.crest': 'Lambang',
'outright.err_country': 'Sila pilih negara',
'outright.btn.save_odds': 'Simpan semua odds',
'outright.btn.apply_canonical': 'Guna data jadual asas',
'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini',
'outright.team_count': '{n} / {total} pasukan',
'outright.err_odds_min': 'Odds mesti lebih 1.00',
'outright.field.title_zh': 'Tajuk (ZH)',
'outright.field.title_en': 'Tajuk (EN)',
'outright.field.title_ms': 'Tajuk (MS)',
'outright.btn.create_event': 'Acara juara baharu',
'msg.load_matches_failed': 'Gagal memuatkan perlawanan',
'msg.cashback_issued': 'Rebat telah dikeluarkan',
'msg.freeze_confirm_title': '{action} akaun',

View File

@@ -129,6 +129,9 @@ export const adminPagesZh: Record<string, string> = {
'match.filter.keyword_ph': '赛事名 / 球队代码',
'match.col.league': '赛事',
'match.col.fixture_count': '单场',
'match.col.bet_count': '注单数',
'match.col.total_stake': '总投注额',
'match.col.pending_bets': '待结算',
'match.col.league_code': '代码',
'match.col.matchup': '对阵',
'match.col.kickoff': '开赛时间',
@@ -227,8 +230,10 @@ export const adminPagesZh: Record<string, string> = {
'match.ph.kickoff': '2026-06-11T19:00:00Z',
'match.ph.home_en': 'Mexico',
'match.ph.home_zh': '墨西哥',
'match.ph.home_ms': 'Mexico',
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': '南非',
'match.ph.away_ms': 'Afrika Selatan',
'matchEditor.manage_btn': '基本信息',
'matchEditor.back': '返回列表',
@@ -294,11 +299,28 @@ export const adminPagesZh: Record<string, string> = {
'err.credit_negative': '授信额度不能为负',
'err.kickoff_required': '请填写开赛时间',
'err.teams_required': '请填写主客队名称(中文、英文或马来文至少一项)',
'err.teams_same': '主客队不能相同,请填写不同的队名',
'err.league_required': '请填写联赛名称(中文、英文或马来文至少一项)',
'err.user_required': '请选择用户',
'err.agent_no_parent': '一级代理不可设置上级玩家',
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
'settlement.back': '返回赛事列表',
'settlement.kickoff': '开赛时间',
'settlement.stats_title': '下注统计',
'settlement.stats_total_bets': '注单数',
'settlement.stats_single': '单关',
'settlement.stats_parlay': '串关',
'settlement.stats_total_stake': '总投注额',
'settlement.stats_potential': '最大可赢',
'settlement.stats_by_market': '按玩法 / 选项汇总',
'settlement.bet_list': '全部注单',
'settlement.no_bets': '本场暂无注单',
'settlement.col.market': '玩法',
'settlement.col.selection': '选项',
'settlement.col.legs': '笔数',
'settlement.col.single_stake': '单关投注额',
'settlement.col.parlay_legs': '串关腿数',
'settlement.ht_score': '半场比分',
'settlement.ft_score': '全场比分',
'settlement.record_score': '录入比分',
@@ -405,6 +427,8 @@ export const adminPagesZh: Record<string, string> = {
'outright.col.country': '国家/地区',
'outright.col.odds': '夺冠赔率',
'outright.country_ph': '搜索或选择国家',
'teamLogo.kind.flag': '国旗',
'teamLogo.kind.crest': '队徽',
'outright.err_country': '请选择国家',
'outright.btn.save_odds': '保存全部赔率',
'outright.btn.save_meta': '保存赛事信息',
@@ -427,6 +451,7 @@ export const adminPagesZh: Record<string, string> = {
'outright.field.title_placeholder': '玩家端展示的冠军赛事名称',
'outright.field.title_zh': '标题(中文)',
'outright.field.title_en': '标题(英文)',
'outright.field.title_ms': '标题(马来)',
'outright.field.status': '发布状态',
'outright.status.draft': '草稿',
'outright.status.published': '已发布',
@@ -583,6 +608,9 @@ export const adminPagesEn: Record<string, string> = {
'match.filter.keyword_ph': 'Tournament / team code',
'match.col.league': 'Tournament',
'match.col.fixture_count': 'Fixtures',
'match.col.bet_count': 'Bets',
'match.col.total_stake': 'Total stake',
'match.col.pending_bets': 'Pending',
'match.col.league_code': 'Code',
'match.col.matchup': 'Matchup',
'match.col.kickoff': 'Kickoff',
@@ -681,8 +709,10 @@ export const adminPagesEn: Record<string, string> = {
'match.ph.kickoff': '2026-06-11T19:00:00Z',
'match.ph.home_en': 'Mexico',
'match.ph.home_zh': 'Mexico',
'match.ph.home_ms': 'Mexico',
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': 'South Africa',
'match.ph.away_ms': 'Afrika Selatan',
'matchEditor.manage_btn': 'Basic info',
'matchEditor.back': 'Back to list',
@@ -748,11 +778,28 @@ export const adminPagesEn: Record<string, string> = {
'err.credit_negative': 'Credit limit cannot be negative',
'err.kickoff_required': 'Kickoff time is required',
'err.teams_required': 'Enter home and away team names (ZH or EN)',
'err.teams_same': 'Home and away teams must be different',
'err.league_required': 'League name is required',
'err.user_required': 'Please select a user',
'err.agent_no_parent': 'Tier-1 agents cannot have a parent player',
'err.agent_no_initial_deposit': 'Do not set initial player balance when creating an agent',
'settlement.back': 'Back to matches',
'settlement.kickoff': 'Kick-off',
'settlement.stats_title': 'Betting statistics',
'settlement.stats_total_bets': 'Bets',
'settlement.stats_single': 'Singles',
'settlement.stats_parlay': 'Parlays',
'settlement.stats_total_stake': 'Total stake',
'settlement.stats_potential': 'Max potential win',
'settlement.stats_by_market': 'By market / selection',
'settlement.bet_list': 'All bets',
'settlement.no_bets': 'No bets on this match',
'settlement.col.market': 'Market',
'settlement.col.selection': 'Selection',
'settlement.col.legs': 'Legs',
'settlement.col.single_stake': 'Single stake',
'settlement.col.parlay_legs': 'Parlay legs',
'settlement.ht_score': 'Half-time score',
'settlement.ft_score': 'Full-time score',
'settlement.record_score': 'Save score',
@@ -859,6 +906,8 @@ export const adminPagesEn: Record<string, string> = {
'outright.col.country': 'Country',
'outright.col.odds': 'Winner odds',
'outright.country_ph': 'Search or select country',
'teamLogo.kind.flag': 'Flag',
'teamLogo.kind.crest': 'Crest',
'outright.err_country': 'Please select a country',
'outright.btn.save_odds': 'Save all odds',
'outright.btn.save_meta': 'Save event info',
@@ -881,6 +930,7 @@ export const adminPagesEn: Record<string, string> = {
'outright.field.title_placeholder': 'Title shown on player outright tab',
'outright.field.title_zh': 'Title (ZH)',
'outright.field.title_en': 'Title (EN)',
'outright.field.title_ms': 'Title (MS)',
'outright.field.status': 'Status',
'outright.status.draft': 'Draft',
'outright.status.published': 'Published',

View File

@@ -1,8 +1,21 @@
import { countryFlagUrl, getBuiltinCountry } from '../data/builtinCountries';
import {
countryFlagUrl,
countryCrestUrl,
countryLogoUrl,
getBuiltinCountry,
hasCountryCrest,
} from '../data/builtinCountries';
export { countryFlagUrl, getBuiltinCountry };
export {
countryFlagUrl,
countryCrestUrl,
countryLogoUrl,
getBuiltinCountry,
hasCountryCrest,
};
export function suggestTeamFlagUrl(code?: string): string {
const c = getBuiltinCountry(code);
return c ? countryFlagUrl(c) : '';
if (!c) return '';
return hasCountryCrest(c) ? countryCrestUrl(c) : countryFlagUrl(c);
}

View File

@@ -5,10 +5,13 @@ import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
import { ElMessage } from 'element-plus';
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
import LogoUrlField from '../components/LogoUrlField.vue';
import { countryDisplayName, type BuiltinCountry } from '../data/builtinCountries';
import {
readMatchesListUiState,
writeMatchesListUiState,
} from '../utils/matchesListState.ts';
import { formatAmount } from '../utils/format-amount';
import {
emptyMatchForm,
buildPlatformPayload,
@@ -135,6 +138,27 @@ async function submitCreateLeague() {
}
}
function applyTeamFromCountry(side: 'home' | 'away', country: BuiltinCountry) {
const msName = countryDisplayName(country, 'ms-MY');
if (side === 'home') {
if (!form.value.homeTeamZh.trim()) form.value.homeTeamZh = country.nameZh;
if (!form.value.homeTeamEn.trim()) form.value.homeTeamEn = country.nameEn;
if (!form.value.homeTeamMs.trim()) form.value.homeTeamMs = msName;
} else {
if (!form.value.awayTeamZh.trim()) form.value.awayTeamZh = country.nameZh;
if (!form.value.awayTeamEn.trim()) form.value.awayTeamEn = country.nameEn;
if (!form.value.awayTeamMs.trim()) form.value.awayTeamMs = msName;
}
}
function draftTeamCode(side: 'home' | 'away') {
const en = side === 'home' ? form.value.homeTeamEn : form.value.awayTeamEn;
const zh = side === 'home' ? form.value.homeTeamZh : form.value.awayTeamZh;
const name = (en || zh).trim();
if (!name) return '';
return `NAME_${name.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '').toUpperCase().slice(0, 48)}`;
}
function openCreateFixture(leagueRow: unknown) {
const r = rowOf(leagueRow);
form.value = emptyMatchForm();
@@ -245,6 +269,24 @@ function leagueTitle(row: unknown) {
function leagueMatchCount(row: unknown) {
return Number(rowOf(row).matchCount ?? 0);
}
function leagueBetStats(row: unknown) {
return rowOf(row).betStats as
| { betCount?: number; totalStake?: string; pendingCount?: number }
| undefined;
}
function leagueBetCount(row: unknown) {
return Number(leagueBetStats(row)?.betCount ?? 0);
}
function leagueTotalStake(row: unknown) {
return formatAmount(String(leagueBetStats(row)?.totalStake ?? '0'));
}
function leaguePendingBets(row: unknown) {
return Number(leagueBetStats(row)?.pendingCount ?? 0);
}
function isLeagueExpanded(id: string) {
return expandedRowKeys.value.includes(id);
}
@@ -320,10 +362,26 @@ function isLeagueExpanded(id: string) {
</div>
</template>
</el-table-column>
<el-table-column :label="t('match.col.fixture_count')" width="100" align="center">
<el-table-column :label="t('match.col.fixture_count')" width="88" align="center">
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
</el-table-column>
<el-table-column :label="t('match.col.league_code')" width="120">
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
<template #default="{ row }">
<span :class="{ 'bet-stat-active': leagueBetCount(row) > 0 }">{{ leagueBetCount(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.total_stake')" width="108" align="right">
<template #default="{ row }">{{ leagueTotalStake(row) }}</template>
</el-table-column>
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="leaguePendingBets(row) > 0" type="warning" size="small" effect="plain">
{{ leaguePendingBets(row) }}
</el-tag>
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.league_code')" width="100">
<template #default="{ row }">{{ rowOf(row).code }}</template>
</el-table-column>
</el-table>
@@ -369,7 +427,7 @@ function isLeagueExpanded(id: string) {
<el-dialog
v-model="createVisible"
:title="t('match.dialog.create_fixture')"
width="520px"
width="560px"
destroy-on-close
>
<el-form label-width="96px">
@@ -399,12 +457,32 @@ function isLeagueExpanded(id: string) {
<el-form-item :label="t('match.field.home_zh')">
<el-input v-model="form.homeTeamZh" :placeholder="t('match.ph.home_zh')" />
</el-form-item>
<el-form-item :label="t('match.field.home_ms')">
<el-input v-model="form.homeTeamMs" :placeholder="t('match.ph.home_ms')" />
</el-form-item>
<el-form-item :label="t('matchEditor.field.home_logo')">
<LogoUrlField
v-model="form.homeTeamLogoUrl"
:team-code="draftTeamCode('home')"
@pick="applyTeamFromCountry('home', $event)"
/>
</el-form-item>
<el-form-item :label="t('match.field.away_en')">
<el-input v-model="form.awayTeamEn" :placeholder="t('match.ph.away_en')" />
</el-form-item>
<el-form-item :label="t('match.field.away_zh')">
<el-input v-model="form.awayTeamZh" :placeholder="t('match.ph.away_zh')" />
</el-form-item>
<el-form-item :label="t('match.field.away_ms')">
<el-input v-model="form.awayTeamMs" :placeholder="t('match.ph.away_ms')" />
</el-form-item>
<el-form-item :label="t('matchEditor.field.away_logo')">
<LogoUrlField
v-model="form.awayTeamLogoUrl"
:team-code="draftTeamCode('away')"
@pick="applyTeamFromCountry('away', $event)"
/>
</el-form-item>
<el-form-item :label="t('match.field.featured')">
<el-switch v-model="form.isHot" />
</el-form-item>
@@ -518,6 +596,13 @@ function isLeagueExpanded(id: string) {
.matchup-link {
color: var(--green-text);
}
.bet-stat-active {
color: var(--green-text);
font-weight: 600;
}
.bet-stat-zero {
color: #555;
}
.league-cell {
display: flex;
@@ -536,4 +621,8 @@ function isLeagueExpanded(id: string) {
color: var(--green-text);
font-weight: 500;
}
:deep(.logo-url-field) {
width: 100%;
}
</style>

View File

@@ -1,22 +1,186 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount } from '../utils/format-amount';
import {
betStatusLabel,
betStatusTagType,
betTypeLabel,
} from '../utils/bet-labels';
import { adminSelectionLabel } from '../utils/adminSelectionLabel.ts';
import type { AdminMatchDetail } from './match-form.ts';
const { t } = useAdminLocale();
interface SettlementBetStats {
summary: {
totalBets: number;
singleBets: number;
parlayBets: number;
totalStake: string;
totalPotentialReturn: string;
statusCounts: Record<string, number>;
legCount: number;
};
bySelection: Array<{
marketType: string;
period: string | null;
selectionName: string;
selectionId: string;
legCount: number;
singleStake: string;
parlayLegCount: number;
}>;
bets: Array<{
id: string;
betNo: string;
username: string;
betType: string;
status: string;
stake: string;
potentialReturn: string | null;
placedAt: string;
marketType: string;
period: string | null;
selectionName: string;
odds: string;
}>;
}
const { t, locale, localeTag } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const match = ref<AdminMatchDetail | null>(null);
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const preview = ref<Record<string, unknown> | null>(null);
const stats = ref<SettlementBetStats | null>(null);
const statsLoading = ref(false);
const matchId = computed(() => String(route.params.id ?? ''));
const statusSummary = computed(() => {
const counts = stats.value?.summary.statusCounts ?? {};
return ['PENDING', 'WON', 'LOST', 'VOID', 'PUSH'].filter((s) => (counts[s] ?? 0) > 0);
});
const leagueLabel = computed(() => {
const m = match.value;
if (!m) return '';
if (locale.value === 'en-US') return m.leagueEn || m.leagueZh;
if (locale.value === 'ms-MY') return m.leagueMs || m.leagueEn || m.leagueZh;
return m.leagueZh || m.leagueEn;
});
const homeLabel = computed(() => {
const m = match.value;
if (!m) return '';
if (locale.value === 'en-US') return m.homeTeamEn || m.homeTeamZh;
if (locale.value === 'ms-MY') return m.homeTeamMs || m.homeTeamEn || m.homeTeamZh;
return m.homeTeamZh || m.homeTeamEn;
});
const awayLabel = computed(() => {
const m = match.value;
if (!m) return '';
if (locale.value === 'en-US') return m.awayTeamEn || m.awayTeamZh;
if (locale.value === 'ms-MY') return m.awayTeamMs || m.awayTeamEn || m.awayTeamZh;
return m.awayTeamZh || m.awayTeamEn;
});
const kickoffLabel = computed(() => {
const iso = match.value?.startTime;
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(locale.value === 'en-US' ? 'en-GB' : 'zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
});
const statusLabel = computed(() => {
const s = match.value?.status;
if (!s) return '—';
const key = `match.status.${s}`;
const translated = t(key);
return translated === key ? s : translated;
});
function marketLabel(marketType: string) {
const key = `matchEditor.market.${marketType}`;
const label = t(key);
return label === key ? marketType : label;
}
function selectionDisplay(row: { selectionName: string; period?: string | null }) {
return adminSelectionLabel(t, row.selectionName, {
period: row.period ?? undefined,
});
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
async function loadStats() {
if (!matchId.value) return;
statsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`);
stats.value = data.data as SettlementBetStats;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
statsLoading.value = false;
}
}
async function loadMatch() {
if (!matchId.value) return;
loading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}`);
const detail = data.data as AdminMatchDetail & {
score?: { htHome: number; htAway: number; ftHome: number; ftAway: number } | null;
};
if (detail.isOutright) {
ElMessage.warning(t('msg.outright_no_edit'));
router.replace('/outrights');
return;
}
match.value = detail;
if (detail.score) {
score.value = { ...detail.score };
}
await loadStats();
} 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;
}
}
async function recordScore() {
await api.post(`/admin/matches/${route.params.id}/settlement/score`, score.value);
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
ElMessage.success(t('msg.score_recorded'));
await loadMatch();
}
async function previewSettlement() {
const { data } = await api.post(`/admin/matches/${route.params.id}/settlement/preview`);
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`);
preview.value = data.data;
}
@@ -25,104 +189,480 @@ async function confirm() {
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.settlement_confirmed'));
preview.value = null;
await loadMatch();
}
function goBack() {
router.push('/matches');
}
onMounted(() => {
void loadMatch();
});
</script>
<template>
<div class="page-header">
<h2 class="page-title">{{ t('page.settlement.title') }}</h2>
<span class="page-id"># {{ route.params.id }}</span>
<div v-loading="loading" class="settlement-page page-scroll">
<div class="page-header">
<div class="header-left">
<el-button size="small" text @click="goBack"> {{ t('settlement.back') }}</el-button>
<h2 class="page-title">{{ t('page.settlement.title') }}</h2>
<span class="page-id">#{{ matchId }}</span>
</div>
<el-tag v-if="match" size="small" type="info">{{ statusLabel }}</el-tag>
</div>
<el-card v-if="match" class="settle-top-card" shadow="never">
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
<div class="settle-top-row">
<div class="match-inline">
<div class="team-chip">
<img
v-if="match.homeTeamLogoUrl"
:src="match.homeTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ homeLabel }}</span>
</div>
<span class="vs">VS</span>
<div class="team-chip">
<img
v-if="match.awayTeamLogoUrl"
:src="match.awayTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ awayLabel }}</span>
</div>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.kickoff') }}</span>
{{ kickoffLabel }}
</span>
</div>
<div class="score-panel">
<div class="score-inline-group">
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ht_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ft_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
</div>
<div class="action-row">
<el-button size="small" @click="recordScore">{{ t('settlement.record_score') }}</el-button>
<el-button type="primary" size="small" @click="previewSettlement">
{{ t('settlement.preview_btn') }}
</el-button>
</div>
</div>
</div>
</el-card>
<el-card v-if="preview" class="preview-card" shadow="never">
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
<el-row :gutter="20">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ preview.singleBetCount }}</div>
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
</div>
</el-col>
</el-row>
<el-button type="success" class="confirm-btn" @click="confirm">
{{ t('settlement.confirm_btn') }}
</el-button>
</el-card>
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
<div class="section-title">{{ t('settlement.stats_title') }}</div>
<template v-if="stats">
<div class="summary-grid">
<div class="sstat">
<div class="sstat-value">{{ stats.summary.totalBets }}</div>
<div class="sstat-label">{{ t('settlement.stats_total_bets') }}</div>
</div>
<div class="sstat">
<div class="sstat-value">{{ stats.summary.singleBets }}</div>
<div class="sstat-label">{{ t('settlement.stats_single') }}</div>
</div>
<div class="sstat">
<div class="sstat-value">{{ stats.summary.parlayBets }}</div>
<div class="sstat-label">{{ t('settlement.stats_parlay') }}</div>
</div>
<div class="sstat">
<div class="sstat-value">{{ formatAmount(stats.summary.totalStake) }}</div>
<div class="sstat-label">{{ t('settlement.stats_total_stake') }}</div>
</div>
<div class="sstat">
<div class="sstat-value sstat-muted">{{ formatAmount(stats.summary.totalPotentialReturn) }}</div>
<div class="sstat-label">{{ t('settlement.stats_potential') }}</div>
</div>
</div>
<div v-if="statusSummary.length" class="status-chips">
<el-tag
v-for="st in statusSummary"
:key="st"
size="small"
:type="betStatusTagType(st)"
effect="plain"
>
{{ betStatusLabel(st) }} {{ stats.summary.statusCounts[st] }}
</el-tag>
</div>
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
<el-table
v-if="stats.bySelection.length"
:data="stats.bySelection"
size="small"
stripe
class="stats-table"
>
<el-table-column :label="t('settlement.col.market')" min-width="140">
<template #default="{ row }">
{{ marketLabel(row.marketType) }}
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="160">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.legs')" width="88" align="center" prop="legCount" />
<el-table-column :label="t('settlement.col.single_stake')" width="120" align="right">
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.parlay_legs')" width="100" align="center" prop="parlayLegCount" />
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
<div class="subsection-title">{{ t('settlement.bet_list') }} ({{ stats.bets.length }})</div>
<el-table
v-if="stats.bets.length"
:data="stats.bets"
size="small"
stripe
max-height="420"
class="stats-table"
>
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="150" />
<el-table-column prop="username" :label="t('bet.col.player')" width="110" />
<el-table-column :label="t('common.type')" width="72">
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.market')" min-width="120">
<template #default="{ row }">{{ marketLabel(row.marketType) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="120">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('bet.col.odds')" width="72" align="right" prop="odds" />
<el-table-column :label="t('bet.col.stake')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="96">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" width="130">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</template>
</el-card>
</div>
<el-card class="tool-card" shadow="never">
<div class="score-section">
<div class="score-block">
<div class="score-title">{{ t('settlement.ht_score') }}</div>
<div class="score-inputs">
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 100px" />
<span class="score-sep"></span>
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 100px" />
</div>
</div>
<div class="score-block">
<div class="score-title">{{ t('settlement.ft_score') }}</div>
<div class="score-inputs">
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 100px" />
<span class="score-sep"></span>
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 100px" />
</div>
</div>
</div>
<div class="action-row">
<el-button @click="recordScore">{{ t('settlement.record_score') }}</el-button>
<el-button type="primary" @click="previewSettlement">{{ t('settlement.preview_btn') }}</el-button>
</div>
</el-card>
<el-card v-if="preview" class="preview-card" shadow="never">
<div class="preview-title">{{ t('settlement.preview_title') }}</div>
<el-row :gutter="20">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ preview.singleBetCount }}</div>
<div class="pstat-label">{{ t('settlement.single_count') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-green">{{ preview.totalPayout }}</div>
<div class="pstat-label">{{ t('settlement.est_payout') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-orange">{{ preview.totalRefund }}</div>
<div class="pstat-label">{{ t('settlement.refund_amount') }}</div>
</div>
</el-col>
</el-row>
<el-button type="success" @click="confirm" style="margin-top: 24px">{{ t('settlement.confirm_btn') }}</el-button>
</el-card>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-id { font-size: 14px; color: #3a3a3a; font-family: monospace; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.preview-card { border-radius: 12px; }
.score-section {
.settlement-page {
display: flex;
gap: 40px;
margin-bottom: 20px;
flex-direction: column;
gap: 16px;
flex: none !important;
min-height: auto !important;
align-self: flex-start;
width: 100%;
}
.score-block { }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e8e8e8;
margin: 0;
}
.page-id {
font-size: 13px;
color: #666;
font-family: monospace;
}
.settle-top-card,
.stats-card,
.preview-card {
border-radius: 12px;
}
.settle-top-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.match-inline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
flex: 1;
min-width: 280px;
}
.team-chip {
display: inline-flex;
align-items: center;
gap: 8px;
}
.team-logo-sm {
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
}
.team-name-inline {
font-size: 16px;
font-weight: 600;
color: #eee;
white-space: nowrap;
}
.kickoff-inline {
font-size: 13px;
color: #aaa;
white-space: nowrap;
}
.kickoff-inline .meta-k {
color: #777;
margin-right: 4px;
}
.score-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
flex-shrink: 0;
}
.score-inline-group {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 16px 20px;
}
.score-block.compact {
display: flex;
flex-direction: column;
gap: 6px;
}
.score-block.compact .score-title {
margin-bottom: 0;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 14px;
}
.subsection-title {
font-size: 13px;
font-weight: 600;
color: #aaa;
margin: 18px 0 10px;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
}
.sstat {
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 8px;
text-align: center;
}
.sstat-value {
font-size: 22px;
font-weight: 700;
color: #eee;
}
.sstat-muted {
color: var(--green-text, #2fb56a);
}
.sstat-label {
font-size: 11px;
color: #888;
margin-top: 4px;
}
.status-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.stats-table {
width: 100%;
}
.period-tag {
margin-left: 6px;
font-size: 11px;
color: #666;
}
.empty-hint {
margin: 0;
font-size: 13px;
color: #666;
text-align: center;
padding: 16px 0;
}
.match-league {
margin: 0 0 10px;
font-size: 13px;
color: #888;
}
.vs {
font-size: 13px;
font-weight: 700;
color: var(--green-text, #2fb56a);
flex-shrink: 0;
}
.score-title {
font-size: 13px;
color: #3a3a3a;
color: #999;
margin-bottom: 8px;
font-weight: 500;
}
.score-inputs {
display: flex;
align-items: center;
gap: 10px;
}
.score-sep {
font-size: 18px;
color: #ccc;
color: #666;
font-weight: 300;
}
.action-row {
display: flex;
gap: 10px;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
.pstat { padding: 16px; background: #f9f9fb; border-radius: 10px; text-align: center; }
.pstat-value { font-size: 26px; font-weight: 700; color: #e0e0e0; }
.pstat-green { color: var(--green-glow); text-shadow: 0 0 20px rgba(47, 181, 106, 0.35); }
.pstat-orange { color: #c85a00; }
.pstat-label { font-size: 12px; color: #3a3a3a; margin-top: 4px; }
.preview-title {
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
margin-bottom: 16px;
}
.pstat {
padding: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid #2a2a2a;
border-radius: 10px;
text-align: center;
}
.pstat-value {
font-size: 26px;
font-weight: 700;
color: #e0e0e0;
}
.pstat-green {
color: var(--green-glow);
text-shadow: 0 0 20px rgba(47, 181, 106, 0.35);
}
.pstat-orange {
color: #e8a040;
}
.pstat-label {
font-size: 12px;
color: #888;
margin-top: 4px;
}
.confirm-btn {
margin-top: 24px;
}
</style>

View File

@@ -90,6 +90,12 @@ export type AdminMatchDetail = {
matchName: string;
stage?: string;
groupName?: string;
score?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
} | null;
markets?: AdminMarket[];
};
@@ -142,6 +148,11 @@ export function buildPlatformPayload(form: MatchCreateForm) {
if (!homeOk || !awayOk) {
throw new FormValidationError('err.teams_required');
}
const homeKey = `${form.homeTeamZh.trim()}|${form.homeTeamEn.trim()}|${form.homeTeamMs.trim()}`.toLowerCase();
const awayKey = `${form.awayTeamZh.trim()}|${form.awayTeamEn.trim()}|${form.awayTeamMs.trim()}`.toLowerCase();
if (homeKey === awayKey) {
throw new FormValidationError('err.teams_same');
}
if (
!form.leagueId.trim() &&
!form.leagueZh.trim() &&

View File

@@ -5,6 +5,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
import { formatAmount } from '../../utils/format-amount';
const props = defineProps<{
leagueId: string;
filterStatus: string;
@@ -127,6 +128,16 @@ function matchId(row: unknown) {
function matchTime(row: unknown) {
return new Date(String(rowOf(row).startTime)).toLocaleString();
}
function betCount(row: unknown) {
return Number(rowOf(row).betCount ?? 0);
}
function totalStake(row: unknown) {
return formatAmount(String(rowOf(row).totalStake ?? '0'));
}
function pendingBets(row: unknown) {
return Number(rowOf(row).pendingBets ?? 0);
}
function matchTitle(row: unknown) {
const r = rowOf(row);
const home =
@@ -202,6 +213,22 @@ defineExpose({ reload: load });
<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('match.col.bet_count')" width="72" align="center">
<template #default="{ row }">
<span :class="{ 'bet-stat-active': betCount(row) > 0 }">{{ betCount(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.total_stake')" width="100" align="right">
<template #default="{ row }">{{ totalStake(row) }}</template>
</el-table-column>
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="pendingBets(row) > 0" type="warning" size="small" effect="plain">
{{ pendingBets(row) }}
</el-tag>
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="420" align="center">
<template #default="{ row }">
<div class="action-btns">
@@ -247,6 +274,13 @@ defineExpose({ reload: load });
.matchup-link {
color: var(--green-text);
}
.bet-stat-active {
color: var(--green-text);
font-weight: 600;
}
.bet-stat-zero {
color: #555;
}
.action-btns {
display: flex;
flex-wrap: wrap;

View File

@@ -6,7 +6,7 @@ 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 { countryDisplayName, type BuiltinCountry } from '../../data/builtinCountries';
import {
buildPlatformPayload,
emptyMatchForm,
@@ -32,12 +32,15 @@ function applyTeamFromCountry(
side: 'home' | 'away',
country: BuiltinCountry,
) {
const msName = countryDisplayName(country, 'ms-MY');
if (side === 'home') {
if (!form.value.homeTeamZh.trim()) form.value.homeTeamZh = country.nameZh;
if (!form.value.homeTeamEn.trim()) form.value.homeTeamEn = country.nameEn;
if (!form.value.homeTeamMs.trim()) form.value.homeTeamMs = msName;
} else {
if (!form.value.awayTeamZh.trim()) form.value.awayTeamZh = country.nameZh;
if (!form.value.awayTeamEn.trim()) form.value.awayTeamEn = country.nameEn;
if (!form.value.awayTeamMs.trim()) form.value.awayTeamMs = msName;
}
}

View File

@@ -4,9 +4,8 @@ import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import CountryFlagSelect from '../../components/outright/CountryFlagSelect.vue';
import LogoUrlField from '../../components/LogoUrlField.vue';
import {
countryFlagUrl,
getBuiltinCountry,
resolveCountryCode,
type BuiltinCountry,
@@ -30,6 +29,7 @@ interface SelectionRow {
logoUrl: string | null;
editOdds: number;
editCountryCode: string;
editLogoUrl: string;
}
const loading = ref(false);
@@ -39,7 +39,9 @@ const meta = ref({
leagueZh: '',
leagueEn: '',
leagueCode: '',
matchName: '',
titleZh: '',
titleEn: '',
titleMs: '',
status: 'DRAFT',
expectedCanonicalCount: null as number | null,
playerVisible: true,
@@ -53,6 +55,7 @@ const addForm = ref({
teamCode: '',
teamZh: '',
teamEn: '',
logoUrl: '',
odds: 10,
});
@@ -81,7 +84,9 @@ async function load() {
leagueZh: string;
leagueEn: string;
leagueCode: string;
matchName: string;
titleZh: string;
titleEn: string;
titleMs: string;
status: string;
expectedCanonicalCount: number | null;
playerVisible: boolean;
@@ -92,7 +97,9 @@ async function load() {
leagueZh: payload.leagueZh,
leagueEn: payload.leagueEn,
leagueCode: payload.leagueCode,
matchName: payload.matchName,
titleZh: payload.titleZh ?? '',
titleEn: payload.titleEn ?? '',
titleMs: payload.titleMs ?? '',
status: payload.status,
expectedCanonicalCount: payload.expectedCanonicalCount,
playerVisible: payload.playerVisible,
@@ -103,6 +110,7 @@ async function load() {
logoUrl: s.logoUrl ?? null,
editOdds: Number(s.odds),
editCountryCode: resolveCountryCode(s.teamCode, s.logoUrl ?? null),
editLogoUrl: s.logoUrl ?? '',
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -119,7 +127,9 @@ async function saveMeta() {
try {
await api.put(`/admin/outrights/${matchId.value}`, {
status: meta.value.status,
matchName: meta.value.matchName,
titleZh: meta.value.titleZh,
titleEn: meta.value.titleEn,
titleMs: meta.value.titleMs,
});
ElMessage.success(t('msg.saved'));
await load();
@@ -171,12 +181,12 @@ async function submitAdd() {
teamCode: country.code,
teamZh: country.nameZh,
teamEn: country.nameEn,
logoUrl: countryFlagUrl(country),
logoUrl: addForm.value.logoUrl.trim() || undefined,
odds: addForm.value.odds,
});
ElMessage.success(t('msg.saved'));
addVisible.value = false;
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', odds: 10 };
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', logoUrl: '', odds: 10 };
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -255,8 +265,29 @@ function rowDisplayCode(row: SelectionRow) {
function isRowDirty(row: SelectionRow) {
const countryDirty =
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
const logoDirty = (row.editLogoUrl || '').trim() !== (row.logoUrl || '').trim();
const oddsDirty = row.editOdds !== Number(row.odds);
return countryDirty || oddsDirty;
return countryDirty || logoDirty || oddsDirty;
}
function onRowCountryPick(row: SelectionRow, country: BuiltinCountry) {
row.editCountryCode = country.code;
}
function onRowLogoChange(row: SelectionRow, url: string) {
row.editLogoUrl = url;
const code = resolveCountryCode(row.editCountryCode || row.teamCode, url);
if (code) row.editCountryCode = code;
}
function onAddLogoChange(url: string) {
addForm.value.logoUrl = url;
const code = resolveCountryCode(addForm.value.countryCode, url);
if (code) addForm.value.countryCode = code;
}
function onAddLogoPick(country: BuiltinCountry) {
onAddCountryPick(country);
}
async function saveRow(row: SelectionRow) {
@@ -271,8 +302,9 @@ async function saveRow(row: SelectionRow) {
const country = getBuiltinCountry(row.editCountryCode);
const countryDirty =
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
const logoDirty = (row.editLogoUrl || '').trim() !== (row.logoUrl || '').trim();
if (countryDirty) {
if (countryDirty || logoDirty) {
if (!country) {
ElMessage.warning(t('outright.err_country'));
return;
@@ -281,7 +313,7 @@ async function saveRow(row: SelectionRow) {
teamCode: country.code,
teamZh: country.nameZh,
teamEn: country.nameEn,
logoUrl: countryFlagUrl(country),
logoUrl: row.editLogoUrl.trim() || undefined,
});
}
@@ -322,13 +354,19 @@ async function saveRow(row: SelectionRow) {
<section class="panel settings-panel">
<div class="settings-top">
<el-input
v-model="meta.matchName"
size="small"
class="title-input"
:placeholder="t('outright.field.title_placeholder')"
@keyup.enter="saveMeta"
/>
<div class="title-fields">
<el-form label-width="88px" size="small" @submit.prevent="saveMeta">
<el-form-item :label="t('outright.field.title_zh')">
<el-input v-model="meta.titleZh" @keyup.enter="saveMeta" />
</el-form-item>
<el-form-item :label="t('outright.field.title_en')">
<el-input v-model="meta.titleEn" @keyup.enter="saveMeta" />
</el-form-item>
<el-form-item :label="t('outright.field.title_ms')">
<el-input v-model="meta.titleMs" @keyup.enter="saveMeta" />
</el-form-item>
</el-form>
</div>
<div class="settings-actions">
<el-button size="small" :loading="saving" @click="saveMeta">
{{ t('common.save') }}
@@ -369,11 +407,14 @@ async function saveRow(row: SelectionRow) {
<div class="table-wrap">
<el-table :data="selections" stripe size="small" empty-text="">
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
<el-table-column :label="t('outright.col.country')" min-width="220">
<el-table-column :label="t('outright.col.country')" min-width="340">
<template #default="{ row }">
<CountryFlagSelect
v-model="row.editCountryCode"
:disabled="!!savingRowId"
<LogoUrlField
:model-value="row.editLogoUrl"
compact
:team-code="row.editCountryCode || row.teamCode"
@update:model-value="onRowLogoChange(row, $event)"
@pick="onRowCountryPick(row, $event)"
/>
</template>
</el-table-column>
@@ -431,13 +472,14 @@ async function saveRow(row: SelectionRow) {
</div>
</section>
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="440px">
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="520px">
<el-form label-width="100px">
<el-form-item :label="t('outright.col.country')" required>
<CountryFlagSelect
v-model="addForm.countryCode"
size="default"
@pick="onAddCountryPick"
<LogoUrlField
:model-value="addForm.logoUrl"
:team-code="addForm.countryCode"
@update:model-value="onAddLogoChange"
@pick="onAddLogoPick"
/>
</el-form-item>
<el-form-item v-if="addForm.teamCode" :label="t('outright.col.code')">
@@ -505,9 +547,14 @@ async function saveRow(row: SelectionRow) {
.settings-top {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
gap: 12px;
}
.title-fields {
flex: 1;
min-width: 0;
}
.league-meta {
@@ -528,11 +575,6 @@ async function saveRow(row: SelectionRow) {
gap: 6px;
}
.title-input {
flex: 1;
min-width: 0;
}
.panel-head.compact {
display: flex;
align-items: center;

View File

@@ -59,6 +59,7 @@ const createForm = ref({
leagueId: '',
titleZh: '',
titleEn: '',
titleMs: '',
status: 'PUBLISHED',
});
@@ -166,7 +167,7 @@ async function submitCreate() {
const { data } = await api.post('/admin/outrights', createForm.value);
ElMessage.success(t('msg.saved'));
createVisible.value = false;
createForm.value = { leagueId: '', titleZh: '', titleEn: '', status: 'PUBLISHED' };
createForm.value = { leagueId: '', titleZh: '', titleEn: '', titleMs: '', status: 'PUBLISHED' };
listReady.value = false;
rowDetails.value = {};
await loadEvents(false);
@@ -292,7 +293,7 @@ onMounted(() => {
</el-table>
</el-card>
<el-dialog v-model="createVisible" :title="t('outright.btn.create_event')" width="440px">
<el-dialog v-model="createVisible" :title="t('outright.btn.create_event')" width="480px">
<el-form label-width="100px" size="small">
<el-form-item :label="t('outright.field.league')">
<el-select v-model="createForm.leagueId" filterable style="width: 100%">
@@ -310,6 +311,9 @@ onMounted(() => {
<el-form-item :label="t('outright.field.title_en')">
<el-input v-model="createForm.titleEn" />
</el-form-item>
<el-form-item :label="t('outright.field.title_ms')">
<el-input v-model="createForm.titleMs" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="createVisible = false">{{ t('common.cancel') }}</el-button>