feat(admin,api,player): settlement stats, team crests, MS fields and list bet summary
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
103
apps/admin/src/data/nationalTeamCrests.ts
Normal file
103
apps/admin/src/data/nationalTeamCrests.ts
Normal 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',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -388,6 +388,10 @@ class CreateOutrightDto {
|
||||
@IsString()
|
||||
titleEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@@ -402,6 +406,18 @@ class UpdateOutrightDto {
|
||||
@IsString()
|
||||
matchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleZh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
isHot?: boolean;
|
||||
|
||||
@@ -1068,6 +1084,7 @@ export class AdminController {
|
||||
leagueId: BigInt(dto.leagueId),
|
||||
titleZh: dto.titleZh,
|
||||
titleEn: dto.titleEn,
|
||||
titleMs: dto.titleMs,
|
||||
status: dto.status,
|
||||
});
|
||||
return jsonResponse(data);
|
||||
@@ -1172,6 +1189,12 @@ export class AdminController {
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Get('matches/:id/settlement/stats')
|
||||
async getMatchSettlementStats(@Param('id') id: string) {
|
||||
const data = await this.settlement.getMatchBetStats(BigInt(id));
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Post('matches/:id/settlement/score')
|
||||
async recordScore(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
|
||||
@@ -2,7 +2,14 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
@@ -139,32 +146,39 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
async upsertTeamFromZhiboExport(team: ZhiboTeamExport) {
|
||||
const code = teamCodeFromExport(team);
|
||||
const translations = translationsFromZhiboNames(team.names, team.name);
|
||||
|
||||
let record =
|
||||
team.id != null
|
||||
? await this.prisma.team.findFirst({ where: { externalId: team.id } })
|
||||
: await this.prisma.team.findUnique({ where: { code } });
|
||||
|
||||
if (!record) {
|
||||
record = await this.prisma.team.create({
|
||||
data: {
|
||||
code,
|
||||
externalId: team.id ?? undefined,
|
||||
logoUrl: team.image || undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
record = await this.prisma.team.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
logoUrl: team.image || record.logoUrl,
|
||||
externalId: team.id ?? record.externalId,
|
||||
},
|
||||
if (team.id != null) {
|
||||
const existing = await this.prisma.team.findFirst({
|
||||
where: { externalId: team.id },
|
||||
});
|
||||
if (existing) {
|
||||
const record = await this.prisma.team.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
logoUrl: team.image || existing.logoUrl,
|
||||
externalId: team.id,
|
||||
},
|
||||
});
|
||||
await this.upsertEntityTranslations('TEAM', record.id, translations);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
const code = teamCodeFromExport(team);
|
||||
const record = await this.prisma.team.upsert({
|
||||
where: { code },
|
||||
create: {
|
||||
code,
|
||||
externalId: team.id ?? undefined,
|
||||
logoUrl: team.image || undefined,
|
||||
},
|
||||
update: {
|
||||
logoUrl: team.image || undefined,
|
||||
externalId: team.id ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await this.upsertEntityTranslations('TEAM', record.id, translations);
|
||||
return record;
|
||||
}
|
||||
@@ -322,10 +336,51 @@ export class MatchesService {
|
||||
leagueZh,
|
||||
leagueMs,
|
||||
matchCount,
|
||||
betStats: { betCount: 0, totalStake: '0', pendingCount: 0 },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const leagueIds = leagues.map((l) => l.id);
|
||||
const leagueMatches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
leagueId: { in: leagueIds },
|
||||
deletedAt: null,
|
||||
isOutright: false,
|
||||
...(opts.status ? { status: opts.status } : {}),
|
||||
},
|
||||
select: { id: true, leagueId: true },
|
||||
});
|
||||
const matchStats = await this.betStatsForMatches(
|
||||
leagueMatches.map((m) => m.id),
|
||||
);
|
||||
const leagueBetRollup = new Map<string, MatchBetStatsSummary>();
|
||||
for (const lm of leagueMatches) {
|
||||
const lid = lm.leagueId.toString();
|
||||
const cur = leagueBetRollup.get(lid) ?? {
|
||||
betCount: 0,
|
||||
totalStake: new Decimal(0),
|
||||
pendingCount: 0,
|
||||
};
|
||||
const ms = matchStats.get(lm.id.toString());
|
||||
if (ms) {
|
||||
cur.betCount += ms.betCount;
|
||||
cur.totalStake = cur.totalStake.add(ms.totalStake);
|
||||
cur.pendingCount += ms.pendingCount;
|
||||
}
|
||||
leagueBetRollup.set(lid, cur);
|
||||
}
|
||||
for (const item of items) {
|
||||
const roll = leagueBetRollup.get(item.id);
|
||||
if (roll) {
|
||||
item.betStats = {
|
||||
betCount: roll.betCount,
|
||||
totalStake: roll.totalStake.toString(),
|
||||
pendingCount: roll.pendingCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { items, total, page: opts.page, pageSize: opts.pageSize };
|
||||
}
|
||||
|
||||
@@ -353,12 +408,17 @@ export class MatchesService {
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
});
|
||||
const locale = opts.locale ?? 'zh-CN';
|
||||
const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id));
|
||||
return Promise.all(
|
||||
items.map(async (m) => {
|
||||
const [homeTeamName, awayTeamName] = await Promise.all([
|
||||
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||
]);
|
||||
const raw = betStatsMap.get(m.id.toString());
|
||||
const betCount = raw?.betCount ?? 0;
|
||||
const totalStake = raw?.totalStake.toString() ?? '0';
|
||||
const pendingBets = raw?.pendingCount ?? 0;
|
||||
return {
|
||||
id: m.id.toString(),
|
||||
status: m.status,
|
||||
@@ -371,11 +431,76 @@ export class MatchesService {
|
||||
awayTeamName,
|
||||
homeTeam: { code: m.homeTeam.code },
|
||||
awayTeam: { code: m.awayTeam.code },
|
||||
betCount,
|
||||
totalStake,
|
||||
pendingBets,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary & { totalStake: Decimal }>> {
|
||||
const result = new Map<
|
||||
string,
|
||||
MatchBetStatsSummary & { totalStake: Decimal }
|
||||
>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: new Decimal(0),
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake,
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async createPlatformMatch(data: {
|
||||
leagueId?: bigint;
|
||||
leagueEn?: string;
|
||||
@@ -438,34 +563,36 @@ export class MatchesService {
|
||||
});
|
||||
}
|
||||
}
|
||||
const [homeTeam, awayTeam] = await Promise.all([
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh || homeMs,
|
||||
names: {
|
||||
zh: homeZh || null,
|
||||
en: homeEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: homeMs || null,
|
||||
},
|
||||
image: data.homeTeamLogoUrl?.trim() || '',
|
||||
}),
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh || awayMs,
|
||||
names: {
|
||||
zh: awayZh || null,
|
||||
en: awayEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: awayMs || null,
|
||||
},
|
||||
image: data.awayTeamLogoUrl?.trim() || '',
|
||||
}),
|
||||
]);
|
||||
const homeTeam = await this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh || homeMs,
|
||||
names: {
|
||||
zh: homeZh || null,
|
||||
en: homeEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: homeMs || null,
|
||||
},
|
||||
image: data.homeTeamLogoUrl?.trim() || '',
|
||||
});
|
||||
const awayTeam = await this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh || awayMs,
|
||||
names: {
|
||||
zh: awayZh || null,
|
||||
en: awayEn || null,
|
||||
zhTw: '',
|
||||
vi: null,
|
||||
km: null,
|
||||
ms: awayMs || null,
|
||||
},
|
||||
image: data.awayTeamLogoUrl?.trim() || '',
|
||||
});
|
||||
|
||||
if (homeTeam.id === awayTeam.id) {
|
||||
throw new BadRequestException('主客队不能为同一支球队,请填写不同的队名');
|
||||
}
|
||||
|
||||
const matchName =
|
||||
data.matchName?.trim() ||
|
||||
@@ -499,6 +626,9 @@ export class MatchesService {
|
||||
|
||||
async getAdminMatchDetail(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
const scoreRow = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId },
|
||||
});
|
||||
const markets = await this.prisma.market.findMany({
|
||||
where: { matchId },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
@@ -542,6 +672,14 @@ export class MatchesService {
|
||||
matchName: match.matchName ?? '',
|
||||
stage: match.stage ?? '',
|
||||
groupName: match.groupName ?? '',
|
||||
score: scoreRow
|
||||
? {
|
||||
htHome: scoreRow.htHomeScore ?? 0,
|
||||
htAway: scoreRow.htAwayScore ?? 0,
|
||||
ftHome: scoreRow.ftHomeScore ?? 0,
|
||||
ftAway: scoreRow.ftAwayScore ?? 0,
|
||||
}
|
||||
: null,
|
||||
markets: markets.map((m) => ({
|
||||
id: m.id.toString(),
|
||||
marketType: m.marketType,
|
||||
@@ -1009,21 +1147,24 @@ export class MatchesService {
|
||||
return results;
|
||||
}
|
||||
|
||||
private marketLabelKey(marketType: string): string {
|
||||
const keys: Record<string, string> = {
|
||||
FT_1X2: '全场独赢',
|
||||
FT_HANDICAP: '全场让球',
|
||||
FT_OVER_UNDER: '全场大小',
|
||||
FT_ODD_EVEN: '全场单双',
|
||||
HT_1X2: '半场独赢',
|
||||
HT_HANDICAP: '半场让球',
|
||||
HT_OVER_UNDER: '半场大小',
|
||||
OUTRIGHT_WINNER: '冠军',
|
||||
FT_CORRECT_SCORE: '波胆',
|
||||
HT_CORRECT_SCORE: '上半场波胆',
|
||||
SH_CORRECT_SCORE: '下半场波胆',
|
||||
private marketLabelKey(marketType: string, locale = 'zh-CN'): string {
|
||||
type LangMap = Record<string, string>;
|
||||
const labels: Record<string, LangMap> = {
|
||||
FT_1X2: { 'zh-CN': '全场独赢', 'en-US': 'FT 1X2', 'ms-MY': '1X2 Penuh' },
|
||||
FT_HANDICAP: { 'zh-CN': '全场让球', 'en-US': 'FT Handicap', 'ms-MY': 'Handicap Penuh' },
|
||||
FT_OVER_UNDER: { 'zh-CN': '全场大小', 'en-US': 'FT O/U', 'ms-MY': 'Atas/Bawah Penuh' },
|
||||
FT_ODD_EVEN: { 'zh-CN': '全场单双', 'en-US': 'FT Odd/Even', 'ms-MY': 'Ganjil/Genap Penuh' },
|
||||
HT_1X2: { 'zh-CN': '半场独赢', 'en-US': 'HT 1X2', 'ms-MY': '1X2 Separuh' },
|
||||
HT_HANDICAP: { 'zh-CN': '半场让球', 'en-US': 'HT Handicap', 'ms-MY': 'Handicap Separuh' },
|
||||
HT_OVER_UNDER: { 'zh-CN': '半场大小', 'en-US': 'HT O/U', 'ms-MY': 'Atas/Bawah Separuh' },
|
||||
OUTRIGHT_WINNER: { 'zh-CN': '冠军', 'en-US': 'Outright', 'ms-MY': 'Juara' },
|
||||
FT_CORRECT_SCORE: { 'zh-CN': '波胆', 'en-US': 'Correct Score', 'ms-MY': 'Skor Tepat' },
|
||||
HT_CORRECT_SCORE: { 'zh-CN': '上半场波胆', 'en-US': '1H Correct Score', 'ms-MY': 'Skor Tepat PB1' },
|
||||
SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
|
||||
};
|
||||
return keys[marketType] ?? marketType;
|
||||
const entry = labels[marketType];
|
||||
if (!entry) return marketType;
|
||||
return entry[locale] ?? entry['en-US'] ?? marketType;
|
||||
}
|
||||
|
||||
async enrichBetsForHistory(
|
||||
@@ -1090,7 +1231,7 @@ export class MatchesService {
|
||||
const m = mid ? matchMeta.get(mid) : undefined;
|
||||
return {
|
||||
marketType: sel.marketType,
|
||||
marketLabel: this.marketLabelKey(sel.marketType),
|
||||
marketLabel: this.marketLabelKey(sel.marketType, locale),
|
||||
selectionName: sel.selectionNameSnapshot,
|
||||
odds: sel.odds,
|
||||
resultStatus: sel.resultStatus,
|
||||
|
||||
@@ -68,6 +68,11 @@ export class OutrightService {
|
||||
market,
|
||||
openCount,
|
||||
);
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(m.id, 'zh-CN'),
|
||||
this.getOutrightTitle(m.id, 'en-US'),
|
||||
this.getOutrightTitle(m.id, 'ms-MY'),
|
||||
]);
|
||||
return {
|
||||
id: m.id.toString(),
|
||||
leagueId: league.id.toString(),
|
||||
@@ -75,6 +80,9 @@ export class OutrightService {
|
||||
leagueZh,
|
||||
leagueEn,
|
||||
matchName: m.matchName ?? '',
|
||||
titleZh: titleZh || m.matchName || '',
|
||||
titleEn: titleEn || m.matchName || '',
|
||||
titleMs,
|
||||
status: m.status,
|
||||
selectionCount,
|
||||
canImportCanonical: leagueCode === WC2026_LEAGUE_CODE,
|
||||
@@ -154,6 +162,12 @@ export class OutrightService {
|
||||
),
|
||||
);
|
||||
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(match.id, 'zh-CN'),
|
||||
this.getOutrightTitle(match.id, 'en-US'),
|
||||
this.getOutrightTitle(match.id, 'ms-MY'),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: match.id.toString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -161,6 +175,9 @@ export class OutrightService {
|
||||
leagueZh,
|
||||
leagueEn,
|
||||
matchName: match.matchName ?? '',
|
||||
titleZh: titleZh || match.matchName || '',
|
||||
titleEn: titleEn || match.matchName || '',
|
||||
titleMs,
|
||||
status: match.status,
|
||||
marketId: fullMarket.id.toString(),
|
||||
marketStatus: fullMarket.status,
|
||||
@@ -177,6 +194,7 @@ export class OutrightService {
|
||||
leagueId: bigint;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
titleMs?: string;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
@@ -189,13 +207,14 @@ export class OutrightService {
|
||||
|
||||
const placeholder = await this.ensurePlaceholderTeam();
|
||||
const status = data.status ?? 'PUBLISHED';
|
||||
const matchName = this.resolveOutrightMatchName(data);
|
||||
const match = await this.prisma.match.create({
|
||||
data: {
|
||||
leagueId: data.leagueId,
|
||||
homeTeamId: placeholder.id,
|
||||
awayTeamId: placeholder.id,
|
||||
isOutright: true,
|
||||
matchName: data.titleEn || data.titleZh,
|
||||
matchName,
|
||||
startTime: data.startTime ?? new Date('2030-01-01T00:00:00Z'),
|
||||
status,
|
||||
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
||||
@@ -204,6 +223,11 @@ export class OutrightService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.upsertOutrightTitles(match.id, {
|
||||
zh: data.titleZh,
|
||||
en: data.titleEn,
|
||||
ms: data.titleMs,
|
||||
});
|
||||
await this.ensureOutrightMarket(match.id);
|
||||
return this.getForAdmin(match.id);
|
||||
}
|
||||
@@ -217,15 +241,35 @@ export class OutrightService {
|
||||
displayOrder?: number;
|
||||
titleZh?: string;
|
||||
titleEn?: string;
|
||||
titleMs?: string;
|
||||
},
|
||||
) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const status = data.status ?? match.status;
|
||||
|
||||
let matchName = data.matchName?.trim();
|
||||
const titlesTouched =
|
||||
data.titleZh !== undefined ||
|
||||
data.titleEn !== undefined ||
|
||||
data.titleMs !== undefined;
|
||||
if (titlesTouched) {
|
||||
const [curZh, curEn, curMs] = await Promise.all([
|
||||
this.getOutrightTitle(matchId, 'zh-CN'),
|
||||
this.getOutrightTitle(matchId, 'en-US'),
|
||||
this.getOutrightTitle(matchId, 'ms-MY'),
|
||||
]);
|
||||
const zh = data.titleZh !== undefined ? data.titleZh : curZh;
|
||||
const en = data.titleEn !== undefined ? data.titleEn : curEn;
|
||||
const ms = data.titleMs !== undefined ? data.titleMs : curMs;
|
||||
await this.upsertOutrightTitles(matchId, { zh, en, ms });
|
||||
matchName = this.resolveOutrightMatchName({ titleZh: zh, titleEn: en, titleMs: ms });
|
||||
}
|
||||
|
||||
await this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: {
|
||||
status,
|
||||
matchName: data.matchName,
|
||||
matchName: matchName !== undefined ? matchName : undefined,
|
||||
isHot: data.isHot,
|
||||
displayOrder: data.displayOrder,
|
||||
publishTime:
|
||||
@@ -490,7 +534,18 @@ export class OutrightService {
|
||||
|
||||
if (!selections.length) continue;
|
||||
|
||||
const [titleZh, titleEn, titleMs] = await Promise.all([
|
||||
this.getOutrightTitle(match.id, 'zh-CN'),
|
||||
this.getOutrightTitle(match.id, 'en-US'),
|
||||
this.getOutrightTitle(match.id, 'ms-MY'),
|
||||
]);
|
||||
const localizedTitle = this.pickOutrightTitleForLocale(locale, {
|
||||
zh: titleZh,
|
||||
en: titleEn,
|
||||
ms: titleMs,
|
||||
});
|
||||
const title =
|
||||
localizedTitle ||
|
||||
match.matchName?.trim() ||
|
||||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
|
||||
|
||||
@@ -625,4 +680,76 @@ export class OutrightService {
|
||||
});
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
private async getOutrightTitle(matchId: bigint, locale: string): Promise<string> {
|
||||
const row = await this.prisma.entityTranslation.findFirst({
|
||||
where: {
|
||||
entityType: 'MATCH',
|
||||
entityId: matchId,
|
||||
locale,
|
||||
fieldName: 'title',
|
||||
},
|
||||
});
|
||||
return row?.value?.trim() ?? '';
|
||||
}
|
||||
|
||||
private resolveOutrightMatchName(data: {
|
||||
titleZh?: string;
|
||||
titleEn?: string;
|
||||
titleMs?: string;
|
||||
}): string {
|
||||
return (
|
||||
data.titleEn?.trim() ||
|
||||
data.titleZh?.trim() ||
|
||||
data.titleMs?.trim() ||
|
||||
'Outright'
|
||||
);
|
||||
}
|
||||
|
||||
private pickOutrightTitleForLocale(
|
||||
locale: string,
|
||||
titles: { zh: string; en: string; ms: string },
|
||||
): string {
|
||||
if (locale === 'ms-MY' || locale.startsWith('ms')) {
|
||||
return titles.ms || titles.en || titles.zh;
|
||||
}
|
||||
if (locale.startsWith('zh')) {
|
||||
return titles.zh || titles.en || titles.ms;
|
||||
}
|
||||
return titles.en || titles.zh || titles.ms;
|
||||
}
|
||||
|
||||
private async upsertOutrightTitles(
|
||||
matchId: bigint,
|
||||
titles: { zh?: string; en?: string; ms?: string },
|
||||
) {
|
||||
const entries: Array<[string, string | undefined]> = [
|
||||
['zh-CN', titles.zh],
|
||||
['en-US', titles.en],
|
||||
['ms-MY', titles.ms],
|
||||
];
|
||||
for (const [locale, raw] of entries) {
|
||||
if (raw === undefined) continue;
|
||||
const value = raw.trim();
|
||||
if (!value) continue;
|
||||
await this.prisma.entityTranslation.upsert({
|
||||
where: {
|
||||
entityType_entityId_locale_fieldName: {
|
||||
entityType: 'MATCH',
|
||||
entityId: matchId,
|
||||
locale,
|
||||
fieldName: 'title',
|
||||
},
|
||||
},
|
||||
create: {
|
||||
entityType: 'MATCH',
|
||||
entityId: matchId,
|
||||
locale,
|
||||
fieldName: 'title',
|
||||
value,
|
||||
},
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,141 @@ export class SettlementService {
|
||||
return { success: true, batchId: batchId.toString() };
|
||||
}
|
||||
|
||||
async getMatchBetStats(matchId: bigint) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId },
|
||||
include: {
|
||||
bet: {
|
||||
select: {
|
||||
id: true,
|
||||
betNo: true,
|
||||
betType: true,
|
||||
stake: true,
|
||||
status: true,
|
||||
settlementStatus: true,
|
||||
potentialReturn: true,
|
||||
actualReturn: true,
|
||||
placedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
const betById = new Map<string, (typeof legs)[0]['bet']>();
|
||||
for (const leg of legs) {
|
||||
betById.set(leg.betId.toString(), leg.bet);
|
||||
}
|
||||
|
||||
let totalStake = new Decimal(0);
|
||||
let totalPotential = new Decimal(0);
|
||||
let singleBets = 0;
|
||||
let parlayBets = 0;
|
||||
const statusCounts: Record<string, number> = {};
|
||||
|
||||
for (const bet of betById.values()) {
|
||||
totalStake = totalStake.add(bet.stake);
|
||||
if (bet.potentialReturn) {
|
||||
totalPotential = totalPotential.add(bet.potentialReturn);
|
||||
}
|
||||
if (bet.betType === 'SINGLE') singleBets += 1;
|
||||
else if (bet.betType === 'PARLAY') parlayBets += 1;
|
||||
statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1;
|
||||
}
|
||||
|
||||
type SelAgg = {
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
selectionName: string;
|
||||
selectionId: string;
|
||||
legCount: number;
|
||||
singleStake: Decimal;
|
||||
parlayLegCount: number;
|
||||
};
|
||||
const selMap = new Map<string, SelAgg>();
|
||||
|
||||
for (const leg of legs) {
|
||||
const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`;
|
||||
let row = selMap.get(key);
|
||||
if (!row) {
|
||||
row = {
|
||||
marketType: leg.marketType,
|
||||
period: leg.period,
|
||||
selectionName: leg.selectionNameSnapshot,
|
||||
selectionId: leg.selectionId.toString(),
|
||||
legCount: 0,
|
||||
singleStake: new Decimal(0),
|
||||
parlayLegCount: 0,
|
||||
};
|
||||
selMap.set(key, row);
|
||||
}
|
||||
row.legCount += 1;
|
||||
if (leg.bet.betType === 'SINGLE') {
|
||||
row.singleStake = row.singleStake.add(leg.bet.stake);
|
||||
} else if (leg.bet.betType === 'PARLAY') {
|
||||
row.parlayLegCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const bySelection = Array.from(selMap.values())
|
||||
.map((r) => ({
|
||||
marketType: r.marketType,
|
||||
period: r.period,
|
||||
selectionName: r.selectionName,
|
||||
selectionId: r.selectionId,
|
||||
legCount: r.legCount,
|
||||
singleStake: r.singleStake.toString(),
|
||||
parlayLegCount: r.parlayLegCount,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const mk = a.marketType.localeCompare(b.marketType);
|
||||
if (mk !== 0) return mk;
|
||||
return a.selectionName.localeCompare(b.selectionName);
|
||||
});
|
||||
|
||||
const bets = Array.from(legs)
|
||||
.map((leg) => ({
|
||||
id: leg.bet.id.toString(),
|
||||
betNo: leg.bet.betNo,
|
||||
username: leg.bet.user.username,
|
||||
betType: leg.bet.betType,
|
||||
status: leg.bet.status,
|
||||
settlementStatus: leg.bet.settlementStatus,
|
||||
stake: leg.bet.stake.toString(),
|
||||
potentialReturn: leg.bet.potentialReturn?.toString() ?? null,
|
||||
actualReturn: leg.bet.actualReturn.toString(),
|
||||
placedAt: leg.bet.placedAt.toISOString(),
|
||||
marketType: leg.marketType,
|
||||
period: leg.period,
|
||||
selectionName: leg.selectionNameSnapshot,
|
||||
odds: leg.odds.toString(),
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
totalBets: betById.size,
|
||||
singleBets,
|
||||
parlayBets,
|
||||
totalStake: totalStake.toString(),
|
||||
totalPotentialReturn: totalPotential.toString(),
|
||||
statusCounts,
|
||||
legCount: legs.length,
|
||||
},
|
||||
bySelection,
|
||||
bets,
|
||||
};
|
||||
}
|
||||
|
||||
async voidMatchBets(matchId: bigint) {
|
||||
const bets = await this.prisma.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
|
||||
@@ -56,7 +56,13 @@ const matchTitle = computed(() => {
|
||||
|
||||
const pickLabel = computed(() => {
|
||||
if (props.bet.isParlay) return '';
|
||||
return props.bet.pickLabel;
|
||||
// pickLabel format is "marketLabel: selectionName"
|
||||
const raw = props.bet.pickLabel;
|
||||
if (locale.value === 'zh-CN' || !raw) return raw;
|
||||
const colonIdx = raw.indexOf(': ');
|
||||
if (colonIdx < 0) return raw;
|
||||
const sel = raw.slice(colonIdx + 2);
|
||||
return raw.slice(0, colonIdx + 2) + translateSelection(sel);
|
||||
});
|
||||
|
||||
const returnLabel = computed(() =>
|
||||
@@ -71,10 +77,44 @@ const returnAmount = computed(() => {
|
||||
});
|
||||
|
||||
const returnHighlight = computed(() => statusKey.value === 'won');
|
||||
|
||||
// Translate Chinese selection-name snapshots stored in DB
|
||||
const SEL_TRANS: Record<string, Record<string, string>> = {
|
||||
'主胜': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
|
||||
'客胜': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
|
||||
'和局': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
|
||||
'主': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
|
||||
'客': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
|
||||
'大': { 'en-US': 'Over', 'ms-MY': 'Atas' },
|
||||
'小': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
|
||||
'单': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
|
||||
'双': { 'en-US': 'Even', 'ms-MY': 'Genap' },
|
||||
'冠军': { 'en-US': 'Winner', 'ms-MY': 'Juara' },
|
||||
};
|
||||
|
||||
function translateSelection(name: string): string {
|
||||
if (locale.value === 'zh-CN') return name;
|
||||
// exact match
|
||||
const exact = SEL_TRANS[name];
|
||||
if (exact) return exact[locale.value] ?? exact['en-US'] ?? name;
|
||||
// e.g. "大 2.5" → translate first token, keep rest
|
||||
const spaceIdx = name.indexOf(' ');
|
||||
if (spaceIdx > 0) {
|
||||
const head = name.slice(0, spaceIdx);
|
||||
const tail = name.slice(spaceIdx);
|
||||
const m = SEL_TRANS[head];
|
||||
if (m) return (m[locale.value] ?? m['en-US'] ?? head) + tail;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// use grid when 3+ legs
|
||||
const useGrid = computed(() => (props.bet.legs?.length ?? 0) >= 3);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="bet-card">
|
||||
<!-- top strip: meta + badge -->
|
||||
<header class="card-head">
|
||||
<div class="meta">
|
||||
<span class="sport-icon" aria-hidden="true">⚽</span>
|
||||
@@ -87,26 +127,33 @@ const returnHighlight = computed(() => statusKey.value === 'won');
|
||||
<span class="status-badge" :class="statusKey">{{ statusLabel }}</span>
|
||||
</header>
|
||||
|
||||
<!-- title -->
|
||||
<h3 class="match-title">{{ matchTitle }}</h3>
|
||||
<p v-if="pickLabel" class="pick-line">{{ pickLabel }}</p>
|
||||
|
||||
<div v-if="bet.isParlay && bet.legs?.length" class="parlay-legs">
|
||||
<!-- parlay legs -->
|
||||
<div v-if="bet.isParlay && bet.legs?.length" class="parlay-legs" :class="{ grid: useGrid }">
|
||||
<div v-for="(leg, i) in bet.legs" :key="i" class="leg">
|
||||
<span class="leg-match">{{ leg.matchTitle }}</span>
|
||||
<span class="leg-pick">{{ leg.marketLabel }}: {{ leg.selectionName }}</span>
|
||||
<span class="leg-num">{{ i + 1 }}</span>
|
||||
<div class="leg-info">
|
||||
<span class="leg-match">{{ leg.matchTitle }}</span>
|
||||
<span class="leg-pick">{{ leg.marketLabel }}: {{ translateSelection(leg.selectionName) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<!-- footer -->
|
||||
<footer class="card-foot">
|
||||
<div class="money-col">
|
||||
<span class="money-label">{{ t('history.stake') }}</span>
|
||||
<span class="money-value">{{ formatMoney(bet.stake, locale) }}</span>
|
||||
<span class="money-value stake">{{ formatMoney(bet.stake, locale) }}</span>
|
||||
</div>
|
||||
<div class="money-col align-right">
|
||||
<span class="money-label">{{ returnLabel }}</span>
|
||||
<span class="money-value" :class="{ highlight: returnHighlight }">{{ returnAmount }}</span>
|
||||
<span
|
||||
class="money-value return"
|
||||
:class="{ highlight: returnHighlight, pending: statusKey === 'pending' }"
|
||||
>{{ returnAmount }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
@@ -115,136 +162,175 @@ const returnHighlight = computed(() => statusKey.value === 'won');
|
||||
<style scoped>
|
||||
.bet-card {
|
||||
background: #141414;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
padding: 14px 14px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
padding: 10px 14px 8px 16px;
|
||||
background: #181818;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sport-icon {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.league {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
.dot { opacity: 0.4; }
|
||||
|
||||
.dot {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: #7a7a7a;
|
||||
}
|
||||
.date { color: #666; }
|
||||
|
||||
.status-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0.07em;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.won {
|
||||
color: var(--primary-light);
|
||||
background: rgba(46, 125, 50, 0.35);
|
||||
border: 1px solid rgba(212, 175, 55, 0.25);
|
||||
color: #3db865;
|
||||
background: rgba(61, 184, 101, 0.12);
|
||||
border: 1px solid rgba(61, 184, 101, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
color: #9a9a9a;
|
||||
background: #1f1f1f;
|
||||
border: 1px solid #333;
|
||||
color: #e8c84a;
|
||||
background: rgba(232, 200, 74, 0.1);
|
||||
border: 1px solid rgba(232, 200, 74, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.lost {
|
||||
color: #ff6b6b;
|
||||
background: rgba(198, 40, 40, 0.2);
|
||||
border: 1px solid rgba(198, 40, 40, 0.35);
|
||||
color: #e05050;
|
||||
background: rgba(224, 80, 80, 0.1);
|
||||
border: 1px solid rgba(224, 80, 80, 0.28);
|
||||
}
|
||||
|
||||
.status-badge.push {
|
||||
color: #aaa;
|
||||
background: #1f1f1f;
|
||||
color: #888;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
/* body */
|
||||
.match-title {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
color: #f0f0f0;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 6px;
|
||||
padding: 10px 14px 4px 16px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.pick-line {
|
||||
font-size: 13px;
|
||||
color: #9a9a9a;
|
||||
font-size: 12.5px;
|
||||
color: #888;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 14px 8px 16px;
|
||||
}
|
||||
|
||||
/* ── parlay legs ── */
|
||||
.parlay-legs {
|
||||
margin-top: 8px;
|
||||
padding: 4px 14px 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* grid mode: 2-column when 3+ legs */
|
||||
.parlay-legs.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px 8px;
|
||||
}
|
||||
|
||||
.leg {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 7px;
|
||||
padding: 6px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leg-num {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #2a2a2a;
|
||||
color: #777;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.leg-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leg-match {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #b0b0b0;
|
||||
color: #c0c0c0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leg-pick {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #2a2a2a;
|
||||
margin: 12px 0 10px;
|
||||
font-size: 10.5px;
|
||||
color: #777;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* footer */
|
||||
.card-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
padding: 8px 14px 12px 16px;
|
||||
border-top: 1px solid #1e1e1e;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.money-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.money-col.align-right {
|
||||
@@ -253,18 +339,34 @@ const returnHighlight = computed(() => statusKey.value === 'won');
|
||||
}
|
||||
|
||||
.money-label {
|
||||
font-size: 11px;
|
||||
color: #7a7a7a;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.money-value {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.money-value.stake {
|
||||
color: #c8c8c8;
|
||||
}
|
||||
|
||||
.money-value.return {
|
||||
color: #c8c8c8;
|
||||
}
|
||||
|
||||
.money-value.return.pending {
|
||||
color: #e8c84a;
|
||||
text-shadow: 0 0 14px rgba(232, 200, 74, 0.3);
|
||||
}
|
||||
|
||||
.money-value.highlight {
|
||||
color: var(--primary-light);
|
||||
color: #3db865;
|
||||
text-shadow: 0 0 14px rgba(61, 184, 101, 0.35);
|
||||
font-size: 21px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,8 +8,6 @@ const { locale, t } = useI18n();
|
||||
const open = ref(false);
|
||||
const wallet = ref<{ availableBalance?: unknown; frozenBalance?: unknown; currency?: string } | null>(null);
|
||||
|
||||
const available = computed(() => formatMoney(wallet.value?.availableBalance, locale.value));
|
||||
const frozen = computed(() => formatMoney(wallet.value?.frozenBalance, locale.value));
|
||||
function amountValue(value: unknown): number {
|
||||
if (value == null) return 0;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
||||
@@ -24,6 +22,9 @@ function amountValue(value: unknown): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const available = computed(() => formatMoney(wallet.value?.availableBalance, locale.value));
|
||||
const frozen = computed(() => formatMoney(wallet.value?.frozenBalance, locale.value));
|
||||
|
||||
const total = computed(() =>
|
||||
formatMoney(
|
||||
amountValue(wallet.value?.availableBalance) + amountValue(wallet.value?.frozenBalance),
|
||||
@@ -119,7 +120,6 @@ function close() {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
text-shadow: none;
|
||||
white-space: nowrap;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const i18n = createI18n({
|
||||
parlay_title: '串关 · {n} 场',
|
||||
parlay_league: '串关 Parlay',
|
||||
empty: '暂无投注记录',
|
||||
no_more: '没有更多记录了',
|
||||
status_won: 'WON 赢',
|
||||
status_pending: 'PENDING 待定',
|
||||
status_lost: 'LOST 输',
|
||||
@@ -52,6 +53,18 @@ const i18n = createI18n({
|
||||
unsettled: '未结算',
|
||||
available: '可用',
|
||||
no_records: '暂无账单记录',
|
||||
tx_deposit: '人工存款',
|
||||
tx_withdraw: '人工提款',
|
||||
tx_adjust: '人工调整',
|
||||
tx_bet_freeze: '投注冻结',
|
||||
tx_bet_deduct: '投注扣款',
|
||||
tx_bet_win: '投注派彩',
|
||||
tx_bet_lose: '投注结算',
|
||||
tx_bet_push: '投注退水',
|
||||
tx_bet_refund: '投注退款',
|
||||
tx_bet_void: '投注撤销',
|
||||
tx_cashback: '返水',
|
||||
tx_resettle: '重新结算',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: '投注单',
|
||||
@@ -243,6 +256,7 @@ const i18n = createI18n({
|
||||
parlay_title: 'Parlay · {n} legs',
|
||||
parlay_league: 'Parlay 串关',
|
||||
empty: 'No bets yet',
|
||||
no_more: 'No more bets',
|
||||
status_won: 'WON 赢',
|
||||
status_pending: 'PENDING 待定',
|
||||
status_lost: 'LOST 输',
|
||||
@@ -264,6 +278,18 @@ const i18n = createI18n({
|
||||
unsettled: 'Unsettled',
|
||||
available: 'Available',
|
||||
no_records: 'No records',
|
||||
tx_deposit: 'Deposit',
|
||||
tx_withdraw: 'Withdrawal',
|
||||
tx_adjust: 'Manual Adjust',
|
||||
tx_bet_freeze: 'Bet Frozen',
|
||||
tx_bet_deduct: 'Bet Deducted',
|
||||
tx_bet_win: 'Bet Payout',
|
||||
tx_bet_lose: 'Bet Settled',
|
||||
tx_bet_push: 'Bet Push',
|
||||
tx_bet_refund: 'Bet Refund',
|
||||
tx_bet_void: 'Bet Voided',
|
||||
tx_cashback: 'Cashback',
|
||||
tx_resettle: 'Resettlement',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Bet Slip',
|
||||
@@ -461,6 +487,7 @@ const i18n = createI18n({
|
||||
parlay_title: 'Berganda · {n} perlawanan',
|
||||
parlay_league: 'Berganda',
|
||||
empty: 'Tiada rekod pertaruhan',
|
||||
no_more: 'Tiada lagi rekod',
|
||||
status_won: 'MENANG',
|
||||
status_pending: 'MENUNGGU',
|
||||
status_lost: 'KALAH',
|
||||
@@ -482,6 +509,18 @@ const i18n = createI18n({
|
||||
unsettled: 'Belum Selesai',
|
||||
available: 'Tersedia',
|
||||
no_records: 'Tiada rekod',
|
||||
tx_deposit: 'Deposit',
|
||||
tx_withdraw: 'Pengeluaran',
|
||||
tx_adjust: 'Pelarasan Manual',
|
||||
tx_bet_freeze: 'Pertaruhan Ditahan',
|
||||
tx_bet_deduct: 'Pertaruhan Ditolak',
|
||||
tx_bet_win: 'Bayaran Pertaruhan',
|
||||
tx_bet_lose: 'Pertaruhan Selesai',
|
||||
tx_bet_push: 'Pertaruhan Seri',
|
||||
tx_bet_refund: 'Bayaran Balik',
|
||||
tx_bet_void: 'Pertaruhan Dibatalkan',
|
||||
tx_cashback: 'Cashback',
|
||||
tx_resettle: 'Penyelesaian Semula',
|
||||
},
|
||||
bet: {
|
||||
bet_slip: 'Slip Pertaruhan',
|
||||
|
||||
@@ -180,7 +180,7 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
height: 32px;
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -191,7 +191,7 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -223,7 +223,7 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(94, 184, 255, 0) 0%,
|
||||
@@ -239,8 +239,8 @@ function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
|
||||
.vs-img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 26px;
|
||||
z-index: 0;
|
||||
width: 58px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
animation: vs-glow 2.4s ease-in-out infinite;
|
||||
|
||||
@@ -15,6 +15,10 @@ import CorrectScoreConfirmModal, {
|
||||
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
|
||||
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import cardBg from '../assets/images/卡片.png';
|
||||
|
||||
const heroCardBg = `url(${cardBg})`;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -257,6 +261,7 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
<div class="detail-page">
|
||||
<header class="toolbar">
|
||||
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()">←</button>
|
||||
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
|
||||
<div class="toolbar-actions">
|
||||
<MatchBetGuide />
|
||||
<button
|
||||
@@ -274,26 +279,49 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||
|
||||
<template v-else-if="match">
|
||||
<section v-if="match.leagueName" class="league-banner">
|
||||
<span class="league-title">*{{ match.leagueName }}</span>
|
||||
<img
|
||||
v-if="match.leagueLogoUrl"
|
||||
:src="match.leagueLogoUrl"
|
||||
alt=""
|
||||
class="league-logo"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="match-hero">
|
||||
<p class="kickoff">{{ kickoff }}</p>
|
||||
<div class="match-line">
|
||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="flag" />
|
||||
<div class="names">
|
||||
<span class="team">{{ match.homeTeamName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span class="team">{{ match.awayTeamName }}</span>
|
||||
<div class="hero-teams">
|
||||
<!-- home -->
|
||||
<div class="hero-team">
|
||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="hero-flag" />
|
||||
<span v-else class="hero-flag-ph">⚽</span>
|
||||
<span class="hero-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- VS arena with lightning -->
|
||||
<div class="vs-arena">
|
||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="hzDetailGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
||||
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
||||
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="hz-path hz-path-main"
|
||||
stroke="url(#hzDetailGrad)"
|
||||
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
||||
/>
|
||||
<path
|
||||
class="hz-path hz-path-sub"
|
||||
stroke="url(#hzDetailGrad)"
|
||||
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hz-beam" aria-hidden="true" />
|
||||
<img :src="vsImg" alt="" class="vs-img" />
|
||||
</div>
|
||||
|
||||
<!-- away -->
|
||||
<div class="hero-team">
|
||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="hero-flag" />
|
||||
<span v-else class="hero-flag-ph">⚽</span>
|
||||
<span class="hero-name">{{ match.awayTeamName }}</span>
|
||||
</div>
|
||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="flag" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -377,6 +405,18 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -401,72 +441,190 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── match hero ── */
|
||||
.match-hero {
|
||||
padding: 2px 12px 10px;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin: 0 0 10px;
|
||||
padding: 14px 12px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.league-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 12px 2px;
|
||||
}
|
||||
|
||||
.league-banner .league-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.league-logo {
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
max-width: 44px;
|
||||
object-fit: contain;
|
||||
.match-hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(heroCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.22;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kickoff {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.match-line {
|
||||
.hero-teams {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 36px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.names {
|
||||
.hero-team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.team {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
.hero-flag {
|
||||
width: 54px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.hero-flag-ph {
|
||||
width: 54px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
text-align: center;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vs {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
/* VS arena — same as HomeView */
|
||||
.vs-arena {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 140px;
|
||||
height: 126px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hz-lightning {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.hz-path {
|
||||
fill: none;
|
||||
stroke-width: 2.2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hz-path-main {
|
||||
animation: hz-strike-main 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.hz-path-sub {
|
||||
stroke-width: 1.6;
|
||||
animation: hz-strike-sub 2.6s ease-in-out infinite;
|
||||
animation-delay: 0.12s;
|
||||
}
|
||||
|
||||
.hz-beam {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(94, 184, 255, 0) 0%,
|
||||
rgba(184, 236, 255, 0.95) 28%,
|
||||
#fff 50%,
|
||||
rgba(255, 208, 128, 0.95) 72%,
|
||||
rgba(255, 144, 64, 0) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
filter: blur(0.4px);
|
||||
animation: hz-beam-flash 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.vs-img {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 126px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@keyframes hz-strike-main {
|
||||
0%, 72%, 100% { opacity: 0; }
|
||||
74% { opacity: 1; }
|
||||
75% { opacity: 0.25; }
|
||||
76% { opacity: 0.95; }
|
||||
78% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes hz-strike-sub {
|
||||
0%, 74%, 100% { opacity: 0; }
|
||||
76% { opacity: 0.85; }
|
||||
77% { opacity: 0.2; }
|
||||
78% { opacity: 0.7; }
|
||||
80% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes hz-beam-flash {
|
||||
0%, 71%, 100% { opacity: 0; transform: translateY(-50%) scaleX(0.6); }
|
||||
73% { opacity: 0.85; transform: translateY(-50%) scaleX(1); }
|
||||
75% { opacity: 0.15; transform: translateY(-50%) scaleX(0.95); }
|
||||
76% { opacity: 0.75; transform: translateY(-50%) scaleX(1); }
|
||||
78% { opacity: 0; transform: translateY(-50%) scaleX(1.05); }
|
||||
}
|
||||
|
||||
@keyframes vs-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.82;
|
||||
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 3px rgba(255, 230, 140, 0.7)) drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vs-img { animation: none; filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35)); }
|
||||
.hz-path, .hz-beam { animation: none; opacity: 0; }
|
||||
}
|
||||
|
||||
.markets-section {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import BetHistoryCard, { type BetHistoryItem } from '../components/BetHistoryCard.vue';
|
||||
@@ -7,30 +7,82 @@ import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const bets = ref<{ items: BetHistoryItem[]; total: number }>({ items: [], total: 0 });
|
||||
const loading = ref(true);
|
||||
const items = ref<BetHistoryItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const loading = ref(false);
|
||||
const initialLoading = ref(true);
|
||||
const hasMore = ref(true);
|
||||
|
||||
async function load() {
|
||||
const sentinel = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
async function loadPage(p: number) {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/bets');
|
||||
bets.value = data.data ?? { items: [], total: 0 };
|
||||
const { data } = await api.get('/player/bets', { params: { page: p } });
|
||||
const result = data.data ?? { items: [], total: 0, pageSize: 20 };
|
||||
total.value = result.total ?? 0;
|
||||
const pageSize = result.pageSize ?? 20;
|
||||
if (p === 1) {
|
||||
items.value = result.items ?? [];
|
||||
} else {
|
||||
items.value = [...items.value, ...(result.items ?? [])];
|
||||
}
|
||||
hasMore.value = items.value.length < total.value && (result.items?.length ?? 0) >= pageSize;
|
||||
page.value = p;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
useOnLocaleChange(load);
|
||||
function reset() {
|
||||
items.value = [];
|
||||
total.value = 0;
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
initialLoading.value = true;
|
||||
loadPage(1);
|
||||
}
|
||||
|
||||
useOnLocaleChange(reset);
|
||||
|
||||
onMounted(() => {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
|
||||
loadPage(page.value + 1);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '120px' },
|
||||
);
|
||||
if (sentinel.value) observer.observe(sentinel.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-page">
|
||||
<h2 class="page-title">{{ t('nav.bet_history') }}</h2>
|
||||
|
||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||
<div v-if="initialLoading" class="state">{{ t('bet.loading') }}</div>
|
||||
|
||||
<template v-else-if="bets.items.length">
|
||||
<BetHistoryCard v-for="bet in bets.items" :key="bet.betNo" :bet="bet" />
|
||||
<template v-else-if="items.length">
|
||||
<BetHistoryCard v-for="bet in items" :key="bet.betNo" :bet="bet" />
|
||||
|
||||
<div ref="sentinel" class="sentinel" />
|
||||
|
||||
<div v-if="loading" class="load-more-spinner">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasMore && items.length > 0" class="end-hint">
|
||||
{{ t('history.no_more') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="state">{{ t('history.empty') }}</div>
|
||||
@@ -39,15 +91,7 @@ useOnLocaleChange(load);
|
||||
|
||||
<style scoped>
|
||||
.history-page {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: 0.02em;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.state {
|
||||
@@ -57,4 +101,37 @@ useOnLocaleChange(load);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.load-more-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px 0 8px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 3px solid #2a2a2a;
|
||||
border-top-color: var(--primary-light);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.end-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
padding: 16px 0 4px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter, RouterLink } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
@@ -21,10 +21,44 @@ const profile = ref<{
|
||||
|
||||
const rulesExpanded = ref(false);
|
||||
|
||||
const displayAmount = ref(0);
|
||||
const animating = ref(false);
|
||||
|
||||
function amountValue(value: unknown): number {
|
||||
if (value == null) return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function runCountUp(target: number) {
|
||||
const duration = 3000;
|
||||
const start = performance.now();
|
||||
animating.value = true;
|
||||
function step(now: number) {
|
||||
const progress = Math.min((now - start) / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 5);
|
||||
displayAmount.value = target * eased;
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
displayAmount.value = target;
|
||||
animating.value = false;
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
const displayedBalance = computed(() =>
|
||||
animating.value
|
||||
? formatMoney(displayAmount.value, locale.value)
|
||||
: formatMoney(profile.value?.wallet?.availableBalance, locale.value),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/player/profile');
|
||||
profile.value = data.data;
|
||||
initFromUser(data.data?.locale);
|
||||
runCountUp(amountValue(data.data?.wallet?.availableBalance));
|
||||
});
|
||||
|
||||
async function changeLocale(code: string) {
|
||||
@@ -48,7 +82,7 @@ function logout() {
|
||||
<LocaleFlag :locale="locale" :size="14" />
|
||||
{{ t('wallet.balance') }}
|
||||
</span>
|
||||
<p class="card-balance">{{ formatMoney(profile?.wallet?.availableBalance, locale) }}</p>
|
||||
<p class="card-balance">{{ displayedBalance }}</p>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<div class="card-holder">
|
||||
|
||||
@@ -9,6 +9,34 @@ const transactions = ref<
|
||||
Array<{ transactionType: string; amount: string; createdAt: string; transactionId?: string }>
|
||||
>([]);
|
||||
|
||||
const TX_KEY_MAP: Record<string, string> = {
|
||||
MANUAL_DEPOSIT: 'wallet.tx_deposit',
|
||||
MANUAL_WITHDRAW: 'wallet.tx_withdraw',
|
||||
MANUAL_ADJUST: 'wallet.tx_adjust',
|
||||
BET_FREEZE: 'wallet.tx_bet_freeze',
|
||||
BET_DEDUCT: 'wallet.tx_bet_deduct',
|
||||
BET_SETTLE_WIN: 'wallet.tx_bet_win',
|
||||
BET_SETTLE_LOSE: 'wallet.tx_bet_lose',
|
||||
BET_SETTLE_PUSH: 'wallet.tx_bet_push',
|
||||
BET_WIN: 'wallet.tx_bet_win',
|
||||
BET_REFUND: 'wallet.tx_bet_refund',
|
||||
BET_VOID: 'wallet.tx_bet_void',
|
||||
BET_VOID_REFUND: 'wallet.tx_bet_void',
|
||||
CASHBACK: 'wallet.tx_cashback',
|
||||
RESETTLE_REVERSE: 'wallet.tx_resettle',
|
||||
DEPOSIT: 'wallet.tx_deposit',
|
||||
WITHDRAW: 'wallet.tx_withdraw',
|
||||
};
|
||||
|
||||
function txLabel(type: string): string {
|
||||
const key = TX_KEY_MAP[type.toUpperCase()];
|
||||
if (key) {
|
||||
const translated = t(key);
|
||||
if (translated !== key) return translated;
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/player/wallet/transactions');
|
||||
transactions.value = data.data.items ?? [];
|
||||
@@ -24,7 +52,7 @@ onMounted(async () => {
|
||||
:key="tx.transactionId ?? tx.createdAt"
|
||||
class="tx-row"
|
||||
>
|
||||
<span>{{ tx.transactionType }}</span>
|
||||
<span class="tx-type">{{ txLabel(tx.transactionType) }}</span>
|
||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||
{{ formatMoney(tx.amount, locale) }}
|
||||
</span>
|
||||
@@ -47,6 +75,7 @@ onMounted(async () => {
|
||||
.tx-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tx-type { font-weight: 700; color: var(--text); }
|
||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||
.neg { color: var(--danger); font-weight: 700; }
|
||||
.tx-time { width: 100%; font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
Reference in New Issue
Block a user