diff --git a/apps/admin/src/components/LogoUrlField.vue b/apps/admin/src/components/LogoUrlField.vue index 63d705c..4aa3ab6 100644 --- a/apps/admin/src/components/LogoUrlField.vue +++ b/apps/admin/src/components/LogoUrlField.vue @@ -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('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); } diff --git a/apps/admin/src/components/outright/CountryFlagSelect.vue b/apps/admin/src/components/outright/CountryFlagSelect.vue index 5683736..1202868 100644 --- a/apps/admin/src/components/outright/CountryFlagSelect.vue +++ b/apps/admin/src/components/outright/CountryFlagSelect.vue @@ -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) { >
+ {{ countryDisplayName(c, locale) }} {{ c.code }}
@@ -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; diff --git a/apps/admin/src/data/builtinCountries.ts b/apps/admin/src/data/builtinCountries.ts index 820f086..2515a57 100644 --- a/apps/admin/src/data/builtinCountries.ts +++ b/apps/admin/src/data/builtinCountries.ts @@ -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> = { - 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); diff --git a/apps/admin/src/data/nationalTeamCrests.ts b/apps/admin/src/data/nationalTeamCrests.ts new file mode 100644 index 0000000..a5285c0 --- /dev/null +++ b/apps/admin/src/data/nationalTeamCrests.ts @@ -0,0 +1,103 @@ +/** 世界杯 48 强国家队徽(footylogos CDN) */ +export const NATIONAL_TEAM_CREST: Record = { + 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 = { + 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', +}; diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index d2b39b6..cfba5e8 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -162,6 +162,7 @@ const zh: Record = { 'match.status.PUBLISHED': '已发布', 'match.status.CLOSED': '已封盘', 'match.status.SETTLED': '已结算', + 'match.status.PENDING_SETTLEMENT': '待结算', ...adminPagesZh, }; @@ -314,6 +315,7 @@ const en: Record = { '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 = { 'match.status.PUBLISHED': 'Diterbitkan', 'match.status.CLOSED': 'Ditutup', 'match.status.SETTLED': 'Diselesaikan', + 'match.status.PENDING_SETTLEMENT': 'Menunggu penyelesaian', ...adminPagesMs, }; diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index d36e6a8..1fa5f3c 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -129,6 +129,9 @@ export const adminPagesMs: Record = { '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 = { '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 = { '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 = { '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', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 985987f..345b69a 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -129,6 +129,9 @@ export const adminPagesZh: Record = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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 = { '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', diff --git a/apps/admin/src/utils/teamFlag.ts b/apps/admin/src/utils/teamFlag.ts index 1f3397a..2768c18 100644 --- a/apps/admin/src/utils/teamFlag.ts +++ b/apps/admin/src/utils/teamFlag.ts @@ -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); } diff --git a/apps/admin/src/views/Matches.vue b/apps/admin/src/views/Matches.vue index 79a5892..e72bd16 100644 --- a/apps/admin/src/views/Matches.vue +++ b/apps/admin/src/views/Matches.vue @@ -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) { - + - + + + + + + + + + + @@ -369,7 +427,7 @@ function isLeagueExpanded(id: string) { @@ -399,12 +457,32 @@ function isLeagueExpanded(id: string) { + + + + + + + + + + + + @@ -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%; +} diff --git a/apps/admin/src/views/Settlement.vue b/apps/admin/src/views/Settlement.vue index 95a4a23..18ddf16 100644 --- a/apps/admin/src/views/Settlement.vue +++ b/apps/admin/src/views/Settlement.vue @@ -1,22 +1,186 @@ diff --git a/apps/admin/src/views/match-form.ts b/apps/admin/src/views/match-form.ts index a7f4802..4e6399e 100644 --- a/apps/admin/src/views/match-form.ts +++ b/apps/admin/src/views/match-form.ts @@ -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() && diff --git a/apps/admin/src/views/matches/LeagueMatchesPanel.vue b/apps/admin/src/views/matches/LeagueMatchesPanel.vue index 6986a52..bb68929 100644 --- a/apps/admin/src/views/matches/LeagueMatchesPanel.vue +++ b/apps/admin/src/views/matches/LeagueMatchesPanel.vue @@ -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 }); + + + + + + + + +