Compare commits
5 Commits
27580b2479
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fcee31a9a | |||
| cc737e2924 | |||
| c68abadceb | |||
| a8e4ead618 | |||
| f76728dc3e |
@@ -5,7 +5,7 @@
|
||||
"description": "统一管理后台(平台管理员 + 代理,单入口登录)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5174",
|
||||
"dev": "vite --port 5174 --host",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -299,4 +299,17 @@ body {
|
||||
|
||||
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
|
||||
.el-date-editor .el-input__wrapper { background: #0d0d0d !important; }
|
||||
.el-date-editor .el-input__inner { color: #fff !important; }
|
||||
.el-picker-panel {
|
||||
background: #1c1c1c !important;
|
||||
border-color: #333 !important;
|
||||
color: #ddd !important;
|
||||
}
|
||||
.el-picker-panel__footer { background: #1c1c1c !important; border-top-color: #333 !important; }
|
||||
.el-date-picker__header-label,
|
||||
.el-date-table th,
|
||||
.el-date-table td .el-date-table-cell__text { color: #ccc !important; }
|
||||
.el-time-panel { background: #1c1c1c !important; border-color: #333 !important; }
|
||||
.el-time-spinner__item { color: #aaa !important; }
|
||||
.el-time-spinner__item.is-active:not(.is-disabled) { color: #fff !important; }
|
||||
</style>
|
||||
|
||||
164
apps/admin/src/components/LogoUrlField.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import CountryFlagSelect from './outright/CountryFlagSelect.vue';
|
||||
import {
|
||||
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<{
|
||||
'update:modelValue': [value: string];
|
||||
pick: [country: BuiltinCountry];
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
const countryCode = ref('');
|
||||
const logoKind = ref<CountryLogoKind>('crest');
|
||||
|
||||
const canPickCrest = computed(() => {
|
||||
const code = countryCode.value;
|
||||
return code ? hasCountryCrest(code) : false;
|
||||
});
|
||||
|
||||
const previewUrl = computed(() => props.modelValue.trim() || '');
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.teamCode] as const,
|
||||
([url, code]) => {
|
||||
countryCode.value = resolveCountryCode(code, url || null);
|
||||
if (url) {
|
||||
logoKind.value = detectCountryLogoKind(url, countryCode.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(countryCode, (code, prev) => {
|
||||
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) {
|
||||
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" :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')"
|
||||
clearable
|
||||
class="url-part"
|
||||
@update:model-value="onCustomUrlInput"
|
||||
/>
|
||||
</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;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flag-part {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.kind-group {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-preview {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
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>
|
||||
153
apps/admin/src/components/outright/CountryFlagSelect.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
countryFlagUrl,
|
||||
countryCrestUrl,
|
||||
countryDisplayName,
|
||||
countryOptionLabel,
|
||||
getBuiltinCountry,
|
||||
hasCountryCrest,
|
||||
searchBuiltinCountries,
|
||||
type BuiltinCountry,
|
||||
} from '../../data/builtinCountries';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
size?: 'small' | 'default' | 'large';
|
||||
disabled?: boolean;
|
||||
hidePreview?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [code: string];
|
||||
pick: [country: BuiltinCountry];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useAdminLocale();
|
||||
|
||||
const filterKeyword = ref('');
|
||||
const options = computed(() => searchBuiltinCountries(filterKeyword.value, locale.value));
|
||||
|
||||
const selected = computed(() => getBuiltinCountry(props.modelValue));
|
||||
|
||||
function optionLabel(c: BuiltinCountry) {
|
||||
return countryOptionLabel(c, locale.value);
|
||||
}
|
||||
|
||||
function onFilter(q: string) {
|
||||
filterKeyword.value = q;
|
||||
}
|
||||
|
||||
function onChange(code: string | undefined) {
|
||||
const value = code ?? '';
|
||||
emit('update:modelValue', value);
|
||||
const country = getBuiltinCountry(value);
|
||||
if (country) emit('pick', country);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="country-flag-select">
|
||||
<el-select
|
||||
:model-value="modelValue || undefined"
|
||||
:size="size ?? 'small'"
|
||||
:disabled="disabled"
|
||||
filterable
|
||||
clearable
|
||||
:filter-method="onFilter"
|
||||
:placeholder="t('outright.country_ph')"
|
||||
class="country-select"
|
||||
@update:model-value="onChange"
|
||||
@visible-change="(v: boolean) => { if (v) filterKeyword = ''; }"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in options"
|
||||
:key="c.code"
|
||||
:label="optionLabel(c)"
|
||||
:value="c.code"
|
||||
>
|
||||
<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>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<img
|
||||
v-if="selected && !hidePreview"
|
||||
:src="countryFlagUrl(selected)"
|
||||
alt=""
|
||||
class="country-preview"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.country-flag-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.country-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.country-preview {
|
||||
width: 32px;
|
||||
height: 22px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.country-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.country-option-flag {
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.country-option-crest {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.country-option-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.country-option-code {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
197
apps/admin/src/data/builtinCountries.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NATIONAL_TEAM_CREST, NATIONAL_TEAM_MS } from './nationalTeamCrests';
|
||||
|
||||
/** 内置国家队(世界杯 48 强 + 常用队),供管理端选择国旗/队徽与自动填充队名 */
|
||||
export type BuiltinCountry = {
|
||||
code: string;
|
||||
nameZh: string;
|
||||
nameEn: string;
|
||||
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' },
|
||||
{ code: 'ENG', nameZh: '英格兰', nameEn: 'England', iso: 'gb-eng' },
|
||||
{ code: 'BRA', nameZh: '巴西', nameEn: 'Brazil', iso: 'br' },
|
||||
{ code: 'ARG', nameZh: '阿根廷', nameEn: 'Argentina', iso: 'ar' },
|
||||
{ code: 'POR', nameZh: '葡萄牙', nameEn: 'Portugal', iso: 'pt' },
|
||||
{ code: 'GER', nameZh: '德国', nameEn: 'Germany', iso: 'de' },
|
||||
{ code: 'NED', nameZh: '荷兰', nameEn: 'Netherlands', iso: 'nl' },
|
||||
{ code: 'NOR', nameZh: '挪威', nameEn: 'Norway', iso: 'no' },
|
||||
{ code: 'BEL', nameZh: '比利时', nameEn: 'Belgium', iso: 'be' },
|
||||
{ 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: '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' },
|
||||
{ code: 'SUI', nameZh: '瑞士', nameEn: 'Switzerland', iso: 'ch' },
|
||||
{ code: 'TUR', nameZh: '土耳其', nameEn: 'Turkey', iso: 'tr' },
|
||||
{ code: 'SEN', nameZh: '塞内加尔', nameEn: 'Senegal', iso: 'sn' },
|
||||
{ code: 'KOR', nameZh: '韩国', nameEn: 'South Korea', iso: 'kr' },
|
||||
{ code: 'AUT', nameZh: '奥地利', nameEn: 'Austria', iso: 'at' },
|
||||
{ code: 'ECU', nameZh: '厄瓜多尔', nameEn: 'Ecuador', iso: 'ec' },
|
||||
{ code: 'SWE', nameZh: '瑞典', nameEn: 'Sweden', iso: 'se' },
|
||||
{ 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 & 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: '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: '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: "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' },
|
||||
{ code: 'QAT', nameZh: '卡塔尔', nameEn: 'Qatar', iso: 'qa' },
|
||||
{ code: 'CPV', nameZh: '佛得角', nameEn: 'Cape Verde', iso: 'cv' },
|
||||
{ code: 'CUW', nameZh: '库拉索', nameEn: 'Curacao', iso: 'cw' },
|
||||
{ code: 'SCO', nameZh: '苏格兰', nameEn: 'Scotland', iso: 'gb-sct' },
|
||||
{ code: 'CHN', nameZh: '中国', nameEn: 'China', iso: 'cn' },
|
||||
{ code: 'ITA', nameZh: '意大利', nameEn: 'Italy', iso: 'it' },
|
||||
{ code: 'WAL', nameZh: '威尔士', nameEn: 'Wales', iso: 'gb-wls' },
|
||||
{ code: 'UKR', nameZh: '乌克兰', nameEn: 'Ukraine', iso: 'ua' },
|
||||
{ code: 'POL', nameZh: '波兰', nameEn: 'Poland', iso: 'pl' },
|
||||
{ code: 'DEN', nameZh: '丹麦', nameEn: 'Denmark', iso: 'dk' },
|
||||
{ code: 'FIN', nameZh: '芬兰', nameEn: 'Finland', iso: 'fi' },
|
||||
{ code: 'IRL', nameZh: '爱尔兰', nameEn: 'Ireland', iso: 'ie' },
|
||||
{ code: 'ISL', nameZh: '冰岛', nameEn: 'Iceland', iso: 'is' },
|
||||
{ code: 'GRE', nameZh: '希腊', nameEn: 'Greece', iso: 'gr' },
|
||||
{ code: 'SRB', nameZh: '塞尔维亚', nameEn: 'Serbia', iso: 'rs' },
|
||||
{ code: 'ROU', nameZh: '罗马尼亚', nameEn: 'Romania', iso: 'ro' },
|
||||
{ code: 'HUN', nameZh: '匈牙利', nameEn: 'Hungary', iso: 'hu' },
|
||||
{ code: 'SVK', nameZh: '斯洛伐克', nameEn: 'Slovakia', iso: 'sk' },
|
||||
{ code: 'SVN', nameZh: '斯洛文尼亚', nameEn: 'Slovenia', iso: 'si' },
|
||||
{ code: 'NGA', nameZh: '尼日利亚', nameEn: 'Nigeria', iso: 'ng' },
|
||||
{ code: 'CMR', nameZh: '喀麦隆', nameEn: 'Cameroon', iso: 'cm' },
|
||||
{ code: 'CHI', nameZh: '智利', nameEn: 'Chile', iso: 'cl' },
|
||||
{ code: 'PER', nameZh: '秘鲁', nameEn: 'Peru', iso: 'pe' },
|
||||
{ code: 'VEN', nameZh: '委内瑞拉', nameEn: 'Venezuela', iso: 've' },
|
||||
{ code: 'CRC', nameZh: '哥斯达黎加', nameEn: 'Costa Rica', iso: 'cr' },
|
||||
{ code: 'JAM', nameZh: '牙买加', nameEn: 'Jamaica', iso: 'jm' },
|
||||
{ code: 'UAE', nameZh: '阿联酋', nameEn: 'UAE', iso: 'ae' },
|
||||
{ code: 'THA', nameZh: '泰国', nameEn: 'Thailand', iso: 'th' },
|
||||
{ code: 'VIE', nameZh: '越南', nameEn: 'Vietnam', iso: 'vn' },
|
||||
{ code: 'IDN', nameZh: '印度尼西亚', nameEn: 'Indonesia', iso: 'id' },
|
||||
{ code: 'MAS', nameZh: '马来西亚', nameEn: 'Malaysia', iso: 'my' },
|
||||
];
|
||||
|
||||
const byCode = new Map(BUILTIN_COUNTRIES.map((c) => [c.code, c]));
|
||||
|
||||
export function getBuiltinCountry(code?: string | null): BuiltinCountry | undefined {
|
||||
const key = (code ?? '').trim().toUpperCase();
|
||||
return key ? byCode.get(key) : undefined;
|
||||
}
|
||||
|
||||
export function countryFlagUrl(country: BuiltinCountry | string): string {
|
||||
const c = typeof country === 'string' ? getBuiltinCountry(country) : country;
|
||||
if (!c) return '';
|
||||
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;
|
||||
if (locale === 'ms-MY') return countryNameMs(c);
|
||||
return c.nameZh;
|
||||
}
|
||||
|
||||
export function countryOptionLabel(c: BuiltinCountry, locale: string): string {
|
||||
return `${countryDisplayName(c, locale)} (${c.code})`;
|
||||
}
|
||||
|
||||
function countryNameMs(c: BuiltinCountry): string {
|
||||
return NATIONAL_TEAM_MS[c.code] ?? c.nameEn;
|
||||
}
|
||||
|
||||
export function searchBuiltinCountries(keyword: string, locale = 'zh-CN'): BuiltinCountry[] {
|
||||
const k = keyword.trim().toLowerCase();
|
||||
if (!k) return BUILTIN_COUNTRIES;
|
||||
return BUILTIN_COUNTRIES.filter((c) => {
|
||||
const display = countryDisplayName(c, locale);
|
||||
return (
|
||||
c.code.toLowerCase().includes(k) ||
|
||||
c.nameZh.includes(keyword.trim()) ||
|
||||
c.nameEn.toLowerCase().includes(k) ||
|
||||
display.toLowerCase().includes(k) ||
|
||||
c.iso.toLowerCase().includes(k)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCountryCode(
|
||||
teamCode?: string,
|
||||
logoUrl?: string | null,
|
||||
): string {
|
||||
const fromCode = getBuiltinCountry(teamCode);
|
||||
if (fromCode) return fromCode.code;
|
||||
if (logoUrl) {
|
||||
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);
|
||||
if (hit) return hit.code;
|
||||
}
|
||||
}
|
||||
return (teamCode ?? '').trim().toUpperCase();
|
||||
}
|
||||
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',
|
||||
};
|
||||
@@ -36,6 +36,7 @@ const zh: Record<string, string> = {
|
||||
'nav.outrights': '优胜冠军',
|
||||
'nav.bets': '注单管理',
|
||||
'nav.cashback': '返水管理',
|
||||
'nav.contents': '公共管理',
|
||||
'nav.audit': '操作日志',
|
||||
'nav.players': '直属玩家',
|
||||
'nav.subAgents': '下级代理',
|
||||
@@ -161,6 +162,7 @@ const zh: Record<string, string> = {
|
||||
'match.status.PUBLISHED': '已发布',
|
||||
'match.status.CLOSED': '已封盘',
|
||||
'match.status.SETTLED': '已结算',
|
||||
'match.status.PENDING_SETTLEMENT': '待结算',
|
||||
...adminPagesZh,
|
||||
};
|
||||
|
||||
@@ -187,6 +189,7 @@ const en: Record<string, string> = {
|
||||
'nav.outrights': 'Outrights',
|
||||
'nav.bets': 'Bets',
|
||||
'nav.cashback': 'Cashback',
|
||||
'nav.contents': 'Public Content',
|
||||
'nav.audit': 'Audit Log',
|
||||
'nav.players': 'My Players',
|
||||
'nav.subAgents': 'Sub-Agents',
|
||||
@@ -312,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,
|
||||
};
|
||||
|
||||
@@ -338,6 +342,7 @@ const ms: Record<string, string> = {
|
||||
'nav.outrights': 'Juara',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
'nav.cashback': 'Rebat',
|
||||
'nav.contents': 'Kandungan awam',
|
||||
'nav.audit': 'Log audit',
|
||||
'nav.players': 'Pemain saya',
|
||||
'nav.subAgents': 'Sub-ejen',
|
||||
@@ -463,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,
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,22 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.field.login_fail': 'Log masuk gagal',
|
||||
'user.field.phone': 'Telefon',
|
||||
'user.field.email': 'E-mel',
|
||||
'user.field.allow_password_change': 'Benarkan pemain tukar kata laluan',
|
||||
'user.field.allow_username_change': 'Benarkan pemain tukar nama akaun',
|
||||
'user.field.view_password': 'Kata laluan log masuk',
|
||||
'user.field.reset_password': 'Set semula kata laluan',
|
||||
'user.password_not_stored': 'Tiada rekod (pemain telah ubah sendiri)',
|
||||
'user.btn.show_password': 'Lihat',
|
||||
'user.btn.hide_password': 'Sembunyi',
|
||||
'user.ph.reset_password': 'Biarkan kosong untuk kekalkan; nilai baharu boleh dilihat',
|
||||
'user.ph.reset_password_short': 'Biarkan kosong',
|
||||
'user.global_settings': 'Kata laluan & akaun (global)',
|
||||
'user.global_settings_hint': 'Kawal sama ada semua pemain boleh ubah kata laluan/nama akaun dalam app',
|
||||
'user.section.password_mgmt': 'Pengurusan kata laluan',
|
||||
'user.field.current_password': 'Kata laluan semasa',
|
||||
'user.msg.created_with_password': 'Pemain dicipta. Kata laluan: {password}',
|
||||
'user.msg.password_saved': 'Kata laluan dikemas kini: {password}',
|
||||
'user.hint.password_reset_to_view': 'Tiada rekod. Isi Set semula kata laluan di bawah dan simpan untuk lihat di sini.',
|
||||
'user.ph.username_unique': 'Nama log masuk unik',
|
||||
'user.ph.no_agent': 'Tiada (terus platform)',
|
||||
'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus platform',
|
||||
@@ -58,6 +74,10 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0',
|
||||
'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai',
|
||||
'user.hint.agent_change': 'Kosong = terus platform; perubahan dikira semula kredit ejen',
|
||||
'user.hint.allow_password_change': 'Matikan: semua pemain tidak boleh ubah kata laluan',
|
||||
'user.hint.allow_username_change': 'Hidupkan: semua pemain boleh ubah nama log masuk',
|
||||
'user.hint.view_password': 'Hanya kata laluan cipta/set semula admin; dibersihkan jika pemain ubah sendiri',
|
||||
'user.hint.reset_password': 'Berkuat kuasa serta-merta dan kemas kini kata laluan boleh lihat',
|
||||
'user.btn.create': 'Cipta',
|
||||
'user.btn.save_profile': 'Simpan',
|
||||
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
||||
@@ -103,23 +123,45 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'agent.ph.select_user': 'Cari nama pengguna pemain',
|
||||
'agent.hint.select_user': 'Pilih akaun pemain sedia ada untuk naik taraf ke ejen peringkat 1',
|
||||
|
||||
'match.create_btn': '+ Perlawanan baharu',
|
||||
'match.filter.keyword_ph': 'Nama perlawanan / kod pasukan',
|
||||
'match.create_btn': '+ Kejohanan baharu',
|
||||
'match.create_fixture_btn': '+ Perlawanan tunggal',
|
||||
'match.btn.markets': 'Pasaran',
|
||||
'match.filter.keyword_ph': 'Nama kejohanan / kod pasukan',
|
||||
'match.col.league': 'Kejohanan',
|
||||
'match.col.fixture_count': 'Perlawanan',
|
||||
'match.col.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',
|
||||
'match.dialog.create': 'Perlawanan baharu',
|
||||
'match.dialog.edit': 'Edit perlawanan',
|
||||
'match.dialog.create_league': 'Kejohanan baharu',
|
||||
'match.dialog.create_fixture': 'Perlawanan tunggal baharu',
|
||||
'match.dialog.create': 'Perlawanan tunggal baharu',
|
||||
'match.dialog.edit': 'Edit perlawanan tunggal',
|
||||
'match.dialog.import': 'Import perlawanan',
|
||||
'match.field.league_en': 'Liga (EN)',
|
||||
'match.field.league_zh': 'Liga (ZH)',
|
||||
'match.field.league_ms': 'Liga (MS)',
|
||||
'match.field.league_logo': 'Logo kejohanan',
|
||||
'match.field.lang_zh': 'ZH',
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': 'Masa mula',
|
||||
'match.field.home_en': 'Tuan rumah (EN)',
|
||||
'match.field.home_zh': 'Tuan rumah (ZH)',
|
||||
'match.field.home_ms': 'Tuan rumah (MS)',
|
||||
'match.field.away_en': 'Pelawat (EN)',
|
||||
'match.field.away_zh': 'Pelawat (ZH)',
|
||||
'match.field.away_ms': 'Pelawat (MS)',
|
||||
'match.field.featured': 'Pilihan utama',
|
||||
'match.hint.create_draft': 'Disimpan sebagai draf; klik Terbitkan dalam senarai untuk buka pasaran.',
|
||||
'match.hint.create_draft': 'Disimpan sebagai draf; kembangkan kejohanan dan terbitkan setiap perlawanan tunggal.',
|
||||
'match.hint.create_league': 'Cipta kejohanan dahulu, kemudian kembangkan untuk tambah perlawanan tunggal.',
|
||||
'match.hint.edit_published': 'Diterbitkan: edit masa mula, pilihan utama, nama paparan; tertutup/selesai dikunci.',
|
||||
'match.expand_league_hint': 'Kembangkan kejohanan untuk senarai perlawanan; klik Pasaran untuk halaman tetapan odds (sama seperti aplikasi pemain).',
|
||||
'match.expand_markets_hint': 'Klik Pasaran pada perlawanan tunggal untuk halaman pasaran berasingan.',
|
||||
'match.no_fixtures': 'Tiada perlawanan tunggal di bawah kejohanan ini.',
|
||||
'match.ph.league_ms': 'Piala Dunia 2027',
|
||||
|
||||
'bet.filter.keyword_ph': 'No. pertaruhan / nama pengguna',
|
||||
'bet.filter.date_from': 'Tarikh pertaruhan dari',
|
||||
@@ -188,8 +230,68 @@ 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',
|
||||
'matchEditor.title': 'Edit maklumat asas',
|
||||
'matchEditor.section_info': 'Maklumat asas',
|
||||
'matchEditor.section_markets': 'Pasaran & odds',
|
||||
'matchEditor.field.league_logo': 'Logo',
|
||||
'matchEditor.field.home_logo': 'Logo',
|
||||
'matchEditor.field.away_logo': 'Logo',
|
||||
'matchEditor.field.pick_flag': 'Pilih bendera',
|
||||
'matchEditor.field.custom_logo_url': 'URL imej tersuai',
|
||||
'matchEditor.ph.logo_url': 'https://...',
|
||||
'matchEditor.field.match_name': 'Nama paparan',
|
||||
'matchEditor.field.stage': 'Peringkat',
|
||||
'matchEditor.field.group': 'Kumpulan',
|
||||
'matchEditor.field.display_order': 'Susunan',
|
||||
'matchEditor.field.promo_label': 'Label promosi',
|
||||
'matchEditor.field.promo_label_optional': 'Label promosi (pilihan)',
|
||||
'matchEditor.field.line_value': 'Garisan',
|
||||
'matchEditor.ph.kickoff': 'Pilih tarikh & masa mula',
|
||||
'matchEditor.group.league': 'Liga',
|
||||
'matchEditor.group.home': 'Tuan rumah',
|
||||
'matchEditor.group.away': 'Pelawat',
|
||||
'matchEditor.group.schedule': 'Jadual & paparan',
|
||||
'matchEditor.save_info': 'Simpan maklumat',
|
||||
'matchEditor.save_market': 'Simpan pasaran',
|
||||
'matchEditor.save_odds': 'Simpan odds',
|
||||
'matchEditor.generate_templates': 'Jana templat lalai',
|
||||
'matchEditor.templates_generated': 'Templat pasaran dijana',
|
||||
'matchEditor.no_markets': 'Tiada pasaran — terbitkan perlawanan atau jana templat.',
|
||||
'matchEditor.market.FT_1X2': 'FT 1X2',
|
||||
'matchEditor.market.FT_HANDICAP': 'FT handicap',
|
||||
'matchEditor.market.FT_OVER_UNDER': 'FT O/U',
|
||||
'matchEditor.market.FT_ODD_EVEN': 'FT ganjil/genap',
|
||||
'matchEditor.market.HT_1X2': 'HT 1X2',
|
||||
'matchEditor.market.HT_HANDICAP': 'HT handicap',
|
||||
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': 'FT skor tepat',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': 'HT skor tepat',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '2H skor tepat',
|
||||
'matchEditor.period.FT': 'Sepenuh masa',
|
||||
'matchEditor.period.HT': 'Separuh masa',
|
||||
'matchEditor.period.SH': 'Separuh masa ke-2',
|
||||
'matchEditor.period.OUTRIGHT': 'Juara',
|
||||
'matchEditor.selection.HOME': 'Tuan rumah',
|
||||
'matchEditor.selection.DRAW': 'Seri',
|
||||
'matchEditor.selection.AWAY': 'Pelawat',
|
||||
'matchEditor.selection.OVER': 'Atas',
|
||||
'matchEditor.selection.UNDER': 'Bawah',
|
||||
'matchEditor.selection.ODD': 'Ganjil',
|
||||
'matchEditor.selection.EVEN': 'Genap',
|
||||
'matchEditor.selection.OTHER_DRAW': 'Seri (skor lain)',
|
||||
'matchEditor.selection.OTHER_HOME': 'Menang rumah (skor lain)',
|
||||
'matchEditor.selection.OTHER_AWAY': 'Menang pelawat (skor lain)',
|
||||
'matchEditor.col.selection_code': 'Pilihan',
|
||||
'matchEditor.col.selection_name': 'Nama paparan',
|
||||
'matchEditor.col.odds': 'Odds',
|
||||
'matchEditor.ph.selection_name': 'Nama dipaparkan kepada pemain',
|
||||
|
||||
'err.username_required': 'Sila isi nama pengguna',
|
||||
'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara',
|
||||
@@ -197,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',
|
||||
@@ -222,6 +341,9 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'agent_portal.agent_username_ph': 'Nama pengguna ejen',
|
||||
'agent_portal.player_id_ph': 'ID pemain',
|
||||
'agent_portal.withdraw_btn': 'Keluarkan {amount}',
|
||||
'agent_portal.withdraw_btn_label': 'Keluarkan',
|
||||
'agent_portal.transfer_title_deposit': 'Tambah baki {name}',
|
||||
'agent_portal.transfer_title_withdraw': 'Keluarkan dari {name}',
|
||||
'msg.agent_sub_created': 'Sub-ejen dicipta',
|
||||
'msg.withdraw_ok': 'Pengeluaran berjaya',
|
||||
|
||||
@@ -233,7 +355,8 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.save_failed': 'Gagal menyimpan',
|
||||
'msg.deleted': 'Dipadam',
|
||||
'msg.delete_failed': 'Gagal memadam',
|
||||
'msg.match_created_draft': 'Perlawanan dicipta (draf)',
|
||||
'msg.league_created': 'Kejohanan dicipta',
|
||||
'msg.match_created_draft': 'Perlawanan tunggal dicipta (draf)',
|
||||
'msg.published': 'Diterbitkan dengan pasaran',
|
||||
'msg.closed': 'Pertaruhan ditutup',
|
||||
'msg.invalid_json': 'JSON tidak sah',
|
||||
@@ -241,6 +364,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',
|
||||
'msg.topup_ok': 'Tambah baki berjaya',
|
||||
'msg.topup_failed': 'Tambah baki gagal',
|
||||
'msg.transfer_failed': 'Operasi gagal',
|
||||
'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0',
|
||||
'msg.credit_zero': 'Pelarasan tidak boleh 0',
|
||||
'msg.credit_adjusted': 'Kredit dikemas kini',
|
||||
@@ -249,18 +373,72 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.outright_odds_saved': 'Odds juara disimpan',
|
||||
'msg.load_failed': 'Gagal memuatkan',
|
||||
|
||||
'content.btn.create': 'Kandungan baharu',
|
||||
'content.btn.enable': 'Aktifkan',
|
||||
'content.btn.disable': 'Nyahaktif',
|
||||
'content.dialog.create': 'Kandungan awam baharu',
|
||||
'content.dialog.edit': 'Edit kandungan awam',
|
||||
'content.confirm_delete': 'Padam "{title}"?',
|
||||
'content.type.BANNER': 'Banner laman utama',
|
||||
'content.type.ANNOUNCEMENT': 'Pengumuman',
|
||||
'content.hint.announcement': 'Dipaparkan di ticker atas pemain; isi tajuk atau kandungan',
|
||||
'content.status.DRAFT': 'Draf',
|
||||
'content.status.ACTIVE': 'Aktif',
|
||||
'content.status.INACTIVE': 'Tidak aktif',
|
||||
'content.col.sort': 'Susunan',
|
||||
'content.col.preview': 'Pratonton',
|
||||
'content.col.title': 'Tajuk / ringkasan',
|
||||
'content.col.player_visible': 'Pemain nampak',
|
||||
'content.col.schedule': 'Jadual',
|
||||
'content.col.link': 'Pautan',
|
||||
'content.field.link_type': 'Jenis pautan',
|
||||
'content.field.link_target': 'Sasaran pautan',
|
||||
'content.field.start_time': 'Masa mula',
|
||||
'content.field.end_time': 'Masa tamat',
|
||||
'content.field.title': 'Tajuk',
|
||||
'content.field.title_ph': 'Pilihan',
|
||||
'content.field.body': 'Kandungan',
|
||||
'content.field.announce_text': 'Teks ticker',
|
||||
'content.field.image_url': 'URL imej',
|
||||
'content.link.none': 'Tiada pautan',
|
||||
'content.locale.zh-CN': 'Cina Ringkas',
|
||||
'content.locale.en-US': 'English',
|
||||
'content.locale.ms-MY': 'Bahasa Melayu',
|
||||
'content.hidden_reason.NOT_ACTIVE': 'Tidak aktif atau draf',
|
||||
'content.hidden_reason.NOT_STARTED': 'Belum bermula',
|
||||
'content.hidden_reason.EXPIRED': 'Tamat tempoh',
|
||||
'content.hidden_reason.INCOMPLETE': 'Terjemahan tidak lengkap',
|
||||
'content.batch.selected': '{n} dipilih',
|
||||
'content.batch.enable': 'Aktifkan dipilih',
|
||||
'content.batch.disable': 'Nyahaktif dipilih',
|
||||
'content.batch.delete': 'Padam dipilih',
|
||||
'content.confirm_batch_enable': 'Aktifkan {n} item dipilih?',
|
||||
'content.confirm_batch_disable': 'Nyahaktif {n} item dipilih?',
|
||||
'content.confirm_batch_delete': 'Padam {n} item dipilih?',
|
||||
'content.batch.all_ok': '{n} item berjaya',
|
||||
'content.batch.partial': '{ok} berjaya, {fail} gagal',
|
||||
|
||||
'page.outrights.title': 'Juara',
|
||||
'page.outrights.desc': 'Cipta dan edit pasaran juara; Piala Dunia 2026 boleh import asas',
|
||||
'outright.col.rank': 'Kedudukan',
|
||||
'outright.col.team_zh': 'Pasukan (ZH)',
|
||||
'outright.col.team_en': 'Pasukan (EN)',
|
||||
'outright.col.code': 'Kod',
|
||||
'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',
|
||||
|
||||
@@ -51,6 +51,22 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.field.login_fail': '登录失败',
|
||||
'user.field.phone': '手机',
|
||||
'user.field.email': '邮箱',
|
||||
'user.field.allow_password_change': '允许玩家改密码',
|
||||
'user.field.allow_username_change': '允许玩家改账号名',
|
||||
'user.field.view_password': '登录密码',
|
||||
'user.field.reset_password': '重置密码',
|
||||
'user.password_not_stored': '未记录(玩家已自行修改或未保存)',
|
||||
'user.btn.show_password': '查看',
|
||||
'user.btn.hide_password': '隐藏',
|
||||
'user.ph.reset_password': '留空则不修改;填写后将更新并可查看',
|
||||
'user.ph.reset_password_short': '留空不修改',
|
||||
'user.global_settings': '密码与账号管理(全局)',
|
||||
'user.global_settings_hint': '控制所有玩家是否可在 App 内改密码、改账号名',
|
||||
'user.section.password_mgmt': '密码管理',
|
||||
'user.field.current_password': '当前密码',
|
||||
'user.msg.created_with_password': '玩家已创建,登录密码:{password}',
|
||||
'user.msg.password_saved': '密码已更新,当前可查密码:{password}',
|
||||
'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。',
|
||||
'user.ph.username_unique': '登录用户名,唯一',
|
||||
'user.ph.no_agent': '不设置(平台直属玩家)',
|
||||
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
||||
@@ -58,6 +74,10 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
||||
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
||||
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
|
||||
'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码',
|
||||
'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名',
|
||||
'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除',
|
||||
'user.hint.reset_password': '重置后立即生效,并更新上方可查密码',
|
||||
'user.btn.create': '创建',
|
||||
'user.btn.save_profile': '保存资料',
|
||||
'user.btn.confirm_deposit': '确认上分',
|
||||
@@ -104,22 +124,44 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
|
||||
|
||||
'match.create_btn': '+ 新增赛事',
|
||||
'match.create_fixture_btn': '+ 新增单场',
|
||||
'match.btn.markets': '盘口',
|
||||
'match.filter.keyword_ph': '赛事名 / 球队代码',
|
||||
'match.col.league': '赛事',
|
||||
'match.col.fixture_count': '单场',
|
||||
'match.col.bet_count': '注单数',
|
||||
'match.col.total_stake': '总投注额',
|
||||
'match.col.pending_bets': '待结算',
|
||||
'match.col.league_code': '代码',
|
||||
'match.col.matchup': '对阵',
|
||||
'match.col.kickoff': '开赛时间',
|
||||
'match.dialog.create': '新增赛事',
|
||||
'match.dialog.edit': '编辑赛事',
|
||||
'match.dialog.create_league': '新增赛事',
|
||||
'match.dialog.create_fixture': '新增单场',
|
||||
'match.dialog.create': '新增单场',
|
||||
'match.dialog.edit': '编辑单场',
|
||||
'match.dialog.import': '导入赛事',
|
||||
'match.field.league_en': '联赛(英)',
|
||||
'match.field.league_zh': '联赛(中)',
|
||||
'match.field.league_ms': '联赛(马来)',
|
||||
'match.field.league_logo': '赛事 Logo',
|
||||
'match.field.lang_zh': '中',
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': '开赛时间',
|
||||
'match.field.home_en': '主队(英)',
|
||||
'match.field.home_zh': '主队(中)',
|
||||
'match.field.home_ms': '主队(马来)',
|
||||
'match.field.away_en': '客队(英)',
|
||||
'match.field.away_zh': '客队(中)',
|
||||
'match.field.away_ms': '客队(马来)',
|
||||
'match.field.featured': '热门',
|
||||
'match.hint.create_draft': '创建后为草稿,请在列表点击「发布」并生成盘口。',
|
||||
'match.hint.create_draft': '创建后为草稿,请展开赛事后在单场行点击「发布」并生成盘口。',
|
||||
'match.hint.create_league': '创建赛事(联赛)后,展开该行可添加多场单场比赛。',
|
||||
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
|
||||
'match.expand_league_hint': '展开赛事查看单场列表;点击「盘口」进入单独页面设置盘口与赔率(与玩家端按联赛分组一致)。',
|
||||
'match.expand_markets_hint': '在单场列表点击「盘口」进入单独页面设置盘口与赔率。',
|
||||
'match.no_fixtures': '该赛事下暂无单场。',
|
||||
'match.ph.league_ms': '2027 世界杯',
|
||||
|
||||
'bet.filter.keyword_ph': '流水编号 / 玩家用户名',
|
||||
'bet.filter.date_from': '投注日起',
|
||||
@@ -188,20 +230,97 @@ 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': '返回列表',
|
||||
'matchEditor.title': '编辑基本信息',
|
||||
'matchEditor.section_info': '基本信息',
|
||||
'matchEditor.section_markets': '盘口与赔率',
|
||||
'matchEditor.field.league_logo': 'Logo',
|
||||
'matchEditor.field.home_logo': 'Logo',
|
||||
'matchEditor.field.away_logo': 'Logo',
|
||||
'matchEditor.field.pick_flag': '选择国旗',
|
||||
'matchEditor.field.custom_logo_url': '自定义图片 URL',
|
||||
'matchEditor.ph.logo_url': 'https://...',
|
||||
'matchEditor.field.match_name': '赛事显示名',
|
||||
'matchEditor.field.stage': '阶段',
|
||||
'matchEditor.field.group': '小组',
|
||||
'matchEditor.field.display_order': '排序',
|
||||
'matchEditor.field.promo_label': '促销标签',
|
||||
'matchEditor.field.promo_label_optional': '促销标签(可选)',
|
||||
'matchEditor.field.line_value': '盘口线',
|
||||
'matchEditor.ph.kickoff': '选择开赛日期与时间',
|
||||
'matchEditor.group.league': '联赛信息',
|
||||
'matchEditor.group.home': '主队',
|
||||
'matchEditor.group.away': '客队',
|
||||
'matchEditor.group.schedule': '赛程与展示',
|
||||
'matchEditor.save_info': '保存基本信息',
|
||||
'matchEditor.save_market': '保存盘口设置',
|
||||
'matchEditor.save_odds': '保存赔率',
|
||||
'matchEditor.generate_templates': '生成默认盘口',
|
||||
'matchEditor.templates_generated': '盘口模板已生成',
|
||||
'matchEditor.no_markets': '暂无盘口,请先发布赛事或点击「生成默认盘口」。',
|
||||
'matchEditor.market.FT_1X2': '全场 1X2',
|
||||
'matchEditor.market.FT_HANDICAP': '全场让球',
|
||||
'matchEditor.market.FT_OVER_UNDER': '全场大小',
|
||||
'matchEditor.market.FT_ODD_EVEN': '全场单双',
|
||||
'matchEditor.market.HT_1X2': '半场 1X2',
|
||||
'matchEditor.market.HT_HANDICAP': '半场让球',
|
||||
'matchEditor.market.HT_OVER_UNDER': '半场大小',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': '全场波胆',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': '半场波胆',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '下半场波胆',
|
||||
'matchEditor.period.FT': '全场',
|
||||
'matchEditor.period.HT': '半场',
|
||||
'matchEditor.period.SH': '下半场',
|
||||
'matchEditor.period.OUTRIGHT': '冠军',
|
||||
'matchEditor.selection.HOME': '主',
|
||||
'matchEditor.selection.DRAW': '和',
|
||||
'matchEditor.selection.AWAY': '客',
|
||||
'matchEditor.selection.OVER': '大',
|
||||
'matchEditor.selection.UNDER': '小',
|
||||
'matchEditor.selection.ODD': '单',
|
||||
'matchEditor.selection.EVEN': '双',
|
||||
'matchEditor.selection.OTHER_DRAW': '和局其它比分',
|
||||
'matchEditor.selection.OTHER_HOME': '主胜其它比分',
|
||||
'matchEditor.selection.OTHER_AWAY': '客胜其它比分',
|
||||
'matchEditor.col.selection_code': '选项',
|
||||
'matchEditor.col.selection_name': '显示名',
|
||||
'matchEditor.col.odds': '赔率',
|
||||
'matchEditor.ph.selection_name': '玩家端显示名称',
|
||||
|
||||
'err.username_required': '请填写用户名',
|
||||
'err.password_min': '密码至少 8 位',
|
||||
'err.password_mismatch': '两次密码不一致',
|
||||
'err.credit_negative': '授信额度不能为负',
|
||||
'err.kickoff_required': '请填写开赛时间',
|
||||
'err.teams_required': '请填写主客队名称(中文或英文至少一项)',
|
||||
'err.league_required': '请填写联赛名称',
|
||||
'err.teams_required': '请填写主客队名称(中文、英文或马来文至少一项)',
|
||||
'err.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': '录入比分',
|
||||
@@ -222,6 +341,9 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent_portal.agent_username_ph': '代理用户名',
|
||||
'agent_portal.player_id_ph': '玩家 ID',
|
||||
'agent_portal.withdraw_btn': '下分 {amount}',
|
||||
'agent_portal.withdraw_btn_label': '下分',
|
||||
'agent_portal.transfer_title_deposit': '给 {name} 上分',
|
||||
'agent_portal.transfer_title_withdraw': '从 {name} 下分',
|
||||
'msg.agent_sub_created': '下级代理已创建',
|
||||
'msg.withdraw_ok': '下分成功',
|
||||
|
||||
@@ -233,7 +355,8 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.save_failed': '保存失败',
|
||||
'msg.deleted': '已删除',
|
||||
'msg.delete_failed': '删除失败',
|
||||
'msg.match_created_draft': '赛事已创建(草稿)',
|
||||
'msg.league_created': '赛事已创建',
|
||||
'msg.match_created_draft': '单场已创建(草稿)',
|
||||
'msg.published': '已发布并生成盘口',
|
||||
'msg.closed': '已封盘',
|
||||
'msg.invalid_json': 'JSON 格式无效',
|
||||
@@ -241,6 +364,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
|
||||
'msg.topup_ok': '上分成功',
|
||||
'msg.topup_failed': '上分失败',
|
||||
'msg.transfer_failed': '操作失败',
|
||||
'msg.amount_gt_zero': '金额须大于 0',
|
||||
'msg.credit_zero': '调整金额不能为 0',
|
||||
'msg.credit_adjusted': '授信已调整',
|
||||
@@ -249,13 +373,63 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.outright_odds_saved': '夺冠赔率已保存',
|
||||
'msg.load_failed': '加载失败',
|
||||
|
||||
'content.btn.create': '新建内容',
|
||||
'content.btn.enable': '启用',
|
||||
'content.btn.disable': '停用',
|
||||
'content.dialog.create': '新建公共内容',
|
||||
'content.dialog.edit': '编辑公共内容',
|
||||
'content.confirm_delete': '确定删除「{title}」?',
|
||||
'content.type.BANNER': '首页轮播',
|
||||
'content.type.ANNOUNCEMENT': '公告滚动',
|
||||
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
|
||||
'content.status.DRAFT': '草稿',
|
||||
'content.status.ACTIVE': '已启用',
|
||||
'content.status.INACTIVE': '已停用',
|
||||
'content.col.sort': '排序',
|
||||
'content.col.preview': '预览',
|
||||
'content.col.title': '标题/摘要',
|
||||
'content.col.player_visible': '玩家可见',
|
||||
'content.col.schedule': '展示时段',
|
||||
'content.col.link': '跳转',
|
||||
'content.field.link_type': '链接类型',
|
||||
'content.field.link_target': '链接目标',
|
||||
'content.field.start_time': '开始时间',
|
||||
'content.field.end_time': '结束时间',
|
||||
'content.field.title': '标题',
|
||||
'content.field.title_ph': '选填,可与正文相同',
|
||||
'content.field.body': '正文',
|
||||
'content.field.announce_text': '滚动文案',
|
||||
'content.field.image_url': '图片地址',
|
||||
'content.link.none': '无跳转',
|
||||
'content.locale.zh-CN': '简体中文',
|
||||
'content.locale.en-US': 'English',
|
||||
'content.locale.ms-MY': 'Bahasa Melayu',
|
||||
'content.hidden_reason.NOT_ACTIVE': '未启用或草稿',
|
||||
'content.hidden_reason.NOT_STARTED': '未到开始时间',
|
||||
'content.hidden_reason.EXPIRED': '已过结束时间',
|
||||
'content.hidden_reason.INCOMPLETE': '多语言内容不完整',
|
||||
'content.batch.selected': '已选 {n} 项',
|
||||
'content.batch.enable': '批量启用',
|
||||
'content.batch.disable': '批量停用',
|
||||
'content.batch.delete': '批量删除',
|
||||
'content.confirm_batch_enable': '确定启用选中的 {n} 项?',
|
||||
'content.confirm_batch_disable': '确定停用选中的 {n} 项?',
|
||||
'content.confirm_batch_delete': '确定删除选中的 {n} 项?',
|
||||
'content.batch.all_ok': '已成功处理 {n} 项',
|
||||
'content.batch.partial': '成功 {ok} 项,失败 {fail} 项',
|
||||
|
||||
'page.outrights.title': '优胜冠军',
|
||||
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
|
||||
'outright.col.rank': '排名',
|
||||
'outright.col.team_zh': '队伍(中文)',
|
||||
'outright.col.team_en': '队伍(英文)',
|
||||
'outright.col.code': '代码',
|
||||
'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': '保存赛事信息',
|
||||
'outright.btn.publish': '发布',
|
||||
@@ -277,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': '已发布',
|
||||
@@ -355,6 +530,22 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'user.field.login_fail': 'Failed logins',
|
||||
'user.field.phone': 'Phone',
|
||||
'user.field.email': 'Email',
|
||||
'user.field.allow_password_change': 'Allow player password change',
|
||||
'user.field.allow_username_change': 'Allow player username change',
|
||||
'user.field.view_password': 'Login password',
|
||||
'user.field.reset_password': 'Reset password',
|
||||
'user.password_not_stored': 'Not stored (player changed it or never saved)',
|
||||
'user.btn.show_password': 'Show',
|
||||
'user.btn.hide_password': 'Hide',
|
||||
'user.ph.reset_password': 'Leave empty to keep; new value will be viewable',
|
||||
'user.ph.reset_password_short': 'Leave empty to keep',
|
||||
'user.global_settings': 'Password & account (global)',
|
||||
'user.global_settings_hint': 'Controls whether all players can change password or username in the app',
|
||||
'user.section.password_mgmt': 'Password management',
|
||||
'user.field.current_password': 'Current password',
|
||||
'user.msg.created_with_password': 'Player created. Login password: {password}',
|
||||
'user.msg.password_saved': 'Password updated. Viewable password: {password}',
|
||||
'user.hint.password_reset_to_view': 'No stored password. Set one below under Reset password and save to view it here.',
|
||||
'user.ph.username_unique': 'Unique login username',
|
||||
'user.ph.no_agent': 'None (platform direct)',
|
||||
'user.hint.no_agent': 'Leave empty for platform-managed player',
|
||||
@@ -362,6 +553,10 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
||||
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
|
||||
'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit',
|
||||
'user.hint.allow_password_change': 'When off, no player can change password in the app',
|
||||
'user.hint.allow_username_change': 'When on, all players can change login username in profile',
|
||||
'user.hint.view_password': 'Only passwords set on create/reset; cleared after player self-change',
|
||||
'user.hint.reset_password': 'Takes effect immediately and updates viewable password above',
|
||||
'user.btn.create': 'Create',
|
||||
'user.btn.save_profile': 'Save',
|
||||
'user.btn.confirm_deposit': 'Confirm top-up',
|
||||
@@ -407,23 +602,45 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent.ph.select_user': 'Search player username',
|
||||
'agent.hint.select_user': 'Pick an existing player account to promote to tier-1 agent (no new login)',
|
||||
|
||||
'match.create_btn': '+ New match',
|
||||
'match.filter.keyword_ph': 'Match name / team code',
|
||||
'match.create_btn': '+ New tournament',
|
||||
'match.create_fixture_btn': '+ Add fixture',
|
||||
'match.btn.markets': 'Markets',
|
||||
'match.filter.keyword_ph': 'Tournament / team code',
|
||||
'match.col.league': 'Tournament',
|
||||
'match.col.fixture_count': 'Fixtures',
|
||||
'match.col.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',
|
||||
'match.dialog.create': 'New match',
|
||||
'match.dialog.edit': 'Edit match',
|
||||
'match.dialog.create_league': 'New tournament',
|
||||
'match.dialog.create_fixture': 'New fixture',
|
||||
'match.dialog.create': 'New fixture',
|
||||
'match.dialog.edit': 'Edit fixture',
|
||||
'match.dialog.import': 'Import matches',
|
||||
'match.field.league_en': 'League (EN)',
|
||||
'match.field.league_zh': 'League (ZH)',
|
||||
'match.field.league_ms': 'League (MS)',
|
||||
'match.field.league_logo': 'Tournament logo',
|
||||
'match.field.lang_zh': 'ZH',
|
||||
'match.field.lang_en': 'EN',
|
||||
'match.field.lang_ms': 'MS',
|
||||
'match.field.kickoff': 'Kickoff time',
|
||||
'match.field.home_en': 'Home (EN)',
|
||||
'match.field.home_zh': 'Home (ZH)',
|
||||
'match.field.home_ms': 'Home (MS)',
|
||||
'match.field.away_en': 'Away (EN)',
|
||||
'match.field.away_zh': 'Away (ZH)',
|
||||
'match.field.away_ms': 'Away (MS)',
|
||||
'match.field.featured': 'Featured',
|
||||
'match.hint.create_draft': 'Saved as draft; click Publish in the list to open markets.',
|
||||
'match.hint.create_draft': 'Saved as draft; expand the tournament and publish each fixture to open markets.',
|
||||
'match.hint.create_league': 'Create a tournament first, then expand it to add fixtures.',
|
||||
'match.hint.edit_published': 'Published: edit kickoff, featured, display names; closed/settled are locked.',
|
||||
'match.expand_league_hint': 'Expand a tournament to see fixtures; use Markets for a dedicated odds page (same grouping as player app).',
|
||||
'match.expand_markets_hint': 'Click Markets on a fixture to open the dedicated markets page.',
|
||||
'match.no_fixtures': 'No fixtures under this tournament yet.',
|
||||
'match.ph.league_ms': 'World Cup 2027',
|
||||
|
||||
'bet.filter.keyword_ph': 'Bet no. / username',
|
||||
'bet.filter.date_from': 'Placed from',
|
||||
@@ -492,8 +709,68 @@ 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',
|
||||
'matchEditor.title': 'Edit basic info',
|
||||
'matchEditor.section_info': 'Basic info',
|
||||
'matchEditor.section_markets': 'Markets & odds',
|
||||
'matchEditor.field.league_logo': 'Logo',
|
||||
'matchEditor.field.home_logo': 'Logo',
|
||||
'matchEditor.field.away_logo': 'Logo',
|
||||
'matchEditor.field.pick_flag': 'Pick flag',
|
||||
'matchEditor.field.custom_logo_url': 'Custom image URL',
|
||||
'matchEditor.ph.logo_url': 'https://...',
|
||||
'matchEditor.field.match_name': 'Display name',
|
||||
'matchEditor.field.stage': 'Stage',
|
||||
'matchEditor.field.group': 'Group',
|
||||
'matchEditor.field.display_order': 'Sort order',
|
||||
'matchEditor.field.promo_label': 'Promo label',
|
||||
'matchEditor.field.promo_label_optional': 'Promo label (optional)',
|
||||
'matchEditor.field.line_value': 'Line',
|
||||
'matchEditor.ph.kickoff': 'Select kickoff date & time',
|
||||
'matchEditor.group.league': 'League',
|
||||
'matchEditor.group.home': 'Home team',
|
||||
'matchEditor.group.away': 'Away team',
|
||||
'matchEditor.group.schedule': 'Schedule & display',
|
||||
'matchEditor.save_info': 'Save info',
|
||||
'matchEditor.save_market': 'Save market',
|
||||
'matchEditor.save_odds': 'Save odds',
|
||||
'matchEditor.generate_templates': 'Generate templates',
|
||||
'matchEditor.templates_generated': 'Market templates created',
|
||||
'matchEditor.no_markets': 'No markets yet — publish the match or generate templates.',
|
||||
'matchEditor.market.FT_1X2': 'FT 1X2',
|
||||
'matchEditor.market.FT_HANDICAP': 'FT handicap',
|
||||
'matchEditor.market.FT_OVER_UNDER': 'FT O/U',
|
||||
'matchEditor.market.FT_ODD_EVEN': 'FT odd/even',
|
||||
'matchEditor.market.HT_1X2': 'HT 1X2',
|
||||
'matchEditor.market.HT_HANDICAP': 'HT handicap',
|
||||
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
|
||||
'matchEditor.market.FT_CORRECT_SCORE': 'FT correct score',
|
||||
'matchEditor.market.HT_CORRECT_SCORE': 'HT correct score',
|
||||
'matchEditor.market.SH_CORRECT_SCORE': '2H correct score',
|
||||
'matchEditor.period.FT': 'Full time',
|
||||
'matchEditor.period.HT': 'Half time',
|
||||
'matchEditor.period.SH': 'Second half',
|
||||
'matchEditor.period.OUTRIGHT': 'Outright',
|
||||
'matchEditor.selection.HOME': 'Home',
|
||||
'matchEditor.selection.DRAW': 'Draw',
|
||||
'matchEditor.selection.AWAY': 'Away',
|
||||
'matchEditor.selection.OVER': 'Over',
|
||||
'matchEditor.selection.UNDER': 'Under',
|
||||
'matchEditor.selection.ODD': 'Odd',
|
||||
'matchEditor.selection.EVEN': 'Even',
|
||||
'matchEditor.selection.OTHER_DRAW': 'Draw (other score)',
|
||||
'matchEditor.selection.OTHER_HOME': 'Home win (other score)',
|
||||
'matchEditor.selection.OTHER_AWAY': 'Away win (other score)',
|
||||
'matchEditor.col.selection_code': 'Option',
|
||||
'matchEditor.col.selection_name': 'Display name',
|
||||
'matchEditor.col.odds': 'Odds',
|
||||
'matchEditor.ph.selection_name': 'Name shown to players',
|
||||
|
||||
'err.username_required': 'Username is required',
|
||||
'err.password_min': 'Password must be at least 8 characters',
|
||||
@@ -501,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',
|
||||
@@ -526,6 +820,9 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent_portal.agent_username_ph': 'Agent username',
|
||||
'agent_portal.player_id_ph': 'Player ID',
|
||||
'agent_portal.withdraw_btn': 'Withdraw {amount}',
|
||||
'agent_portal.withdraw_btn_label': 'Withdraw',
|
||||
'agent_portal.transfer_title_deposit': 'Top up {name}',
|
||||
'agent_portal.transfer_title_withdraw': 'Withdraw from {name}',
|
||||
'msg.agent_sub_created': 'Sub-agent created',
|
||||
'msg.withdraw_ok': 'Withdrawal successful',
|
||||
|
||||
@@ -537,7 +834,8 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.save_failed': 'Save failed',
|
||||
'msg.deleted': 'Deleted',
|
||||
'msg.delete_failed': 'Delete failed',
|
||||
'msg.match_created_draft': 'Match created (draft)',
|
||||
'msg.league_created': 'Tournament created',
|
||||
'msg.match_created_draft': 'Fixture created (draft)',
|
||||
'msg.published': 'Published with markets',
|
||||
'msg.closed': 'Betting closed',
|
||||
'msg.invalid_json': 'Invalid JSON',
|
||||
@@ -545,6 +843,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
|
||||
'msg.topup_ok': 'Top-up successful',
|
||||
'msg.topup_failed': 'Top-up failed',
|
||||
'msg.transfer_failed': 'Operation failed',
|
||||
'msg.amount_gt_zero': 'Amount must be greater than 0',
|
||||
'msg.credit_zero': 'Adjustment cannot be 0',
|
||||
'msg.credit_adjusted': 'Credit updated',
|
||||
@@ -553,13 +852,63 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.outright_odds_saved': 'Outright odds saved',
|
||||
'msg.load_failed': 'Load failed',
|
||||
|
||||
'content.btn.create': 'New content',
|
||||
'content.btn.enable': 'Enable',
|
||||
'content.btn.disable': 'Disable',
|
||||
'content.dialog.create': 'New public content',
|
||||
'content.dialog.edit': 'Edit public content',
|
||||
'content.confirm_delete': 'Delete "{title}"?',
|
||||
'content.type.BANNER': 'Home banners',
|
||||
'content.type.ANNOUNCEMENT': 'Announcements',
|
||||
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
|
||||
'content.status.DRAFT': 'Draft',
|
||||
'content.status.ACTIVE': 'Active',
|
||||
'content.status.INACTIVE': 'Inactive',
|
||||
'content.col.sort': 'Sort',
|
||||
'content.col.preview': 'Preview',
|
||||
'content.col.title': 'Title / summary',
|
||||
'content.col.player_visible': 'Player visible',
|
||||
'content.col.schedule': 'Schedule',
|
||||
'content.col.link': 'Link',
|
||||
'content.field.link_type': 'Link type',
|
||||
'content.field.link_target': 'Link target',
|
||||
'content.field.start_time': 'Start time',
|
||||
'content.field.end_time': 'End time',
|
||||
'content.field.title': 'Title',
|
||||
'content.field.title_ph': 'Optional; can match body',
|
||||
'content.field.body': 'Body',
|
||||
'content.field.announce_text': 'Marquee text',
|
||||
'content.field.image_url': 'Image URL',
|
||||
'content.link.none': 'No link',
|
||||
'content.locale.zh-CN': 'Chinese (Simplified)',
|
||||
'content.locale.en-US': 'English',
|
||||
'content.locale.ms-MY': 'Malay',
|
||||
'content.hidden_reason.NOT_ACTIVE': 'Not active or draft',
|
||||
'content.hidden_reason.NOT_STARTED': 'Not started yet',
|
||||
'content.hidden_reason.EXPIRED': 'Expired',
|
||||
'content.hidden_reason.INCOMPLETE': 'Incomplete translations',
|
||||
'content.batch.selected': '{n} selected',
|
||||
'content.batch.enable': 'Enable selected',
|
||||
'content.batch.disable': 'Disable selected',
|
||||
'content.batch.delete': 'Delete selected',
|
||||
'content.confirm_batch_enable': 'Enable {n} selected item(s)?',
|
||||
'content.confirm_batch_disable': 'Disable {n} selected item(s)?',
|
||||
'content.confirm_batch_delete': 'Delete {n} selected item(s)?',
|
||||
'content.batch.all_ok': '{n} item(s) processed',
|
||||
'content.batch.partial': '{ok} succeeded, {fail} failed',
|
||||
|
||||
'page.outrights.title': 'Outrights',
|
||||
'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
|
||||
'outright.col.rank': 'Rank',
|
||||
'outright.col.team_zh': 'Team (ZH)',
|
||||
'outright.col.team_en': 'Team (EN)',
|
||||
'outright.col.code': 'Code',
|
||||
'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',
|
||||
'outright.btn.publish': 'Publish',
|
||||
@@ -581,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',
|
||||
|
||||
@@ -14,10 +14,11 @@ const adminMenus = computed(() => [
|
||||
{ path: '/', label: t('nav.dashboard') },
|
||||
{ path: '/users', label: t('nav.users') },
|
||||
{ path: '/agents', label: t('nav.agents') },
|
||||
{ path: '/matches', label: t('nav.matches') },
|
||||
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
|
||||
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/cashback', label: t('nav.cashback') },
|
||||
{ path: '/contents', label: t('nav.contents') },
|
||||
{ path: '/audit', label: t('nav.audit') },
|
||||
]);
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ const router = createRouter({
|
||||
component: () => import('../views/Matches.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'matches/:matchId/edit',
|
||||
name: 'admin-match-edit',
|
||||
component: () => import('../views/matches/MatchEventEditor.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'matches/:matchId/markets',
|
||||
name: 'admin-match-markets',
|
||||
component: () => import('../views/matches/MatchMarketsPage.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'outrights',
|
||||
name: 'admin-outrights',
|
||||
@@ -54,6 +66,11 @@ const router = createRouter({
|
||||
component: () => import('../views/Cashback.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'contents',
|
||||
component: () => import('../views/Contents.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
component: () => import('../views/Audit.vue'),
|
||||
|
||||
22
apps/admin/src/utils/adminSelectionLabel.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defaultSelectionName } from './selectionDefaults';
|
||||
|
||||
/** 管理后台盘口选项展示名(按 code + 当前语言) */
|
||||
export function adminSelectionLabel(
|
||||
t: (key: string) => string,
|
||||
code: string,
|
||||
opts?: { lineValue?: number | null; period?: string },
|
||||
): string {
|
||||
const i18nKey = `matchEditor.selection.${code}`;
|
||||
const translated = t(i18nKey);
|
||||
if (translated !== i18nKey) return translated;
|
||||
|
||||
const score = code.match(/^SCORE_(\d+)_(\d+)$/);
|
||||
if (score) return `${score[1]}-${score[2]}`;
|
||||
|
||||
if (opts) {
|
||||
const name = defaultSelectionName(code, opts);
|
||||
if (name !== code) return name;
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
51
apps/admin/src/utils/matchesListState.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** 赛事列表 UI 状态(返回列表时恢复展开等) */
|
||||
|
||||
const STORAGE_KEY = 'admin_matches_list_ui';
|
||||
|
||||
export type MatchesListUiState = {
|
||||
expandedLeagueIds: string[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
filterStatus: string;
|
||||
keyword: string;
|
||||
};
|
||||
|
||||
function defaultState(): MatchesListUiState {
|
||||
return {
|
||||
expandedLeagueIds: [],
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
filterStatus: '',
|
||||
keyword: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function readMatchesListUiState(): MatchesListUiState | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as MatchesListUiState;
|
||||
if (!Array.isArray(parsed.expandedLeagueIds)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMatchesListUiState(state: MatchesListUiState) {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function patchMatchesListUiState(patch: Partial<MatchesListUiState>) {
|
||||
const base = readMatchesListUiState() ?? defaultState();
|
||||
writeMatchesListUiState({ ...base, ...patch });
|
||||
}
|
||||
|
||||
/** 从子页返回前确保该赛事行处于展开记录中 */
|
||||
export function ensureLeagueExpanded(leagueId: string) {
|
||||
if (!leagueId) return;
|
||||
const base = readMatchesListUiState() ?? defaultState();
|
||||
const ids = new Set(base.expandedLeagueIds);
|
||||
ids.add(leagueId);
|
||||
writeMatchesListUiState({ ...base, expandedLeagueIds: [...ids] });
|
||||
}
|
||||
42
apps/admin/src/utils/selectionDefaults.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/** 标准盘口选项的固定显示名(写入 DB,玩家端也会按 code 做 i18n) */
|
||||
|
||||
function handicapName(side: 'home' | 'away', line: number, half: boolean) {
|
||||
const sideLabel = side === 'home' ? '主队' : '客队';
|
||||
const value = side === 'home' ? line : -line;
|
||||
const lineText = value > 0 ? `+${value}` : `${value}`;
|
||||
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
|
||||
}
|
||||
|
||||
function ouName(side: 'over' | 'under', line: number, half: boolean) {
|
||||
const sideLabel = side === 'over' ? '大' : '小';
|
||||
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
|
||||
}
|
||||
|
||||
export function defaultSelectionName(
|
||||
code: string,
|
||||
opts: { lineValue?: number | null; period?: string },
|
||||
): string {
|
||||
const half = opts.period === 'HT';
|
||||
const line = opts.lineValue;
|
||||
|
||||
if (code === 'HOME' && line != null) return handicapName('home', line, half);
|
||||
if (code === 'AWAY' && line != null) return handicapName('away', line, half);
|
||||
if (code === 'OVER' && line != null) return ouName('over', line, half);
|
||||
if (code === 'UNDER' && line != null) return ouName('under', line, half);
|
||||
|
||||
const fixed: Record<string, string> = {
|
||||
HOME: '主胜',
|
||||
DRAW: '和',
|
||||
AWAY: '客胜',
|
||||
ODD: '单',
|
||||
EVEN: '双',
|
||||
OTHER_DRAW: '和局其它比分',
|
||||
OTHER_HOME: '主胜其它比分',
|
||||
OTHER_AWAY: '客胜其它比分',
|
||||
};
|
||||
if (fixed[code]) return fixed[code];
|
||||
if (code.startsWith('SCORE_')) {
|
||||
return code.replace('SCORE_', '').replace('_', '-');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
21
apps/admin/src/utils/teamFlag.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
countryFlagUrl,
|
||||
countryCrestUrl,
|
||||
countryLogoUrl,
|
||||
getBuiltinCountry,
|
||||
hasCountryCrest,
|
||||
} from '../data/builtinCountries';
|
||||
|
||||
export {
|
||||
countryFlagUrl,
|
||||
countryCrestUrl,
|
||||
countryLogoUrl,
|
||||
getBuiltinCountry,
|
||||
hasCountryCrest,
|
||||
};
|
||||
|
||||
export function suggestTeamFlagUrl(code?: string): string {
|
||||
const c = getBuiltinCountry(code);
|
||||
if (!c) return '';
|
||||
return hasCountryCrest(c) ? countryCrestUrl(c) : countryFlagUrl(c);
|
||||
}
|
||||
633
apps/admin/src/views/Contents.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const { t, localeTag } = useAdminLocale();
|
||||
|
||||
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
|
||||
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
|
||||
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
|
||||
|
||||
interface TranslationForm {
|
||||
locale: string;
|
||||
title: string;
|
||||
body: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
interface ContentItem {
|
||||
id: string;
|
||||
contentType: StoredContentType;
|
||||
sortOrder: number;
|
||||
status: ContentStatus;
|
||||
linkType: string | null;
|
||||
linkTarget: string | null;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
previewTitle: string;
|
||||
previewImageUrl: string | null;
|
||||
playerVisible: boolean;
|
||||
playerHiddenReason: string | null;
|
||||
translations: TranslationForm[];
|
||||
}
|
||||
|
||||
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT'];
|
||||
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||||
|
||||
const activeType = ref<AdminTab>('BANNER');
|
||||
const filterStatus = ref<ContentStatus | ''>('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const items = ref<ContentItem[]>([]);
|
||||
const tableRef = ref<TableInstance>();
|
||||
const selectedRows = ref<ContentItem[]>([]);
|
||||
|
||||
const hasSelection = computed(() => selectedRows.value.length > 0);
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
const editingContentType = ref<StoredContentType>('NOTICE');
|
||||
|
||||
const form = ref({
|
||||
sortOrder: 0,
|
||||
status: 'DRAFT' as ContentStatus,
|
||||
linkType: '' as '' | 'ROUTE' | 'URL',
|
||||
linkTarget: '',
|
||||
startTime: '' as string,
|
||||
endTime: '' as string,
|
||||
translations: emptyTranslations(),
|
||||
});
|
||||
|
||||
function emptyTranslations(): TranslationForm[] {
|
||||
return LOCALES.map((locale) => ({
|
||||
locale,
|
||||
title: '',
|
||||
body: '',
|
||||
imageUrl: '',
|
||||
}));
|
||||
}
|
||||
|
||||
function localeLabel(code: string) {
|
||||
const key = `content.locale.${code}`;
|
||||
const label = t(key);
|
||||
return label === key ? code : label;
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const key = `content.status.${status}`;
|
||||
const label = t(key);
|
||||
return label === key ? status : label;
|
||||
}
|
||||
|
||||
function statusTagType(status: string) {
|
||||
if (status === 'ACTIVE') return 'success';
|
||||
if (status === 'DRAFT') return 'info';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
function hiddenTip(reason: string | null) {
|
||||
if (!reason) return '';
|
||||
const key = `content.hidden_reason.${reason}`;
|
||||
const label = t(key);
|
||||
return label === key ? reason : label;
|
||||
}
|
||||
|
||||
function formatTime(v: string | null) {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleString(localeTag.value, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const isBanner = computed(() => activeType.value === 'BANNER');
|
||||
const isAnnouncement = computed(() => activeType.value === 'ANNOUNCEMENT');
|
||||
const dialogTitle = computed(() =>
|
||||
editingId.value ? t('content.dialog.edit') : t('content.dialog.create'),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/admin/contents', {
|
||||
params: {
|
||||
type: activeType.value,
|
||||
status: filterStatus.value || undefined,
|
||||
},
|
||||
});
|
||||
items.value = data.data ?? [];
|
||||
selectedRows.value = [];
|
||||
tableRef.value?.clearSelection();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([activeType, filterStatus], () => {
|
||||
selectedRows.value = [];
|
||||
tableRef.value?.clearSelection();
|
||||
void load();
|
||||
});
|
||||
|
||||
function onSelectionChange(rows: ContentItem[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
async function runBatch(
|
||||
action: (row: ContentItem) => Promise<void>,
|
||||
confirmKey?: string,
|
||||
) {
|
||||
const rows = [...selectedRows.value];
|
||||
if (!rows.length) return;
|
||||
|
||||
if (confirmKey) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t(confirmKey, { n: rows.length }), { type: 'warning' });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
try {
|
||||
for (const row of rows) {
|
||||
try {
|
||||
await action(row);
|
||||
ok += 1;
|
||||
} catch {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
if (fail === 0) {
|
||||
ElMessage.success(t('content.batch.all_ok', { n: ok }));
|
||||
} else {
|
||||
ElMessage.warning(t('content.batch.partial', { ok, fail }));
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function batchEnable() {
|
||||
void runBatch(
|
||||
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'ACTIVE' }),
|
||||
'content.confirm_batch_enable',
|
||||
);
|
||||
}
|
||||
|
||||
function batchDisable() {
|
||||
void runBatch(
|
||||
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'INACTIVE' }),
|
||||
'content.confirm_batch_disable',
|
||||
);
|
||||
}
|
||||
|
||||
function batchDelete() {
|
||||
void runBatch(
|
||||
(row) => api.delete(`/admin/contents/${row.id}`),
|
||||
'content.confirm_batch_delete',
|
||||
);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
sortOrder: items.value.length + 1,
|
||||
status: 'DRAFT',
|
||||
linkType: '',
|
||||
linkTarget: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
translations: emptyTranslations(),
|
||||
};
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
resetForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: ContentItem) {
|
||||
editingId.value = row.id;
|
||||
editingContentType.value = row.contentType;
|
||||
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
|
||||
form.value = {
|
||||
sortOrder: row.sortOrder,
|
||||
status: row.status,
|
||||
linkType: (row.linkType as '' | 'ROUTE' | 'URL') || '',
|
||||
linkTarget: row.linkTarget ?? '',
|
||||
startTime: row.startTime ? row.startTime.slice(0, 19) : '',
|
||||
endTime: row.endTime ? row.endTime.slice(0, 19) : '',
|
||||
translations: LOCALES.map((locale) => {
|
||||
const tr = byLocale.get(locale);
|
||||
return {
|
||||
locale,
|
||||
title: tr?.title ?? '',
|
||||
body: tr?.body ?? '',
|
||||
imageUrl: tr?.imageUrl ?? '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const contentType: StoredContentType = editingId.value
|
||||
? editingContentType.value
|
||||
: isBanner.value
|
||||
? 'BANNER'
|
||||
: 'NOTICE';
|
||||
|
||||
return {
|
||||
contentType,
|
||||
sortOrder: form.value.sortOrder,
|
||||
status: form.value.status,
|
||||
linkType: isBanner.value && form.value.linkType ? form.value.linkType : null,
|
||||
linkTarget:
|
||||
isBanner.value && form.value.linkType ? form.value.linkTarget.trim() : null,
|
||||
startTime: form.value.startTime || null,
|
||||
endTime: form.value.endTime || null,
|
||||
translations: form.value.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title.trim() || undefined,
|
||||
body: tr.body.trim() || undefined,
|
||||
imageUrl: tr.imageUrl.trim() || undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (editingId.value) {
|
||||
const { contentType: _type, ...updateBody } = payload;
|
||||
await api.put(`/admin/contents/${editingId.value}`, updateBody);
|
||||
} else {
|
||||
await api.post('/admin/contents', payload);
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
|
||||
const msg = err.response?.data?.error
|
||||
?? (Array.isArray(err.response?.data?.message)
|
||||
? err.response?.data?.message.join(', ')
|
||||
: err.response?.data?.message)
|
||||
?? t('msg.save_failed');
|
||||
ElMessage.error(String(msg));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(row: ContentItem, status: ContentStatus) {
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.patch(`/admin/contents/${row.id}/status`, { status });
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(row: ContentItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
t('content.confirm_delete', { title: row.previewTitle || row.id }),
|
||||
{ type: 'warning' },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.delete(`/admin/contents/${row.id}`);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page contents-page">
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-tabs v-model="activeType" class="type-tabs">
|
||||
<el-tab-pane
|
||||
v-for="tp in ADMIN_TABS"
|
||||
:key="tp"
|
||||
:label="t(`content.type.${tp}`)"
|
||||
:name="tp"
|
||||
/>
|
||||
</el-tabs>
|
||||
<p v-if="isAnnouncement" class="type-hint">{{ t('content.hint.announcement') }}</p>
|
||||
<el-form inline class="filter-row">
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="filterStatus" clearable style="width: 140px">
|
||||
<el-option :label="t('common.all')" value="" />
|
||||
<el-option
|
||||
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||
:key="st"
|
||||
:label="statusLabel(st)"
|
||||
:value="st"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="small" @click="load">{{ t('common.search') }}</el-button>
|
||||
<el-button type="primary" plain size="small" @click="openCreate">
|
||||
{{ t('content.btn.create') }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="loading" class="data-card" shadow="never">
|
||||
<div v-if="hasSelection" class="table-toolbar">
|
||||
<span class="batch-hint">{{ t('content.batch.selected', { n: selectedRows.length }) }}</span>
|
||||
<el-button size="small" :disabled="saving" @click="batchEnable">
|
||||
{{ t('content.batch.enable') }}
|
||||
</el-button>
|
||||
<el-button size="small" :disabled="saving" @click="batchDisable">
|
||||
{{ t('content.batch.disable') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" :disabled="saving" @click="batchDelete">
|
||||
{{ t('content.batch.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
:data="items"
|
||||
row-key="id"
|
||||
stripe
|
||||
size="small"
|
||||
empty-text="—"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="44" :selectable="() => !saving" />
|
||||
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
|
||||
<el-table-column v-if="isBanner" :label="t('content.col.preview')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<img
|
||||
v-if="row.previewImageUrl"
|
||||
:src="row.previewImageUrl"
|
||||
alt=""
|
||||
class="thumb"
|
||||
/>
|
||||
<span v-else class="thumb-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('content.col.title')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="preview-title">{{ row.previewTitle || '—' }}</span>
|
||||
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
|
||||
{{ hiddenTip(row.playerHiddenReason) }}
|
||||
</p>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="96" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="statusTagType(row.status)" effect="dark">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('content.col.player_visible')" width="88" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.playerVisible ? 'success' : 'warning'" effect="plain">
|
||||
{{ row.playerVisible ? t('common.yes') : t('common.no') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('content.col.schedule')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="schedule-line">{{ formatTime(row.startTime) }}</span>
|
||||
<span class="schedule-sep">→</span>
|
||||
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isBanner" :label="t('content.col.link')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.linkType">
|
||||
{{ row.linkType }} · {{ row.linkTarget || '—' }}
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status !== 'ACTIVE'"
|
||||
link
|
||||
type="success"
|
||||
:disabled="saving"
|
||||
@click="setStatus(row, 'ACTIVE')"
|
||||
>
|
||||
{{ t('content.btn.enable') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
type="warning"
|
||||
:disabled="saving"
|
||||
@click="setStatus(row, 'INACTIVE')"
|
||||
>
|
||||
{{ t('content.btn.disable') }}
|
||||
</el-button>
|
||||
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close>
|
||||
<el-form label-width="96px" size="small">
|
||||
<el-form-item :label="t('content.col.sort')">
|
||||
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('common.status')">
|
||||
<el-select v-model="form.status" style="width: 160px">
|
||||
<el-option
|
||||
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
|
||||
:key="st"
|
||||
:label="statusLabel(st)"
|
||||
:value="st"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="isBanner">
|
||||
<el-form-item :label="t('content.field.link_type')">
|
||||
<el-select v-model="form.linkType" clearable style="width: 160px">
|
||||
<el-option :label="t('content.link.none')" value="" />
|
||||
<el-option label="ROUTE" value="ROUTE" />
|
||||
<el-option label="URL" value="URL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.linkType" :label="t('content.field.link_target')">
|
||||
<el-input
|
||||
v-model="form.linkTarget"
|
||||
:placeholder="form.linkType === 'ROUTE' ? '/football' : 'https://'"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="t('content.field.start_time')">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('content.field.end_time')">
|
||||
<el-date-picker
|
||||
v-model="form.endTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div v-for="tr in form.translations" :key="tr.locale" class="locale-block">
|
||||
<div class="locale-head">{{ localeLabel(tr.locale) }}</div>
|
||||
<el-form-item :label="t('content.field.title')">
|
||||
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="isBanner"
|
||||
:label="t('content.field.image_url')"
|
||||
:required="form.status === 'ACTIVE'"
|
||||
>
|
||||
<el-input v-model="tr.imageUrl" placeholder="/uploads/banners/welcome.svg" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
|
||||
:required="isAnnouncement && form.status === 'ACTIVE'"
|
||||
>
|
||||
<el-input v-model="tr.body" type="textarea" :rows="isAnnouncement ? 2 : 3" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitForm">
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contents-page .type-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #222;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.type-hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.thumb-empty {
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.hidden-tip {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #c9a227;
|
||||
}
|
||||
|
||||
.schedule-line {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.schedule-sep {
|
||||
margin: 0 4px;
|
||||
color: #555;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.locale-block {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.locale-head {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,46 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../i18n/form-validation';
|
||||
import api from '../api';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
import { ElMessage } from 'element-plus';
|
||||
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
|
||||
import 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,
|
||||
formFromDetail,
|
||||
type MatchCreateForm,
|
||||
type AdminMatchDetail,
|
||||
} from './match-form.ts';
|
||||
|
||||
const router = useRouter();
|
||||
const matches = ref<unknown[]>([]);
|
||||
const { t } = useAdminLocale();
|
||||
const leagues = ref<unknown[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const filterStatus = ref('');
|
||||
const keyword = ref('');
|
||||
const expandedRowKeys = ref<string[]>([]);
|
||||
|
||||
const createLeagueVisible = ref(false);
|
||||
const createLeagueLoading = ref(false);
|
||||
const leagueForm = ref({ leagueEn: '', leagueZh: '', leagueMs: '', logoUrl: '' });
|
||||
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const importVisible = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const editLoading = ref(false);
|
||||
const importLoading = ref(false);
|
||||
const importJson = ref('');
|
||||
const form = ref<MatchCreateForm>(emptyMatchForm());
|
||||
const editingId = ref('');
|
||||
const editingStatus = ref('');
|
||||
const createUnderLeagueLabel = ref('');
|
||||
|
||||
const isEditPublished = computed(() => editingStatus.value === 'PUBLISHED');
|
||||
const isFixtureCreate = computed(() => !!form.value.leagueId.trim());
|
||||
|
||||
onMounted(load);
|
||||
function persistListUiState() {
|
||||
writeMatchesListUiState({
|
||||
expandedLeagueIds: [...expandedRowKeys.value],
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
filterStatus: filterStatus.value,
|
||||
keyword: keyword.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/admin/matches', {
|
||||
function applyExpandedFromSaved(savedIds: string[]) {
|
||||
const allowed = new Set(leagues.value.map((row) => leagueId(row)));
|
||||
expandedRowKeys.value = savedIds.filter((id) => allowed.has(id));
|
||||
}
|
||||
|
||||
type LoadOptions = { restoreExpand?: boolean; keepExpand?: boolean };
|
||||
|
||||
async function load(options: LoadOptions = {}) {
|
||||
const saved = options.restoreExpand ? readMatchesListUiState() : null;
|
||||
if (saved) {
|
||||
page.value = saved.page;
|
||||
pageSize.value = saved.pageSize;
|
||||
filterStatus.value = saved.filterStatus;
|
||||
keyword.value = saved.keyword;
|
||||
}
|
||||
|
||||
const { data } = await api.get('/admin/leagues', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
@@ -48,24 +75,98 @@ async function load() {
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
},
|
||||
});
|
||||
matches.value = data.data.items;
|
||||
leagues.value = data.data.items;
|
||||
total.value = data.data.total;
|
||||
|
||||
if (options.restoreExpand && saved) {
|
||||
applyExpandedFromSaved(saved.expandedLeagueIds);
|
||||
} else if (!options.keepExpand) {
|
||||
expandedRowKeys.value = [];
|
||||
} else {
|
||||
applyExpandedFromSaved(expandedRowKeys.value);
|
||||
}
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
expandedRowKeys.value = [];
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(() => load({ restoreExpand: true }));
|
||||
onBeforeUnmount(persistListUiState);
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
load({ keepExpand: true });
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
load();
|
||||
load({ keepExpand: true });
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
function openCreateLeague() {
|
||||
leagueForm.value = { leagueEn: '', leagueZh: '', leagueMs: '', logoUrl: '' };
|
||||
createLeagueVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreateLeague() {
|
||||
const { leagueEn, leagueZh, leagueMs, logoUrl } = leagueForm.value;
|
||||
if (!leagueZh.trim() && !leagueEn.trim()) {
|
||||
ElMessage.warning(t('err.league_required'));
|
||||
return;
|
||||
}
|
||||
createLeagueLoading.value = true;
|
||||
try {
|
||||
await api.post('/admin/leagues', {
|
||||
leagueEn: leagueEn.trim(),
|
||||
leagueZh: leagueZh.trim(),
|
||||
leagueMs: leagueMs.trim() || undefined,
|
||||
logoUrl: logoUrl.trim() || undefined,
|
||||
});
|
||||
ElMessage.success(t('msg.league_created'));
|
||||
createLeagueVisible.value = false;
|
||||
load({ keepExpand: true });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
} finally {
|
||||
createLeagueLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function 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();
|
||||
editingId.value = '';
|
||||
form.value.leagueId = String(r.id ?? '');
|
||||
form.value.leagueEn = String(r.leagueEn ?? '');
|
||||
form.value.leagueZh = String(r.leagueZh ?? '');
|
||||
form.value.leagueMs = String(r.leagueMs ?? '');
|
||||
createUnderLeagueLabel.value = leagueTitle(leagueRow);
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -74,24 +175,6 @@ function openImport() {
|
||||
importVisible.value = true;
|
||||
}
|
||||
|
||||
async function openEdit(id: string) {
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${id}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
return;
|
||||
}
|
||||
editingId.value = id;
|
||||
editingStatus.value = detail.status;
|
||||
form.value = formFromDetail(detail);
|
||||
editVisible.value = true;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||
try {
|
||||
@@ -104,8 +187,14 @@ async function submitCreate() {
|
||||
try {
|
||||
await api.post('/admin/matches', payload);
|
||||
ElMessage.success(t('msg.match_created_draft'));
|
||||
createUnderLeagueLabel.value = '';
|
||||
createVisible.value = false;
|
||||
load();
|
||||
const lid = form.value.leagueId.trim();
|
||||
await load({ keepExpand: true });
|
||||
if (lid && !expandedRowKeys.value.includes(lid)) {
|
||||
expandedRowKeys.value = [...expandedRowKeys.value, lid];
|
||||
persistListUiState();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
@@ -114,47 +203,6 @@ async function submitCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await api.put(`/admin/matches/${editingId.value}`, payload);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
editLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
let payload: unknown;
|
||||
try {
|
||||
@@ -181,7 +229,7 @@ async function submitImport() {
|
||||
}),
|
||||
);
|
||||
importVisible.value = false;
|
||||
load();
|
||||
load({ keepExpand: true });
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.import_failed'));
|
||||
@@ -190,91 +238,65 @@ async function submitImport() {
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(id: string) {
|
||||
await api.post(`/admin/matches/${id}/publish`);
|
||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||
marketTypes: [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
],
|
||||
});
|
||||
ElMessage.success(t('msg.published'));
|
||||
load();
|
||||
function onExpandChange(_row: unknown, expanded: unknown[]) {
|
||||
expandedRowKeys.value = expanded.map((r) => leagueId(r));
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
async function close(id: string) {
|
||||
await api.post(`/admin/matches/${id}/close`);
|
||||
ElMessage.success(t('msg.closed'));
|
||||
load();
|
||||
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
|
||||
const id = leagueId(row);
|
||||
expandedRowKeys.value = expandedRowKeys.value.includes(id) ? [] : [id];
|
||||
persistListUiState();
|
||||
}
|
||||
|
||||
function settle(id: string) {
|
||||
router.push(`/settlement/${id}`);
|
||||
function rowClassName() {
|
||||
return 'row-expandable';
|
||||
}
|
||||
|
||||
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
function matchStatusText(status: string) {
|
||||
const key = `match.status.${status}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
const statusTagTypes: Record<string, TagType> = {
|
||||
DRAFT: 'info',
|
||||
PUBLISHED: 'warning',
|
||||
CLOSED: 'danger',
|
||||
SETTLED: 'success',
|
||||
};
|
||||
|
||||
function rowOf(row: unknown) {
|
||||
return row as Record<string, unknown>;
|
||||
}
|
||||
function matchStatus(row: unknown) {
|
||||
return String(rowOf(row).status ?? '');
|
||||
}
|
||||
function matchStatusLabel(row: unknown) {
|
||||
return matchStatusText(matchStatus(row));
|
||||
}
|
||||
function matchStatusType(row: unknown): TagType {
|
||||
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||
}
|
||||
function matchId(row: unknown) {
|
||||
function leagueId(row: unknown) {
|
||||
return String(rowOf(row).id ?? '');
|
||||
}
|
||||
function matchTime(row: unknown) {
|
||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||
}
|
||||
function matchTitle(row: unknown) {
|
||||
function leagueTitle(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
if (r.matchName) return String(r.matchName);
|
||||
const home = (r.homeTeam as { code?: string })?.code ?? '';
|
||||
const away = (r.awayTeam as { code?: string })?.code ?? '';
|
||||
return home && away ? `${home} vs ${away}` : '—';
|
||||
const zh = String(r.leagueZh ?? '').trim();
|
||||
const en = String(r.leagueEn ?? '').trim();
|
||||
return zh || en || String(r.code ?? '—');
|
||||
}
|
||||
function canEdit(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
if (r.isOutright) return false;
|
||||
return matchStatus(row) === 'DRAFT' || matchStatus(row) === 'PUBLISHED';
|
||||
function leagueMatchCount(row: unknown) {
|
||||
return Number(rowOf(row).matchCount ?? 0);
|
||||
}
|
||||
function canDelete(row: unknown) {
|
||||
const r = rowOf(row);
|
||||
if (r.isOutright) return false;
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
|
||||
function 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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="admin-list-page matches-page">
|
||||
<div class="page-toolbar">
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||
<el-button type="primary" @click="openCreateLeague">{{ t('match.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
@@ -297,68 +319,71 @@ function canDelete(row: unknown) {
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||||
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<p class="table-hint">{{ t('match.expand_league_hint') }}</p>
|
||||
<div class="table-wrap">
|
||||
<el-table :data="matches" stripe>
|
||||
<el-table
|
||||
:data="leagues"
|
||||
stripe
|
||||
row-key="id"
|
||||
:expand-row-keys="expandedRowKeys"
|
||||
:row-class-name="rowClassName"
|
||||
@expand-change="onExpandChange"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<el-table-column type="expand" width="40">
|
||||
<template #default="{ row }">
|
||||
<LeagueMatchesPanel
|
||||
v-if="isLeagueExpanded(leagueId(row))"
|
||||
:league-id="leagueId(row)"
|
||||
:filter-status="filterStatus"
|
||||
:keyword="keyword"
|
||||
@changed="() => load({ keepExpand: true })"
|
||||
@add-match="openCreateFixture(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="72" />
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="200">
|
||||
<template #default="{ row }">{{ matchTitle(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="96">
|
||||
<el-table-column :label="t('match.col.league')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
<div class="league-cell">
|
||||
<img
|
||||
v-if="rowOf(row).logoUrl"
|
||||
:src="String(rowOf(row).logoUrl)"
|
||||
alt=""
|
||||
class="league-logo"
|
||||
/>
|
||||
<span class="matchup-link">{{ leagueTitle(row) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="160">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
<el-table-column :label="t('match.col.fixture_count')" width="88" align="center">
|
||||
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="340" align="center" fixed="right">
|
||||
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canEdit(row)"
|
||||
size="small"
|
||||
plain
|
||||
@click="openEdit(matchId(row))"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(row)"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmDelete(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'DRAFT'"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@click="publish(matchId(row))"
|
||||
>
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="matchStatus(row) === 'PUBLISHED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="close(matchId(row))"
|
||||
>
|
||||
{{ t('common.close_betting') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" plain @click="settle(matchId(row))">
|
||||
{{ t('common.settle') }}
|
||||
</el-button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="pager">
|
||||
@@ -375,16 +400,56 @@ function canDelete(row: unknown) {
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="t('match.dialog.create')" width="520px" destroy-on-close>
|
||||
<el-dialog v-model="createLeagueVisible" :title="t('match.dialog.create_league')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
<el-input v-model="leagueForm.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="leagueForm.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_ms')">
|
||||
<el-input v-model="leagueForm.leagueMs" :placeholder="t('match.ph.league_ms')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_logo')">
|
||||
<el-input v-model="leagueForm.logoUrl" :placeholder="t('matchEditor.ph.logo_url')" />
|
||||
</el-form-item>
|
||||
<p class="field-hint">{{ t('match.hint.create_league') }}</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createLeagueVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="createLeagueLoading" @click="submitCreateLeague">
|
||||
{{ t('user.btn.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="createVisible"
|
||||
:title="t('match.dialog.create_fixture')"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="96px">
|
||||
<el-form-item v-if="isFixtureCreate" :label="t('match.col.league')">
|
||||
<span class="league-readonly">{{ createUnderLeagueLabel }}</span>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" :placeholder="t('match.ph.kickoff')" />
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
:placeholder="t('matchEditor.ph.kickoff')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
|
||||
@@ -392,12 +457,32 @@ function canDelete(row: unknown) {
|
||||
<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>
|
||||
@@ -409,42 +494,6 @@ function canDelete(row: unknown) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" :title="t('match.dialog.edit')" width="520px" destroy-on-close>
|
||||
<el-form label-width="96px">
|
||||
<p v-if="isEditPublished" class="field-hint edit-hint">
|
||||
{{ t('match.hint.edit_published') }}
|
||||
</p>
|
||||
<el-form-item :label="t('match.field.league_en')">
|
||||
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.league_zh')">
|
||||
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-input v-model="form.startTime" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.home_en')">
|
||||
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.home_zh')">
|
||||
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_en')">
|
||||
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.away_zh')">
|
||||
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="t('match.dialog.import')" width="640px" destroy-on-close>
|
||||
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
|
||||
<el-input
|
||||
@@ -484,4 +533,96 @@ function canDelete(row: unknown) {
|
||||
.edit-hint {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btns :deep(.action-btn) {
|
||||
margin: 0 !important;
|
||||
min-width: 52px;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
background: #1a1a1a !important;
|
||||
border-color: #333 !important;
|
||||
color: #bbb !important;
|
||||
}
|
||||
|
||||
.action-btns :deep(.action-btn:not(.is-disabled):hover) {
|
||||
background: #252525 !important;
|
||||
border-color: #444 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.action-btns :deep(.action-btn.is-disabled) {
|
||||
background: #121212 !important;
|
||||
border-color: #252525 !important;
|
||||
color: #444 !important;
|
||||
opacity: 1 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.table-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 列表表格随内容增高,滚动交给外层 table-wrap(仅赛事行) */
|
||||
.matches-page .table-wrap .el-table {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.matches-page :deep(.el-table__expanded-cell) {
|
||||
padding: 0 !important;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.data-card :deep(.row-expandable) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-card :deep(.row-no-expand .el-table__expand-icon) {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
}
|
||||
.bet-stat-active {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bet-stat-zero {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.league-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.league-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.league-readonly {
|
||||
color: var(--green-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
: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>
|
||||
|
||||
@@ -46,12 +46,39 @@ const detail = ref<PlayerDetail | null>(null);
|
||||
const editingId = ref('');
|
||||
|
||||
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
||||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||
const settingsSaving = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
loadAgentOptions();
|
||||
loadPlayerSettings();
|
||||
load();
|
||||
});
|
||||
|
||||
async function loadPlayerSettings() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/users/settings/account');
|
||||
playerSettings.value = data.data;
|
||||
} catch {
|
||||
/* 使用默认值 */
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlayerSettings() {
|
||||
settingsSaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
|
||||
playerSettings.value = data.data;
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
loadPlayerSettings();
|
||||
} finally {
|
||||
settingsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentOptions() {
|
||||
const { data } = await api.get('/admin/agents/options');
|
||||
agentOptions.value = data.data;
|
||||
@@ -122,7 +149,9 @@ async function submitCreate() {
|
||||
try {
|
||||
await api.post('/admin/users', payload);
|
||||
ElMessage.success(
|
||||
createForm.value.asTier1Agent ? t('msg.agent_created') : t('msg.player_created'),
|
||||
createForm.value.asTier1Agent
|
||||
? t('msg.agent_created')
|
||||
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
||||
);
|
||||
createVisible.value = false;
|
||||
load();
|
||||
@@ -163,13 +192,27 @@ async function toggleFreeze(row: PlayerRow) {
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (editForm.value.newPassword && editForm.value.newPassword.length < 8) {
|
||||
ElMessage.warning(t('err.password_min'));
|
||||
return;
|
||||
}
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await api.put(`/admin/users/${editingId.value}`, {
|
||||
const newPwd = editForm.value.newPassword.trim();
|
||||
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
||||
username: editForm.value.username.trim(),
|
||||
parentId: editForm.value.parentId || '',
|
||||
phone: editForm.value.phone.trim() || undefined,
|
||||
email: editForm.value.email.trim() || undefined,
|
||||
password: newPwd || undefined,
|
||||
});
|
||||
const updated = data.data as PlayerDetail;
|
||||
if (newPwd) {
|
||||
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
|
||||
editForm.value.newPassword = '';
|
||||
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
|
||||
return;
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
@@ -246,6 +289,29 @@ function statusLabel(s: string) {
|
||||
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="settings-card" shadow="never">
|
||||
<div class="global-settings">
|
||||
<span class="settings-title">{{ t('user.global_settings') }}</span>
|
||||
<span class="settings-desc">{{ t('user.global_settings_hint') }}</span>
|
||||
<el-form inline size="small" class="settings-form">
|
||||
<el-form-item :label="t('user.field.allow_password_change')">
|
||||
<el-switch
|
||||
v-model="playerSettings.allowPasswordChange"
|
||||
:loading="settingsSaving"
|
||||
@change="savePlayerSettings"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.allow_username_change')">
|
||||
<el-switch
|
||||
v-model="playerSettings.allowUsernameChange"
|
||||
:loading="settingsSaving"
|
||||
@change="savePlayerSettings"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
@@ -455,20 +521,42 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input :model-value="editForm.id" disabled />
|
||||
</el-form-item>
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="t('user.dialog.edit')"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
class="user-edit-dialog"
|
||||
>
|
||||
<el-form label-width="84px" size="small" class="compact-edit-form">
|
||||
<div class="edit-meta">
|
||||
<span>ID {{ editForm.id }}</span>
|
||||
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input :model-value="editForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.account_status')">
|
||||
<el-tag :type="statusTagType(editForm.status)" size="small">
|
||||
{{ statusLabel(editForm.status) }}
|
||||
</el-tag>
|
||||
<span class="field-hint inline-hint">{{ t('user.hint.freeze_in_list') }}</span>
|
||||
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="password-mgmt-block">
|
||||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||||
<el-form-item :label="t('user.field.current_password')">
|
||||
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
|
||||
<span v-else class="password-empty">—</span>
|
||||
</el-form-item>
|
||||
<p v-if="!editForm.managedPassword" class="field-hint block-hint">
|
||||
{{ t('user.hint.password_reset_to_view') }}
|
||||
</p>
|
||||
<el-form-item :label="t('user.field.reset_password')">
|
||||
<el-input
|
||||
v-model="editForm.newPassword"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="t('user.ph.reset_password_short')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item :label="t('user.filter.agent')">
|
||||
<el-select
|
||||
v-model="editForm.parentId"
|
||||
@@ -483,46 +571,38 @@ function statusLabel(s: string) {
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="field-hint">{{ t('user.hint.agent_change') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.available')">
|
||||
<el-input :model-value="formatAmount(editForm.availableBalance)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.frozen_balance')">
|
||||
<el-input :model-value="formatAmount(editForm.frozenBalance)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.bets_summary')">
|
||||
<el-input
|
||||
:model-value="t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) })"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.total_payout')">
|
||||
<el-input :model-value="formatAmount(editForm.totalReturn)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.col.last_login')">
|
||||
<el-input
|
||||
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login')"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.login_fail')">
|
||||
<el-input :model-value="t('user.login_fail_value', { n: editForm.loginFailCount })" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.col.created')">
|
||||
<el-input :model-value="formatTime(editForm.createdAt)" disabled />
|
||||
</el-form-item>
|
||||
<el-divider />
|
||||
<el-form-item :label="t('user.field.phone')">
|
||||
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
|
||||
<el-descriptions :column="2" size="small" border class="edit-stats">
|
||||
<el-descriptions-item :label="t('user.field.available')">
|
||||
{{ formatAmount(editForm.availableBalance) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.frozen_balance')">
|
||||
{{ formatAmount(editForm.frozenBalance) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.bets_summary')">
|
||||
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.total_payout')">
|
||||
{{ formatAmount(editForm.totalReturn) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
|
||||
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
|
||||
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button>
|
||||
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button size="small" type="primary" :loading="editLoading" @click="submitEdit">
|
||||
{{ t('user.btn.save_profile') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -549,6 +629,12 @@ function statusLabel(s: string) {
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item :label="t('common.col_id')">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('user.field.current_password')">
|
||||
{{ detail.managedPassword ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="!detail.managedPassword" :span="2">
|
||||
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="t('common.status')">
|
||||
<el-tag :type="statusTagType(detail.status)" size="small">
|
||||
{{ statusLabel(detail.status) }}
|
||||
@@ -588,7 +674,8 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.filter-card { margin-bottom: 12px; border-radius: 12px; }
|
||||
.settings-card { margin-bottom: 12px; border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
@@ -596,6 +683,69 @@ function statusLabel(s: string) {
|
||||
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
||||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||
.text-muted { color: #666; font-size: 12px; }
|
||||
.global-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
.settings-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ccc;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.settings-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.password-mgmt-block {
|
||||
margin: 4px 0 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.block-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #e8a84a;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.password-plain {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f0d090;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.password-empty {
|
||||
color: #666;
|
||||
}
|
||||
.block-hint {
|
||||
margin: -4px 0 8px;
|
||||
}
|
||||
.settings-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 16px;
|
||||
}
|
||||
.edit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
.compact-edit-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.edit-stats {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -7,40 +7,84 @@ import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const players = ref<unknown[]>([]);
|
||||
type PlayerRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
wallet?: { availableBalance: string };
|
||||
};
|
||||
|
||||
const players = ref<PlayerRow[]>([]);
|
||||
const form = ref({ username: '', password: 'Player@123' });
|
||||
const depositForm = ref({ playerId: '', amount: 100, requestId: '' });
|
||||
|
||||
const transferVisible = ref(false);
|
||||
const transferLoading = ref(false);
|
||||
const transferType = ref<'deposit' | 'withdraw'>('deposit');
|
||||
const transferTarget = ref<PlayerRow | null>(null);
|
||||
const transferAmount = ref(100);
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/agent/players');
|
||||
players.value = data.data;
|
||||
players.value = data.data as PlayerRow[];
|
||||
}
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/players', form.value);
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
load();
|
||||
if (!form.value.username.trim()) {
|
||||
ElMessage.warning(t('err.username_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/agent/players', form.value);
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
form.value.username = '';
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deposit() {
|
||||
depositForm.value.requestId = `dep-${Date.now()}`;
|
||||
await api.post(`/agent/players/${depositForm.value.playerId}/deposit`, {
|
||||
amount: depositForm.value.amount,
|
||||
requestId: depositForm.value.requestId,
|
||||
});
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
load();
|
||||
function openTransfer(type: 'deposit' | 'withdraw', row: PlayerRow) {
|
||||
transferType.value = type;
|
||||
transferTarget.value = row;
|
||||
transferAmount.value = 100;
|
||||
transferVisible.value = true;
|
||||
}
|
||||
|
||||
async function withdraw(playerId: string, amount: number) {
|
||||
await api.post(`/agent/players/${playerId}/withdraw`, {
|
||||
amount,
|
||||
requestId: `wd-${Date.now()}`,
|
||||
});
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
load();
|
||||
async function submitTransfer() {
|
||||
if (!transferTarget.value) return;
|
||||
if (transferAmount.value <= 0) {
|
||||
ElMessage.warning(t('msg.amount_gt_zero'));
|
||||
return;
|
||||
}
|
||||
const playerId = transferTarget.value.id;
|
||||
const amount = transferAmount.value;
|
||||
transferLoading.value = true;
|
||||
try {
|
||||
const requestId = `${transferType.value === 'deposit' ? 'dep' : 'wd'}-${playerId}-${Date.now()}`;
|
||||
if (transferType.value === 'deposit') {
|
||||
await api.post(`/agent/players/${playerId}/deposit`, { amount, requestId });
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
} else {
|
||||
await api.post(`/agent/players/${playerId}/withdraw`, { amount, requestId });
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
}
|
||||
transferVisible.value = false;
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
|
||||
} finally {
|
||||
transferLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function transferTitle() {
|
||||
const name = transferTarget.value?.username ?? '';
|
||||
return transferType.value === 'deposit'
|
||||
? t('agent_portal.transfer_title_deposit', { name })
|
||||
: t('agent_portal.transfer_title_withdraw', { name });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,80 +96,94 @@ async function withdraw(playerId: string, amount: number) {
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="tool-row">
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="tool-divider" />
|
||||
<div class="tool-section">
|
||||
<div class="tool-section-title">{{ t('agent_portal.deposit_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input v-model="depositForm.playerId" :placeholder="t('agent_portal.player_id_ph')" style="width: 110px" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="deposit">{{ t('common.topup') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<el-table :data="players" stripe>
|
||||
<el-table-column prop="id" :label="t('common.col_id')" width="80" />
|
||||
<el-table-column prop="id" :label="t('common.col_id')" width="72" />
|
||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
||||
<el-tooltip
|
||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
||||
<template v-if="row.wallet?.availableBalance != null">
|
||||
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
|
||||
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="120" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="168" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">
|
||||
{{ t('agent_portal.withdraw_btn', { amount: 50 }) }}
|
||||
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">
|
||||
{{ t('common.topup') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">
|
||||
{{ t('agent_portal.withdraw_btn_label') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="transferVisible" :title="transferTitle()" width="360px" destroy-on-close>
|
||||
<el-form label-width="72px">
|
||||
<el-form-item :label="t('common.col_id')">
|
||||
<span>{{ transferTarget?.id }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.amount')">
|
||||
<el-input-number
|
||||
v-model="transferAmount"
|
||||
:min="0.01"
|
||||
:step="10"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="transferLoading" @click="submitTransfer">
|
||||
{{ t('common.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</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-desc { font-size: 13px; color: #3a3a3a; }
|
||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
|
||||
.tool-row {
|
||||
.page-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: flex-start;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #3a3a3a;
|
||||
}
|
||||
.tool-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.data-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.tool-section { flex: 1; padding-right: 24px; }
|
||||
.tool-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -134,10 +192,4 @@ async function withdraw(playerId: string, amount: number) {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.tool-divider {
|
||||
width: 1px;
|
||||
background: #eee;
|
||||
align-self: stretch;
|
||||
margin: 0 24px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,54 +3,139 @@
|
||||
import { FormValidationError } from '../i18n/form-validation';
|
||||
|
||||
export interface MatchCreateForm {
|
||||
leagueId: string;
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs: string;
|
||||
startTime: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamMs: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamEn: string;
|
||||
awayTeamMs: string;
|
||||
isHot: boolean;
|
||||
displayOrder: number;
|
||||
matchName: string;
|
||||
stage: string;
|
||||
groupName: string;
|
||||
leagueLogoUrl: string;
|
||||
homeTeamLogoUrl: string;
|
||||
awayTeamLogoUrl: string;
|
||||
}
|
||||
|
||||
export function emptyMatchForm(): MatchCreateForm {
|
||||
return {
|
||||
leagueId: '',
|
||||
leagueEn: 'FIFA World Cup 2026',
|
||||
leagueZh: '2026 世界杯',
|
||||
leagueMs: 'Piala Dunia 2026',
|
||||
startTime: '',
|
||||
homeTeamZh: '',
|
||||
homeTeamEn: '',
|
||||
homeTeamMs: '',
|
||||
awayTeamZh: '',
|
||||
awayTeamEn: '',
|
||||
awayTeamMs: '',
|
||||
isHot: false,
|
||||
displayOrder: 0,
|
||||
matchName: '',
|
||||
stage: '',
|
||||
groupName: '',
|
||||
leagueLogoUrl: '',
|
||||
homeTeamLogoUrl: '',
|
||||
awayTeamLogoUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminMarketSelection {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminMarket {
|
||||
id: string;
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
status: string;
|
||||
promoLabel: string;
|
||||
selections: AdminMarketSelection[];
|
||||
}
|
||||
|
||||
export type AdminMatchDetail = {
|
||||
id: string;
|
||||
status: string;
|
||||
isOutright: boolean;
|
||||
isHot: boolean;
|
||||
displayOrder: number;
|
||||
startTime: string;
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs: string;
|
||||
leagueLogoUrl?: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamMs: string;
|
||||
homeTeamCode?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamEn: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamMs: string;
|
||||
awayTeamCode?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
matchName: string;
|
||||
stage?: string;
|
||||
groupName?: string;
|
||||
score?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
} | null;
|
||||
markets?: AdminMarket[];
|
||||
};
|
||||
|
||||
export function normalizeStartTimeForPicker(iso?: string): string {
|
||||
if (!iso?.trim()) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso.slice(0, 19);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function normalizeStartTimeForApi(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
const d = new Date(trimmed);
|
||||
if (Number.isNaN(d.getTime())) return trimmed;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
|
||||
return {
|
||||
leagueId: '',
|
||||
leagueEn: d.leagueEn,
|
||||
leagueZh: d.leagueZh,
|
||||
startTime: d.startTime,
|
||||
leagueMs: d.leagueMs ?? '',
|
||||
startTime: normalizeStartTimeForPicker(d.startTime),
|
||||
homeTeamZh: d.homeTeamZh,
|
||||
homeTeamEn: d.homeTeamEn,
|
||||
homeTeamMs: d.homeTeamMs ?? '',
|
||||
awayTeamZh: d.awayTeamZh,
|
||||
awayTeamEn: d.awayTeamEn,
|
||||
awayTeamMs: d.awayTeamMs ?? '',
|
||||
isHot: d.isHot,
|
||||
displayOrder: d.displayOrder ?? 0,
|
||||
matchName: d.matchName ?? '',
|
||||
stage: d.stage ?? '',
|
||||
groupName: d.groupName ?? '',
|
||||
leagueLogoUrl: d.leagueLogoUrl ?? '',
|
||||
homeTeamLogoUrl: d.homeTeamLogoUrl ?? '',
|
||||
awayTeamLogoUrl: d.awayTeamLogoUrl ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,23 +143,44 @@ export function buildPlatformPayload(form: MatchCreateForm) {
|
||||
if (!form.startTime.trim()) {
|
||||
throw new FormValidationError('err.kickoff_required');
|
||||
}
|
||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim();
|
||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim();
|
||||
const homeOk = form.homeTeamZh.trim() || form.homeTeamEn.trim() || form.homeTeamMs.trim();
|
||||
const awayOk = form.awayTeamZh.trim() || form.awayTeamEn.trim() || form.awayTeamMs.trim();
|
||||
if (!homeOk || !awayOk) {
|
||||
throw new FormValidationError('err.teams_required');
|
||||
}
|
||||
if (!form.leagueZh.trim() && !form.leagueEn.trim()) {
|
||||
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() &&
|
||||
!form.leagueEn.trim() &&
|
||||
!form.leagueMs.trim()
|
||||
) {
|
||||
throw new FormValidationError('err.league_required');
|
||||
}
|
||||
|
||||
return {
|
||||
leagueId: form.leagueId.trim() || undefined,
|
||||
leagueEn: form.leagueEn.trim(),
|
||||
leagueZh: form.leagueZh.trim(),
|
||||
leagueMs: form.leagueMs.trim() || undefined,
|
||||
homeTeamEn: form.homeTeamEn.trim(),
|
||||
homeTeamZh: form.homeTeamZh.trim(),
|
||||
homeTeamMs: form.homeTeamMs.trim() || undefined,
|
||||
awayTeamEn: form.awayTeamEn.trim(),
|
||||
awayTeamZh: form.awayTeamZh.trim(),
|
||||
startTime: form.startTime.trim(),
|
||||
awayTeamMs: form.awayTeamMs.trim() || undefined,
|
||||
startTime: normalizeStartTimeForApi(form.startTime),
|
||||
isHot: form.isHot,
|
||||
displayOrder: form.displayOrder,
|
||||
matchName: form.matchName.trim() || undefined,
|
||||
stage: form.stage.trim() || undefined,
|
||||
groupName: form.groupName.trim() || undefined,
|
||||
leagueLogoUrl: form.leagueLogoUrl.trim() || undefined,
|
||||
homeTeamLogoUrl: form.homeTeamLogoUrl.trim() || undefined,
|
||||
awayTeamLogoUrl: form.awayTeamLogoUrl.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
296
apps/admin/src/views/matches/LeagueMatchesPanel.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import { ensureLeagueExpanded } from '../../utils/matchesListState.ts';
|
||||
import { formatAmount } from '../../utils/format-amount';
|
||||
const props = defineProps<{
|
||||
leagueId: string;
|
||||
filterStatus: string;
|
||||
keyword: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
'add-match': [];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useAdminLocale();
|
||||
const router = useRouter();
|
||||
const matches = ref<unknown[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
|
||||
params: {
|
||||
status: props.filterStatus || undefined,
|
||||
keyword: props.keyword.trim() || undefined,
|
||||
locale: locale.value,
|
||||
},
|
||||
});
|
||||
matches.value = data.data.items;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.leagueId, props.filterStatus, props.keyword, locale.value] as const,
|
||||
() => load(),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function notifyParent() {
|
||||
emit('changed');
|
||||
load();
|
||||
}
|
||||
|
||||
async function publish(id: string) {
|
||||
await api.post(`/admin/matches/${id}/publish`);
|
||||
await api.post(`/admin/matches/${id}/markets/templates`, {
|
||||
marketTypes: [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
],
|
||||
});
|
||||
ElMessage.success(t('msg.published'));
|
||||
notifyParent();
|
||||
}
|
||||
|
||||
async function close(id: string) {
|
||||
await api.post(`/admin/matches/${id}/close`);
|
||||
ElMessage.success(t('msg.closed'));
|
||||
notifyParent();
|
||||
}
|
||||
|
||||
function beforeLeaveList() {
|
||||
ensureLeagueExpanded(props.leagueId);
|
||||
}
|
||||
|
||||
function openManage(id: string) {
|
||||
beforeLeaveList();
|
||||
router.push(`/matches/${id}/edit`);
|
||||
}
|
||||
|
||||
function openMarkets(id: string) {
|
||||
beforeLeaveList();
|
||||
router.push(`/matches/${id}/markets`);
|
||||
}
|
||||
|
||||
function settle(id: string) {
|
||||
beforeLeaveList();
|
||||
router.push(`/settlement/${id}`);
|
||||
}
|
||||
|
||||
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
|
||||
function matchStatusText(status: string) {
|
||||
const key = `match.status.${status}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : status;
|
||||
}
|
||||
const statusTagTypes: Record<string, TagType> = {
|
||||
DRAFT: 'info',
|
||||
PUBLISHED: 'warning',
|
||||
CLOSED: 'danger',
|
||||
SETTLED: 'success',
|
||||
};
|
||||
|
||||
function rowOf(row: unknown) {
|
||||
return row as Record<string, unknown>;
|
||||
}
|
||||
function matchStatus(row: unknown) {
|
||||
return String(rowOf(row).status ?? '');
|
||||
}
|
||||
function matchStatusLabel(row: unknown) {
|
||||
return matchStatusText(matchStatus(row));
|
||||
}
|
||||
function matchStatusType(row: unknown): TagType {
|
||||
return statusTagTypes[matchStatus(row)] ?? 'info';
|
||||
}
|
||||
function matchId(row: unknown) {
|
||||
return String(rowOf(row).id ?? '');
|
||||
}
|
||||
function matchTime(row: unknown) {
|
||||
return new Date(String(rowOf(row).startTime)).toLocaleString();
|
||||
}
|
||||
function 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 =
|
||||
String(r.homeTeamName ?? '').trim() ||
|
||||
(r.homeTeam as { code?: string })?.code ||
|
||||
'';
|
||||
const away =
|
||||
String(r.awayTeamName ?? '').trim() ||
|
||||
(r.awayTeam as { code?: string })?.code ||
|
||||
'';
|
||||
if (home && away) return `${home} vs ${away}`;
|
||||
const matchName = String(r.matchName ?? '').trim();
|
||||
return matchName || '—';
|
||||
}
|
||||
function canManage(row: unknown) {
|
||||
const s = matchStatus(row);
|
||||
return s === 'DRAFT' || s === 'PUBLISHED';
|
||||
}
|
||||
function canDeleteRow(row: unknown) {
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
}
|
||||
function canPublishRow(row: unknown) {
|
||||
return matchStatus(row) === 'DRAFT';
|
||||
}
|
||||
function canCloseRow(row: unknown) {
|
||||
return matchStatus(row) === 'PUBLISHED';
|
||||
}
|
||||
function canSettleRow(row: unknown) {
|
||||
return matchStatus(row) !== 'DRAFT';
|
||||
}
|
||||
|
||||
async function confirmDelete(row: unknown) {
|
||||
const id = matchId(row);
|
||||
const title = matchTitle(row);
|
||||
try {
|
||||
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
|
||||
type: 'warning',
|
||||
confirmButtonText: t('common.delete'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
await api.delete(`/admin/matches/${id}`);
|
||||
ElMessage.success(t('msg.deleted'));
|
||||
notifyParent();
|
||||
} catch (e) {
|
||||
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reload: load });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="league-matches-panel">
|
||||
<div class="panel-toolbar">
|
||||
<el-button type="primary" size="small" @click="emit('add-match')">
|
||||
{{ t('match.create_fixture_btn') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
|
||||
<el-table-column prop="id" label="ID" width="64" />
|
||||
<el-table-column :label="t('match.col.matchup')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="matchup-link">{{ matchTitle(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="88">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('match.col.kickoff')" min-width="150">
|
||||
<template #default="{ row }">{{ matchTime(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('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">
|
||||
<el-button size="small" class="action-btn" :disabled="!canManage(row)" @click="openManage(matchId(row))">
|
||||
{{ t('matchEditor.manage_btn') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canManage(row)" @click="openMarkets(matchId(row))">
|
||||
{{ t('match.btn.markets') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canDeleteRow(row)" @click="confirmDelete(row)">
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canPublishRow(row)" @click="publish(matchId(row))">
|
||||
{{ t('common.publish') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canCloseRow(row)" @click="close(matchId(row))">
|
||||
{{ t('common.close_betting') }}
|
||||
</el-button>
|
||||
<el-button size="small" class="action-btn" :disabled="!canSettleRow(row)" @click="settle(matchId(row))">
|
||||
{{ t('common.settle') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.league-matches-panel {
|
||||
padding: 10px 12px 12px;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
.panel-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.matchup-link {
|
||||
color: var(--green-text);
|
||||
}
|
||||
.bet-stat-active {
|
||||
color: var(--green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bet-stat-zero {
|
||||
color: #555;
|
||||
}
|
||||
.action-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
}
|
||||
.action-btns :deep(.action-btn) {
|
||||
margin: 0 !important;
|
||||
min-width: 52px;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
</style>
|
||||
370
apps/admin/src/views/matches/MatchEventEditor.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import { resolveFormError } from '../../i18n/form-validation';
|
||||
import api from '../../api';
|
||||
import LogoUrlField from '../../components/LogoUrlField.vue';
|
||||
import { countryDisplayName, type BuiltinCountry } from '../../data/builtinCountries';
|
||||
import {
|
||||
buildPlatformPayload,
|
||||
emptyMatchForm,
|
||||
formFromDetail,
|
||||
type AdminMatchDetail,
|
||||
type MatchCreateForm,
|
||||
} from '../match-form.ts';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const matchId = computed(() => String(route.params.matchId ?? ''));
|
||||
|
||||
const loading = ref(false);
|
||||
const savingMeta = ref(false);
|
||||
const status = ref('DRAFT');
|
||||
const form = ref<MatchCreateForm>(emptyMatchForm());
|
||||
const homeTeamCode = ref('');
|
||||
const awayTeamCode = ref('');
|
||||
|
||||
function applyTeamFromCountry(
|
||||
side: 'home' | 'away',
|
||||
country: BuiltinCountry,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
router.replace('/outrights');
|
||||
return;
|
||||
}
|
||||
status.value = detail.status;
|
||||
form.value = formFromDetail(detail);
|
||||
homeTeamCode.value = detail.homeTeamCode ?? '';
|
||||
awayTeamCode.value = detail.awayTeamCode ?? '';
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(matchId, load, { immediate: true });
|
||||
|
||||
async function saveMeta() {
|
||||
let payload: ReturnType<typeof buildPlatformPayload>;
|
||||
try {
|
||||
payload = buildPlatformPayload(form.value);
|
||||
} catch (e) {
|
||||
ElMessage.warning(resolveFormError(e, t));
|
||||
return;
|
||||
}
|
||||
savingMeta.value = true;
|
||||
try {
|
||||
await api.put(`/admin/matches/${matchId.value}`, payload);
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
savingMeta.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-editor-page page-scroll">
|
||||
<div class="editor-topbar">
|
||||
<el-button size="small" text class="back-btn" @click="router.push('/matches')">
|
||||
← {{ t('matchEditor.back') }}
|
||||
</el-button>
|
||||
<div class="topbar-title">
|
||||
<h2>{{ t('matchEditor.title') }} #{{ matchId }}</h2>
|
||||
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">{{ t('matchEditor.section_info') }}</span>
|
||||
</div>
|
||||
|
||||
<el-form label-width="72px" label-position="left" class="meta-form compact-form">
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.league') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_zh')">
|
||||
<el-input v-model="form.leagueZh" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_en')">
|
||||
<el-input v-model="form.leagueEn" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_ms')">
|
||||
<el-input v-model="form.leagueMs" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<div class="logo-inline">
|
||||
<span class="logo-inline-label">{{ t('matchEditor.field.league_logo') }}</span>
|
||||
<LogoUrlField v-model="form.leagueLogoUrl" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.home') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_zh')">
|
||||
<el-input v-model="form.homeTeamZh" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_en')">
|
||||
<el-input v-model="form.homeTeamEn" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_ms')">
|
||||
<el-input v-model="form.homeTeamMs" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<div class="logo-inline">
|
||||
<span class="logo-inline-label">{{ t('matchEditor.field.home_logo') }}</span>
|
||||
<LogoUrlField
|
||||
v-model="form.homeTeamLogoUrl"
|
||||
:team-code="homeTeamCode"
|
||||
@pick="applyTeamFromCountry('home', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.away') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_zh')">
|
||||
<el-input v-model="form.awayTeamZh" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_en')">
|
||||
<el-input v-model="form.awayTeamEn" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item :label="t('match.field.lang_ms')">
|
||||
<el-input v-model="form.awayTeamMs" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<div class="logo-inline">
|
||||
<span class="logo-inline-label">{{ t('matchEditor.field.away_logo') }}</span>
|
||||
<LogoUrlField
|
||||
v-model="form.awayTeamLogoUrl"
|
||||
:team-code="awayTeamCode"
|
||||
@pick="applyTeamFromCountry('away', $event)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="section-label">{{ t('matchEditor.group.schedule') }}</div>
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="t('match.field.kickoff')" required>
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
size="small"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
:placeholder="t('matchEditor.ph.kickoff')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12">
|
||||
<el-form-item :label="t('matchEditor.field.match_name')">
|
||||
<el-input v-model="form.matchName" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.stage')">
|
||||
<el-input v-model="form.stage" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.group')">
|
||||
<el-input v-model="form.groupName" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('matchEditor.field.display_order')">
|
||||
<el-input-number v-model="form.displayOrder" :min="0" size="small" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-form-item :label="t('match.field.featured')">
|
||||
<el-switch v-model="form.isHot" size="small" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="panel-foot">
|
||||
<el-button type="primary" :loading="savingMeta" @click="saveMeta">
|
||||
{{ t('matchEditor.save_info') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-editor-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
color: var(--green-text) !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.topbar-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.compact-form :deep(.el-form-item) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.logo-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-inline-label {
|
||||
flex: 0 0 72px;
|
||||
font-size: 12px;
|
||||
color: #8e8e93;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.logo-inline :deep(.logo-url-field) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta-form :deep(.el-form-item__label) {
|
||||
color: #8e8e93 !important;
|
||||
}
|
||||
|
||||
.meta-form :deep(.el-input__inner),
|
||||
.meta-form :deep(.el-input-number .el-input__inner) {
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
107
apps/admin/src/views/matches/MatchMarketsPage.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import MatchMarketsPanel from './MatchMarketsPanel.vue';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
const matchId = computed(() => String(route.params.matchId ?? ''));
|
||||
const loading = ref(false);
|
||||
const status = ref('DRAFT');
|
||||
const matchLabel = ref('');
|
||||
|
||||
async function load() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${matchId.value}`);
|
||||
const detail = data.data as AdminMatchDetail;
|
||||
if (detail.isOutright) {
|
||||
ElMessage.warning(t('msg.outright_no_edit'));
|
||||
router.replace('/outrights');
|
||||
return;
|
||||
}
|
||||
status.value = detail.status;
|
||||
const home = detail.homeTeamZh || detail.homeTeamEn || detail.homeTeamCode || '';
|
||||
const away = detail.awayTeamZh || detail.awayTeamEn || detail.awayTeamCode || '';
|
||||
matchLabel.value =
|
||||
detail.matchName?.trim() ||
|
||||
(home && away ? `${home} vs ${away}` : `#${matchId.value}`);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(matchId, load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-markets-page page-scroll">
|
||||
<div class="editor-topbar">
|
||||
<el-button size="small" text class="back-btn" @click="router.push('/matches')">
|
||||
← {{ t('matchEditor.back') }}
|
||||
</el-button>
|
||||
<div class="topbar-title">
|
||||
<h2>{{ t('matchEditor.section_markets') }}</h2>
|
||||
<span class="match-subtitle">{{ matchLabel }}</span>
|
||||
<el-tag size="small" type="info">{{ t(`match.status.${status}`) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="matchId" class="panel">
|
||||
<MatchMarketsPanel :match-id="matchId" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-markets-page {
|
||||
padding: 0 4px 24px;
|
||||
}
|
||||
|
||||
.editor-topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
flex-shrink: 0;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match-subtitle {
|
||||
color: var(--green-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #111;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults.ts';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
matchId: string;
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
interface SelectionRow {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
status: string;
|
||||
editOdds: number;
|
||||
}
|
||||
|
||||
interface MarketRow {
|
||||
id: string;
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
status: string;
|
||||
promoLabel: string;
|
||||
editPromoLabel: string;
|
||||
editLineValue: number | null;
|
||||
selections: SelectionRow[];
|
||||
}
|
||||
|
||||
const DEFAULT_MARKET_TYPES = [
|
||||
'FT_1X2',
|
||||
'FT_HANDICAP',
|
||||
'FT_OVER_UNDER',
|
||||
'FT_ODD_EVEN',
|
||||
'HT_1X2',
|
||||
'HT_HANDICAP',
|
||||
'HT_OVER_UNDER',
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const savingMarketId = ref<string | null>(null);
|
||||
const markets = ref<MarketRow[]>([]);
|
||||
|
||||
function mapMarkets(detail: AdminMatchDetail) {
|
||||
markets.value = (detail.markets ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
marketType: m.marketType,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue,
|
||||
status: m.status,
|
||||
promoLabel: m.promoLabel,
|
||||
editPromoLabel: m.promoLabel,
|
||||
editLineValue: m.lineValue,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
odds: s.odds,
|
||||
status: s.status,
|
||||
editOdds: s.odds,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.matchId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/admin/matches/${props.matchId}`);
|
||||
mapMarkets(data.data as AdminMatchDetail);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.matchId,
|
||||
() => {
|
||||
load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function marketLabel(type: string) {
|
||||
const key = `matchEditor.market.${type}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : type;
|
||||
}
|
||||
|
||||
function selectionCodeLabel(code: string, market?: MarketRow) {
|
||||
return adminSelectionLabel(t, code, {
|
||||
lineValue: market?.editLineValue ?? market?.lineValue,
|
||||
period: market?.period,
|
||||
});
|
||||
}
|
||||
|
||||
function hasLine(market: MarketRow) {
|
||||
return market.lineValue != null || market.editLineValue != null;
|
||||
}
|
||||
|
||||
const CORRECT_SCORE_TYPES = new Set([
|
||||
'FT_CORRECT_SCORE',
|
||||
'HT_CORRECT_SCORE',
|
||||
'SH_CORRECT_SCORE',
|
||||
]);
|
||||
|
||||
function isMultiRowMarket(market: MarketRow) {
|
||||
return CORRECT_SCORE_TYPES.has(market.marketType) || market.selections.length > 6;
|
||||
}
|
||||
|
||||
function normPromo(value: string | null | undefined) {
|
||||
return (value ?? '').trim() || null;
|
||||
}
|
||||
|
||||
function normLine(value: number | null | undefined) {
|
||||
return value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
function isMarketDirty(market: MarketRow) {
|
||||
if (normPromo(market.editPromoLabel) !== normPromo(market.promoLabel)) return true;
|
||||
if (normLine(market.editLineValue) !== normLine(market.lineValue)) return true;
|
||||
return market.selections.some((s) => Number(s.editOdds) !== Number(s.odds));
|
||||
}
|
||||
|
||||
async function generateTemplates() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await api.post(`/admin/matches/${props.matchId}/markets/templates`, {
|
||||
marketTypes: DEFAULT_MARKET_TYPES,
|
||||
});
|
||||
ElMessage.success(t('matchEditor.templates_generated'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMarket(market: MarketRow) {
|
||||
const invalid = market.selections.find((s) => !s.editOdds || s.editOdds <= 1);
|
||||
if (invalid) {
|
||||
ElMessage.warning(t('outright.err_odds_min'));
|
||||
return;
|
||||
}
|
||||
|
||||
savingMarketId.value = market.id;
|
||||
try {
|
||||
await api.patch(`/admin/markets/${market.id}`, {
|
||||
promoLabel: market.editPromoLabel.trim() || null,
|
||||
lineValue: market.editLineValue,
|
||||
});
|
||||
await api.put(`/admin/matches/${props.matchId}/odds`, {
|
||||
updates: market.selections.map((s) => ({
|
||||
selectionId: s.id,
|
||||
odds: s.editOdds,
|
||||
})),
|
||||
});
|
||||
for (const s of market.selections) {
|
||||
const name = defaultSelectionName(s.selectionCode, {
|
||||
lineValue: market.editLineValue,
|
||||
period: market.period,
|
||||
});
|
||||
if (name !== s.selectionName) {
|
||||
await api.patch(`/admin/selections/${s.id}`, {
|
||||
selectionName: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
savingMarketId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="match-markets-panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">{{ t('matchEditor.section_markets') }}</span>
|
||||
<el-button size="small" type="primary" plain @click="generateTemplates">
|
||||
{{ t('matchEditor.generate_templates') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
|
||||
|
||||
<div v-else class="market-lines">
|
||||
<div
|
||||
v-for="market in markets"
|
||||
:key="market.id"
|
||||
class="market-line"
|
||||
:class="{ 'market-line--wrap': isMultiRowMarket(market) }"
|
||||
>
|
||||
<label class="field-promo-wrap">
|
||||
<span class="promo-label">{{ t('matchEditor.field.promo_label_optional') }}</span>
|
||||
<el-input
|
||||
v-model="market.editPromoLabel"
|
||||
size="small"
|
||||
class="field-promo"
|
||||
clearable
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="market-line-head">
|
||||
<span class="market-label" :title="market.marketType">{{ marketLabel(market.marketType) }}</span>
|
||||
|
||||
<el-input-number
|
||||
v-if="hasLine(market)"
|
||||
v-model="market.editLineValue"
|
||||
size="small"
|
||||
class="field-line"
|
||||
:step="0.25"
|
||||
controls-position="right"
|
||||
/>
|
||||
|
||||
<el-button
|
||||
v-if="isMultiRowMarket(market)"
|
||||
size="small"
|
||||
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||
class="btn-save"
|
||||
:disabled="!isMarketDirty(market)"
|
||||
:loading="savingMarketId === market.id"
|
||||
@click="saveMarket(market)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="selections-wrap"
|
||||
:class="isMultiRowMarket(market) ? 'selections-grid' : 'selections-inline'"
|
||||
>
|
||||
<div v-for="sel in market.selections" :key="sel.id" class="sel-inline">
|
||||
<span class="sel-label" :title="sel.selectionCode">{{
|
||||
selectionCodeLabel(sel.selectionCode, market)
|
||||
}}</span>
|
||||
<el-input-number
|
||||
v-model="sel.editOdds"
|
||||
size="small"
|
||||
class="sel-odds"
|
||||
:min="1.01"
|
||||
:step="0.01"
|
||||
controls-position="right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
v-if="!isMultiRowMarket(market)"
|
||||
size="small"
|
||||
:type="isMarketDirty(market) ? 'primary' : 'default'"
|
||||
class="btn-save"
|
||||
:disabled="!isMarketDirty(market)"
|
||||
:loading="savingMarketId === market.id"
|
||||
@click="saveMarket(market)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-markets-panel {
|
||||
padding: 10px 12px 12px 16px;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.market-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.market-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #252525;
|
||||
border-radius: 6px;
|
||||
background: #111;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-line--wrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.market-line--wrap .field-promo-wrap {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.field-promo-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.promo-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8e8e93;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-promo {
|
||||
flex: 0 0 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.market-line--wrap .field-promo {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.market-line-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.market-line:not(.market-line--wrap) .market-line-head {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.market-line--wrap .market-line-head {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.market-line--wrap .market-line-head .btn-save {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.market-label {
|
||||
flex: 0 0 76px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-line {
|
||||
flex: 0 0 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-line :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-inline {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-grid .sel-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.selections-grid .sel-label {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.selections-grid .sel-odds {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selections-grid .sel-odds :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sel-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.sel-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sel-odds {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.sel-odds :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex-shrink: 0;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.btn-save.is-disabled,
|
||||
.btn-save:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import LogoUrlField from '../../components/LogoUrlField.vue';
|
||||
import {
|
||||
getBuiltinCountry,
|
||||
resolveCountryCode,
|
||||
type BuiltinCountry,
|
||||
} from '../../data/builtinCountries';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -20,16 +26,22 @@ interface SelectionRow {
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
status: string;
|
||||
logoUrl: string | null;
|
||||
editOdds: number;
|
||||
editCountryCode: string;
|
||||
editLogoUrl: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const savingRowId = ref<string | null>(null);
|
||||
const meta = ref({
|
||||
leagueZh: '',
|
||||
leagueEn: '',
|
||||
leagueCode: '',
|
||||
matchName: '',
|
||||
titleZh: '',
|
||||
titleEn: '',
|
||||
titleMs: '',
|
||||
status: 'DRAFT',
|
||||
expectedCanonicalCount: null as number | null,
|
||||
playerVisible: true,
|
||||
@@ -39,12 +51,30 @@ const selections = ref<SelectionRow[]>([]);
|
||||
|
||||
const addVisible = ref(false);
|
||||
const addForm = ref({
|
||||
countryCode: '',
|
||||
teamCode: '',
|
||||
teamZh: '',
|
||||
teamEn: '',
|
||||
logoUrl: '',
|
||||
odds: 10,
|
||||
});
|
||||
|
||||
function applyCountry(target: {
|
||||
countryCode: string;
|
||||
teamCode: string;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
}, country: BuiltinCountry) {
|
||||
target.countryCode = country.code;
|
||||
target.teamCode = country.code;
|
||||
target.teamZh = country.nameZh;
|
||||
target.teamEn = country.nameEn;
|
||||
}
|
||||
|
||||
function onAddCountryPick(country: BuiltinCountry) {
|
||||
applyCountry(addForm.value, country);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!matchId.value) return;
|
||||
loading.value = true;
|
||||
@@ -54,18 +84,22 @@ async function load() {
|
||||
leagueZh: string;
|
||||
leagueEn: string;
|
||||
leagueCode: string;
|
||||
matchName: string;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
titleMs: string;
|
||||
status: string;
|
||||
expectedCanonicalCount: number | null;
|
||||
playerVisible: boolean;
|
||||
playerHiddenReason: string | null;
|
||||
selections: SelectionRow[];
|
||||
selections: Array<SelectionRow & { logoUrl?: string | null }>;
|
||||
};
|
||||
meta.value = {
|
||||
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,
|
||||
@@ -73,7 +107,10 @@ async function load() {
|
||||
};
|
||||
selections.value = payload.selections.map((s) => ({
|
||||
...s,
|
||||
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 } } };
|
||||
@@ -90,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();
|
||||
@@ -127,21 +166,27 @@ async function saveAllOdds() {
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
if (!addForm.value.teamCode.trim()) {
|
||||
ElMessage.warning(t('outright.err_team_code'));
|
||||
if (!addForm.value.countryCode) {
|
||||
ElMessage.warning(t('outright.err_country'));
|
||||
return;
|
||||
}
|
||||
const country = getBuiltinCountry(addForm.value.countryCode);
|
||||
if (!country) {
|
||||
ElMessage.warning(t('outright.err_country'));
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post(`/admin/outrights/${matchId.value}/selections`, {
|
||||
teamCode: addForm.value.teamCode.trim().toUpperCase(),
|
||||
teamZh: addForm.value.teamZh,
|
||||
teamEn: addForm.value.teamEn,
|
||||
teamCode: country.code,
|
||||
teamZh: country.nameZh,
|
||||
teamEn: country.nameEn,
|
||||
logoUrl: addForm.value.logoUrl.trim() || undefined,
|
||||
odds: addForm.value.odds,
|
||||
});
|
||||
ElMessage.success(t('msg.saved'));
|
||||
addVisible.value = false;
|
||||
addForm.value = { 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 } } };
|
||||
@@ -200,6 +245,93 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
meta.value.status = status;
|
||||
await saveMeta();
|
||||
}
|
||||
|
||||
function rowDisplayName(row: SelectionRow, field: 'zh' | 'en') {
|
||||
const picked = getBuiltinCountry(row.editCountryCode);
|
||||
if (picked && row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl)) {
|
||||
return field === 'zh' ? picked.nameZh : picked.nameEn;
|
||||
}
|
||||
return field === 'zh' ? row.teamZh : row.teamEn;
|
||||
}
|
||||
|
||||
function rowDisplayCode(row: SelectionRow) {
|
||||
const picked = getBuiltinCountry(row.editCountryCode);
|
||||
if (picked && row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl)) {
|
||||
return picked.code;
|
||||
}
|
||||
return row.teamCode;
|
||||
}
|
||||
|
||||
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 || 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) {
|
||||
if (!isRowDirty(row)) return;
|
||||
if (!row.editOdds || row.editOdds <= 1) {
|
||||
ElMessage.warning(t('outright.err_odds_min'));
|
||||
return;
|
||||
}
|
||||
|
||||
savingRowId.value = row.id;
|
||||
try {
|
||||
const country = getBuiltinCountry(row.editCountryCode);
|
||||
const countryDirty =
|
||||
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
|
||||
const logoDirty = (row.editLogoUrl || '').trim() !== (row.logoUrl || '').trim();
|
||||
|
||||
if (countryDirty || logoDirty) {
|
||||
if (!country) {
|
||||
ElMessage.warning(t('outright.err_country'));
|
||||
return;
|
||||
}
|
||||
await api.patch(`/admin/outrights/${matchId.value}/selections/${row.id}`, {
|
||||
teamCode: country.code,
|
||||
teamZh: country.nameZh,
|
||||
teamEn: country.nameEn,
|
||||
logoUrl: row.editLogoUrl.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (row.editOdds !== Number(row.odds)) {
|
||||
await api.put(`/admin/outrights/${matchId.value}/odds`, {
|
||||
updates: [{ selectionId: row.id, odds: row.editOdds }],
|
||||
});
|
||||
}
|
||||
|
||||
ElMessage.success(t('msg.saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
savingRowId.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -222,13 +354,19 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
|
||||
<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') }}
|
||||
@@ -269,9 +407,26 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
<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 prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
|
||||
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
|
||||
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
|
||||
<el-table-column :label="t('outright.col.country')" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
<el-table-column :label="t('outright.col.team_zh')" min-width="120">
|
||||
<template #default="{ row }">{{ rowDisplayName(row, 'zh') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('outright.col.team_en')" min-width="140">
|
||||
<template #default="{ row }">{{ rowDisplayName(row, 'en') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('outright.col.code')" width="88">
|
||||
<template #default="{ row }">{{ rowDisplayCode(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
@@ -285,9 +440,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="88" align="center">
|
||||
<el-table-column :label="t('common.actions')" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeSelection(row)">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:loading="savingRowId === row.id"
|
||||
:disabled="!isRowDirty(row) || (!!savingRowId && savingRowId !== row.id)"
|
||||
@click="saveRow(row)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
:disabled="!!savingRowId"
|
||||
@click="removeSelection(row)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -303,16 +472,24 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="420px">
|
||||
<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.code')">
|
||||
<el-input v-model="addForm.teamCode" placeholder="FRA" />
|
||||
<el-form-item :label="t('outright.col.country')" required>
|
||||
<LogoUrlField
|
||||
:model-value="addForm.logoUrl"
|
||||
:team-code="addForm.countryCode"
|
||||
@update:model-value="onAddLogoChange"
|
||||
@pick="onAddLogoPick"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('outright.col.team_zh')">
|
||||
<el-input v-model="addForm.teamZh" />
|
||||
<el-form-item v-if="addForm.teamCode" :label="t('outright.col.code')">
|
||||
<span class="readonly-field">{{ addForm.teamCode }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('outright.col.team_en')">
|
||||
<el-input v-model="addForm.teamEn" />
|
||||
<el-form-item v-if="addForm.teamZh" :label="t('outright.col.team_zh')">
|
||||
<span class="readonly-field">{{ addForm.teamZh }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="addForm.teamEn" :label="t('outright.col.team_en')">
|
||||
<span class="readonly-field">{{ addForm.teamEn }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('outright.col.odds')">
|
||||
<el-input-number v-model="addForm.odds" :min="1.01" :step="0.05" :precision="2" />
|
||||
@@ -370,9 +547,14 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
|
||||
.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 {
|
||||
@@ -393,11 +575,6 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-head.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -430,4 +607,9 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
||||
min-height: 80px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.readonly-field {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface PlayerEditForm {
|
||||
loginFailCount: number;
|
||||
phone: string;
|
||||
email: string;
|
||||
managedPassword: string | null;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface PlayerRow {
|
||||
@@ -42,6 +44,7 @@ export interface PlayerRow {
|
||||
parentUsername: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
managedPassword: string | null;
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
lastLoginAt: string | null;
|
||||
@@ -90,6 +93,8 @@ export function emptyPlayerEditForm(): PlayerEditForm {
|
||||
loginFailCount: 0,
|
||||
phone: '',
|
||||
email: '',
|
||||
managedPassword: null,
|
||||
newPassword: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +115,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
||||
loginFailCount: d.loginFailCount,
|
||||
phone: d.phone ?? '',
|
||||
email: d.email ?? '',
|
||||
managedPassword: d.managedPassword ?? null,
|
||||
newPassword: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"test:cov": "jest --coverage",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:migrate:deploy": "prisma migrate deploy && prisma generate",
|
||||
"db:seed": "ts-node prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: user_preferences 增加头像(内置球员 key)
|
||||
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "avatar_key" VARCHAR(128);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: 玩家账号权限与后台可查密码
|
||||
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "allow_password_change" BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "allow_username_change" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "managed_password" VARCHAR(128);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "leagues" ADD COLUMN "logo_url" VARCHAR(500);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "markets" ADD COLUMN "promo_label" VARCHAR(100);
|
||||
@@ -52,13 +52,17 @@ model UserAuth {
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
phone String? @db.VarChar(32)
|
||||
email String? @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
phone String? @db.VarChar(32)
|
||||
email String? @db.VarChar(128)
|
||||
avatarKey String? @map("avatar_key") @db.VarChar(128)
|
||||
allowPasswordChange Boolean @default(true) @map("allow_password_change")
|
||||
allowUsernameChange Boolean @default(false) @map("allow_username_change")
|
||||
managedPassword String? @map("managed_password") @db.VarChar(128)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@ -215,6 +219,7 @@ model League {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
code String @unique @db.VarChar(64)
|
||||
logoUrl String? @map("logo_url") @db.VarChar(500)
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -326,6 +331,7 @@ model Market {
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -579,7 +579,7 @@ async function main() {
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: playerHash } },
|
||||
wallet: { create: { availableBalance: 1000 } },
|
||||
preferences: { create: { locale: 'zh-CN' } },
|
||||
preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './domains/identity/guards';
|
||||
import { PrismaModule } from './shared/prisma/prisma.module';
|
||||
import { SystemConfigModule } from './shared/config/system-config.module';
|
||||
import { IdentityModule } from './domains/identity/identity.module';
|
||||
import { AgentsModule } from './domains/agent/agents.module';
|
||||
import { WalletModule } from './domains/ledger/wallet.module';
|
||||
@@ -21,6 +22,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
PrismaModule,
|
||||
SystemConfigModule,
|
||||
IdentityModule,
|
||||
AgentsModule,
|
||||
WalletModule,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
@@ -28,6 +29,7 @@ import { AuditService } from '../../domains/operations/audit/audit.service';
|
||||
import { BetsService } from '../../domains/betting/bets.service';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
MinLength,
|
||||
IsIn,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
|
||||
|
||||
@@ -126,6 +129,25 @@ class UpdatePlayerAdminDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password?: string;
|
||||
}
|
||||
|
||||
class PlayerAccountSettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowPasswordChange?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowUsernameChange?: boolean;
|
||||
}
|
||||
|
||||
class CreateAgentAdminDto {
|
||||
@@ -186,25 +208,63 @@ class DepositDto {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class CreatePlatformMatchDto {
|
||||
class CreatePlatformLeagueDto {
|
||||
@IsString()
|
||||
leagueEn!: string;
|
||||
|
||||
@IsString()
|
||||
leagueZh!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leagueMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
class CreatePlatformMatchDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leagueId?: string;
|
||||
|
||||
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
|
||||
@IsString()
|
||||
leagueEn?: string;
|
||||
|
||||
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
|
||||
@IsString()
|
||||
leagueZh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leagueMs?: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamEn!: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamZh!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
homeTeamMs?: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamEn!: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamZh!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
awayTeamMs?: string;
|
||||
|
||||
@IsString()
|
||||
startTime!: string;
|
||||
|
||||
@@ -215,6 +275,64 @@ class CreatePlatformMatchDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
matchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
leagueLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
homeTeamLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
awayTeamLogoUrl?: string;
|
||||
}
|
||||
|
||||
class BatchMatchOddsDto {
|
||||
@IsArray()
|
||||
updates!: OutrightOddsUpdateItemDto[];
|
||||
}
|
||||
|
||||
class UpdateMarketDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lineValue?: number | null;
|
||||
}
|
||||
|
||||
class UpdateSelectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selectionName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
odds?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
|
||||
@@ -270,6 +388,10 @@ class CreateOutrightDto {
|
||||
@IsString()
|
||||
titleEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@@ -284,6 +406,18 @@ class UpdateOutrightDto {
|
||||
@IsString()
|
||||
matchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleZh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleMs?: string;
|
||||
|
||||
@IsOptional()
|
||||
isHot?: boolean;
|
||||
|
||||
@@ -304,6 +438,113 @@ class AddOutrightSelectionDto {
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
odds!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
class UpdateOutrightSelectionTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamZh?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
teamEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
class ContentTranslationDto {
|
||||
@IsString()
|
||||
locale!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
body?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
class CreateContentDto {
|
||||
@IsString()
|
||||
@IsIn(['BANNER', 'NOTICE', 'TICKER'])
|
||||
contentType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkType?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkTarget?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endTime?: string | null;
|
||||
|
||||
@IsArray()
|
||||
translations!: ContentTranslationDto[];
|
||||
}
|
||||
|
||||
class UpdateContentDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkType?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkTarget?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endTime?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
translations?: ContentTranslationDto[];
|
||||
}
|
||||
|
||||
class ContentStatusDto {
|
||||
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
class CashbackPreviewDto {
|
||||
@@ -334,6 +575,7 @@ export class AdminController {
|
||||
private bets: BetsService,
|
||||
private prisma: PrismaService,
|
||||
private readonly dashboardService: AdminDashboardService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
@@ -342,6 +584,28 @@ export class AdminController {
|
||||
return jsonResponse(overview);
|
||||
}
|
||||
|
||||
@Get('users/settings/account')
|
||||
async getPlayerAccountSettings() {
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Put('users/settings/account')
|
||||
async updatePlayerAccountSettings(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Body() dto: PlayerAccountSettingsDto,
|
||||
) {
|
||||
const settings = await this.systemConfig.updatePlayerAccountSettings(dto);
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'UPDATE_PLAYER_ACCOUNT_SETTINGS',
|
||||
module: 'USERS',
|
||||
afterData: JSON.stringify(settings),
|
||||
});
|
||||
return jsonResponse(settings);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers(
|
||||
@Query('page') page?: string,
|
||||
@@ -553,11 +817,58 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('leagues')
|
||||
async createLeague(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||
const league = await this.matches.createLeague(dto.code, dto.translations);
|
||||
async createLeague(
|
||||
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
|
||||
) {
|
||||
if ('leagueZh' in dto || 'leagueEn' in dto) {
|
||||
const body = dto as CreatePlatformLeagueDto;
|
||||
const league = await this.matches.createPlatformLeague({
|
||||
leagueEn: body.leagueEn,
|
||||
leagueZh: body.leagueZh,
|
||||
leagueMs: body.leagueMs,
|
||||
logoUrl: body.logoUrl,
|
||||
displayOrder: body.displayOrder,
|
||||
});
|
||||
return jsonResponse(league);
|
||||
}
|
||||
const legacy = dto as { code: string; translations: Record<string, string> };
|
||||
const league = await this.matches.createLeague(legacy.code, legacy.translations);
|
||||
return jsonResponse(league);
|
||||
}
|
||||
|
||||
@Get('leagues')
|
||||
async listLeagues(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
const p = Math.max(1, page ? parseInt(page, 10) : 1);
|
||||
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
|
||||
const result = await this.matches.listAdminLeagues({
|
||||
page: p,
|
||||
pageSize: size,
|
||||
status: status || undefined,
|
||||
keyword: keyword || undefined,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('leagues/:leagueId/matches')
|
||||
async listLeagueMatches(
|
||||
@Param('leagueId') leagueId: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('locale') locale?: string,
|
||||
) {
|
||||
const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
|
||||
status: status || undefined,
|
||||
keyword: keyword || undefined,
|
||||
locale: locale || undefined,
|
||||
});
|
||||
return jsonResponse({ items });
|
||||
}
|
||||
|
||||
@Post('teams')
|
||||
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||
const team = await this.matches.createTeam(dto.code, dto.translations);
|
||||
@@ -613,15 +924,24 @@ export class AdminController {
|
||||
@Body() dto: CreatePlatformMatchDto,
|
||||
) {
|
||||
const match = await this.matches.updatePlatformMatch(BigInt(id), {
|
||||
leagueEn: dto.leagueEn,
|
||||
leagueZh: dto.leagueZh,
|
||||
leagueEn: dto.leagueEn ?? '',
|
||||
leagueZh: dto.leagueZh ?? '',
|
||||
leagueMs: dto.leagueMs,
|
||||
homeTeamEn: dto.homeTeamEn,
|
||||
homeTeamZh: dto.homeTeamZh,
|
||||
homeTeamMs: dto.homeTeamMs,
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
awayTeamMs: dto.awayTeamMs,
|
||||
startTime: new Date(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
displayOrder: dto.displayOrder,
|
||||
matchName: dto.matchName,
|
||||
stage: dto.stage,
|
||||
groupName: dto.groupName,
|
||||
leagueLogoUrl: dto.leagueLogoUrl,
|
||||
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
return jsonResponse(match);
|
||||
@@ -636,15 +956,25 @@ export class AdminController {
|
||||
@Post('matches')
|
||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
|
||||
const match = await this.matches.createPlatformMatch({
|
||||
leagueEn: dto.leagueEn,
|
||||
leagueZh: dto.leagueZh,
|
||||
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
|
||||
leagueEn: dto.leagueEn ?? '',
|
||||
leagueZh: dto.leagueZh ?? '',
|
||||
leagueMs: dto.leagueMs,
|
||||
homeTeamEn: dto.homeTeamEn,
|
||||
homeTeamZh: dto.homeTeamZh,
|
||||
homeTeamMs: dto.homeTeamMs,
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
awayTeamMs: dto.awayTeamMs,
|
||||
startTime: new Date(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
displayOrder: dto.displayOrder,
|
||||
matchName: dto.matchName,
|
||||
stage: dto.stage,
|
||||
groupName: dto.groupName,
|
||||
leagueLogoUrl: dto.leagueLogoUrl,
|
||||
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||
createdBy: operatorId,
|
||||
});
|
||||
return jsonResponse(match);
|
||||
@@ -684,6 +1014,48 @@ export class AdminController {
|
||||
return jsonResponse(markets);
|
||||
}
|
||||
|
||||
@Put('matches/:id/odds')
|
||||
async batchUpdateMatchOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BatchMatchOddsDto,
|
||||
) {
|
||||
const updates = dto.updates.map((u) => ({
|
||||
selectionId: BigInt(u.selectionId),
|
||||
odds: u.odds,
|
||||
}));
|
||||
const results = await this.markets.batchUpdateOdds(updates, operatorId);
|
||||
return jsonResponse({ matchId: id, updated: results.length });
|
||||
}
|
||||
|
||||
@Patch('markets/:id')
|
||||
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
||||
const market = await this.markets.updateMarket(BigInt(id), {
|
||||
promoLabel: dto.promoLabel,
|
||||
status: dto.status,
|
||||
lineValue: dto.lineValue,
|
||||
});
|
||||
return jsonResponse(market);
|
||||
}
|
||||
|
||||
@Patch('selections/:id')
|
||||
async updateSelection(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSelectionDto,
|
||||
) {
|
||||
const selection = await this.markets.updateSelection(
|
||||
BigInt(id),
|
||||
{
|
||||
selectionName: dto.selectionName,
|
||||
odds: dto.odds,
|
||||
status: dto.status,
|
||||
},
|
||||
operatorId,
|
||||
);
|
||||
return jsonResponse(selection);
|
||||
}
|
||||
|
||||
@Put('selections/:id/odds')
|
||||
async updateOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@@ -712,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);
|
||||
@@ -790,6 +1163,20 @@ export class AdminController {
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Patch('outrights/:matchId/selections/:selectionId')
|
||||
async updateOutrightSelectionTeam(
|
||||
@Param('matchId') matchId: string,
|
||||
@Param('selectionId') selectionId: string,
|
||||
@Body() dto: UpdateOutrightSelectionTeamDto,
|
||||
) {
|
||||
const data = await this.outright.updateSelectionTeam(
|
||||
BigInt(matchId),
|
||||
BigInt(selectionId),
|
||||
dto,
|
||||
);
|
||||
return jsonResponse(data);
|
||||
}
|
||||
|
||||
@Delete('outrights/:matchId/selections/:selectionId')
|
||||
async removeOutrightSelection(
|
||||
@Param('matchId') matchId: string,
|
||||
@@ -802,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,
|
||||
@@ -875,17 +1268,47 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
async listContents(@Query('type') type?: string) {
|
||||
const items = await this.content.listAll(type);
|
||||
async listContents(
|
||||
@Query('type') type?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const items = await this.content.listForAdmin(type, status);
|
||||
return jsonResponse(items);
|
||||
}
|
||||
|
||||
@Get('contents/:id')
|
||||
async getContent(@Param('id') id: string) {
|
||||
const item = await this.content.getForAdmin(BigInt(id));
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Post('contents')
|
||||
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
|
||||
async createContent(@Body() dto: CreateContentDto) {
|
||||
const item = await this.content.create(dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Put('contents/:id')
|
||||
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
|
||||
const item = await this.content.update(BigInt(id), dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Patch('contents/:id/status')
|
||||
async updateContentStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ContentStatusDto,
|
||||
) {
|
||||
const item = await this.content.updateStatus(BigInt(id), dto.status);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Delete('contents/:id')
|
||||
async deleteContent(@Param('id') id: string) {
|
||||
const result = await this.content.remove(BigInt(id));
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('i18n/messages')
|
||||
async getMessages(@Query('locale') locale = 'en-US') {
|
||||
const messages = await this.i18n.getMessages(locale);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
|
||||
import { CurrentUser } from '../../shared/common/decorators';
|
||||
import { jsonResponse } from '../../shared/common/filters';
|
||||
import { UsersService } from '../../domains/identity/users.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||
import { MatchesService } from '../../domains/catalog/matches.service';
|
||||
import { OutrightService } from '../../domains/catalog/outright.service';
|
||||
@@ -72,6 +73,14 @@ class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatarKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Player')
|
||||
@@ -87,12 +96,39 @@ export class PlayerController {
|
||||
private bets: BetsService,
|
||||
private content: ContentService,
|
||||
private cashback: CashbackService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
|
||||
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
|
||||
const prefs = user.preferences;
|
||||
const viewablePassword = prefs?.managedPassword ?? null;
|
||||
const safePrefs = prefs
|
||||
? (({
|
||||
managedPassword: _m,
|
||||
allowPasswordChange: _a,
|
||||
allowUsernameChange: _b,
|
||||
...rest
|
||||
}) => rest)(prefs)
|
||||
: {};
|
||||
return {
|
||||
...user,
|
||||
id: user.id.toString(),
|
||||
parentId: user.parentId?.toString() ?? null,
|
||||
preferences: {
|
||||
...safePrefs,
|
||||
viewablePassword,
|
||||
allowPasswordChange: accountSettings.allowPasswordChange,
|
||||
allowUsernameChange: accountSettings.allowUsernameChange,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('profile')
|
||||
async profile(@CurrentUser('id') userId: bigint) {
|
||||
const user = await this.users.findById(userId);
|
||||
return jsonResponse(user);
|
||||
if (!user) return jsonResponse(null);
|
||||
return jsonResponse(await this.formatPlayerProfile(user));
|
||||
}
|
||||
|
||||
@Post('language')
|
||||
@@ -104,22 +140,25 @@ export class PlayerController {
|
||||
@Patch('profile')
|
||||
async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) {
|
||||
const user = await this.users.updateProfile(userId, dto);
|
||||
return jsonResponse(user);
|
||||
if (!user) return jsonResponse(null);
|
||||
return jsonResponse(await this.formatPlayerProfile(user));
|
||||
}
|
||||
|
||||
@Get('home')
|
||||
async home(@CurrentUser('locale') locale: string) {
|
||||
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
|
||||
const [banners, announcements, hotMatches, todayMatches] = await Promise.all([
|
||||
this.content.listActive('BANNER', locale),
|
||||
this.content.listActive('NOTICE', locale),
|
||||
this.content.listActive('TICKER', locale),
|
||||
this.content.listActiveAnnouncements(locale),
|
||||
this.matches.listPublished(locale),
|
||||
this.matches.listPublished(locale),
|
||||
]);
|
||||
return jsonResponse({
|
||||
banners,
|
||||
notices,
|
||||
ticker,
|
||||
announcements,
|
||||
/** @deprecated 使用 announcements */
|
||||
ticker: announcements,
|
||||
/** @deprecated 使用 announcements */
|
||||
notices: announcements,
|
||||
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
|
||||
todayMatches,
|
||||
});
|
||||
|
||||
@@ -626,6 +626,7 @@ export class AgentsService {
|
||||
locale,
|
||||
phone: data.phone?.trim() || null,
|
||||
email: data.email?.trim() || null,
|
||||
managedPassword: data.password,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -193,45 +207,396 @@ export class MatchesService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async createPlatformMatch(data: {
|
||||
async createPlatformLeague(data: {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs?: string;
|
||||
logoUrl?: string;
|
||||
displayOrder?: number;
|
||||
}) {
|
||||
const leagueEn = data.leagueEn.trim();
|
||||
const leagueZh = data.leagueZh.trim();
|
||||
if (!leagueEn && !leagueZh) {
|
||||
throw new BadRequestException('请填写赛事名称(中文或英文至少一项)');
|
||||
}
|
||||
const league = await this.upsertLeagueFromZhiboExport({
|
||||
type: 'FOOTBALL',
|
||||
en: leagueEn || leagueZh,
|
||||
zh: leagueZh || leagueEn,
|
||||
});
|
||||
if (data.leagueMs?.trim()) {
|
||||
await this.upsertEntityTranslations('LEAGUE', league.id, {
|
||||
'ms-MY': data.leagueMs.trim(),
|
||||
});
|
||||
}
|
||||
const updates: { logoUrl?: string; displayOrder?: number } = {};
|
||||
if (data.logoUrl?.trim()) updates.logoUrl = data.logoUrl.trim();
|
||||
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
|
||||
if (Object.keys(updates).length) {
|
||||
await this.prisma.league.update({ where: { id: league.id }, data: updates });
|
||||
}
|
||||
const [en, zh, ms] = await Promise.all([
|
||||
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
|
||||
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
|
||||
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
|
||||
]);
|
||||
const fresh = await this.prisma.league.findUniqueOrThrow({ where: { id: league.id } });
|
||||
return {
|
||||
id: fresh.id.toString(),
|
||||
code: fresh.code,
|
||||
logoUrl: fresh.logoUrl,
|
||||
displayOrder: fresh.displayOrder,
|
||||
leagueEn: en,
|
||||
leagueZh: zh,
|
||||
leagueMs: ms,
|
||||
};
|
||||
}
|
||||
|
||||
async listAdminLeagues(opts: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const skip = (opts.page - 1) * opts.pageSize;
|
||||
const kw = opts.keyword?.trim();
|
||||
let idFilter: bigint[] | undefined;
|
||||
|
||||
if (kw || opts.status) {
|
||||
const ids = new Set<bigint>();
|
||||
if (kw) {
|
||||
const trRows = await this.prisma.entityTranslation.findMany({
|
||||
where: {
|
||||
entityType: 'LEAGUE',
|
||||
fieldName: 'name',
|
||||
value: { contains: kw, mode: 'insensitive' },
|
||||
},
|
||||
select: { entityId: true },
|
||||
});
|
||||
for (const r of trRows) ids.add(r.entityId);
|
||||
}
|
||||
const matchWhere: Prisma.MatchWhereInput = {
|
||||
deletedAt: null,
|
||||
isOutright: false,
|
||||
};
|
||||
if (opts.status) matchWhere.status = opts.status;
|
||||
if (kw) {
|
||||
matchWhere.OR = [
|
||||
{ matchName: { contains: kw, mode: 'insensitive' } },
|
||||
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
const matchLeagues = await this.prisma.match.findMany({
|
||||
where: matchWhere,
|
||||
select: { leagueId: true },
|
||||
distinct: ['leagueId'],
|
||||
});
|
||||
for (const m of matchLeagues) ids.add(m.leagueId);
|
||||
idFilter = [...ids];
|
||||
if (!idFilter.length) {
|
||||
return { items: [], total: 0, page: opts.page, pageSize: opts.pageSize };
|
||||
}
|
||||
}
|
||||
|
||||
const where: Prisma.LeagueWhereInput = { deletedAt: null };
|
||||
if (idFilter) where.id = { in: idFilter };
|
||||
|
||||
const [leagues, total] = await Promise.all([
|
||||
this.prisma.league.findMany({
|
||||
where,
|
||||
orderBy: [{ displayOrder: 'asc' }, { id: 'desc' }],
|
||||
skip,
|
||||
take: opts.pageSize,
|
||||
}),
|
||||
this.prisma.league.count({ where }),
|
||||
]);
|
||||
|
||||
const items = await Promise.all(
|
||||
leagues.map(async (league) => {
|
||||
const [leagueEn, leagueZh, leagueMs, matchCount] = await Promise.all([
|
||||
this.getTranslationExact('LEAGUE', league.id, 'en-US'),
|
||||
this.getTranslationExact('LEAGUE', league.id, 'zh-CN'),
|
||||
this.getTranslationExact('LEAGUE', league.id, 'ms-MY'),
|
||||
this.prisma.match.count({
|
||||
where: {
|
||||
leagueId: league.id,
|
||||
deletedAt: null,
|
||||
isOutright: false,
|
||||
...(opts.status ? { status: opts.status } : {}),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
id: league.id.toString(),
|
||||
code: league.code,
|
||||
logoUrl: league.logoUrl,
|
||||
displayOrder: league.displayOrder,
|
||||
leagueEn,
|
||||
leagueZh,
|
||||
leagueMs,
|
||||
matchCount,
|
||||
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 };
|
||||
}
|
||||
|
||||
async listAdminLeagueMatches(
|
||||
leagueId: bigint,
|
||||
opts: { status?: string; keyword?: string; locale?: string },
|
||||
) {
|
||||
const where: Prisma.MatchWhereInput = {
|
||||
leagueId,
|
||||
deletedAt: null,
|
||||
isOutright: false,
|
||||
};
|
||||
if (opts.status) where.status = opts.status;
|
||||
const kw = opts.keyword?.trim();
|
||||
if (kw) {
|
||||
where.OR = [
|
||||
{ matchName: { contains: kw, mode: 'insensitive' } },
|
||||
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
const items = await this.prisma.match.findMany({
|
||||
where,
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
|
||||
});
|
||||
const locale = opts.locale ?? 'zh-CN';
|
||||
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,
|
||||
isOutright: m.isOutright,
|
||||
isHot: m.isHot,
|
||||
displayOrder: m.displayOrder,
|
||||
startTime: m.startTime,
|
||||
matchName: m.matchName,
|
||||
homeTeamName,
|
||||
awayTeamName,
|
||||
homeTeam: { code: m.homeTeam.code },
|
||||
awayTeam: { code: m.awayTeam.code },
|
||||
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;
|
||||
leagueZh?: string;
|
||||
leagueMs?: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamMs?: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamEn: string;
|
||||
awayTeamMs?: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
groupName?: string;
|
||||
leagueLogoUrl?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
createdBy?: bigint;
|
||||
}) {
|
||||
const homeEn = data.homeTeamEn.trim();
|
||||
const homeZh = data.homeTeamZh.trim();
|
||||
const homeMs = data.homeTeamMs?.trim() ?? '';
|
||||
const awayEn = data.awayTeamEn.trim();
|
||||
const awayZh = data.awayTeamZh.trim();
|
||||
if ((!homeEn && !homeZh) || (!awayEn && !awayZh)) {
|
||||
throw new BadRequestException('请填写主客队中英文名至少各一项');
|
||||
const awayMs = data.awayTeamMs?.trim() ?? '';
|
||||
if ((!homeEn && !homeZh && !homeMs) || (!awayEn && !awayZh && !awayMs)) {
|
||||
throw new BadRequestException('请填写主客队名称(中文、英文或马来文至少一项)');
|
||||
}
|
||||
|
||||
const league = await this.upsertLeagueFromZhiboExport({
|
||||
type: 'FOOTBALL',
|
||||
en: data.leagueEn.trim(),
|
||||
zh: data.leagueZh.trim(),
|
||||
let league;
|
||||
if (data.leagueId) {
|
||||
league = await this.prisma.league.findFirst({
|
||||
where: { id: data.leagueId, deletedAt: null },
|
||||
});
|
||||
if (!league) throw new NotFoundException('赛事不存在');
|
||||
} else {
|
||||
const leagueEn = data.leagueEn?.trim() ?? '';
|
||||
const leagueZh = data.leagueZh?.trim() ?? '';
|
||||
const leagueMs = data.leagueMs?.trim() ?? '';
|
||||
if (!leagueEn && !leagueZh && !leagueMs) {
|
||||
throw new BadRequestException('请填写赛事名称(中文、英文或马来文至少一项)');
|
||||
}
|
||||
league = await this.upsertLeagueFromZhiboExport({
|
||||
type: 'FOOTBALL',
|
||||
en: leagueEn || leagueZh || leagueMs,
|
||||
zh: leagueZh || leagueEn || leagueMs,
|
||||
});
|
||||
if (leagueMs) {
|
||||
await this.upsertEntityTranslations('LEAGUE', league.id, {
|
||||
'ms-MY': leagueMs,
|
||||
});
|
||||
}
|
||||
if (data.leagueLogoUrl?.trim()) {
|
||||
await this.prisma.league.update({
|
||||
where: { id: league.id },
|
||||
data: { logoUrl: data.leagueLogoUrl.trim() },
|
||||
});
|
||||
}
|
||||
}
|
||||
const homeTeam = 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 [homeTeam, awayTeam] = await Promise.all([
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: homeEn || homeZh,
|
||||
names: { zh: homeZh || null, en: homeEn || null, zhTw: '', vi: null, km: null, ms: null },
|
||||
image: '',
|
||||
}),
|
||||
this.upsertTeamFromZhiboExport({
|
||||
id: null,
|
||||
name: awayEn || awayZh,
|
||||
names: { zh: awayZh || null, en: awayEn || null, zhTw: '', vi: null, km: null, ms: null },
|
||||
image: '',
|
||||
}),
|
||||
]);
|
||||
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() ||
|
||||
`${homeEn || homeZh || homeMs} - ${awayEn || awayZh || awayMs}`;
|
||||
|
||||
return this.createMatch({
|
||||
leagueId: league.id,
|
||||
@@ -243,7 +608,9 @@ export class MatchesService {
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
zhibo: {
|
||||
matchName: `${homeEn || homeZh} - ${awayEn || awayZh}`,
|
||||
matchName,
|
||||
stage: data.stage?.trim() || undefined,
|
||||
groupName: data.groupName?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -251,7 +618,7 @@ export class MatchesService {
|
||||
private async requireAdminMatch(matchId: bigint) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
include: { homeTeam: true, awayTeam: true },
|
||||
include: { homeTeam: true, awayTeam: true, league: true },
|
||||
});
|
||||
if (!match) throw new NotFoundException('赛事不存在');
|
||||
return match;
|
||||
@@ -259,27 +626,77 @@ export class MatchesService {
|
||||
|
||||
async getAdminMatchDetail(matchId: bigint) {
|
||||
const match = await this.requireAdminMatch(matchId);
|
||||
const [leagueEn, leagueZh, homeEn, homeZh, awayEn, awayZh] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
||||
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
||||
this.getTranslation('TEAM', match.homeTeamId, 'en-US'),
|
||||
this.getTranslation('TEAM', match.homeTeamId, 'zh-CN'),
|
||||
this.getTranslation('TEAM', match.awayTeamId, 'en-US'),
|
||||
this.getTranslation('TEAM', match.awayTeamId, 'zh-CN'),
|
||||
const scoreRow = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId },
|
||||
});
|
||||
const markets = await this.prisma.market.findMany({
|
||||
where: { matchId },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const [leagueEn, leagueZh, leagueMs, homeEn, homeZh, homeMs, awayEn, awayZh, awayMs] =
|
||||
await Promise.all([
|
||||
this.getTranslationExact('LEAGUE', match.leagueId, 'en-US'),
|
||||
this.getTranslationExact('LEAGUE', match.leagueId, 'zh-CN'),
|
||||
this.getTranslationExact('LEAGUE', match.leagueId, 'ms-MY'),
|
||||
this.getTranslationExact('TEAM', match.homeTeamId, 'en-US'),
|
||||
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
|
||||
this.getTranslationExact('TEAM', match.homeTeamId, 'ms-MY'),
|
||||
this.getTranslationExact('TEAM', match.awayTeamId, 'en-US'),
|
||||
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
|
||||
this.getTranslationExact('TEAM', match.awayTeamId, 'ms-MY'),
|
||||
]);
|
||||
return {
|
||||
id: match.id.toString(),
|
||||
status: match.status,
|
||||
isOutright: match.isOutright,
|
||||
isHot: match.isHot,
|
||||
displayOrder: match.displayOrder,
|
||||
startTime: match.startTime.toISOString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
leagueCode: match.league.code,
|
||||
leagueEn,
|
||||
leagueZh,
|
||||
leagueMs,
|
||||
leagueLogoUrl: match.league.logoUrl ?? '',
|
||||
homeTeamEn: homeEn,
|
||||
homeTeamZh: homeZh,
|
||||
homeTeamMs: homeMs,
|
||||
homeTeamCode: match.homeTeam.code,
|
||||
homeTeamLogoUrl: match.homeTeam.logoUrl ?? '',
|
||||
awayTeamEn: awayEn,
|
||||
awayTeamZh: awayZh,
|
||||
awayTeamMs: awayMs,
|
||||
awayTeamCode: match.awayTeam.code,
|
||||
awayTeamLogoUrl: match.awayTeam.logoUrl ?? '',
|
||||
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,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||||
status: m.status,
|
||||
promoLabel: m.promoLabel ?? '',
|
||||
sortOrder: m.sortOrder,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -288,13 +705,22 @@ export class MatchesService {
|
||||
data: {
|
||||
leagueEn: string;
|
||||
leagueZh: string;
|
||||
leagueMs?: string;
|
||||
homeTeamZh: string;
|
||||
homeTeamEn: string;
|
||||
homeTeamMs?: string;
|
||||
awayTeamZh: string;
|
||||
awayTeamEn: string;
|
||||
awayTeamMs?: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
groupName?: string;
|
||||
leagueLogoUrl?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
updatedBy?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -306,23 +732,55 @@ export class MatchesService {
|
||||
throw new BadRequestException('当前状态不可编辑');
|
||||
}
|
||||
|
||||
const matchName = `${data.homeTeamEn.trim() || data.homeTeamZh.trim()} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim()}`;
|
||||
const matchName =
|
||||
data.matchName?.trim() ||
|
||||
`${data.homeTeamEn.trim() || data.homeTeamZh.trim() || data.homeTeamMs?.trim() || ''} - ${data.awayTeamEn.trim() || data.awayTeamZh.trim() || data.awayTeamMs?.trim() || ''}`;
|
||||
|
||||
await Promise.all([
|
||||
this.upsertEntityTranslations('LEAGUE', match.leagueId, {
|
||||
'zh-CN': data.leagueZh.trim(),
|
||||
'en-US': data.leagueEn.trim(),
|
||||
'ms-MY': (data.leagueMs ?? '').trim(),
|
||||
}),
|
||||
this.upsertEntityTranslations('TEAM', match.homeTeamId, {
|
||||
'zh-CN': data.homeTeamZh.trim(),
|
||||
'en-US': data.homeTeamEn.trim(),
|
||||
'ms-MY': (data.homeTeamMs ?? '').trim(),
|
||||
}),
|
||||
this.upsertEntityTranslations('TEAM', match.awayTeamId, {
|
||||
'zh-CN': data.awayTeamZh.trim(),
|
||||
'en-US': data.awayTeamEn.trim(),
|
||||
'ms-MY': (data.awayTeamMs ?? '').trim(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const logoUpdates: Promise<unknown>[] = [];
|
||||
if (data.leagueLogoUrl !== undefined) {
|
||||
logoUpdates.push(
|
||||
this.prisma.league.update({
|
||||
where: { id: match.leagueId },
|
||||
data: { logoUrl: data.leagueLogoUrl.trim() || null },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (data.homeTeamLogoUrl !== undefined) {
|
||||
logoUpdates.push(
|
||||
this.prisma.team.update({
|
||||
where: { id: match.homeTeamId },
|
||||
data: { logoUrl: data.homeTeamLogoUrl.trim() || null },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (data.awayTeamLogoUrl !== undefined) {
|
||||
logoUpdates.push(
|
||||
this.prisma.team.update({
|
||||
where: { id: match.awayTeamId },
|
||||
data: { logoUrl: data.awayTeamLogoUrl.trim() || null },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (logoUpdates.length) await Promise.all(logoUpdates);
|
||||
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: {
|
||||
@@ -330,6 +788,8 @@ export class MatchesService {
|
||||
isHot: data.isHot ?? match.isHot,
|
||||
displayOrder: data.displayOrder ?? match.displayOrder,
|
||||
matchName,
|
||||
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
||||
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
||||
updatedBy: data.updatedBy,
|
||||
},
|
||||
});
|
||||
@@ -484,6 +944,13 @@ export class MatchesService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
|
||||
const row = await this.prisma.entityTranslation.findFirst({
|
||||
where: { entityType, entityId, locale, fieldName: 'name' },
|
||||
});
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
async getTranslation(entityType: string, entityId: bigint, locale: string) {
|
||||
const translations = await this.prisma.entityTranslation.findMany({
|
||||
where: { entityType, entityId },
|
||||
@@ -500,25 +967,63 @@ export class MatchesService {
|
||||
leagueId: bigint;
|
||||
homeTeamId: bigint;
|
||||
awayTeamId: bigint;
|
||||
homeTeam?: { code: string };
|
||||
awayTeam?: { code: string };
|
||||
markets?: unknown[];
|
||||
startTime: Date;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string | null;
|
||||
stage?: string | null;
|
||||
groupName?: string | null;
|
||||
homeTeam?: { code: string; logoUrl?: string | null };
|
||||
awayTeam?: { code: string; logoUrl?: string | null };
|
||||
league?: { logoUrl?: string | null };
|
||||
markets?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const [leagueName, homeName, awayName] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', m.leagueId, locale),
|
||||
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||
]);
|
||||
return {
|
||||
...match,
|
||||
const base = {
|
||||
id: m.id.toString(),
|
||||
leagueId: m.leagueId.toString(),
|
||||
leagueName,
|
||||
leagueLogoUrl: m.league?.logoUrl ?? null,
|
||||
homeTeamName: homeName,
|
||||
awayTeamName: awayName,
|
||||
homeTeamCode: m.homeTeam?.code ?? '',
|
||||
awayTeamCode: m.awayTeam?.code ?? '',
|
||||
homeTeamLogoUrl: m.homeTeam?.logoUrl ?? null,
|
||||
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
||||
startTime: m.startTime.toISOString(),
|
||||
isHot: m.isHot ?? false,
|
||||
displayOrder: m.displayOrder ?? 0,
|
||||
matchName: m.matchName ?? null,
|
||||
stage: m.stage ?? null,
|
||||
groupName: m.groupName ?? null,
|
||||
status: m.status ?? 'PUBLISHED',
|
||||
};
|
||||
if (m.markets) {
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets.map((market) => ({
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
period: market.period as string,
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
|
||||
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
||||
id: (s.id as bigint).toString(),
|
||||
selectionCode: s.selectionCode as string,
|
||||
selectionName: s.selectionName as string,
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
@@ -528,27 +1033,37 @@ export class MatchesService {
|
||||
status: 'PUBLISHED',
|
||||
isOutright: false,
|
||||
sportType: 'FOOTBALL',
|
||||
deletedAt: null,
|
||||
startTime: { gt: now },
|
||||
...(leagueId ? { leagueId } : {}),
|
||||
},
|
||||
include: {
|
||||
league: true,
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
markets: {
|
||||
where: { status: 'OPEN' },
|
||||
include: { selections: { where: { status: 'OPEN' } } },
|
||||
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
|
||||
orderBy: [{ isHot: 'desc' }, { displayOrder: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
||||
}
|
||||
|
||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||
const match = await this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: {
|
||||
id: matchId,
|
||||
deletedAt: null,
|
||||
sportType: 'FOOTBALL',
|
||||
isOutright: false,
|
||||
status: { in: ['PUBLISHED', 'CLOSED'] },
|
||||
},
|
||||
include: {
|
||||
league: true,
|
||||
homeTeam: true,
|
||||
awayTeam: true,
|
||||
markets: {
|
||||
@@ -560,9 +1075,6 @@ export class MatchesService {
|
||||
},
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
if (match.sportType !== 'FOOTBALL') {
|
||||
throw new NotFoundException('Match not found');
|
||||
}
|
||||
return this.enrichMatch(match, locale);
|
||||
}
|
||||
|
||||
@@ -635,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(
|
||||
@@ -716,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,
|
||||
@@ -138,6 +146,7 @@ export class OutrightService {
|
||||
rank: sel.sortOrder + 1 || index + 1,
|
||||
teamZh: teamZh || sel.selectionName,
|
||||
teamEn: teamEn || sel.selectionName,
|
||||
logoUrl: team?.logoUrl ?? null,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
status: sel.status,
|
||||
@@ -153,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(),
|
||||
@@ -160,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,
|
||||
@@ -176,6 +194,7 @@ export class OutrightService {
|
||||
leagueId: bigint;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
titleMs?: string;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
@@ -188,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,
|
||||
@@ -203,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);
|
||||
}
|
||||
@@ -216,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:
|
||||
@@ -243,6 +288,7 @@ export class OutrightService {
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
odds: number;
|
||||
logoUrl?: string;
|
||||
},
|
||||
) {
|
||||
if (!data.teamCode?.trim()) {
|
||||
@@ -256,10 +302,19 @@ export class OutrightService {
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
const code = data.teamCode.trim().toUpperCase();
|
||||
|
||||
const logoUrl =
|
||||
data.logoUrl === undefined
|
||||
? undefined
|
||||
: data.logoUrl.trim()
|
||||
? data.logoUrl.trim()
|
||||
: null;
|
||||
const team = await this.prisma.team.upsert({
|
||||
where: { code },
|
||||
create: { code },
|
||||
update: {},
|
||||
create: {
|
||||
code,
|
||||
...(logoUrl !== undefined ? { logoUrl } : {}),
|
||||
},
|
||||
update: logoUrl !== undefined ? { logoUrl } : {},
|
||||
});
|
||||
await this.upsertTeamTranslations(team.id, {
|
||||
'zh-CN': data.teamZh.trim() || data.teamEn,
|
||||
@@ -292,6 +347,77 @@ export class OutrightService {
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
|
||||
async updateSelectionTeam(
|
||||
matchId: bigint,
|
||||
selectionId: bigint,
|
||||
data: {
|
||||
teamCode?: string;
|
||||
teamZh?: string;
|
||||
teamEn?: string;
|
||||
logoUrl?: string | null;
|
||||
},
|
||||
) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
const sel = await this.prisma.marketSelection.findFirst({
|
||||
where: { id: selectionId, marketId: market.id },
|
||||
});
|
||||
if (!sel) throw new NotFoundException('Selection not found');
|
||||
|
||||
const nextCode = data.teamCode?.trim().toUpperCase() || sel.selectionCode;
|
||||
if (nextCode !== sel.selectionCode) {
|
||||
const dup = await this.prisma.marketSelection.findFirst({
|
||||
where: {
|
||||
marketId: market.id,
|
||||
selectionCode: nextCode,
|
||||
id: { not: selectionId },
|
||||
},
|
||||
});
|
||||
if (dup) {
|
||||
throw new BadRequestException('Selection already exists for this team code');
|
||||
}
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
selectionCode: nextCode,
|
||||
selectionName:
|
||||
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
|
||||
},
|
||||
});
|
||||
} else if (data.teamZh?.trim() || data.teamEn?.trim()) {
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
selectionName:
|
||||
data.teamZh?.trim() || data.teamEn?.trim() || sel.selectionName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const team = await this.prisma.team.upsert({
|
||||
where: { code: nextCode },
|
||||
create: { code: nextCode },
|
||||
update: {},
|
||||
});
|
||||
|
||||
if (data.teamZh !== undefined || data.teamEn !== undefined) {
|
||||
await this.upsertTeamTranslations(team.id, {
|
||||
'zh-CN': data.teamZh?.trim() || data.teamEn?.trim() || nextCode,
|
||||
'en-US': data.teamEn?.trim() || data.teamZh?.trim() || nextCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.logoUrl !== undefined) {
|
||||
const logoUrl = data.logoUrl?.trim() ? data.logoUrl.trim() : null;
|
||||
await this.prisma.team.update({
|
||||
where: { id: team.id },
|
||||
data: { logoUrl },
|
||||
});
|
||||
}
|
||||
|
||||
return this.getForAdmin(matchId);
|
||||
}
|
||||
|
||||
async closeSelection(matchId: bigint, selectionId: bigint) {
|
||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||
const market = await this.ensureOutrightMarket(match.id);
|
||||
@@ -399,6 +525,7 @@ export class OutrightService {
|
||||
id: sel.id.toString(),
|
||||
teamCode: sel.selectionCode,
|
||||
teamName,
|
||||
logoUrl: team?.logoUrl ?? null,
|
||||
odds: sel.odds.toString(),
|
||||
oddsVersion: sel.oddsVersion.toString(),
|
||||
};
|
||||
@@ -407,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'}`;
|
||||
|
||||
@@ -542,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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
@@ -20,6 +21,7 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private config: ConfigService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
||||
@@ -107,6 +109,11 @@ export class AuthService {
|
||||
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
|
||||
if (!auth) throw new UnauthorizedException('User not found');
|
||||
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
if (!settings.allowPasswordChange) {
|
||||
throw new ForbiddenException('当前平台未开放玩家自行修改密码');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
|
||||
if (!valid) throw new UnauthorizedException('Invalid old password');
|
||||
|
||||
@@ -115,6 +122,10 @@ export class AuthService {
|
||||
where: { userId },
|
||||
data: { passwordHash: hash },
|
||||
});
|
||||
await this.prisma.userPreference.updateMany({
|
||||
where: { userId },
|
||||
data: { managedPassword: null },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SUPPORTED_LOCALES } from '@thebet365/shared';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { SUPPORTED_LOCALES, isValidAvatarKey } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
|
||||
export type PlayerListFilters = {
|
||||
@@ -14,6 +16,7 @@ export class UsersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private agents: AgentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
private formatPlayerRow(
|
||||
@@ -26,7 +29,11 @@ export class UsersService {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null;
|
||||
preferences?: { phone: string | null; email: string | null } | null;
|
||||
preferences?: {
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
managedPassword?: string | null;
|
||||
} | null;
|
||||
parent?: { username: string } | null;
|
||||
auth?: { lastLoginAt: Date | null } | null;
|
||||
},
|
||||
@@ -41,6 +48,7 @@ export class UsersService {
|
||||
parentUsername: u.parent?.username ?? null,
|
||||
phone: u.preferences?.phone ?? null,
|
||||
email: u.preferences?.email ?? null,
|
||||
managedPassword: u.preferences?.managedPassword ?? null,
|
||||
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
|
||||
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
|
||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||
@@ -81,13 +89,61 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(userId: bigint, data: { phone?: string; email?: string }) {
|
||||
const phone = data.phone?.trim() || null;
|
||||
const email = data.email?.trim() || null;
|
||||
async updateProfile(
|
||||
userId: bigint,
|
||||
data: { phone?: string; email?: string; avatarKey?: string | null; username?: string },
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { preferences: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
|
||||
if (data.username !== undefined) {
|
||||
const nextUsername = data.username.trim();
|
||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||
if (!settings.allowUsernameChange) {
|
||||
throw new ForbiddenException('当前平台未开放玩家自行修改账号名称');
|
||||
}
|
||||
if (nextUsername !== user.username) {
|
||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { username: nextUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const phone = data.phone !== undefined ? data.phone.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email.trim() || null : undefined;
|
||||
let avatarKey: string | null | undefined;
|
||||
if (data.avatarKey !== undefined) {
|
||||
avatarKey = data.avatarKey?.trim() || null;
|
||||
if (avatarKey && !isValidAvatarKey(avatarKey)) {
|
||||
throw new BadRequestException('无效头像');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.prisma.userPreference.findUnique({ where: { userId } });
|
||||
if (!existing && phone === undefined && email === undefined && avatarKey === undefined) {
|
||||
return this.findById(userId);
|
||||
}
|
||||
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, phone, email },
|
||||
update: { phone, email },
|
||||
create: {
|
||||
userId,
|
||||
phone: phone ?? null,
|
||||
email: email ?? null,
|
||||
...(avatarKey !== undefined ? { avatarKey } : {}),
|
||||
},
|
||||
update: {
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
...(avatarKey !== undefined ? { avatarKey } : {}),
|
||||
},
|
||||
});
|
||||
return this.findById(userId);
|
||||
}
|
||||
@@ -195,10 +251,13 @@ export class UsersService {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
parentId?: string | null;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { auth: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException('玩家不存在');
|
||||
|
||||
@@ -206,6 +265,35 @@ export class UsersService {
|
||||
throw new BadRequestException('无效状态');
|
||||
}
|
||||
|
||||
if (data.username !== undefined) {
|
||||
const nextUsername = data.username.trim();
|
||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||
if (nextUsername !== user.username) {
|
||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
data: { username: nextUsername },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.password !== undefined) {
|
||||
const nextPassword = data.password;
|
||||
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
|
||||
if (!user.auth) throw new BadRequestException('账号认证信息缺失');
|
||||
const hash = await bcrypt.hash(nextPassword, 10);
|
||||
await this.prisma.userAuth.update({
|
||||
where: { userId: playerId },
|
||||
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||||
});
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: { userId: playerId, managedPassword: nextPassword },
|
||||
update: { managedPassword: nextPassword },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.status) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: playerId },
|
||||
@@ -253,25 +341,43 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
||||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
||||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
||||
const prefPatch: {
|
||||
locale?: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
} = {};
|
||||
|
||||
if (data.locale) prefPatch.locale = data.locale;
|
||||
if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null;
|
||||
if (data.email !== undefined) prefPatch.email = data.email.trim() || null;
|
||||
|
||||
if (Object.keys(prefPatch).length > 0) {
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId: playerId },
|
||||
create: {
|
||||
userId: playerId,
|
||||
locale: data.locale ?? user.locale,
|
||||
phone: phone ?? null,
|
||||
email: email ?? null,
|
||||
},
|
||||
update: {
|
||||
...(data.locale ? { locale: data.locale } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
phone: prefPatch.phone ?? null,
|
||||
email: prefPatch.email ?? null,
|
||||
},
|
||||
update: prefPatch,
|
||||
});
|
||||
}
|
||||
|
||||
return this.getPlayerAdminDetail(playerId);
|
||||
}
|
||||
|
||||
async getPlayerAccountPermissions() {
|
||||
return this.systemConfig.getPlayerAccountSettings();
|
||||
}
|
||||
|
||||
async clearManagedPassword(userId: bigint) {
|
||||
const pref = await this.prisma.userPreference.findUnique({ where: { userId } });
|
||||
if (pref?.managedPassword) {
|
||||
await this.prisma.userPreference.update({
|
||||
where: { userId },
|
||||
data: { managedPassword: null },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,22 @@ export class MarketsService {
|
||||
return created;
|
||||
}
|
||||
|
||||
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
|
||||
const sideLabel = side === 'home' ? '主队' : '客队';
|
||||
const value = side === 'home' ? line : -line;
|
||||
const lineText = value > 0 ? `+${value}` : `${value}`;
|
||||
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
|
||||
}
|
||||
|
||||
private formatOuName(side: 'over' | 'under', line: number, half = false) {
|
||||
const sideLabel = side === 'over' ? '大' : '小';
|
||||
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
|
||||
}
|
||||
|
||||
private formatScoreName(code: string) {
|
||||
return code.replace('SCORE_', '').replace('_', '-');
|
||||
}
|
||||
|
||||
private getMarketConfig(marketType: string) {
|
||||
const configs: Record<string, {
|
||||
period: string;
|
||||
@@ -62,9 +78,9 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'Home', odds: 2.5 },
|
||||
{ code: 'DRAW', name: 'Draw', odds: 3.2 },
|
||||
{ code: 'AWAY', name: 'Away', odds: 2.8 },
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
HT_1X2: {
|
||||
@@ -72,9 +88,9 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'HT Home', odds: 3.0 },
|
||||
{ code: 'DRAW', name: 'HT Draw', odds: 2.0 },
|
||||
{ code: 'AWAY', name: 'HT Away', odds: 3.5 },
|
||||
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
FT_HANDICAP: {
|
||||
@@ -83,8 +99,8 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'Home -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: 'Away +0.5', odds: 1.9 },
|
||||
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
|
||||
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
|
||||
],
|
||||
},
|
||||
HT_HANDICAP: {
|
||||
@@ -93,8 +109,8 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'HT Home -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: 'HT Away +0.5', odds: 1.9 },
|
||||
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
|
||||
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_OVER_UNDER: {
|
||||
@@ -103,8 +119,8 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: 'Over 2.5', odds: 1.85 },
|
||||
{ code: 'UNDER', name: 'Under 2.5', odds: 1.95 },
|
||||
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
|
||||
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
|
||||
],
|
||||
},
|
||||
HT_OVER_UNDER: {
|
||||
@@ -113,8 +129,8 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: 'HT Over 1.5', odds: 2.0 },
|
||||
{ code: 'UNDER', name: 'HT Under 1.5', odds: 1.75 },
|
||||
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
|
||||
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
|
||||
],
|
||||
},
|
||||
FT_ODD_EVEN: {
|
||||
@@ -122,8 +138,8 @@ export class MarketsService {
|
||||
allowParlay: true,
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: 'Odd', odds: 1.9 },
|
||||
{ code: 'EVEN', name: 'Even', odds: 1.9 },
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_CORRECT_SCORE: {
|
||||
@@ -132,7 +148,7 @@ export class MarketsService {
|
||||
sortOrder: 8,
|
||||
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 8.0,
|
||||
})),
|
||||
},
|
||||
@@ -142,7 +158,7 @@ export class MarketsService {
|
||||
sortOrder: 9,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
@@ -152,7 +168,7 @@ export class MarketsService {
|
||||
sortOrder: 10,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
@@ -206,4 +222,45 @@ export class MarketsService {
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async updateMarket(
|
||||
marketId: bigint,
|
||||
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
|
||||
) {
|
||||
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
|
||||
if (!market) throw new NotFoundException('Market not found');
|
||||
|
||||
return this.prisma.market.update({
|
||||
where: { id: marketId },
|
||||
data: {
|
||||
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSelection(
|
||||
selectionId: bigint,
|
||||
data: { selectionName?: string; odds?: number; status?: string },
|
||||
operatorId?: bigint,
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
});
|
||||
if (!selection) throw new NotFoundException('Selection not found');
|
||||
|
||||
if (data.odds != null) {
|
||||
if (!operatorId) throw new BadRequestException('Operator required for odds update');
|
||||
return this.updateOdds(selectionId, data.odds, operatorId);
|
||||
}
|
||||
|
||||
return this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
export const CONTENT_TYPES = ['BANNER', 'NOTICE', 'TICKER'] as const;
|
||||
export type ContentType = (typeof CONTENT_TYPES)[number];
|
||||
|
||||
/** 管理端合并 Tab:公告与滚动条在玩家端均为顶部跑马灯 */
|
||||
export const ANNOUNCEMENT_ADMIN_TYPE = 'ANNOUNCEMENT';
|
||||
export const ANNOUNCEMENT_TYPES: ContentType[] = ['NOTICE', 'TICKER'];
|
||||
|
||||
export const CONTENT_STATUSES = ['DRAFT', 'ACTIVE', 'INACTIVE'] as const;
|
||||
export type ContentStatus = (typeof CONTENT_STATUSES)[number];
|
||||
|
||||
export const CONTENT_LINK_TYPES = ['ROUTE', 'URL'] as const;
|
||||
export type ContentLinkType = (typeof CONTENT_LINK_TYPES)[number];
|
||||
|
||||
export const CONTENT_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
|
||||
|
||||
export type ContentTranslationInput = {
|
||||
locale: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
function pickContentTranslation<T extends { locale: string }>(
|
||||
translations: T[],
|
||||
locale: string,
|
||||
): T | undefined {
|
||||
const chain = [locale, 'en-US', 'zh-CN'];
|
||||
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||||
for (const loc of chain) {
|
||||
const hit = translations.find((tr) => tr.locale === loc);
|
||||
if (hit) return hit;
|
||||
@@ -13,10 +39,194 @@ function pickContentTranslation<T extends { locale: string }>(
|
||||
return translations[0];
|
||||
}
|
||||
|
||||
function normalizeOptionalUrl(value?: string | null) {
|
||||
const v = value?.trim();
|
||||
return v || null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private assertContentType(type: string): ContentType {
|
||||
if (!CONTENT_TYPES.includes(type as ContentType)) {
|
||||
throw new BadRequestException(`Invalid contentType: ${type}`);
|
||||
}
|
||||
return type as ContentType;
|
||||
}
|
||||
|
||||
private assertStatus(status: string): ContentStatus {
|
||||
if (!CONTENT_STATUSES.includes(status as ContentStatus)) {
|
||||
throw new BadRequestException(`Invalid status: ${status}`);
|
||||
}
|
||||
return status as ContentStatus;
|
||||
}
|
||||
|
||||
private validateSchedule(startTime?: Date | null, endTime?: Date | null) {
|
||||
if (startTime && endTime && endTime <= startTime) {
|
||||
throw new BadRequestException('endTime must be after startTime');
|
||||
}
|
||||
}
|
||||
|
||||
private validateTranslations(
|
||||
contentType: ContentType,
|
||||
translations: ContentTranslationInput[],
|
||||
status: ContentStatus,
|
||||
) {
|
||||
if (!translations.length) {
|
||||
throw new BadRequestException('At least one translation required');
|
||||
}
|
||||
|
||||
const locales = new Set<string>();
|
||||
for (const tr of translations) {
|
||||
if (!tr.locale?.trim()) {
|
||||
throw new BadRequestException('Translation locale required');
|
||||
}
|
||||
if (locales.has(tr.locale)) {
|
||||
throw new BadRequestException(`Duplicate locale: ${tr.locale}`);
|
||||
}
|
||||
locales.add(tr.locale);
|
||||
}
|
||||
|
||||
if (status !== 'ACTIVE') return;
|
||||
|
||||
const hasUsable = translations.some((tr) => {
|
||||
if (contentType === 'BANNER') {
|
||||
return !!normalizeOptionalUrl(tr.imageUrl);
|
||||
}
|
||||
if (contentType === 'NOTICE') {
|
||||
return !!(tr.title?.trim() || tr.body?.trim());
|
||||
}
|
||||
return !!tr.body?.trim();
|
||||
});
|
||||
|
||||
if (!hasUsable) {
|
||||
throw new BadRequestException(
|
||||
contentType === 'BANNER'
|
||||
? 'ACTIVE banner requires imageUrl in at least one locale'
|
||||
: contentType === 'NOTICE'
|
||||
? 'ACTIVE notice requires title or body in at least one locale'
|
||||
: 'ACTIVE ticker requires body in at least one locale',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private validateLink(linkType?: string | null, linkTarget?: string | null) {
|
||||
if (!linkType) return;
|
||||
if (!CONTENT_LINK_TYPES.includes(linkType as ContentLinkType)) {
|
||||
throw new BadRequestException(`Invalid linkType: ${linkType}`);
|
||||
}
|
||||
if (!linkTarget?.trim()) {
|
||||
throw new BadRequestException('linkTarget required when linkType is set');
|
||||
}
|
||||
}
|
||||
|
||||
playerVisibility(
|
||||
item: {
|
||||
status: string;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
contentType: string;
|
||||
translations: Array<{
|
||||
locale: string;
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
imageUrl?: string | null;
|
||||
}>;
|
||||
},
|
||||
now = new Date(),
|
||||
): { playerVisible: boolean; playerHiddenReason: string | null } {
|
||||
if (item.status !== 'ACTIVE') {
|
||||
return { playerVisible: false, playerHiddenReason: 'NOT_ACTIVE' };
|
||||
}
|
||||
if (item.startTime && item.startTime > now) {
|
||||
return { playerVisible: false, playerHiddenReason: 'NOT_STARTED' };
|
||||
}
|
||||
if (item.endTime && item.endTime < now) {
|
||||
return { playerVisible: false, playerHiddenReason: 'EXPIRED' };
|
||||
}
|
||||
|
||||
const type = item.contentType as ContentType;
|
||||
const ok = item.translations.some((tr) => {
|
||||
if (type === 'BANNER') return !!normalizeOptionalUrl(tr.imageUrl);
|
||||
if (type === 'NOTICE') return !!(tr.title?.trim() || tr.body?.trim());
|
||||
return !!tr.body?.trim();
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
return { playerVisible: false, playerHiddenReason: 'INCOMPLETE' };
|
||||
}
|
||||
|
||||
return { playerVisible: true, playerHiddenReason: null };
|
||||
}
|
||||
|
||||
private mapAdminItem(
|
||||
item: Awaited<ReturnType<typeof this.getRawById>>,
|
||||
now = new Date(),
|
||||
) {
|
||||
const visibility = this.playerVisibility(item, now);
|
||||
const preview =
|
||||
pickContentTranslation(item.translations, 'zh-CN') ??
|
||||
pickContentTranslation(item.translations, 'en-US');
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
status: item.status,
|
||||
linkType: item.linkType,
|
||||
linkTarget: item.linkTarget,
|
||||
startTime: item.startTime?.toISOString() ?? null,
|
||||
endTime: item.endTime?.toISOString() ?? null,
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
previewTitle: preview?.title ?? preview?.body?.slice(0, 40) ?? '',
|
||||
previewImageUrl: preview?.imageUrl ?? null,
|
||||
playerVisible: visibility.playerVisible,
|
||||
playerHiddenReason: visibility.playerHiddenReason,
|
||||
translations: item.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title ?? '',
|
||||
body: tr.body ?? '',
|
||||
imageUrl: tr.imageUrl ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getRawById(id: bigint) {
|
||||
const item = await this.prisma.content.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true },
|
||||
});
|
||||
if (!item) throw new NotFoundException('Content not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
async listActiveAnnouncements(locale: string) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.content.findMany({
|
||||
where: {
|
||||
contentType: { in: [...ANNOUNCEMENT_TYPES] },
|
||||
status: 'ACTIVE',
|
||||
OR: [{ startTime: null }, { startTime: { lte: now } }],
|
||||
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
|
||||
},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items
|
||||
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||||
.map((item) => {
|
||||
const tr = pickContentTranslation(item.translations, locale);
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
translation: tr,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listActive(contentType: string, locale: string) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.content.findMany({
|
||||
@@ -30,39 +240,208 @@ export class ContentService {
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return { ...item, translation: t };
|
||||
return items
|
||||
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||||
.map((item) => {
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return {
|
||||
id: item.id.toString(),
|
||||
contentType: item.contentType,
|
||||
sortOrder: item.sortOrder,
|
||||
linkType: item.linkType,
|
||||
linkTarget: item.linkTarget,
|
||||
translation: t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async listForAdmin(contentType?: string, status?: string) {
|
||||
const typeWhere =
|
||||
contentType === ANNOUNCEMENT_ADMIN_TYPE
|
||||
? { contentType: { in: [...ANNOUNCEMENT_TYPES] } }
|
||||
: contentType
|
||||
? { contentType }
|
||||
: {};
|
||||
|
||||
const items = await this.prisma.content.findMany({
|
||||
where: {
|
||||
...typeWhere,
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
include: { translations: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return items.map((item) => this.mapAdminItem(item));
|
||||
}
|
||||
|
||||
async getForAdmin(id: bigint) {
|
||||
return this.mapAdminItem(await this.getRawById(id));
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
contentType: string;
|
||||
sortOrder?: number;
|
||||
linkType?: string;
|
||||
linkTarget?: string;
|
||||
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
|
||||
status?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
translations: ContentTranslationInput[];
|
||||
}) {
|
||||
return this.prisma.content.create({
|
||||
const contentType = this.assertContentType(data.contentType);
|
||||
const status = this.assertStatus(data.status ?? 'DRAFT');
|
||||
this.validateLink(data.linkType, data.linkTarget);
|
||||
this.validateTranslations(contentType, data.translations, status);
|
||||
|
||||
const startTime = data.startTime ? new Date(data.startTime) : null;
|
||||
const endTime = data.endTime ? new Date(data.endTime) : null;
|
||||
this.validateSchedule(startTime, endTime);
|
||||
|
||||
const item = await this.prisma.content.create({
|
||||
data: {
|
||||
contentType: data.contentType,
|
||||
contentType,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
linkType: data.linkType,
|
||||
linkTarget: data.linkTarget,
|
||||
status: 'ACTIVE',
|
||||
status,
|
||||
linkType: data.linkType?.trim() || null,
|
||||
linkTarget: data.linkTarget?.trim() || null,
|
||||
startTime,
|
||||
endTime,
|
||||
translations: {
|
||||
create: data.translations,
|
||||
create: data.translations.map((tr) => ({
|
||||
locale: tr.locale.trim(),
|
||||
title: tr.title?.trim() || null,
|
||||
body: tr.body?.trim() || null,
|
||||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { translations: true },
|
||||
});
|
||||
|
||||
return this.mapAdminItem(item);
|
||||
}
|
||||
|
||||
async listAll(contentType?: string) {
|
||||
return this.prisma.content.findMany({
|
||||
where: contentType ? { contentType } : {},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
async update(
|
||||
id: bigint,
|
||||
data: {
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
startTime?: string | null;
|
||||
endTime?: string | null;
|
||||
translations?: ContentTranslationInput[];
|
||||
},
|
||||
) {
|
||||
const existing = await this.getRawById(id);
|
||||
const contentType = existing.contentType as ContentType;
|
||||
const status = data.status
|
||||
? this.assertStatus(data.status)
|
||||
: (existing.status as ContentStatus);
|
||||
|
||||
const linkType =
|
||||
data.linkType !== undefined
|
||||
? data.linkType?.trim() || null
|
||||
: existing.linkType;
|
||||
const linkTarget =
|
||||
data.linkTarget !== undefined
|
||||
? data.linkTarget?.trim() || null
|
||||
: existing.linkTarget;
|
||||
this.validateLink(linkType, linkTarget);
|
||||
|
||||
const startTime =
|
||||
data.startTime !== undefined
|
||||
? data.startTime
|
||||
? new Date(data.startTime)
|
||||
: null
|
||||
: existing.startTime;
|
||||
const endTime =
|
||||
data.endTime !== undefined
|
||||
? data.endTime
|
||||
? new Date(data.endTime)
|
||||
: null
|
||||
: existing.endTime;
|
||||
this.validateSchedule(startTime, endTime);
|
||||
|
||||
const translations =
|
||||
data.translations ??
|
||||
existing.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title ?? undefined,
|
||||
body: tr.body ?? undefined,
|
||||
imageUrl: tr.imageUrl ?? undefined,
|
||||
}));
|
||||
this.validateTranslations(contentType, translations, status);
|
||||
|
||||
await this.prisma.content.update({
|
||||
where: { id },
|
||||
data: {
|
||||
sortOrder: data.sortOrder ?? existing.sortOrder,
|
||||
status,
|
||||
linkType,
|
||||
linkTarget,
|
||||
startTime,
|
||||
endTime,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.translations) {
|
||||
for (const tr of data.translations) {
|
||||
await this.prisma.contentTranslation.upsert({
|
||||
where: {
|
||||
contentId_locale: {
|
||||
contentId: id,
|
||||
locale: tr.locale.trim(),
|
||||
},
|
||||
},
|
||||
create: {
|
||||
contentId: id,
|
||||
locale: tr.locale.trim(),
|
||||
title: tr.title?.trim() || null,
|
||||
body: tr.body?.trim() || null,
|
||||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||||
},
|
||||
update: {
|
||||
title: tr.title?.trim() || null,
|
||||
body: tr.body?.trim() || null,
|
||||
imageUrl: normalizeOptionalUrl(tr.imageUrl),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getForAdmin(id);
|
||||
}
|
||||
|
||||
async updateStatus(id: bigint, status: string) {
|
||||
const existing = await this.getRawById(id);
|
||||
const next = this.assertStatus(status);
|
||||
this.validateTranslations(
|
||||
existing.contentType as ContentType,
|
||||
existing.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title ?? undefined,
|
||||
body: tr.body ?? undefined,
|
||||
imageUrl: tr.imageUrl ?? undefined,
|
||||
})),
|
||||
next,
|
||||
);
|
||||
await this.prisma.content.update({
|
||||
where: { id },
|
||||
data: { status: next },
|
||||
});
|
||||
return this.getForAdmin(id);
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
await this.getRawById(id);
|
||||
await this.prisma.contentTranslation.deleteMany({ where: { contentId: id } });
|
||||
await this.prisma.content.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** @deprecated use listForAdmin */
|
||||
async listAll(contentType?: string) {
|
||||
return this.listForAdmin(contentType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
9
apps/api/src/shared/config/system-config.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SystemConfigService } from './system-config.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SystemConfigService],
|
||||
exports: [SystemConfigService],
|
||||
})
|
||||
export class SystemConfigModule {}
|
||||
59
apps/api/src/shared/config/system-config.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change';
|
||||
export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_change';
|
||||
|
||||
export type PlayerAccountSettings = {
|
||||
allowPasswordChange: boolean;
|
||||
allowUsernameChange: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SystemConfigService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getBoolean(key: string, defaultValue: boolean): Promise<boolean> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
if (!row) return defaultValue;
|
||||
return row.configValue === 'true' || row.configValue === '1';
|
||||
}
|
||||
|
||||
async setBoolean(key: string, value: boolean, description?: string) {
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: {
|
||||
configKey: key,
|
||||
configValue: value ? 'true' : 'false',
|
||||
description,
|
||||
},
|
||||
update: { configValue: value ? 'true' : 'false' },
|
||||
});
|
||||
}
|
||||
|
||||
async getPlayerAccountSettings(): Promise<PlayerAccountSettings> {
|
||||
const [allowPasswordChange, allowUsernameChange] = await Promise.all([
|
||||
this.getBoolean(PLAYER_ALLOW_PASSWORD_CHANGE, true),
|
||||
this.getBoolean(PLAYER_ALLOW_USERNAME_CHANGE, false),
|
||||
]);
|
||||
return { allowPasswordChange, allowUsernameChange };
|
||||
}
|
||||
|
||||
async updatePlayerAccountSettings(data: Partial<PlayerAccountSettings>) {
|
||||
if (data.allowPasswordChange !== undefined) {
|
||||
await this.setBoolean(
|
||||
PLAYER_ALLOW_PASSWORD_CHANGE,
|
||||
data.allowPasswordChange,
|
||||
'玩家是否可在客户端修改密码',
|
||||
);
|
||||
}
|
||||
if (data.allowUsernameChange !== undefined) {
|
||||
await this.setBoolean(
|
||||
PLAYER_ALLOW_USERNAME_CHANGE,
|
||||
data.allowUsernameChange,
|
||||
'玩家是否可在客户端修改登录账号名',
|
||||
);
|
||||
}
|
||||
return this.getPlayerAccountSettings();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5173",
|
||||
"dev": "vite --port 5173 --host",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
BIN
apps/player/src/assets/images/vs.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/player/src/assets/images/卡片.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import saishiImg from '../assets/images/saishi.png';
|
||||
defineProps<{
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
expanded: boolean;
|
||||
matches: {
|
||||
id: string;
|
||||
@@ -12,6 +13,8 @@ defineProps<{
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
}[];
|
||||
}>();
|
||||
@@ -26,7 +29,11 @@ const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
|
||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||
</span>
|
||||
<span class="league-title">*{{ leagueName }}</span>
|
||||
<img :src="saishiImg" alt="" class="league-saishi" />
|
||||
<img
|
||||
:src="leagueLogoUrl || saishiImg"
|
||||
alt=""
|
||||
class="league-saishi"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-show="expanded" class="match-panel">
|
||||
|
||||
@@ -10,6 +10,8 @@ const props = defineProps<{
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
};
|
||||
}>();
|
||||
@@ -29,8 +31,12 @@ const kickoff = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const homeFlag = computed(() => teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName));
|
||||
const awayFlag = computed(() => teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName));
|
||||
const homeFlag = computed(() =>
|
||||
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
|
||||
);
|
||||
const awayFlag = computed(() =>
|
||||
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
125
apps/player/src/components/PlayerAvatarModal.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PlayerAvatarPicker from './PlayerAvatarPicker.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
modelValue: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
confirm: [value: string | null];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const draft = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(visible) => {
|
||||
if (visible) draft.value = props.modelValue;
|
||||
},
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm', draft.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="overlay" @click.self="close">
|
||||
<div class="modal" role="dialog" aria-modal="true" :aria-label="t('profile.avatar')">
|
||||
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close">✕</button>
|
||||
<h3 class="title">{{ t('profile.avatar') }}</h3>
|
||||
<PlayerAvatarPicker v-model="draft" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-cancel" @click="close">{{ t('bet.cancel') }}</button>
|
||||
<button type="button" class="btn-confirm btn-gold-outline" @click="confirm">
|
||||
{{ t('profile.avatar_confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 210;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 14px 14px;
|
||||
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.close-x {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 28px 12px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-confirm {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
border: 1px solid var(--border);
|
||||
background: #0a0a0a;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
167
apps/player/src/components/PlayerAvatarPicker.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
BUILTIN_PLAYERS,
|
||||
playerAvatarUrl,
|
||||
getPlayerDisplayName,
|
||||
formatPlayerMeta,
|
||||
getPlayerSearchTokens,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null];
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const keyword = ref('');
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase();
|
||||
if (!q) return BUILTIN_PLAYERS;
|
||||
return BUILTIN_PLAYERS.filter((p) => getPlayerSearchTokens(p).some((token) => token.includes(q)));
|
||||
});
|
||||
|
||||
function select(key: string) {
|
||||
emit('update:modelValue', props.modelValue === key ? null : key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="picker">
|
||||
<div class="picker-head">
|
||||
<label class="picker-label">{{ t('profile.avatar') }}</label>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
class="picker-search"
|
||||
:placeholder="t('profile.avatar_search')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="picker-grid">
|
||||
<button
|
||||
v-for="player in filtered"
|
||||
:key="player.id"
|
||||
type="button"
|
||||
class="picker-item"
|
||||
:class="{ active: modelValue === player.id }"
|
||||
@click="select(player.id)"
|
||||
>
|
||||
<img
|
||||
:src="playerAvatarUrl(player.id) ?? ''"
|
||||
:alt="getPlayerDisplayName(player, locale)"
|
||||
class="picker-photo"
|
||||
/>
|
||||
<span class="picker-name">{{ getPlayerDisplayName(player, locale) }}</span>
|
||||
<span class="picker-meta">{{ formatPlayerMeta(player, locale) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!filtered.length" class="picker-empty">{{ t('profile.avatar_empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.picker-head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.picker-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.picker-search {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0a0a0a;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picker-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 4px 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0a0a0a;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picker-item.active {
|
||||
border-color: var(--border-gold-soft);
|
||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.18);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
}
|
||||
|
||||
.picker-photo {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.picker-name {
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-meta {
|
||||
width: 100%;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { avatarUrl, loadProfile } = usePlayerProfile();
|
||||
const open = ref(false);
|
||||
|
||||
const initial = computed(() => {
|
||||
const name = auth.user?.username ?? '?';
|
||||
return name.charAt(0).toUpperCase();
|
||||
const displayAvatarUrl = computed(() => {
|
||||
if (avatarUrl.value) return avatarUrl.value;
|
||||
const seed = auth.user?.username;
|
||||
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
@@ -37,7 +45,7 @@ function logout() {
|
||||
<template>
|
||||
<div class="avatar-wrap">
|
||||
<button type="button" class="avatar-btn" :aria-expanded="open" @click="toggle">
|
||||
<span class="avatar-letter">{{ initial }}</span>
|
||||
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="avatar-menu">
|
||||
@@ -68,6 +76,15 @@ function logout() {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
|
||||
@@ -21,7 +21,9 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const columns = computed(() => groupCorrectScoreSelections(props.selections, props.marketType));
|
||||
const columns = computed(() =>
|
||||
groupCorrectScoreSelections(props.selections, props.marketType, t),
|
||||
);
|
||||
|
||||
function setStake(sel: CsSelection, raw: string) {
|
||||
const n = Math.max(0, Number(raw) || 0);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { resolveSelectionLabel } from '../../utils/selectionLabel';
|
||||
|
||||
const props = defineProps<{
|
||||
selections: {
|
||||
id: string;
|
||||
selectionCode?: string;
|
||||
selectionName: string;
|
||||
odds: string;
|
||||
}[];
|
||||
@@ -10,6 +14,14 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [id: string] }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
function label(sel: (typeof props.selections)[number]) {
|
||||
if (sel.selectionCode) {
|
||||
return resolveSelectionLabel(t, sel.selectionCode, sel.selectionName);
|
||||
}
|
||||
return sel.selectionName;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,7 +35,7 @@ const emit = defineEmits<{ pick: [id: string] }>();
|
||||
:class="{ selected: isSelected(sel.id) }"
|
||||
@click="emit('pick', sel.id)"
|
||||
>
|
||||
<span class="label">{{ sel.selectionName }}</span>
|
||||
<span class="label">{{ label(sel) }}</span>
|
||||
<span class="odds">{{ sel.odds }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
promoLabel?: string;
|
||||
expanded: boolean;
|
||||
hasMarket: boolean;
|
||||
}>();
|
||||
@@ -20,6 +21,7 @@ const { t } = useI18n();
|
||||
@click="emit('toggle')"
|
||||
>
|
||||
<span class="row-label">{{ label }}</span>
|
||||
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
|
||||
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
|
||||
<span v-else class="row-chevron" aria-hidden="true">{{ expanded ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
@@ -53,6 +55,17 @@ const { t } = useI18n();
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row-promo {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #ffb800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 184, 0, 0.12);
|
||||
border: 1px solid rgba(255, 184, 0, 0.35);
|
||||
}
|
||||
|
||||
.row.expanded .row-label {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface OutrightSelection {
|
||||
id: string;
|
||||
teamCode: string;
|
||||
teamName: string;
|
||||
logoUrl?: string | null;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
}
|
||||
@@ -85,6 +86,7 @@ const showLoadMore = computed(
|
||||
:key="sel.id"
|
||||
:team-code="sel.teamCode"
|
||||
:team-name="sel.teamName"
|
||||
:logo-url="sel.logoUrl"
|
||||
:odds="sel.odds"
|
||||
@pick="emit('pick', sel)"
|
||||
/>
|
||||
|
||||
@@ -6,11 +6,14 @@ const props = defineProps<{
|
||||
teamCode: string;
|
||||
teamName: string;
|
||||
odds: string;
|
||||
logoUrl?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [] }>();
|
||||
|
||||
const flag = computed(() => teamFlagUrl(props.teamCode, props.teamName));
|
||||
const flag = computed(
|
||||
() => props.logoUrl?.trim() || teamFlagUrl(props.teamCode, props.teamName),
|
||||
);
|
||||
const flagFailed = ref(false);
|
||||
|
||||
function onFlagError() {
|
||||
@@ -18,7 +21,7 @@ function onFlagError() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.teamCode, props.teamName] as const,
|
||||
() => [props.teamCode, props.teamName, props.logoUrl] as const,
|
||||
() => {
|
||||
flagFailed.value = false;
|
||||
},
|
||||
|
||||
@@ -1,38 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||
|
||||
function collectAnnouncementLines(data: {
|
||||
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
notices?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
} | null): string[] {
|
||||
const lines: string[] = [];
|
||||
if (!data) return lines;
|
||||
for (const item of data.ticker ?? []) {
|
||||
const text = item.translation?.body || item.translation?.title;
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
for (const item of data.notices ?? []) {
|
||||
const text = item.translation?.title || item.translation?.body;
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
import { usePlayerHome } from './usePlayerHome';
|
||||
|
||||
/** @deprecated 请使用 usePlayerHome */
|
||||
export function useAnnouncements() {
|
||||
const { t } = useI18n();
|
||||
const items = ref<string[]>(resolveAnnouncements([], t('home.announcement_default')));
|
||||
|
||||
async function load() {
|
||||
const fallback = t('home.announcement_default');
|
||||
try {
|
||||
const { data } = await api.get('/player/home');
|
||||
items.value = resolveAnnouncements(collectAnnouncementLines(data.data), fallback);
|
||||
} catch {
|
||||
items.value = resolveAnnouncements([], fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return { items, load };
|
||||
const { announcements, load } = usePlayerHome();
|
||||
return { items: announcements, load };
|
||||
}
|
||||
|
||||
79
apps/player/src/composables/usePlayerHome.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import type { BannerItem } from '../components/BannerCarousel.vue';
|
||||
import { resolveBanners } from '../constants/defaultBanner';
|
||||
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
|
||||
|
||||
export interface PlayerHomeMatch {
|
||||
id: string;
|
||||
leagueName?: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
isHot?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
interface HomePayload {
|
||||
banners?: BannerItem[];
|
||||
announcements?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
notices?: Array<{ translation?: { title?: string; body?: string } }>;
|
||||
hotMatches?: PlayerHomeMatch[];
|
||||
}
|
||||
|
||||
const homeRaw = ref<HomePayload | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
function collectAnnouncementLines(data: HomePayload | null): string[] {
|
||||
if (!data) return [];
|
||||
const source =
|
||||
data.announcements && data.announcements.length > 0
|
||||
? data.announcements
|
||||
: [...(data.ticker ?? []), ...(data.notices ?? [])];
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const item of source) {
|
||||
const text = item.translation?.title || item.translation?.body;
|
||||
if (text) lines.push(text);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
|
||||
export function usePlayerHome() {
|
||||
const { t } = useI18n();
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/home');
|
||||
homeRaw.value = (data.data ?? null) as HomePayload | null;
|
||||
} catch {
|
||||
homeRaw.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const banners = computed(() => resolveBanners(homeRaw.value?.banners));
|
||||
const announcements = computed(() =>
|
||||
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
|
||||
);
|
||||
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
|
||||
|
||||
return {
|
||||
homeRaw,
|
||||
loading,
|
||||
load,
|
||||
banners,
|
||||
announcements,
|
||||
hotMatches,
|
||||
};
|
||||
}
|
||||
143
apps/player/src/composables/usePlayerProfile.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { isValidAvatarKey, playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||
import api from '../api';
|
||||
|
||||
type ProfileData = {
|
||||
id?: string | number;
|
||||
username?: string;
|
||||
locale?: string;
|
||||
preferences?: {
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
avatarKey?: string | null;
|
||||
allowPasswordChange?: boolean;
|
||||
allowUsernameChange?: boolean;
|
||||
viewablePassword?: string | null;
|
||||
};
|
||||
wallet?: { availableBalance: string; frozenBalance: string };
|
||||
};
|
||||
|
||||
const AVATAR_CACHE_PREFIX = 'player_avatar_key:';
|
||||
|
||||
const profileRaw = ref<ProfileData | null>(null);
|
||||
const loading = ref(false);
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
let assigningDefault = false;
|
||||
|
||||
function profileSeed(profile: ProfileData | null): string {
|
||||
if (!profile) return '';
|
||||
return String(profile.id ?? profile.username ?? '');
|
||||
}
|
||||
|
||||
function readCachedAvatarKey(seed: string): string | null {
|
||||
if (!seed) return null;
|
||||
try {
|
||||
const key = localStorage.getItem(`${AVATAR_CACHE_PREFIX}${seed}`);
|
||||
return key && isValidAvatarKey(key) ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedAvatarKey(seed: string, key: string) {
|
||||
if (!seed || !key) return;
|
||||
try {
|
||||
localStorage.setItem(`${AVATAR_CACHE_PREFIX}${seed}`, key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function applyAvatarKey(key: string | null) {
|
||||
if (!profileRaw.value) {
|
||||
profileRaw.value = { preferences: { avatarKey: key } };
|
||||
return;
|
||||
}
|
||||
profileRaw.value = {
|
||||
...profileRaw.value,
|
||||
preferences: {
|
||||
...profileRaw.value.preferences,
|
||||
avatarKey: key,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureDefaultAvatar() {
|
||||
if (assigningDefault || !profileRaw.value) return;
|
||||
|
||||
const seed = profileSeed(profileRaw.value);
|
||||
const current =
|
||||
profileRaw.value.preferences?.avatarKey ?? readCachedAvatarKey(seed);
|
||||
if (current && isValidAvatarKey(current)) {
|
||||
applyAvatarKey(current);
|
||||
return;
|
||||
}
|
||||
|
||||
assigningDefault = true;
|
||||
const key = randomAvatarKey(seed);
|
||||
try {
|
||||
try {
|
||||
await api.patch('/player/profile', { avatarKey: key });
|
||||
} catch {
|
||||
/* 数据库未迁移等情况仍展示本地头像 */
|
||||
}
|
||||
applyAvatarKey(key);
|
||||
writeCachedAvatarKey(seed, key);
|
||||
} finally {
|
||||
assigningDefault = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProfile(force = false) {
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
if (!force && profileRaw.value) {
|
||||
await ensureDefaultAvatar();
|
||||
return;
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/player/profile');
|
||||
profileRaw.value = data.data ?? null;
|
||||
await ensureDefaultAvatar();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loadPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
const avatarKey = computed(() => {
|
||||
const saved = profileRaw.value?.preferences?.avatarKey;
|
||||
if (saved && isValidAvatarKey(saved)) return saved;
|
||||
|
||||
const seed = profileSeed(profileRaw.value);
|
||||
if (!seed) return null;
|
||||
|
||||
const cached = readCachedAvatarKey(seed);
|
||||
if (cached) return cached;
|
||||
|
||||
return randomAvatarKey(seed);
|
||||
});
|
||||
const avatarUrl = computed(() => playerAvatarUrl(avatarKey.value));
|
||||
|
||||
function setAvatarKey(key: string | null) {
|
||||
applyAvatarKey(key);
|
||||
const seed = profileSeed(profileRaw.value);
|
||||
if (key && seed) writeCachedAvatarKey(seed, key);
|
||||
}
|
||||
|
||||
export function usePlayerProfile() {
|
||||
return {
|
||||
profileRaw,
|
||||
loading,
|
||||
avatarKey,
|
||||
avatarUrl,
|
||||
loadProfile,
|
||||
setAvatarKey,
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export function resolveBanners(banners: BannerItem[] | undefined | null): Banner
|
||||
},
|
||||
}));
|
||||
|
||||
if (fromApi.length > 0) return fromApi;
|
||||
|
||||
const defaultSlide: BannerItem = {
|
||||
...DEFAULT_BANNER,
|
||||
translation: {
|
||||
@@ -37,5 +39,5 @@ export function resolveBanners(banners: BannerItem[] | undefined | null): Banner
|
||||
},
|
||||
};
|
||||
|
||||
return [defaultSlide, ...fromApi];
|
||||
return [defaultSlide];
|
||||
}
|
||||
|
||||
@@ -11,25 +11,36 @@ import { useAppLocale } from '../composables/useAppLocale';
|
||||
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useAnnouncements } from '../composables/useAnnouncements';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { initFromUser } = useAppLocale();
|
||||
const route = useRoute();
|
||||
const slip = useBetSlipStore();
|
||||
|
||||
const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
|
||||
const { items: announcements, load: loadAnnouncements } = useAnnouncements();
|
||||
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||
const { loadProfile } = usePlayerProfile();
|
||||
|
||||
useOnLocaleChange(loadPlayerHome);
|
||||
|
||||
onMounted(() => {
|
||||
loadAnnouncements();
|
||||
if (auth.user?.locale) initFromUser(auth.user.locale);
|
||||
});
|
||||
|
||||
watch(locale, (next, prev) => {
|
||||
if (prev && next !== prev) void loadAnnouncements();
|
||||
});
|
||||
watch(
|
||||
() => auth.token,
|
||||
(token) => {
|
||||
if (token) {
|
||||
void loadPlayerHome();
|
||||
void loadProfile(true);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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: '投注单',
|
||||
@@ -126,6 +139,9 @@ const i18n = createI18n({
|
||||
parlay_sel_under: '小',
|
||||
parlay_sel_odd: '单',
|
||||
parlay_sel_even: '双',
|
||||
cs_other_home: '主胜其它比分',
|
||||
cs_other_draw: '和局其它比分',
|
||||
cs_other_away: '客胜其它比分',
|
||||
col_home: '主场',
|
||||
col_draw: '平',
|
||||
col_away: '客场',
|
||||
@@ -174,6 +190,20 @@ const i18n = createI18n({
|
||||
profile: {
|
||||
edit: '修改资料',
|
||||
language: '语言',
|
||||
avatar: '选择头像',
|
||||
avatar_change: '修改头像',
|
||||
avatar_confirm: '确定',
|
||||
section_contact: '联系方式',
|
||||
section_account: '账号信息',
|
||||
change_password: '修改密码',
|
||||
show_password: '查看',
|
||||
hide_password: '隐藏',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: '密码不可查看,如需重置请联系客服',
|
||||
section_password: '修改密码(可选)',
|
||||
avatar_hint: '从内置球员中选择头像',
|
||||
avatar_search: '搜索球员、位置或国家',
|
||||
avatar_empty: '未找到匹配球员',
|
||||
phone: '手机号',
|
||||
email: '邮箱',
|
||||
phone_placeholder: '请输入手机号',
|
||||
@@ -193,6 +223,10 @@ const i18n = createI18n({
|
||||
password_failed: '密码修改失败',
|
||||
password_mismatch: '两次新密码不一致',
|
||||
password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
|
||||
username_placeholder: '登录账号名',
|
||||
username_readonly_hint: '账号名称由后台管理,如需修改请联系客服',
|
||||
username_updated: '账号名称已更新',
|
||||
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||
rules_title: '投注规则',
|
||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||
rules_p2: '串关为 2 串 1 至 5 串 1,不可同场串关;冠军盘、四分盘让球/大小不可进入串关。',
|
||||
@@ -222,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 输',
|
||||
@@ -243,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',
|
||||
@@ -317,6 +364,9 @@ const i18n = createI18n({
|
||||
parlay_sel_under: 'U',
|
||||
parlay_sel_odd: 'Odd',
|
||||
parlay_sel_even: 'Even',
|
||||
cs_other_home: 'Home win (other score)',
|
||||
cs_other_draw: 'Draw (other score)',
|
||||
cs_other_away: 'Away win (other score)',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Draw',
|
||||
col_away: 'Away',
|
||||
@@ -365,6 +415,20 @@ const i18n = createI18n({
|
||||
profile: {
|
||||
edit: 'Edit Profile',
|
||||
language: 'Language',
|
||||
avatar: 'Avatar',
|
||||
avatar_change: 'Change avatar',
|
||||
avatar_confirm: 'Confirm',
|
||||
section_contact: 'Contact',
|
||||
section_account: 'Account',
|
||||
change_password: 'Change password',
|
||||
show_password: 'Show',
|
||||
hide_password: 'Hide',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: 'Password not available; contact support to reset',
|
||||
section_password: 'Change password (optional)',
|
||||
avatar_hint: 'Choose from built-in player portraits',
|
||||
avatar_search: 'Search player, position or country',
|
||||
avatar_empty: 'No players found',
|
||||
phone: 'Phone',
|
||||
email: 'Email',
|
||||
phone_placeholder: 'Phone number',
|
||||
@@ -384,6 +448,10 @@ const i18n = createI18n({
|
||||
password_failed: 'Password change failed',
|
||||
password_mismatch: 'Passwords do not match',
|
||||
password_incomplete: 'Fill current, new and confirm password to change password',
|
||||
username_placeholder: 'Login username',
|
||||
username_readonly_hint: 'Username is managed by admin; contact support to change',
|
||||
username_updated: 'Username updated',
|
||||
password_disabled: 'Password change is disabled for this account; contact support',
|
||||
rules_title: 'Betting Rules',
|
||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
||||
rules_p2: 'Parlays: 2–5 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.',
|
||||
@@ -419,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',
|
||||
@@ -440,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',
|
||||
@@ -514,6 +595,9 @@ const i18n = createI18n({
|
||||
parlay_sel_under: 'Bwh',
|
||||
parlay_sel_odd: 'G',
|
||||
parlay_sel_even: 'Gn',
|
||||
cs_other_home: 'Menang rumah (skor lain)',
|
||||
cs_other_draw: 'Seri (skor lain)',
|
||||
cs_other_away: 'Menang pelawat (skor lain)',
|
||||
col_home: 'Home',
|
||||
col_draw: 'Seri',
|
||||
col_away: 'Away',
|
||||
@@ -562,6 +646,20 @@ const i18n = createI18n({
|
||||
profile: {
|
||||
edit: 'Edit Profil',
|
||||
language: 'Bahasa',
|
||||
avatar: 'Avatar',
|
||||
avatar_change: 'Tukar avatar',
|
||||
avatar_confirm: 'Sahkan',
|
||||
section_contact: 'Maklumat hubungan',
|
||||
section_account: 'Akaun',
|
||||
change_password: 'Tukar kata laluan',
|
||||
show_password: 'Lihat',
|
||||
hide_password: 'Sembunyi',
|
||||
password_unavailable: '••••••••',
|
||||
password_unavailable_hint: 'Kata laluan tidak tersedia; hubungi sokongan',
|
||||
section_password: 'Tukar kata laluan (pilihan)',
|
||||
avatar_hint: 'Pilih dari potret pemain terbina',
|
||||
avatar_search: 'Cari pemain, posisi atau negara',
|
||||
avatar_empty: 'Tiada pemain dijumpai',
|
||||
phone: 'Telefon',
|
||||
email: 'E-mel',
|
||||
phone_placeholder: 'Nombor telefon',
|
||||
@@ -581,6 +679,10 @@ const i18n = createI18n({
|
||||
password_failed: 'Gagal tukar kata laluan',
|
||||
password_mismatch: 'Kata laluan tidak sepadan',
|
||||
password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar',
|
||||
username_placeholder: 'Nama log masuk',
|
||||
username_readonly_hint: 'Nama akaun diurus admin; hubungi sokongan untuk ubah',
|
||||
username_updated: 'Nama akaun dikemas kini',
|
||||
password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan',
|
||||
rules_title: 'Peraturan Pertaruhan',
|
||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
||||
rules_p2: 'Parlay 2–5 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||
|
||||
@@ -33,10 +33,39 @@ function orderForMarket(marketType: string) {
|
||||
return HT_CORRECT_SCORE_ORDER;
|
||||
}
|
||||
|
||||
export function parseScoreCode(code: string): { display: string; column: CsColumn } | null {
|
||||
if (code === 'OTHER_HOME') return { display: '其它', column: 'home' };
|
||||
if (code === 'OTHER_DRAW') return { display: '其它', column: 'draw' };
|
||||
if (code === 'OTHER_AWAY') return { display: '其它', column: 'away' };
|
||||
const OTHER_SCORE_FALLBACK: Record<string, string> = {
|
||||
OTHER_HOME: '主胜其它比分',
|
||||
OTHER_DRAW: '和局其它比分',
|
||||
OTHER_AWAY: '客胜其它比分',
|
||||
};
|
||||
|
||||
export type OtherScoreCode = 'OTHER_HOME' | 'OTHER_DRAW' | 'OTHER_AWAY';
|
||||
|
||||
export function otherScoreDisplay(
|
||||
code: OtherScoreCode,
|
||||
t?: (key: string) => string,
|
||||
): string {
|
||||
if (t) {
|
||||
const key = `bet.cs_${code.toLowerCase()}`;
|
||||
const v = t(key);
|
||||
if (v !== key) return v;
|
||||
}
|
||||
return OTHER_SCORE_FALLBACK[code];
|
||||
}
|
||||
|
||||
export function parseScoreCode(
|
||||
code: string,
|
||||
t?: (key: string) => string,
|
||||
): { display: string; column: CsColumn } | null {
|
||||
if (code === 'OTHER_HOME') {
|
||||
return { display: otherScoreDisplay('OTHER_HOME', t), column: 'home' };
|
||||
}
|
||||
if (code === 'OTHER_DRAW') {
|
||||
return { display: otherScoreDisplay('OTHER_DRAW', t), column: 'draw' };
|
||||
}
|
||||
if (code === 'OTHER_AWAY') {
|
||||
return { display: otherScoreDisplay('OTHER_AWAY', t), column: 'away' };
|
||||
}
|
||||
const m = code.match(/^SCORE_(\d+)_(\d+)$/);
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
@@ -63,6 +92,7 @@ export function groupCorrectScoreSelections(
|
||||
oddsVersion: string;
|
||||
}>,
|
||||
marketType: string,
|
||||
t?: (key: string) => string,
|
||||
) {
|
||||
const template = orderForMarket(marketType);
|
||||
const home: CsSelection[] = [];
|
||||
@@ -70,7 +100,7 @@ export function groupCorrectScoreSelections(
|
||||
const away: CsSelection[] = [];
|
||||
|
||||
for (const sel of selections) {
|
||||
const parsed = parseScoreCode(sel.selectionCode);
|
||||
const parsed = parseScoreCode(sel.selectionCode, t);
|
||||
if (!parsed) continue;
|
||||
const row: CsSelection = { ...sel, scoreDisplay: parsed.display };
|
||||
if (parsed.column === 'home') home.push(row);
|
||||
|
||||
28
apps/player/src/utils/selectionLabel.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { parseScoreCode } from './correctScoreLayout';
|
||||
|
||||
const CODE_I18N: Record<string, string> = {
|
||||
HOME: 'parlay_sel_home',
|
||||
AWAY: 'parlay_sel_away',
|
||||
DRAW: 'parlay_sel_draw',
|
||||
OVER: 'parlay_sel_over',
|
||||
UNDER: 'parlay_sel_under',
|
||||
ODD: 'parlay_sel_odd',
|
||||
EVEN: 'parlay_sel_even',
|
||||
};
|
||||
|
||||
/** 标准选项按 code 显示固定文案,不依赖后台手填的 selectionName */
|
||||
export function resolveSelectionLabel(
|
||||
t: (key: string) => string,
|
||||
code: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const i18nKey = CODE_I18N[code];
|
||||
if (i18nKey) {
|
||||
const fullKey = `bet.${i18nKey}`;
|
||||
const v = t(fullKey);
|
||||
if (v !== fullKey) return v;
|
||||
}
|
||||
const parsed = parseScoreCode(code, t);
|
||||
if (parsed) return parsed.display;
|
||||
return fallback;
|
||||
}
|
||||
@@ -159,7 +159,12 @@ const NAME_TO_ISO: Record<string, string> = {
|
||||
切尔西: 'gb',
|
||||
};
|
||||
|
||||
export function teamFlagUrl(code?: string, name?: string): string {
|
||||
export function teamFlagUrl(
|
||||
code?: string,
|
||||
name?: string,
|
||||
logoUrl?: string | null,
|
||||
): string {
|
||||
if (logoUrl?.trim()) return logoUrl.trim();
|
||||
const key = (code ?? '').toUpperCase();
|
||||
if (key && CODE_TO_ISO[key]) {
|
||||
return `https://flagcdn.com/w40/${CODE_TO_ISO[key]}.png`;
|
||||
|
||||
@@ -20,13 +20,19 @@ interface Match {
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
leagueName: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
displayOrder?: number;
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
interface LeagueGroup {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
@@ -80,11 +86,28 @@ const leagueGroups = computed<LeagueGroup[]>(() => {
|
||||
for (const m of filteredMatches.value) {
|
||||
const id = m.leagueId ?? m.leagueName;
|
||||
if (!map.has(id)) {
|
||||
map.set(id, { leagueId: id, leagueName: m.leagueName, matches: [] });
|
||||
map.set(id, {
|
||||
leagueId: id,
|
||||
leagueName: m.leagueName,
|
||||
leagueLogoUrl: m.leagueLogoUrl ?? null,
|
||||
matches: [],
|
||||
});
|
||||
}
|
||||
map.get(id)!.matches.push(m);
|
||||
}
|
||||
return [...map.values()];
|
||||
const groups = [...map.values()];
|
||||
for (const g of groups) {
|
||||
g.matches.sort(
|
||||
(a, b) =>
|
||||
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
}
|
||||
return groups.sort(
|
||||
(a, b) =>
|
||||
(a.matches[0]?.displayOrder ?? 0) - (b.matches[0]?.displayOrder ?? 0) ||
|
||||
a.leagueName.localeCompare(b.leagueName),
|
||||
);
|
||||
});
|
||||
|
||||
watch(leagueGroups, (groups) => {
|
||||
@@ -176,6 +199,7 @@ function goMatch(id: string) {
|
||||
:key="group.leagueId"
|
||||
:league-id="group.leagueId"
|
||||
:league-name="group.leagueName"
|
||||
:league-logo-url="group.leagueLogoUrl"
|
||||
:matches="group.matches"
|
||||
:expanded="isLeagueExpanded(group.leagueId)"
|
||||
@toggle="toggleLeague(group.leagueId)"
|
||||
|
||||
@@ -1,67 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
import api from '../api';
|
||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||
import vsImg from '../assets/images/vs.png';
|
||||
import cardBg from '../assets/images/卡片.png';
|
||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||
import { resolveBanners } from '../constants/defaultBanner';
|
||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
||||
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
|
||||
const matchCardBg = `url(${cardBg})`;
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const home = ref<{
|
||||
banners: Banner[];
|
||||
hotMatches: Match[];
|
||||
ticker: ContentItem[];
|
||||
notices: ContentItem[];
|
||||
} | null>(null);
|
||||
|
||||
interface ContentItem {
|
||||
translation?: { title?: string; body?: string; imageUrl?: string };
|
||||
}
|
||||
|
||||
interface Banner {
|
||||
id?: string;
|
||||
linkType?: string | null;
|
||||
linkTarget?: string | null;
|
||||
translation?: { title?: string; body?: string; imageUrl?: string };
|
||||
}
|
||||
|
||||
interface Match {
|
||||
id: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
startTime: string;
|
||||
isHot: boolean;
|
||||
}
|
||||
|
||||
const displayBanners = computed(() => resolveBanners(home.value?.banners));
|
||||
|
||||
async function loadHome() {
|
||||
const { data } = await api.get('/player/home');
|
||||
home.value = data.data;
|
||||
}
|
||||
|
||||
useOnLocaleChange(loadHome);
|
||||
const { banners, hotMatches, loading } = usePlayerHome();
|
||||
|
||||
function goMatch(id: string) {
|
||||
router.push(`/match/${id}`);
|
||||
}
|
||||
|
||||
function formatKickoff(startTime: string) {
|
||||
return new Date(startTime).toLocaleString(locale.value, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function homeFlag(match: (typeof hotMatches.value)[number]) {
|
||||
return teamFlagUrl(match.homeTeamCode, match.homeTeamName);
|
||||
}
|
||||
|
||||
function awayFlag(match: (typeof hotMatches.value)[number]) {
|
||||
return teamFlagUrl(match.awayTeamCode, match.awayTeamName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<BannerCarousel :banners="displayBanners" />
|
||||
<BannerCarousel :banners="banners" />
|
||||
|
||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
||||
<div v-for="match in home?.hotMatches || []" :key="match.id" class="card match-card" @click="goMatch(match.id)">
|
||||
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
|
||||
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
|
||||
<div
|
||||
v-for="match in hotMatches"
|
||||
:key="match.id"
|
||||
class="match-card"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="match-info">
|
||||
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
|
||||
<div class="match-time">{{ formatKickoff(match.startTime) }}</div>
|
||||
</div>
|
||||
<div class="match-flags" aria-hidden="true">
|
||||
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
|
||||
<span v-else class="flag-ph">⚽</span>
|
||||
<div class="vs-arena">
|
||||
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="`hzBoltGrad-${match.id}`" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
|
||||
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
|
||||
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
|
||||
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
class="hz-path hz-path-main"
|
||||
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
|
||||
/>
|
||||
<path
|
||||
class="hz-path hz-path-sub"
|
||||
:stroke="`url(#hzBoltGrad-${match.id})`"
|
||||
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hz-beam" aria-hidden="true" />
|
||||
<img :src="vsImg" alt="" class="vs-img" />
|
||||
</div>
|
||||
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
|
||||
<span v-else class="flag-ph">⚽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="home && !home.hotMatches?.length" class="empty">
|
||||
<div v-if="!loading && !hotMatches.length" class="empty">
|
||||
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||
<p>{{ t('home.no_matches') }}</p>
|
||||
</div>
|
||||
@@ -69,10 +93,274 @@ function goMatch(id: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-card { cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; }
|
||||
.match-card:active { border-color: var(--border-gold-soft); }
|
||||
.match-teams { font-weight: 800; margin-bottom: 8px; font-size: 16px; }
|
||||
.match-time { font-size: 13px; color: var(--text-muted); font-weight: 500; }
|
||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 20px; font-weight: 600; }
|
||||
.empty-icon { width: 96px; height: 96px; margin-bottom: 14px; }
|
||||
.match-card {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: none;
|
||||
background: var(--bg-card);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.match-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
|
||||
opacity: 0.25;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.match-card:active {
|
||||
opacity: 0.92;
|
||||
transform: scale(0.995);
|
||||
}
|
||||
|
||||
.match-info,
|
||||
.match-flags {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.match-teams {
|
||||
font-weight: 800;
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.match-time {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.match-flags {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.flag-ph {
|
||||
width: 40px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.vs-arena {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
height: 58px;
|
||||
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: 58px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
animation: vs-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hz-strike-main {
|
||||
0%,
|
||||
72%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
74% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hz-strike-sub {
|
||||
0%,
|
||||
74%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
77% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hz-beam-flash {
|
||||
0%,
|
||||
71%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(0.6);
|
||||
}
|
||||
|
||||
73% {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
75% {
|
||||
opacity: 0.15;
|
||||
transform: translateY(-50%) scaleX(0.95);
|
||||
}
|
||||
|
||||
76% {
|
||||
opacity: 0.75;
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
}
|
||||
|
||||
78% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%) scaleX(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vs-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.82;
|
||||
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter:
|
||||
drop-shadow(0 0 3px rgba(255, 230, 140, 0.7))
|
||||
drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vs-img {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
|
||||
}
|
||||
|
||||
.hz-path,
|
||||
.hz-beam {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
padding: 40px 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
@@ -27,6 +31,7 @@ interface Market {
|
||||
period: string;
|
||||
lineValue?: string | number | null;
|
||||
allowParlay?: boolean;
|
||||
promoLabel?: string | null;
|
||||
selections: Selection[];
|
||||
}
|
||||
|
||||
@@ -40,11 +45,17 @@ interface Selection {
|
||||
|
||||
interface MatchDetail {
|
||||
id: string;
|
||||
leagueName?: string;
|
||||
leagueLogoUrl?: string | null;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
homeTeamLogoUrl?: string | null;
|
||||
awayTeamLogoUrl?: string | null;
|
||||
startTime: string;
|
||||
stage?: string | null;
|
||||
groupName?: string | null;
|
||||
markets: Market[];
|
||||
}
|
||||
|
||||
@@ -65,12 +76,17 @@ const marketsByType = computed(() => {
|
||||
});
|
||||
|
||||
const homeFlag = computed(() =>
|
||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName),
|
||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName, match.value?.homeTeamLogoUrl),
|
||||
);
|
||||
const awayFlag = computed(() =>
|
||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName),
|
||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName, match.value?.awayTeamLogoUrl),
|
||||
);
|
||||
|
||||
function marketPromoLabel(marketType: string) {
|
||||
const m = marketsByType.value.get(marketType);
|
||||
return m?.promoLabel?.trim() || '';
|
||||
}
|
||||
|
||||
const kickoff = computed(() => {
|
||||
if (!match.value) return '';
|
||||
return new Date(match.value.startTime).toLocaleString(locale.value, {
|
||||
@@ -123,7 +139,7 @@ const csConfirmLines = computed((): CsConfirmLine[] => {
|
||||
return market.selections
|
||||
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
|
||||
.map((s) => {
|
||||
const parsed = parseScoreCode(s.selectionCode);
|
||||
const parsed = parseScoreCode(s.selectionCode, t);
|
||||
return {
|
||||
scoreDisplay: parsed?.display ?? s.selectionName,
|
||||
odds: s.odds,
|
||||
@@ -245,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
|
||||
@@ -264,14 +281,47 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
<template v-else-if="match">
|
||||
<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>
|
||||
|
||||
@@ -298,6 +348,7 @@ function hasSlipPickForMarket(marketType: string) {
|
||||
>
|
||||
<MarketTypeTile
|
||||
:label="marketLabel(marketType)"
|
||||
:promo-label="marketPromoLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:expanded="isExpanded(marketType)"
|
||||
@toggle="toggleMarket(marketType)"
|
||||
@@ -354,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;
|
||||
@@ -378,48 +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;
|
||||
}
|
||||
|
||||
.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,15 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||
import api from '../api';
|
||||
import PlayerAvatarModal from '../components/PlayerAvatarModal.vue';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const { loadProfile, setAvatarKey, profileRaw, avatarUrl, avatarKey } = usePlayerProfile();
|
||||
|
||||
const username = ref('');
|
||||
const viewablePassword = ref('');
|
||||
const passwordVisible = ref(false);
|
||||
const phone = ref('');
|
||||
const email = ref('');
|
||||
const avatarModalOpen = ref(false);
|
||||
const passwordChangeOpen = ref(false);
|
||||
const oldPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
@@ -17,14 +27,60 @@ const message = ref('');
|
||||
const error = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get('/player/profile');
|
||||
const user = data.data;
|
||||
username.value = user?.username ?? '';
|
||||
const allowPasswordChange = computed(
|
||||
() => profileRaw.value?.preferences?.allowPasswordChange ?? true,
|
||||
);
|
||||
const allowUsernameChange = computed(
|
||||
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
|
||||
);
|
||||
|
||||
const passwordDisplay = computed(() => viewablePassword.value || '');
|
||||
const canTogglePassword = computed(() => !!viewablePassword.value);
|
||||
const passwordInputType = computed(() =>
|
||||
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
|
||||
);
|
||||
|
||||
const displayAvatarUrl = computed(() => {
|
||||
if (avatarUrl.value) return avatarUrl.value;
|
||||
const seed = profileRaw.value?.username ?? auth.user?.username;
|
||||
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
|
||||
});
|
||||
|
||||
function syncFromProfile() {
|
||||
const user = profileRaw.value;
|
||||
username.value = user?.username ?? auth.user?.username ?? '';
|
||||
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
|
||||
phone.value = user?.preferences?.phone ?? '';
|
||||
email.value = user?.preferences?.email ?? '';
|
||||
}
|
||||
|
||||
function togglePasswordVisible() {
|
||||
if (!canTogglePassword.value) return;
|
||||
passwordVisible.value = !passwordVisible.value;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProfile(true);
|
||||
syncFromProfile();
|
||||
});
|
||||
|
||||
function openAvatarModal() {
|
||||
avatarModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function confirmAvatar(key: string | null) {
|
||||
avatarModalOpen.value = false;
|
||||
try {
|
||||
await api.patch('/player/profile', { avatarKey: key });
|
||||
setAvatarKey(key);
|
||||
} catch (e: unknown) {
|
||||
setAvatarKey(key);
|
||||
error.value =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
t('profile.save_failed');
|
||||
}
|
||||
}
|
||||
|
||||
function wantsPasswordChange() {
|
||||
return !!(oldPassword.value || newPassword.value || confirmPassword.value);
|
||||
}
|
||||
@@ -48,11 +104,21 @@ async function saveAll() {
|
||||
const parts: string[] = [];
|
||||
|
||||
try {
|
||||
await api.patch('/player/profile', {
|
||||
const profilePayload: { phone?: string; email?: string; username?: string } = {
|
||||
phone: phone.value.trim() || undefined,
|
||||
email: email.value.trim() || undefined,
|
||||
});
|
||||
};
|
||||
if (allowUsernameChange.value) {
|
||||
profilePayload.username = username.value.trim();
|
||||
}
|
||||
await api.patch('/player/profile', profilePayload);
|
||||
if (allowUsernameChange.value && username.value.trim() && auth.user) {
|
||||
auth.user.username = username.value.trim();
|
||||
}
|
||||
parts.push(t('profile.saved'));
|
||||
if (allowUsernameChange.value && profilePayload.username) {
|
||||
parts.push(t('profile.username_updated'));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||
@@ -62,14 +128,22 @@ async function saveAll() {
|
||||
}
|
||||
|
||||
if (wantsPasswordChange()) {
|
||||
if (!allowPasswordChange.value) {
|
||||
error.value = t('profile.password_disabled');
|
||||
saving.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/player/auth/change-password', {
|
||||
oldPassword: oldPassword.value,
|
||||
newPassword: newPassword.value,
|
||||
});
|
||||
viewablePassword.value = newPassword.value;
|
||||
passwordVisible.value = false;
|
||||
oldPassword.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordChangeOpen.value = false;
|
||||
parts.push(t('profile.password_changed'));
|
||||
} catch (e: unknown) {
|
||||
error.value =
|
||||
@@ -96,57 +170,117 @@ function back() {
|
||||
<h2 class="page-title">{{ t('profile.edit') }}</h2>
|
||||
</header>
|
||||
|
||||
<section class="avatar-card">
|
||||
<div class="avatar-circle">
|
||||
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
|
||||
</div>
|
||||
<button type="button" class="avatar-change-btn" @click="openAvatarModal">
|
||||
{{ t('profile.avatar_change') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<form class="form-card" @submit.prevent="saveAll">
|
||||
<h3 class="section-title">{{ t('profile.section_account') }}</h3>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('auth.username') }}</label>
|
||||
<input :value="username" class="readonly" disabled />
|
||||
<input
|
||||
v-model="username"
|
||||
class="field-input"
|
||||
:class="{ readonly: !allowUsernameChange }"
|
||||
:disabled="!allowUsernameChange"
|
||||
:placeholder="t('profile.username_placeholder')"
|
||||
/>
|
||||
<p v-if="!allowUsernameChange" class="field-hint inline-hint">{{ t('profile.username_readonly_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('auth.password') }}</label>
|
||||
<div class="input-eye-wrap">
|
||||
<input
|
||||
:type="passwordInputType"
|
||||
:value="passwordDisplay"
|
||||
class="field-input input-with-eye"
|
||||
readonly
|
||||
:placeholder="canTogglePassword ? '' : t('profile.password_unavailable')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="eye-btn"
|
||||
:disabled="!canTogglePassword"
|
||||
:aria-label="passwordVisible ? t('profile.hide_password') : t('profile.show_password')"
|
||||
@click="togglePasswordVisible"
|
||||
>
|
||||
{{ passwordVisible ? t('profile.hide_password') : t('profile.show_password') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!canTogglePassword" class="field-hint inline-hint">{{ t('profile.password_unavailable_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="allowPasswordChange">
|
||||
<button type="button" class="section-toggle compact-toggle" @click="passwordChangeOpen = !passwordChangeOpen">
|
||||
<span>{{ t('profile.change_password') }}</span>
|
||||
<span class="chevron" :class="{ open: passwordChangeOpen }">›</span>
|
||||
</button>
|
||||
<div v-show="passwordChangeOpen" class="password-block">
|
||||
<div class="field">
|
||||
<label>{{ t('profile.old_password') }}</label>
|
||||
<input
|
||||
v-model="oldPassword"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('profile.old_password_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('profile.new_password') }}</label>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('profile.new_password_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('profile.confirm_password') }}</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('profile.confirm_password_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="section-divider" />
|
||||
|
||||
<h3 class="section-title">{{ t('profile.section_contact') }}</h3>
|
||||
|
||||
<div class="field">
|
||||
<label>{{ t('profile.phone') }}</label>
|
||||
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field field-last">
|
||||
<label>{{ t('profile.email') }}</label>
|
||||
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
|
||||
</div>
|
||||
|
||||
<p class="field-hint">{{ t('profile.password_optional_hint') }}</p>
|
||||
<div class="field">
|
||||
<label>{{ t('profile.old_password') }}</label>
|
||||
<input
|
||||
v-model="oldPassword"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('profile.old_password_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('profile.new_password') }}</label>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('profile.new_password_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ t('profile.confirm_password') }}</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
class="field-input"
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('profile.confirm_password_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-action btn-gold-outline" :disabled="saving">
|
||||
{{ t('profile.save') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<PlayerAvatarModal
|
||||
:open="avatarModalOpen"
|
||||
:model-value="avatarKey"
|
||||
@close="avatarModalOpen = false"
|
||||
@confirm="confirmAvatar"
|
||||
/>
|
||||
|
||||
<p v-if="message" class="msg ok">{{ message }}</p>
|
||||
<p v-if="error" class="msg err">{{ error }}</p>
|
||||
</div>
|
||||
@@ -154,11 +288,14 @@ function back() {
|
||||
|
||||
<style scoped>
|
||||
.edit-page {
|
||||
padding: 8px 0 12px;
|
||||
padding: 8px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
@@ -177,22 +314,150 @@ function back() {
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.avatar-card,
|
||||
.form-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.avatar-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-gold-soft);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(145deg, #2a2210, #141008);
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
}
|
||||
|
||||
.avatar-change-btn {
|
||||
padding: 7px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
|
||||
.section-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0 12px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.password-block {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field-last {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin: 4px 0 10px;
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.inline-hint {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-eye-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.input-with-eye {
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.eye-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
min-width: 48px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
color: var(--primary-light);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.eye-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compact-toggle {
|
||||
padding: 2px 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -200,14 +465,14 @@ label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.readonly {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
@@ -243,7 +508,7 @@ label {
|
||||
.btn-action {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 10px 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
@@ -256,7 +521,7 @@ label {
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin-top: 10px;
|
||||
margin-top: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"test": "pnpm -r run test",
|
||||
"db:generate": "pnpm --filter @thebet365/api db:generate",
|
||||
"db:migrate": "pnpm --filter @thebet365/api db:migrate",
|
||||
"db:migrate:deploy": "pnpm --filter @thebet365/api db:migrate:deploy",
|
||||
"db:seed": "pnpm --filter @thebet365/api db:seed",
|
||||
"db:studio": "pnpm --filter @thebet365/api db:studio"
|
||||
},
|
||||
|
||||
BIN
packages/shared/public/球员/乌古尔坎·卡基尔-守門員-土耳其.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/乔治亚·德·阿拉斯凯塔-中场-乌拉圭.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/亚历杭德罗·曾德哈斯-前锋-美国.jpg
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/shared/public/球员/伦纳特·卡尔-中场-德国.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/shared/public/球员/何塞·曼努埃尔·洛佩斯-前锋-阿根廷.jpg
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
packages/shared/public/球员/佩德里-中场-西班牙.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/克里斯蒂亚诺·罗纳尔多-前锋-葡萄牙.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/克里斯蒂安·罗梅罗-后卫-阿根廷.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/内马尔-前锋-巴西.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/shared/public/球员/凯南·耶尔德兹-前锋-土耳其.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/华金·皮克雷斯-中场-乌拉圭.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/卡塞米罗-中场-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/卢卡·莫德里奇-中场-克罗地亚.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/卢卡斯·帕奎塔-中场-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/吉列尔莫·奥乔亚-守門員-墨西哥.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/哈里·凯恩-前锋-英格兰.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/圣地亚哥·吉梅内斯-前锋-墨西哥.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/埃尔林·哈兰德-前锋-挪威.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/埃德森·阿尔瓦雷斯-后卫-墨西哥.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/shared/public/球员/埃贝雷奇·埃泽-中场-英格兰.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/基利安·姆巴佩-前锋-法国.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/奥斯曼·登贝莱-前锋-法国.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/孟菲斯·德派-前锋-荷兰.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/尼科·威廉斯-前锋-西班牙.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/巴勃罗·加维-中场-西班牙.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/布卡约·萨卡-前锋-英格兰.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/布鲁诺·吉马良斯-中场-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/布鲁诺·费尔南德斯-中场-葡萄牙.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/德尼兹·居尔-前锋-土耳其.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
packages/shared/public/球员/德尼兹·温达夫-前锋-德国.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/恩德里克-前锋-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/拉斐尔·迪亚斯·贝洛利-前锋-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/拉明·亚马尔-前锋-西班牙.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/shared/public/球员/曼努埃尔·诺伊尔-守門員-德国.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/朱利安·阿尔瓦雷斯-前锋-阿根廷.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/梅西-前锋-阿根廷.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/祖德·贝林厄姆-中场-英格兰.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/穆罕默德·萨拉赫-前锋-埃及.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/shared/public/球员/维克托·哲凯赖什-前锋-瑞典.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
packages/shared/public/球员/维尼修斯·儒尼奥尔-前锋-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
packages/shared/public/球员/蒂博·库尔图瓦-守門員-比利时.jpg
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/shared/public/球员/费德里科·巴尔韦德-中场-乌拉圭.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/贾马尔·慕斯拉-中场-德国.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/路易斯·迪亚斯-前锋-哥伦比亚.jpg
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/shared/public/球员/迈克尔·奥利塞-前锋-法国.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/阿什拉夫·哈基米-后卫-摩洛哥.jpg
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
packages/shared/public/球员/阿利松·贝克尔-守門員-巴西.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/阿尔达·居莱尔-前锋-土耳其.jpg
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/shared/public/球员/马库斯·拉什福德-前锋-英格兰.jpg
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/shared/public/球员/马西斯·拉扬·切尔基-中场-法国.jpg
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
104
packages/shared/src/builtinPlayers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export type BuiltinPlayer = {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
country: string;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
const BUILTIN_PLAYER_FILENAMES = [
|
||||
'何塞·曼努埃尔·洛佩斯-前锋-阿根廷.jpg',
|
||||
'佩德里-中场-西班牙.jpg',
|
||||
'卢卡·莫德里奇-中场-克罗地亚.jpg',
|
||||
'华金·皮克雷斯-中场-乌拉圭.jpg',
|
||||
'乔治亚·德·阿拉斯凯塔-中场-乌拉圭.jpg',
|
||||
'乌古尔坎·卡基尔-守門員-土耳其.jpg',
|
||||
'亚历杭德罗·曾德哈斯-前锋-美国.jpg',
|
||||
'恩德里克-前锋-巴西.jpg',
|
||||
'克里斯蒂亚诺·罗纳尔多-前锋-葡萄牙.jpg',
|
||||
'克里斯蒂安·罗梅罗-后卫-阿根廷.jpg',
|
||||
'内马尔-前锋-巴西.jpg',
|
||||
'凯南·耶尔德兹-前锋-土耳其.jpg',
|
||||
'卡塞米罗-中场-巴西.jpg',
|
||||
'卢卡斯·帕奎塔-中场-巴西.jpg',
|
||||
'基利安·姆巴佩-前锋-法国.jpg',
|
||||
'孟菲斯·德派-前锋-荷兰.jpg',
|
||||
'奥斯曼·登贝莱-前锋-法国.jpg',
|
||||
'布鲁诺·吉马良斯-中场-巴西.jpg',
|
||||
'布鲁诺·费尔南德斯-中场-葡萄牙.jpg',
|
||||
'布卡约·萨卡-前锋-英格兰.jpg',
|
||||
'德尼兹·居尔-前锋-土耳其.jpg',
|
||||
'德尼兹·温达夫-前锋-德国.jpg',
|
||||
'拉斐尔·迪亚斯·贝洛利-前锋-巴西.jpg',
|
||||
'拉明·亚马尔-前锋-西班牙.jpg',
|
||||
'朱利安·阿尔瓦雷斯-前锋-阿根廷.jpg',
|
||||
'梅西-前锋-阿根廷.jpg',
|
||||
'迈克尔·奥利塞-前锋-法国.jpg',
|
||||
'穆罕默德·萨拉赫-前锋-埃及.jpg',
|
||||
'维尼修斯·儒尼奥尔-前锋-巴西.jpg',
|
||||
'维克托·哲凯赖什-前锋-瑞典.jpg',
|
||||
'圣地亚哥·吉梅内斯-前锋-墨西哥.jpg',
|
||||
'埃德森·阿尔瓦雷斯-后卫-墨西哥.jpg',
|
||||
'埃贝雷奇·埃泽-中场-英格兰.jpg',
|
||||
'埃尔林·哈兰德-前锋-挪威.jpg',
|
||||
'蒂博·库尔图瓦-守門員-比利时.jpg',
|
||||
'曼努埃尔·诺伊尔-守門員-德国.jpg',
|
||||
'祖德·贝林厄姆-中场-英格兰.jpg',
|
||||
'伦纳特·卡尔-中场-德国.jpg',
|
||||
'费德里科·巴尔韦德-中场-乌拉圭.jpg',
|
||||
'贾马尔·慕斯拉-中场-德国.jpg',
|
||||
'路易斯·迪亚斯-前锋-哥伦比亚.jpg',
|
||||
'阿什拉夫·哈基米-后卫-摩洛哥.jpg',
|
||||
'阿利松·贝克尔-守門員-巴西.jpg',
|
||||
'阿尔达·居莱尔-前锋-土耳其.jpg',
|
||||
'马西斯·拉扬·切尔基-中场-法国.jpg',
|
||||
'马库斯·拉什福德-前锋-英格兰.jpg',
|
||||
'哈里·凯恩-前锋-英格兰.jpg',
|
||||
'尼科·威廉斯-前锋-西班牙.jpg',
|
||||
'巴勃罗·加维-中场-西班牙.jpg',
|
||||
'吉列尔莫·奥乔亚-守門員-墨西哥.jpg',
|
||||
] as const;
|
||||
|
||||
function parsePlayerFilename(filename: string): BuiltinPlayer {
|
||||
const base = filename.replace(/\.jpg$/i, '');
|
||||
const parts = base.split('-');
|
||||
const country = parts.pop() ?? '';
|
||||
const position = parts.pop() ?? '';
|
||||
const name = parts.join('-');
|
||||
return { id: base, name, position, country, filename };
|
||||
}
|
||||
|
||||
export const BUILTIN_PLAYERS: BuiltinPlayer[] = BUILTIN_PLAYER_FILENAMES.map(parsePlayerFilename);
|
||||
|
||||
const AVATAR_KEY_SET = new Set(BUILTIN_PLAYERS.map((p) => p.id));
|
||||
|
||||
export function isValidAvatarKey(key: string | null | undefined): boolean {
|
||||
if (!key) return true;
|
||||
return AVATAR_KEY_SET.has(key);
|
||||
}
|
||||
|
||||
export function playerAvatarUrl(key: string | null | undefined): string | null {
|
||||
if (!key) return null;
|
||||
const player = BUILTIN_PLAYERS.find((p) => p.id === key);
|
||||
if (!player) return null;
|
||||
return `/球员/${player.filename}`;
|
||||
}
|
||||
|
||||
export function getBuiltinPlayer(key: string | null | undefined): BuiltinPlayer | null {
|
||||
if (!key) return null;
|
||||
return BUILTIN_PLAYERS.find((p) => p.id === key) ?? null;
|
||||
}
|
||||
|
||||
/** 按 seed 稳定随机,无 seed 时完全随机 */
|
||||
export function randomAvatarKey(seed?: string | number | null): string {
|
||||
if (!BUILTIN_PLAYERS.length) return '';
|
||||
if (seed === undefined || seed === null || seed === '') {
|
||||
return BUILTIN_PLAYERS[Math.floor(Math.random() * BUILTIN_PLAYERS.length)].id;
|
||||
}
|
||||
const text = String(seed);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return BUILTIN_PLAYERS[hash % BUILTIN_PLAYERS.length].id;
|
||||
}
|
||||
@@ -117,6 +117,8 @@ export const PARLAY_MAX_LEGS = 5;
|
||||
|
||||
export * from './betting-rules';
|
||||
export * from './locale';
|
||||
export * from './builtinPlayers';
|
||||
export * from './playerLocale';
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/** 内容翻译 fallback:当前语言 → 英文 → 中文 */
|
||||
/** 内容翻译 fallback:当前语言 → 英文 → 中文 → 马来文 */
|
||||
export function resolveTranslationFallback(
|
||||
map: Record<string, string | undefined | null>,
|
||||
locale: string,
|
||||
): string {
|
||||
const chain = [locale, 'en-US', 'zh-CN'];
|
||||
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||||
const seen = new Set<string>();
|
||||
for (const loc of chain) {
|
||||
if (seen.has(loc)) continue;
|
||||
|
||||
350
packages/shared/src/playerLocale.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { resolveTranslationFallback } from './locale';
|
||||
import type { BuiltinPlayer } from './builtinPlayers';
|
||||
|
||||
export type PlayerPositionCode = 'FW' | 'MF' | 'DF' | 'GK';
|
||||
|
||||
type LocaleText = Partial<Record<string, string>>;
|
||||
|
||||
export type PlayerLocaleMeta = {
|
||||
name: LocaleText;
|
||||
position: PlayerPositionCode;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export const PLAYER_POSITION_LABELS: Record<PlayerPositionCode, LocaleText> = {
|
||||
FW: { 'zh-CN': '前锋', 'en-US': 'Forward', 'ms-MY': 'Penyerang' },
|
||||
MF: { 'zh-CN': '中场', 'en-US': 'Midfielder', 'ms-MY': 'Pemain Tengah' },
|
||||
DF: { 'zh-CN': '后卫', 'en-US': 'Defender', 'ms-MY': 'Pertahan' },
|
||||
GK: { 'zh-CN': '守门员', 'en-US': 'Goalkeeper', 'ms-MY': 'Penjaga Gol' },
|
||||
};
|
||||
|
||||
export const PLAYER_COUNTRY_LABELS: Record<string, LocaleText> = {
|
||||
ARG: { 'zh-CN': '阿根廷', 'en-US': 'Argentina', 'ms-MY': 'Argentina' },
|
||||
ESP: { 'zh-CN': '西班牙', 'en-US': 'Spain', 'ms-MY': 'Sepanyol' },
|
||||
CRO: { 'zh-CN': '克罗地亚', 'en-US': 'Croatia', 'ms-MY': 'Croatia' },
|
||||
URU: { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay', 'ms-MY': 'Uruguay' },
|
||||
TUR: { 'zh-CN': '土耳其', 'en-US': 'Turkey', 'ms-MY': 'Turki' },
|
||||
USA: { 'zh-CN': '美国', 'en-US': 'USA', 'ms-MY': 'Amerika Syarikat' },
|
||||
BRA: { 'zh-CN': '巴西', 'en-US': 'Brazil', 'ms-MY': 'Brazil' },
|
||||
POR: { 'zh-CN': '葡萄牙', 'en-US': 'Portugal', 'ms-MY': 'Portugal' },
|
||||
ENG: { 'zh-CN': '英格兰', 'en-US': 'England', 'ms-MY': 'England' },
|
||||
FRA: { 'zh-CN': '法国', 'en-US': 'France', 'ms-MY': 'Perancis' },
|
||||
NED: { 'zh-CN': '荷兰', 'en-US': 'Netherlands', 'ms-MY': 'Belanda' },
|
||||
GER: { 'zh-CN': '德国', 'en-US': 'Germany', 'ms-MY': 'Jerman' },
|
||||
NOR: { 'zh-CN': '挪威', 'en-US': 'Norway', 'ms-MY': 'Norway' },
|
||||
MEX: { 'zh-CN': '墨西哥', 'en-US': 'Mexico', 'ms-MY': 'Mexico' },
|
||||
EGY: { 'zh-CN': '埃及', 'en-US': 'Egypt', 'ms-MY': 'Mesir' },
|
||||
SWE: { 'zh-CN': '瑞典', 'en-US': 'Sweden', 'ms-MY': 'Sweden' },
|
||||
COL: { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia', 'ms-MY': 'Colombia' },
|
||||
MAR: { 'zh-CN': '摩洛哥', 'en-US': 'Morocco', 'ms-MY': 'Maghribi' },
|
||||
BEL: { 'zh-CN': '比利时', 'en-US': 'Belgium', 'ms-MY': 'Belgium' },
|
||||
};
|
||||
|
||||
/** key = BuiltinPlayer.id(文件名去掉 .jpg) */
|
||||
export const PLAYER_LOCALE_META: Record<string, PlayerLocaleMeta> = {
|
||||
'何塞·曼努埃尔·洛佩斯-前锋-阿根廷': {
|
||||
name: { 'zh-CN': '何塞·曼努埃尔·洛佩斯', 'en-US': 'José Manuel López', 'ms-MY': 'José Manuel López' },
|
||||
position: 'FW',
|
||||
country: 'ARG',
|
||||
},
|
||||
'佩德里-中场-西班牙': {
|
||||
name: { 'zh-CN': '佩德里', 'en-US': 'Pedri', 'ms-MY': 'Pedri' },
|
||||
position: 'MF',
|
||||
country: 'ESP',
|
||||
},
|
||||
'卢卡·莫德里奇-中场-克罗地亚': {
|
||||
name: { 'zh-CN': '卢卡·莫德里奇', 'en-US': 'Luka Modrić', 'ms-MY': 'Luka Modrić' },
|
||||
position: 'MF',
|
||||
country: 'CRO',
|
||||
},
|
||||
'华金·皮克雷斯-中场-乌拉圭': {
|
||||
name: { 'zh-CN': '华金·皮克雷斯', 'en-US': 'Joaquín Piquerez', 'ms-MY': 'Joaquín Piquerez' },
|
||||
position: 'MF',
|
||||
country: 'URU',
|
||||
},
|
||||
'乔治亚·德·阿拉斯凯塔-中场-乌拉圭': {
|
||||
name: { 'zh-CN': '乔治亚·德·阿拉斯凯塔', 'en-US': 'Giorgian de Arrascaeta', 'ms-MY': 'Giorgian de Arrascaeta' },
|
||||
position: 'MF',
|
||||
country: 'URU',
|
||||
},
|
||||
'乌古尔坎·卡基尔-守門員-土耳其': {
|
||||
name: { 'zh-CN': '乌古尔坎·卡基尔', 'en-US': 'Uğurcan Çakır', 'ms-MY': 'Uğurcan Çakır' },
|
||||
position: 'GK',
|
||||
country: 'TUR',
|
||||
},
|
||||
'亚历杭德罗·曾德哈斯-前锋-美国': {
|
||||
name: { 'zh-CN': '亚历杭德罗·曾德哈斯', 'en-US': 'Alejandro Zendejas', 'ms-MY': 'Alejandro Zendejas' },
|
||||
position: 'FW',
|
||||
country: 'USA',
|
||||
},
|
||||
'恩德里克-前锋-巴西': {
|
||||
name: { 'zh-CN': '恩德里克', 'en-US': 'Endrick', 'ms-MY': 'Endrick' },
|
||||
position: 'FW',
|
||||
country: 'BRA',
|
||||
},
|
||||
'克里斯蒂亚诺·罗纳尔多-前锋-葡萄牙': {
|
||||
name: { 'zh-CN': '克里斯蒂亚诺·罗纳尔多', 'en-US': 'Cristiano Ronaldo', 'ms-MY': 'Cristiano Ronaldo' },
|
||||
position: 'FW',
|
||||
country: 'POR',
|
||||
},
|
||||
'克里斯蒂安·罗梅罗-后卫-阿根廷': {
|
||||
name: { 'zh-CN': '克里斯蒂安·罗梅罗', 'en-US': 'Cristian Romero', 'ms-MY': 'Cristian Romero' },
|
||||
position: 'DF',
|
||||
country: 'ARG',
|
||||
},
|
||||
'内马尔-前锋-巴西': {
|
||||
name: { 'zh-CN': '内马尔', 'en-US': 'Neymar', 'ms-MY': 'Neymar' },
|
||||
position: 'FW',
|
||||
country: 'BRA',
|
||||
},
|
||||
'凯南·耶尔德兹-前锋-土耳其': {
|
||||
name: { 'zh-CN': '凯南·耶尔德兹', 'en-US': 'Kenan Yıldız', 'ms-MY': 'Kenan Yıldız' },
|
||||
position: 'FW',
|
||||
country: 'TUR',
|
||||
},
|
||||
'卡塞米罗-中场-巴西': {
|
||||
name: { 'zh-CN': '卡塞米罗', 'en-US': 'Casemiro', 'ms-MY': 'Casemiro' },
|
||||
position: 'MF',
|
||||
country: 'BRA',
|
||||
},
|
||||
'卢卡斯·帕奎塔-中场-巴西': {
|
||||
name: { 'zh-CN': '卢卡斯·帕奎塔', 'en-US': 'Lucas Paquetá', 'ms-MY': 'Lucas Paquetá' },
|
||||
position: 'MF',
|
||||
country: 'BRA',
|
||||
},
|
||||
'基利安·姆巴佩-前锋-法国': {
|
||||
name: { 'zh-CN': '基利安·姆巴佩', 'en-US': 'Kylian Mbappé', 'ms-MY': 'Kylian Mbappé' },
|
||||
position: 'FW',
|
||||
country: 'FRA',
|
||||
},
|
||||
'孟菲斯·德派-前锋-荷兰': {
|
||||
name: { 'zh-CN': '孟菲斯·德派', 'en-US': 'Memphis Depay', 'ms-MY': 'Memphis Depay' },
|
||||
position: 'FW',
|
||||
country: 'NED',
|
||||
},
|
||||
'奥斯曼·登贝莱-前锋-法国': {
|
||||
name: { 'zh-CN': '奥斯曼·登贝莱', 'en-US': 'Ousmane Dembélé', 'ms-MY': 'Ousmane Dembélé' },
|
||||
position: 'FW',
|
||||
country: 'FRA',
|
||||
},
|
||||
'布鲁诺·吉马良斯-中场-巴西': {
|
||||
name: { 'zh-CN': '布鲁诺·吉马良斯', 'en-US': 'Bruno Guimarães', 'ms-MY': 'Bruno Guimarães' },
|
||||
position: 'MF',
|
||||
country: 'BRA',
|
||||
},
|
||||
'布鲁诺·费尔南德斯-中场-葡萄牙': {
|
||||
name: { 'zh-CN': '布鲁诺·费尔南德斯', 'en-US': 'Bruno Fernandes', 'ms-MY': 'Bruno Fernandes' },
|
||||
position: 'MF',
|
||||
country: 'POR',
|
||||
},
|
||||
'布卡约·萨卡-前锋-英格兰': {
|
||||
name: { 'zh-CN': '布卡约·萨卡', 'en-US': 'Bukayo Saka', 'ms-MY': 'Bukayo Saka' },
|
||||
position: 'FW',
|
||||
country: 'ENG',
|
||||
},
|
||||
'德尼兹·居尔-前锋-土耳其': {
|
||||
name: { 'zh-CN': '德尼兹·居尔', 'en-US': 'Deniz Gül', 'ms-MY': 'Deniz Gül' },
|
||||
position: 'FW',
|
||||
country: 'TUR',
|
||||
},
|
||||
'德尼兹·温达夫-前锋-德国': {
|
||||
name: { 'zh-CN': '德尼兹·温达夫', 'en-US': 'Deniz Undav', 'ms-MY': 'Deniz Undav' },
|
||||
position: 'FW',
|
||||
country: 'GER',
|
||||
},
|
||||
'拉斐尔·迪亚斯·贝洛利-前锋-巴西': {
|
||||
name: { 'zh-CN': '拉斐尔·迪亚斯·贝洛利', 'en-US': 'Raphinha', 'ms-MY': 'Raphinha' },
|
||||
position: 'FW',
|
||||
country: 'BRA',
|
||||
},
|
||||
'拉明·亚马尔-前锋-西班牙': {
|
||||
name: { 'zh-CN': '拉明·亚马尔', 'en-US': 'Lamine Yamal', 'ms-MY': 'Lamine Yamal' },
|
||||
position: 'FW',
|
||||
country: 'ESP',
|
||||
},
|
||||
'朱利安·阿尔瓦雷斯-前锋-阿根廷': {
|
||||
name: { 'zh-CN': '朱利安·阿尔瓦雷斯', 'en-US': 'Julián Álvarez', 'ms-MY': 'Julián Álvarez' },
|
||||
position: 'FW',
|
||||
country: 'ARG',
|
||||
},
|
||||
'梅西-前锋-阿根廷': {
|
||||
name: { 'zh-CN': '梅西', 'en-US': 'Lionel Messi', 'ms-MY': 'Lionel Messi' },
|
||||
position: 'FW',
|
||||
country: 'ARG',
|
||||
},
|
||||
'迈克尔·奥利塞-前锋-法国': {
|
||||
name: { 'zh-CN': '迈克尔·奥利塞', 'en-US': 'Michael Olise', 'ms-MY': 'Michael Olise' },
|
||||
position: 'FW',
|
||||
country: 'FRA',
|
||||
},
|
||||
'穆罕默德·萨拉赫-前锋-埃及': {
|
||||
name: { 'zh-CN': '穆罕默德·萨拉赫', 'en-US': 'Mohamed Salah', 'ms-MY': 'Mohamed Salah' },
|
||||
position: 'FW',
|
||||
country: 'EGY',
|
||||
},
|
||||
'维尼修斯·儒尼奥尔-前锋-巴西': {
|
||||
name: { 'zh-CN': '维尼修斯·儒尼奥尔', 'en-US': 'Vinícius Júnior', 'ms-MY': 'Vinícius Júnior' },
|
||||
position: 'FW',
|
||||
country: 'BRA',
|
||||
},
|
||||
'维克托·哲凯赖什-前锋-瑞典': {
|
||||
name: { 'zh-CN': '维克托·哲凯赖什', 'en-US': 'Viktor Gyökeres', 'ms-MY': 'Viktor Gyökeres' },
|
||||
position: 'FW',
|
||||
country: 'SWE',
|
||||
},
|
||||
'圣地亚哥·吉梅内斯-前锋-墨西哥': {
|
||||
name: { 'zh-CN': '圣地亚哥·吉梅内斯', 'en-US': 'Santiago Giménez', 'ms-MY': 'Santiago Giménez' },
|
||||
position: 'FW',
|
||||
country: 'MEX',
|
||||
},
|
||||
'埃德森·阿尔瓦雷斯-后卫-墨西哥': {
|
||||
name: { 'zh-CN': '埃德森·阿尔瓦雷斯', 'en-US': 'Edson Álvarez', 'ms-MY': 'Edson Álvarez' },
|
||||
position: 'DF',
|
||||
country: 'MEX',
|
||||
},
|
||||
'埃贝雷奇·埃泽-中场-英格兰': {
|
||||
name: { 'zh-CN': '埃贝雷奇·埃泽', 'en-US': 'Eberechi Eze', 'ms-MY': 'Eberechi Eze' },
|
||||
position: 'MF',
|
||||
country: 'ENG',
|
||||
},
|
||||
'埃尔林·哈兰德-前锋-挪威': {
|
||||
name: { 'zh-CN': '埃尔林·哈兰德', 'en-US': 'Erling Haaland', 'ms-MY': 'Erling Haaland' },
|
||||
position: 'FW',
|
||||
country: 'NOR',
|
||||
},
|
||||
'蒂博·库尔图瓦-守門員-比利时': {
|
||||
name: { 'zh-CN': '蒂博·库尔图瓦', 'en-US': 'Thibaut Courtois', 'ms-MY': 'Thibaut Courtois' },
|
||||
position: 'GK',
|
||||
country: 'BEL',
|
||||
},
|
||||
'曼努埃尔·诺伊尔-守門員-德国': {
|
||||
name: { 'zh-CN': '曼努埃尔·诺伊尔', 'en-US': 'Manuel Neuer', 'ms-MY': 'Manuel Neuer' },
|
||||
position: 'GK',
|
||||
country: 'GER',
|
||||
},
|
||||
'祖德·贝林厄姆-中场-英格兰': {
|
||||
name: { 'zh-CN': '祖德·贝林厄姆', 'en-US': 'Jude Bellingham', 'ms-MY': 'Jude Bellingham' },
|
||||
position: 'MF',
|
||||
country: 'ENG',
|
||||
},
|
||||
'伦纳特·卡尔-中场-德国': {
|
||||
name: { 'zh-CN': '伦纳特·卡尔', 'en-US': 'Lennart Karl', 'ms-MY': 'Lennart Karl' },
|
||||
position: 'MF',
|
||||
country: 'GER',
|
||||
},
|
||||
'费德里科·巴尔韦德-中场-乌拉圭': {
|
||||
name: { 'zh-CN': '费德里科·巴尔韦德', 'en-US': 'Federico Valverde', 'ms-MY': 'Federico Valverde' },
|
||||
position: 'MF',
|
||||
country: 'URU',
|
||||
},
|
||||
'贾马尔·慕斯拉-中场-德国': {
|
||||
name: { 'zh-CN': '贾马尔·慕斯拉', 'en-US': 'Jamal Musiala', 'ms-MY': 'Jamal Musiala' },
|
||||
position: 'MF',
|
||||
country: 'GER',
|
||||
},
|
||||
'路易斯·迪亚斯-前锋-哥伦比亚': {
|
||||
name: { 'zh-CN': '路易斯·迪亚斯', 'en-US': 'Luis Díaz', 'ms-MY': 'Luis Díaz' },
|
||||
position: 'FW',
|
||||
country: 'COL',
|
||||
},
|
||||
'阿什拉夫·哈基米-后卫-摩洛哥': {
|
||||
name: { 'zh-CN': '阿什拉夫·哈基米', 'en-US': 'Achraf Hakimi', 'ms-MY': 'Achraf Hakimi' },
|
||||
position: 'DF',
|
||||
country: 'MAR',
|
||||
},
|
||||
'阿利松·贝克尔-守門員-巴西': {
|
||||
name: { 'zh-CN': '阿利松·贝克尔', 'en-US': 'Alisson Becker', 'ms-MY': 'Alisson Becker' },
|
||||
position: 'GK',
|
||||
country: 'BRA',
|
||||
},
|
||||
'阿尔达·居莱尔-前锋-土耳其': {
|
||||
name: { 'zh-CN': '阿尔达·居莱尔', 'en-US': 'Arda Güler', 'ms-MY': 'Arda Güler' },
|
||||
position: 'FW',
|
||||
country: 'TUR',
|
||||
},
|
||||
'马西斯·拉扬·切尔基-中场-法国': {
|
||||
name: { 'zh-CN': '马西斯·拉扬·切尔基', 'en-US': 'Rayan Cherki', 'ms-MY': 'Rayan Cherki' },
|
||||
position: 'MF',
|
||||
country: 'FRA',
|
||||
},
|
||||
'马库斯·拉什福德-前锋-英格兰': {
|
||||
name: { 'zh-CN': '马库斯·拉什福德', 'en-US': 'Marcus Rashford', 'ms-MY': 'Marcus Rashford' },
|
||||
position: 'FW',
|
||||
country: 'ENG',
|
||||
},
|
||||
'哈里·凯恩-前锋-英格兰': {
|
||||
name: { 'zh-CN': '哈里·凯恩', 'en-US': 'Harry Kane', 'ms-MY': 'Harry Kane' },
|
||||
position: 'FW',
|
||||
country: 'ENG',
|
||||
},
|
||||
'尼科·威廉斯-前锋-西班牙': {
|
||||
name: { 'zh-CN': '尼科·威廉斯', 'en-US': 'Nico Williams', 'ms-MY': 'Nico Williams' },
|
||||
position: 'FW',
|
||||
country: 'ESP',
|
||||
},
|
||||
'巴勃罗·加维-中场-西班牙': {
|
||||
name: { 'zh-CN': '巴勃罗·加维', 'en-US': 'Pablo Gavi', 'ms-MY': 'Pablo Gavi' },
|
||||
position: 'MF',
|
||||
country: 'ESP',
|
||||
},
|
||||
'吉列尔莫·奥乔亚-守門員-墨西哥': {
|
||||
name: { 'zh-CN': '吉列尔莫·奥乔亚', 'en-US': 'Guillermo Ochoa', 'ms-MY': 'Guillermo Ochoa' },
|
||||
position: 'GK',
|
||||
country: 'MEX',
|
||||
},
|
||||
};
|
||||
|
||||
function mapPositionFromZh(position: string): PlayerPositionCode {
|
||||
if (position.includes('守') || position.includes('门') || position.includes('門')) return 'GK';
|
||||
if (position.includes('后') || position.includes('後')) return 'DF';
|
||||
if (position.includes('中')) return 'MF';
|
||||
return 'FW';
|
||||
}
|
||||
|
||||
export function getPlayerLocaleMeta(player: BuiltinPlayer): PlayerLocaleMeta {
|
||||
const meta = PLAYER_LOCALE_META[player.id];
|
||||
if (meta) return meta;
|
||||
return {
|
||||
name: { 'zh-CN': player.name },
|
||||
position: mapPositionFromZh(player.position),
|
||||
country: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlayerDisplayName(player: BuiltinPlayer, locale: string): string {
|
||||
const meta = getPlayerLocaleMeta(player);
|
||||
return resolveTranslationFallback(meta.name, locale) || player.name;
|
||||
}
|
||||
|
||||
export function getPlayerPositionLabel(player: BuiltinPlayer, locale: string): string {
|
||||
const meta = getPlayerLocaleMeta(player);
|
||||
const labels = PLAYER_POSITION_LABELS[meta.position];
|
||||
return resolveTranslationFallback(labels, locale) || player.position;
|
||||
}
|
||||
|
||||
export function getPlayerCountryLabel(player: BuiltinPlayer, locale: string): string {
|
||||
const meta = getPlayerLocaleMeta(player);
|
||||
if (!meta.country) return player.country;
|
||||
const labels = PLAYER_COUNTRY_LABELS[meta.country];
|
||||
return resolveTranslationFallback(labels ?? {}, locale) || player.country;
|
||||
}
|
||||
|
||||
export function getPlayerSearchTokens(player: BuiltinPlayer): string[] {
|
||||
const meta = getPlayerLocaleMeta(player);
|
||||
const tokens = new Set<string>();
|
||||
for (const value of Object.values(meta.name)) {
|
||||
if (value) tokens.add(value.toLowerCase());
|
||||
}
|
||||
for (const loc of ['zh-CN', 'en-US', 'ms-MY']) {
|
||||
tokens.add(getPlayerPositionLabel(player, loc).toLowerCase());
|
||||
tokens.add(getPlayerCountryLabel(player, loc).toLowerCase());
|
||||
}
|
||||
tokens.add(player.name.toLowerCase());
|
||||
tokens.add(player.position.toLowerCase());
|
||||
tokens.add(player.country.toLowerCase());
|
||||
return [...tokens];
|
||||
}
|
||||
|
||||
export function formatPlayerMeta(player: BuiltinPlayer, locale: string): string {
|
||||
return `${getPlayerPositionLabel(player, locale)} · ${getPlayerCountryLabel(player, locale)}`;
|
||||
}
|
||||