Compare commits
2 Commits
27580b2479
...
a8e4ead618
| Author | SHA1 | Date | |
|---|---|---|---|
| a8e4ead618 | |||
| f76728dc3e |
137
apps/admin/src/components/outright/CountryFlagSelect.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
BUILTIN_COUNTRIES,
|
||||||
|
countryFlagUrl,
|
||||||
|
getBuiltinCountry,
|
||||||
|
searchBuiltinCountries,
|
||||||
|
type BuiltinCountry,
|
||||||
|
} from '../../data/builtinCountries';
|
||||||
|
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
size?: 'small' | 'default' | 'large';
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [code: string];
|
||||||
|
pick: [country: BuiltinCountry];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t, locale } = useAdminLocale();
|
||||||
|
|
||||||
|
const filterKeyword = ref('');
|
||||||
|
const options = computed(() => searchBuiltinCountries(filterKeyword.value));
|
||||||
|
|
||||||
|
const selected = computed(() => getBuiltinCountry(props.modelValue));
|
||||||
|
|
||||||
|
function optionLabel(c: BuiltinCountry) {
|
||||||
|
return locale.value === 'en-US'
|
||||||
|
? `${c.nameEn} (${c.code})`
|
||||||
|
: `${c.nameZh} (${c.code})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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" />
|
||||||
|
<span class="country-option-name">{{ c.nameZh }} · {{ c.nameEn }}</span>
|
||||||
|
<span class="country-option-code">{{ c.code }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<img
|
||||||
|
v-if="selected"
|
||||||
|
: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-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>
|
||||||
127
apps/admin/src/data/builtinCountries.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/** 内置国家队(世界杯 48 强 + 常用队),供管理端选择国旗与自动填充队名 */
|
||||||
|
export type BuiltinCountry = {
|
||||||
|
code: string;
|
||||||
|
nameZh: string;
|
||||||
|
nameEn: string;
|
||||||
|
iso: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 'USA', 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', iso: 'ba' },
|
||||||
|
{ code: 'EGY', nameZh: '埃及', nameEn: 'Egypt', iso: 'eg' },
|
||||||
|
{ code: 'TUN', nameZh: '突尼斯', nameEn: 'Tunisia', iso: 'tn' },
|
||||||
|
{ code: 'CAN', nameZh: '加拿大', nameEn: 'Canada', iso: 'ca' },
|
||||||
|
{ code: 'PAN', nameZh: '巴拿马', nameEn: 'Panama', iso: 'pa' },
|
||||||
|
{ code: 'AUS', nameZh: '澳大利亚', nameEn: 'Australia', iso: 'au' },
|
||||||
|
{ code: 'CZE', nameZh: '捷克', nameEn: 'Czech Republic', iso: 'cz' },
|
||||||
|
{ code: '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: 'Ivory Coast', 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 searchBuiltinCountries(keyword: string): BuiltinCountry[] {
|
||||||
|
const k = keyword.trim().toLowerCase();
|
||||||
|
if (!k) return BUILTIN_COUNTRIES;
|
||||||
|
return BUILTIN_COUNTRIES.filter(
|
||||||
|
(c) =>
|
||||||
|
c.code.toLowerCase().includes(k) ||
|
||||||
|
c.nameZh.includes(keyword.trim()) ||
|
||||||
|
c.nameEn.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 m = logoUrl.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();
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ const zh: Record<string, string> = {
|
|||||||
'nav.outrights': '优胜冠军',
|
'nav.outrights': '优胜冠军',
|
||||||
'nav.bets': '注单管理',
|
'nav.bets': '注单管理',
|
||||||
'nav.cashback': '返水管理',
|
'nav.cashback': '返水管理',
|
||||||
|
'nav.contents': '公共管理',
|
||||||
'nav.audit': '操作日志',
|
'nav.audit': '操作日志',
|
||||||
'nav.players': '直属玩家',
|
'nav.players': '直属玩家',
|
||||||
'nav.subAgents': '下级代理',
|
'nav.subAgents': '下级代理',
|
||||||
@@ -187,6 +188,7 @@ const en: Record<string, string> = {
|
|||||||
'nav.outrights': 'Outrights',
|
'nav.outrights': 'Outrights',
|
||||||
'nav.bets': 'Bets',
|
'nav.bets': 'Bets',
|
||||||
'nav.cashback': 'Cashback',
|
'nav.cashback': 'Cashback',
|
||||||
|
'nav.contents': 'Public Content',
|
||||||
'nav.audit': 'Audit Log',
|
'nav.audit': 'Audit Log',
|
||||||
'nav.players': 'My Players',
|
'nav.players': 'My Players',
|
||||||
'nav.subAgents': 'Sub-Agents',
|
'nav.subAgents': 'Sub-Agents',
|
||||||
@@ -338,6 +340,7 @@ const ms: Record<string, string> = {
|
|||||||
'nav.outrights': 'Juara',
|
'nav.outrights': 'Juara',
|
||||||
'nav.bets': 'Pertaruhan',
|
'nav.bets': 'Pertaruhan',
|
||||||
'nav.cashback': 'Rebat',
|
'nav.cashback': 'Rebat',
|
||||||
|
'nav.contents': 'Kandungan awam',
|
||||||
'nav.audit': 'Log audit',
|
'nav.audit': 'Log audit',
|
||||||
'nav.players': 'Pemain saya',
|
'nav.players': 'Pemain saya',
|
||||||
'nav.subAgents': 'Sub-ejen',
|
'nav.subAgents': 'Sub-ejen',
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'user.field.login_fail': 'Log masuk gagal',
|
'user.field.login_fail': 'Log masuk gagal',
|
||||||
'user.field.phone': 'Telefon',
|
'user.field.phone': 'Telefon',
|
||||||
'user.field.email': 'E-mel',
|
'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.username_unique': 'Nama log masuk unik',
|
||||||
'user.ph.no_agent': 'Tiada (terus platform)',
|
'user.ph.no_agent': 'Tiada (terus platform)',
|
||||||
'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus 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.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0',
|
||||||
'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai',
|
'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.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.create': 'Cipta',
|
||||||
'user.btn.save_profile': 'Simpan',
|
'user.btn.save_profile': 'Simpan',
|
||||||
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
||||||
@@ -222,6 +242,9 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'agent_portal.agent_username_ph': 'Nama pengguna ejen',
|
'agent_portal.agent_username_ph': 'Nama pengguna ejen',
|
||||||
'agent_portal.player_id_ph': 'ID pemain',
|
'agent_portal.player_id_ph': 'ID pemain',
|
||||||
'agent_portal.withdraw_btn': 'Keluarkan {amount}',
|
'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.agent_sub_created': 'Sub-ejen dicipta',
|
||||||
'msg.withdraw_ok': 'Pengeluaran berjaya',
|
'msg.withdraw_ok': 'Pengeluaran berjaya',
|
||||||
|
|
||||||
@@ -241,6 +264,7 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',
|
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',
|
||||||
'msg.topup_ok': 'Tambah baki berjaya',
|
'msg.topup_ok': 'Tambah baki berjaya',
|
||||||
'msg.topup_failed': 'Tambah baki gagal',
|
'msg.topup_failed': 'Tambah baki gagal',
|
||||||
|
'msg.transfer_failed': 'Operasi gagal',
|
||||||
'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0',
|
'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0',
|
||||||
'msg.credit_zero': 'Pelarasan tidak boleh 0',
|
'msg.credit_zero': 'Pelarasan tidak boleh 0',
|
||||||
'msg.credit_adjusted': 'Kredit dikemas kini',
|
'msg.credit_adjusted': 'Kredit dikemas kini',
|
||||||
@@ -249,13 +273,61 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'msg.outright_odds_saved': 'Odds juara disimpan',
|
'msg.outright_odds_saved': 'Odds juara disimpan',
|
||||||
'msg.load_failed': 'Gagal memuatkan',
|
'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.title': 'Juara',
|
||||||
'page.outrights.desc': 'Cipta dan edit pasaran juara; Piala Dunia 2026 boleh import asas',
|
'page.outrights.desc': 'Cipta dan edit pasaran juara; Piala Dunia 2026 boleh import asas',
|
||||||
'outright.col.rank': 'Kedudukan',
|
'outright.col.rank': 'Kedudukan',
|
||||||
'outright.col.team_zh': 'Pasukan (ZH)',
|
'outright.col.team_zh': 'Pasukan (ZH)',
|
||||||
'outright.col.team_en': 'Pasukan (EN)',
|
'outright.col.team_en': 'Pasukan (EN)',
|
||||||
'outright.col.code': 'Kod',
|
'outright.col.code': 'Kod',
|
||||||
|
'outright.col.country': 'Negara',
|
||||||
'outright.col.odds': 'Odds juara',
|
'outright.col.odds': 'Odds juara',
|
||||||
|
'outright.country_ph': 'Cari atau pilih negara',
|
||||||
|
'outright.err_country': 'Sila pilih negara',
|
||||||
'outright.btn.save_odds': 'Simpan semua odds',
|
'outright.btn.save_odds': 'Simpan semua odds',
|
||||||
'outright.btn.apply_canonical': 'Guna data jadual asas',
|
'outright.btn.apply_canonical': 'Guna data jadual asas',
|
||||||
'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini',
|
'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini',
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'user.field.login_fail': '登录失败',
|
'user.field.login_fail': '登录失败',
|
||||||
'user.field.phone': '手机',
|
'user.field.phone': '手机',
|
||||||
'user.field.email': '邮箱',
|
'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.username_unique': '登录用户名,唯一',
|
||||||
'user.ph.no_agent': '不设置(平台直属玩家)',
|
'user.ph.no_agent': '不设置(平台直属玩家)',
|
||||||
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
||||||
@@ -58,6 +74,10 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
||||||
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
||||||
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
|
'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.create': '创建',
|
||||||
'user.btn.save_profile': '保存资料',
|
'user.btn.save_profile': '保存资料',
|
||||||
'user.btn.confirm_deposit': '确认上分',
|
'user.btn.confirm_deposit': '确认上分',
|
||||||
@@ -222,6 +242,9 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'agent_portal.agent_username_ph': '代理用户名',
|
'agent_portal.agent_username_ph': '代理用户名',
|
||||||
'agent_portal.player_id_ph': '玩家 ID',
|
'agent_portal.player_id_ph': '玩家 ID',
|
||||||
'agent_portal.withdraw_btn': '下分 {amount}',
|
'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.agent_sub_created': '下级代理已创建',
|
||||||
'msg.withdraw_ok': '下分成功',
|
'msg.withdraw_ok': '下分成功',
|
||||||
|
|
||||||
@@ -241,6 +264,7 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
|
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
|
||||||
'msg.topup_ok': '上分成功',
|
'msg.topup_ok': '上分成功',
|
||||||
'msg.topup_failed': '上分失败',
|
'msg.topup_failed': '上分失败',
|
||||||
|
'msg.transfer_failed': '操作失败',
|
||||||
'msg.amount_gt_zero': '金额须大于 0',
|
'msg.amount_gt_zero': '金额须大于 0',
|
||||||
'msg.credit_zero': '调整金额不能为 0',
|
'msg.credit_zero': '调整金额不能为 0',
|
||||||
'msg.credit_adjusted': '授信已调整',
|
'msg.credit_adjusted': '授信已调整',
|
||||||
@@ -249,13 +273,61 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'msg.outright_odds_saved': '夺冠赔率已保存',
|
'msg.outright_odds_saved': '夺冠赔率已保存',
|
||||||
'msg.load_failed': '加载失败',
|
'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.title': '优胜冠军',
|
||||||
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
|
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
|
||||||
'outright.col.rank': '排名',
|
'outright.col.rank': '排名',
|
||||||
'outright.col.team_zh': '队伍(中文)',
|
'outright.col.team_zh': '队伍(中文)',
|
||||||
'outright.col.team_en': '队伍(英文)',
|
'outright.col.team_en': '队伍(英文)',
|
||||||
'outright.col.code': '代码',
|
'outright.col.code': '代码',
|
||||||
|
'outright.col.country': '国家/地区',
|
||||||
'outright.col.odds': '夺冠赔率',
|
'outright.col.odds': '夺冠赔率',
|
||||||
|
'outright.country_ph': '搜索或选择国家',
|
||||||
|
'outright.err_country': '请选择国家',
|
||||||
'outright.btn.save_odds': '保存全部赔率',
|
'outright.btn.save_odds': '保存全部赔率',
|
||||||
'outright.btn.save_meta': '保存赛事信息',
|
'outright.btn.save_meta': '保存赛事信息',
|
||||||
'outright.btn.publish': '发布',
|
'outright.btn.publish': '发布',
|
||||||
@@ -355,6 +427,22 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'user.field.login_fail': 'Failed logins',
|
'user.field.login_fail': 'Failed logins',
|
||||||
'user.field.phone': 'Phone',
|
'user.field.phone': 'Phone',
|
||||||
'user.field.email': 'Email',
|
'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.username_unique': 'Unique login username',
|
||||||
'user.ph.no_agent': 'None (platform direct)',
|
'user.ph.no_agent': 'None (platform direct)',
|
||||||
'user.hint.no_agent': 'Leave empty for platform-managed player',
|
'user.hint.no_agent': 'Leave empty for platform-managed player',
|
||||||
@@ -362,6 +450,10 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
||||||
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
|
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
|
||||||
'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit',
|
'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.create': 'Create',
|
||||||
'user.btn.save_profile': 'Save',
|
'user.btn.save_profile': 'Save',
|
||||||
'user.btn.confirm_deposit': 'Confirm top-up',
|
'user.btn.confirm_deposit': 'Confirm top-up',
|
||||||
@@ -526,6 +618,9 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'agent_portal.agent_username_ph': 'Agent username',
|
'agent_portal.agent_username_ph': 'Agent username',
|
||||||
'agent_portal.player_id_ph': 'Player ID',
|
'agent_portal.player_id_ph': 'Player ID',
|
||||||
'agent_portal.withdraw_btn': 'Withdraw {amount}',
|
'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.agent_sub_created': 'Sub-agent created',
|
||||||
'msg.withdraw_ok': 'Withdrawal successful',
|
'msg.withdraw_ok': 'Withdrawal successful',
|
||||||
|
|
||||||
@@ -545,6 +640,7 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
|
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
|
||||||
'msg.topup_ok': 'Top-up successful',
|
'msg.topup_ok': 'Top-up successful',
|
||||||
'msg.topup_failed': 'Top-up failed',
|
'msg.topup_failed': 'Top-up failed',
|
||||||
|
'msg.transfer_failed': 'Operation failed',
|
||||||
'msg.amount_gt_zero': 'Amount must be greater than 0',
|
'msg.amount_gt_zero': 'Amount must be greater than 0',
|
||||||
'msg.credit_zero': 'Adjustment cannot be 0',
|
'msg.credit_zero': 'Adjustment cannot be 0',
|
||||||
'msg.credit_adjusted': 'Credit updated',
|
'msg.credit_adjusted': 'Credit updated',
|
||||||
@@ -553,13 +649,61 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'msg.outright_odds_saved': 'Outright odds saved',
|
'msg.outright_odds_saved': 'Outright odds saved',
|
||||||
'msg.load_failed': 'Load failed',
|
'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.title': 'Outrights',
|
||||||
'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
|
'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
|
||||||
'outright.col.rank': 'Rank',
|
'outright.col.rank': 'Rank',
|
||||||
'outright.col.team_zh': 'Team (ZH)',
|
'outright.col.team_zh': 'Team (ZH)',
|
||||||
'outright.col.team_en': 'Team (EN)',
|
'outright.col.team_en': 'Team (EN)',
|
||||||
'outright.col.code': 'Code',
|
'outright.col.code': 'Code',
|
||||||
|
'outright.col.country': 'Country',
|
||||||
'outright.col.odds': 'Winner odds',
|
'outright.col.odds': 'Winner odds',
|
||||||
|
'outright.country_ph': 'Search or select country',
|
||||||
|
'outright.err_country': 'Please select a country',
|
||||||
'outright.btn.save_odds': 'Save all odds',
|
'outright.btn.save_odds': 'Save all odds',
|
||||||
'outright.btn.save_meta': 'Save event info',
|
'outright.btn.save_meta': 'Save event info',
|
||||||
'outright.btn.publish': 'Publish',
|
'outright.btn.publish': 'Publish',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const adminMenus = computed(() => [
|
|||||||
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
|
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
|
||||||
{ path: '/bets', label: t('nav.bets') },
|
{ path: '/bets', label: t('nav.bets') },
|
||||||
{ path: '/cashback', label: t('nav.cashback') },
|
{ path: '/cashback', label: t('nav.cashback') },
|
||||||
|
{ path: '/contents', label: t('nav.contents') },
|
||||||
{ path: '/audit', label: t('nav.audit') },
|
{ path: '/audit', label: t('nav.audit') },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ const router = createRouter({
|
|||||||
component: () => import('../views/Cashback.vue'),
|
component: () => import('../views/Cashback.vue'),
|
||||||
meta: { adminOnly: true },
|
meta: { adminOnly: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'contents',
|
||||||
|
component: () => import('../views/Contents.vue'),
|
||||||
|
meta: { adminOnly: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'audit',
|
path: 'audit',
|
||||||
component: () => import('../views/Audit.vue'),
|
component: () => import('../views/Audit.vue'),
|
||||||
|
|||||||
8
apps/admin/src/utils/teamFlag.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { countryFlagUrl, getBuiltinCountry } from '../data/builtinCountries';
|
||||||
|
|
||||||
|
export { countryFlagUrl, getBuiltinCountry };
|
||||||
|
|
||||||
|
export function suggestTeamFlagUrl(code?: string): string {
|
||||||
|
const c = getBuiltinCountry(code);
|
||||||
|
return 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>
|
||||||
@@ -46,12 +46,39 @@ const detail = ref<PlayerDetail | null>(null);
|
|||||||
const editingId = ref('');
|
const editingId = ref('');
|
||||||
|
|
||||||
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
||||||
|
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||||
|
const settingsSaving = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAgentOptions();
|
loadAgentOptions();
|
||||||
|
loadPlayerSettings();
|
||||||
load();
|
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() {
|
async function loadAgentOptions() {
|
||||||
const { data } = await api.get('/admin/agents/options');
|
const { data } = await api.get('/admin/agents/options');
|
||||||
agentOptions.value = data.data;
|
agentOptions.value = data.data;
|
||||||
@@ -122,7 +149,9 @@ async function submitCreate() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/admin/users', payload);
|
await api.post('/admin/users', payload);
|
||||||
ElMessage.success(
|
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;
|
createVisible.value = false;
|
||||||
load();
|
load();
|
||||||
@@ -163,13 +192,27 @@ async function toggleFreeze(row: PlayerRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitEdit() {
|
async function submitEdit() {
|
||||||
|
if (editForm.value.newPassword && editForm.value.newPassword.length < 8) {
|
||||||
|
ElMessage.warning(t('err.password_min'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
editLoading.value = true;
|
editLoading.value = true;
|
||||||
try {
|
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 || '',
|
parentId: editForm.value.parentId || '',
|
||||||
phone: editForm.value.phone.trim() || undefined,
|
phone: editForm.value.phone.trim() || undefined,
|
||||||
email: editForm.value.email.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'));
|
ElMessage.success(t('msg.saved'));
|
||||||
editVisible.value = false;
|
editVisible.value = false;
|
||||||
load();
|
load();
|
||||||
@@ -246,6 +289,29 @@ function statusLabel(s: string) {
|
|||||||
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
||||||
</div>
|
</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-card class="filter-card" shadow="never">
|
||||||
<el-form inline>
|
<el-form inline>
|
||||||
<el-form-item :label="t('common.keyword')">
|
<el-form-item :label="t('common.keyword')">
|
||||||
@@ -455,20 +521,42 @@ function statusLabel(s: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close>
|
<el-dialog
|
||||||
<el-form label-width="100px">
|
v-model="editVisible"
|
||||||
<el-form-item :label="t('user.field.player_id')">
|
:title="t('user.dialog.edit')"
|
||||||
<el-input :model-value="editForm.id" disabled />
|
width="480px"
|
||||||
</el-form-item>
|
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-form-item :label="t('user.col.username')">
|
||||||
<el-input :model-value="editForm.username" disabled />
|
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||||
</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-form-item>
|
</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-form-item :label="t('user.filter.agent')">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="editForm.parentId"
|
v-model="editForm.parentId"
|
||||||
@@ -483,46 +571,38 @@ function statusLabel(s: string) {
|
|||||||
:value="a.id"
|
:value="a.id"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="field-hint">{{ t('user.hint.agent_change') }}</div>
|
|
||||||
</el-form-item>
|
</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-form-item :label="t('user.field.phone')">
|
||||||
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.email')">
|
<el-form-item :label="t('user.field.email')">
|
||||||
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
|
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
|
||||||
</el-form-item>
|
</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>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
<el-button size="small" @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" type="primary" :loading="editLoading" @click="submitEdit">
|
||||||
|
{{ t('user.btn.save_profile') }}
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -549,6 +629,12 @@ function statusLabel(s: string) {
|
|||||||
<el-descriptions :column="2" border size="small">
|
<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('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.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-descriptions-item :label="t('common.status')">
|
||||||
<el-tag :type="statusTagType(detail.status)" size="small">
|
<el-tag :type="statusTagType(detail.status)" size="small">
|
||||||
{{ statusLabel(detail.status) }}
|
{{ statusLabel(detail.status) }}
|
||||||
@@ -588,7 +674,8 @@ function statusLabel(s: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
.data-card { border-radius: 12px; }
|
||||||
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
.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-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
||||||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||||
.text-muted { color: #666; font-size: 12px; }
|
.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>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -7,40 +7,84 @@ import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
|||||||
|
|
||||||
const { t } = useAdminLocale();
|
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 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);
|
onMounted(load);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data } = await api.get('/agent/players');
|
const { data } = await api.get('/agent/players');
|
||||||
players.value = data.data;
|
players.value = data.data as PlayerRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
await api.post('/agent/players', form.value);
|
if (!form.value.username.trim()) {
|
||||||
ElMessage.success(t('msg.player_created'));
|
ElMessage.warning(t('err.username_required'));
|
||||||
load();
|
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() {
|
function openTransfer(type: 'deposit' | 'withdraw', row: PlayerRow) {
|
||||||
depositForm.value.requestId = `dep-${Date.now()}`;
|
transferType.value = type;
|
||||||
await api.post(`/agent/players/${depositForm.value.playerId}/deposit`, {
|
transferTarget.value = row;
|
||||||
amount: depositForm.value.amount,
|
transferAmount.value = 100;
|
||||||
requestId: depositForm.value.requestId,
|
transferVisible.value = true;
|
||||||
});
|
|
||||||
ElMessage.success(t('msg.topup_ok'));
|
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withdraw(playerId: string, amount: number) {
|
async function submitTransfer() {
|
||||||
await api.post(`/agent/players/${playerId}/withdraw`, {
|
if (!transferTarget.value) return;
|
||||||
amount,
|
if (transferAmount.value <= 0) {
|
||||||
requestId: `wd-${Date.now()}`,
|
ElMessage.warning(t('msg.amount_gt_zero'));
|
||||||
});
|
return;
|
||||||
ElMessage.success(t('msg.withdraw_ok'));
|
}
|
||||||
load();
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -52,80 +96,94 @@ async function withdraw(playerId: string, amount: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="tool-card" shadow="never">
|
<el-card class="tool-card" shadow="never">
|
||||||
<div class="tool-row">
|
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||||
<div class="tool-section">
|
<el-form inline>
|
||||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
<el-form-item :label="t('user.col.username')">
|
||||||
<el-form inline>
|
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 160px" />
|
||||||
<el-form-item :label="t('user.col.username')">
|
</el-form-item>
|
||||||
<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-item>
|
||||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
</el-form>
|
||||||
</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>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-card class="data-card" shadow="never">
|
<el-card class="data-card" shadow="never">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table :data="players" stripe>
|
<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 prop="username" :label="t('user.col.username')" min-width="120" />
|
||||||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
<template v-if="row.wallet?.availableBalance != null">
|
||||||
<el-tooltip
|
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
|
||||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">
|
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">
|
||||||
{{ t('agent_portal.withdraw_btn', { amount: 50 }) }}
|
{{ t('common.topup') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">
|
||||||
|
{{ t('agent_portal.withdraw_btn_label') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
|
.page-header {
|
||||||
.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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
align-items: baseline;
|
||||||
align-items: flex-start;
|
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 {
|
.tool-section-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -134,10 +192,4 @@ async function withdraw(playerId: string, amount: number) {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
.tool-divider {
|
|
||||||
width: 1px;
|
|
||||||
background: #eee;
|
|
||||||
align-self: stretch;
|
|
||||||
margin: 0 24px 0 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
import CountryFlagSelect from '../../components/outright/CountryFlagSelect.vue';
|
||||||
|
import {
|
||||||
|
countryFlagUrl,
|
||||||
|
getBuiltinCountry,
|
||||||
|
resolveCountryCode,
|
||||||
|
type BuiltinCountry,
|
||||||
|
} from '../../data/builtinCountries';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,11 +27,14 @@ interface SelectionRow {
|
|||||||
odds: string;
|
odds: string;
|
||||||
oddsVersion: string;
|
oddsVersion: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
logoUrl: string | null;
|
||||||
editOdds: number;
|
editOdds: number;
|
||||||
|
editCountryCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const savingRowId = ref<string | null>(null);
|
||||||
const meta = ref({
|
const meta = ref({
|
||||||
leagueZh: '',
|
leagueZh: '',
|
||||||
leagueEn: '',
|
leagueEn: '',
|
||||||
@@ -39,12 +49,29 @@ const selections = ref<SelectionRow[]>([]);
|
|||||||
|
|
||||||
const addVisible = ref(false);
|
const addVisible = ref(false);
|
||||||
const addForm = ref({
|
const addForm = ref({
|
||||||
|
countryCode: '',
|
||||||
teamCode: '',
|
teamCode: '',
|
||||||
teamZh: '',
|
teamZh: '',
|
||||||
teamEn: '',
|
teamEn: '',
|
||||||
odds: 10,
|
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() {
|
async function load() {
|
||||||
if (!matchId.value) return;
|
if (!matchId.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -59,7 +86,7 @@ async function load() {
|
|||||||
expectedCanonicalCount: number | null;
|
expectedCanonicalCount: number | null;
|
||||||
playerVisible: boolean;
|
playerVisible: boolean;
|
||||||
playerHiddenReason: string | null;
|
playerHiddenReason: string | null;
|
||||||
selections: SelectionRow[];
|
selections: Array<SelectionRow & { logoUrl?: string | null }>;
|
||||||
};
|
};
|
||||||
meta.value = {
|
meta.value = {
|
||||||
leagueZh: payload.leagueZh,
|
leagueZh: payload.leagueZh,
|
||||||
@@ -73,7 +100,9 @@ async function load() {
|
|||||||
};
|
};
|
||||||
selections.value = payload.selections.map((s) => ({
|
selections.value = payload.selections.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
|
logoUrl: s.logoUrl ?? null,
|
||||||
editOdds: Number(s.odds),
|
editOdds: Number(s.odds),
|
||||||
|
editCountryCode: resolveCountryCode(s.teamCode, s.logoUrl ?? null),
|
||||||
}));
|
}));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
@@ -127,21 +156,27 @@ async function saveAllOdds() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitAdd() {
|
async function submitAdd() {
|
||||||
if (!addForm.value.teamCode.trim()) {
|
if (!addForm.value.countryCode) {
|
||||||
ElMessage.warning(t('outright.err_team_code'));
|
ElMessage.warning(t('outright.err_country'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const country = getBuiltinCountry(addForm.value.countryCode);
|
||||||
|
if (!country) {
|
||||||
|
ElMessage.warning(t('outright.err_country'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await api.post(`/admin/outrights/${matchId.value}/selections`, {
|
await api.post(`/admin/outrights/${matchId.value}/selections`, {
|
||||||
teamCode: addForm.value.teamCode.trim().toUpperCase(),
|
teamCode: country.code,
|
||||||
teamZh: addForm.value.teamZh,
|
teamZh: country.nameZh,
|
||||||
teamEn: addForm.value.teamEn,
|
teamEn: country.nameEn,
|
||||||
|
logoUrl: countryFlagUrl(country),
|
||||||
odds: addForm.value.odds,
|
odds: addForm.value.odds,
|
||||||
});
|
});
|
||||||
ElMessage.success(t('msg.saved'));
|
ElMessage.success(t('msg.saved'));
|
||||||
addVisible.value = false;
|
addVisible.value = false;
|
||||||
addForm.value = { teamCode: '', teamZh: '', teamEn: '', odds: 10 };
|
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', odds: 10 };
|
||||||
await load();
|
await load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
@@ -200,6 +235,71 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
|||||||
meta.value.status = status;
|
meta.value.status = status;
|
||||||
await saveMeta();
|
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 oddsDirty = row.editOdds !== Number(row.odds);
|
||||||
|
return countryDirty || oddsDirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (countryDirty) {
|
||||||
|
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: countryFlagUrl(country),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -269,9 +369,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
|||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table :data="selections" stripe size="small" empty-text="—">
|
<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="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 :label="t('outright.col.country')" min-width="220">
|
||||||
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
|
<template #default="{ row }">
|
||||||
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
|
<CountryFlagSelect
|
||||||
|
v-model="row.editCountryCode"
|
||||||
|
:disabled="!!savingRowId"
|
||||||
|
/>
|
||||||
|
</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">
|
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -285,9 +399,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<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') }}
|
{{ t('common.delete') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -303,16 +431,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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="440px">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item :label="t('outright.col.code')">
|
<el-form-item :label="t('outright.col.country')" required>
|
||||||
<el-input v-model="addForm.teamCode" placeholder="FRA" />
|
<CountryFlagSelect
|
||||||
|
v-model="addForm.countryCode"
|
||||||
|
size="default"
|
||||||
|
@pick="onAddCountryPick"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('outright.col.team_zh')">
|
<el-form-item v-if="addForm.teamCode" :label="t('outright.col.code')">
|
||||||
<el-input v-model="addForm.teamZh" />
|
<span class="readonly-field">{{ addForm.teamCode }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('outright.col.team_en')">
|
<el-form-item v-if="addForm.teamZh" :label="t('outright.col.team_zh')">
|
||||||
<el-input v-model="addForm.teamEn" />
|
<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>
|
||||||
<el-form-item :label="t('outright.col.odds')">
|
<el-form-item :label="t('outright.col.odds')">
|
||||||
<el-input-number v-model="addForm.odds" :min="1.01" :step="0.05" :precision="2" />
|
<el-input-number v-model="addForm.odds" :min="1.01" :step="0.05" :precision="2" />
|
||||||
@@ -430,4 +565,9 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
|
|||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.readonly-field {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export interface PlayerEditForm {
|
|||||||
loginFailCount: number;
|
loginFailCount: number;
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
managedPassword: string | null;
|
||||||
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerRow {
|
export interface PlayerRow {
|
||||||
@@ -42,6 +44,7 @@ export interface PlayerRow {
|
|||||||
parentUsername: string | null;
|
parentUsername: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
managedPassword: string | null;
|
||||||
availableBalance: string;
|
availableBalance: string;
|
||||||
frozenBalance: string;
|
frozenBalance: string;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
@@ -90,6 +93,8 @@ export function emptyPlayerEditForm(): PlayerEditForm {
|
|||||||
loginFailCount: 0,
|
loginFailCount: 0,
|
||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
managedPassword: null,
|
||||||
|
newPassword: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +115,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
|||||||
loginFailCount: d.loginFailCount,
|
loginFailCount: d.loginFailCount,
|
||||||
phone: d.phone ?? '',
|
phone: d.phone ?? '',
|
||||||
email: d.email ?? '',
|
email: d.email ?? '',
|
||||||
|
managedPassword: d.managedPassword ?? null,
|
||||||
|
newPassword: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev",
|
"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:seed": "ts-node prisma/seed.ts",
|
||||||
"db:studio": "prisma studio"
|
"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);
|
||||||
@@ -52,13 +52,17 @@ model UserAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model UserPreference {
|
model UserPreference {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
userId BigInt @unique @map("user_id")
|
userId BigInt @unique @map("user_id")
|
||||||
locale String @default("en-US") @db.VarChar(10)
|
locale String @default("en-US") @db.VarChar(10)
|
||||||
phone String? @db.VarChar(32)
|
phone String? @db.VarChar(32)
|
||||||
email String? @db.VarChar(128)
|
email String? @db.VarChar(128)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
avatarKey String? @map("avatar_key") @db.VarChar(128)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
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])
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ async function main() {
|
|||||||
parentId: agent1.id,
|
parentId: agent1.id,
|
||||||
auth: { create: { passwordHash: playerHash } },
|
auth: { create: { passwordHash: playerHash } },
|
||||||
wallet: { create: { availableBalance: 1000 } },
|
wallet: { create: { availableBalance: 1000 } },
|
||||||
preferences: { create: { locale: 'zh-CN' } },
|
preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { JwtAuthGuard } from './domains/identity/guards';
|
import { JwtAuthGuard } from './domains/identity/guards';
|
||||||
import { PrismaModule } from './shared/prisma/prisma.module';
|
import { PrismaModule } from './shared/prisma/prisma.module';
|
||||||
|
import { SystemConfigModule } from './shared/config/system-config.module';
|
||||||
import { IdentityModule } from './domains/identity/identity.module';
|
import { IdentityModule } from './domains/identity/identity.module';
|
||||||
import { AgentsModule } from './domains/agent/agents.module';
|
import { AgentsModule } from './domains/agent/agents.module';
|
||||||
import { WalletModule } from './domains/ledger/wallet.module';
|
import { WalletModule } from './domains/ledger/wallet.module';
|
||||||
@@ -21,6 +22,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
|
|||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SystemConfigModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
WalletModule,
|
WalletModule,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
Query,
|
Query,
|
||||||
@@ -28,6 +29,7 @@ import { AuditService } from '../../domains/operations/audit/audit.service';
|
|||||||
import { BetsService } from '../../domains/betting/bets.service';
|
import { BetsService } from '../../domains/betting/bets.service';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { AdminDashboardService } from './admin-dashboard.service';
|
import { AdminDashboardService } from './admin-dashboard.service';
|
||||||
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -126,6 +128,25 @@ class UpdatePlayerAdminDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlayerAccountSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowPasswordChange?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowUsernameChange?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreateAgentAdminDto {
|
class CreateAgentAdminDto {
|
||||||
@@ -304,6 +325,113 @@ class AddOutrightSelectionDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(1.01)
|
@Min(1.01)
|
||||||
odds!: number;
|
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 {
|
class CashbackPreviewDto {
|
||||||
@@ -334,6 +462,7 @@ export class AdminController {
|
|||||||
private bets: BetsService,
|
private bets: BetsService,
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private readonly dashboardService: AdminDashboardService,
|
private readonly dashboardService: AdminDashboardService,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
@@ -342,6 +471,28 @@ export class AdminController {
|
|||||||
return jsonResponse(overview);
|
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')
|
@Get('users')
|
||||||
async listUsers(
|
async listUsers(
|
||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@@ -790,6 +941,20 @@ export class AdminController {
|
|||||||
return jsonResponse(data);
|
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')
|
@Delete('outrights/:matchId/selections/:selectionId')
|
||||||
async removeOutrightSelection(
|
async removeOutrightSelection(
|
||||||
@Param('matchId') matchId: string,
|
@Param('matchId') matchId: string,
|
||||||
@@ -875,17 +1040,47 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('contents')
|
@Get('contents')
|
||||||
async listContents(@Query('type') type?: string) {
|
async listContents(
|
||||||
const items = await this.content.listAll(type);
|
@Query('type') type?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
) {
|
||||||
|
const items = await this.content.listForAdmin(type, status);
|
||||||
return jsonResponse(items);
|
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')
|
@Post('contents')
|
||||||
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
|
async createContent(@Body() dto: CreateContentDto) {
|
||||||
const item = await this.content.create(dto);
|
const item = await this.content.create(dto);
|
||||||
return jsonResponse(item);
|
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')
|
@Get('i18n/messages')
|
||||||
async getMessages(@Query('locale') locale = 'en-US') {
|
async getMessages(@Query('locale') locale = 'en-US') {
|
||||||
const messages = await this.i18n.getMessages(locale);
|
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 { CurrentUser } from '../../shared/common/decorators';
|
||||||
import { jsonResponse } from '../../shared/common/filters';
|
import { jsonResponse } from '../../shared/common/filters';
|
||||||
import { UsersService } from '../../domains/identity/users.service';
|
import { UsersService } from '../../domains/identity/users.service';
|
||||||
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { WalletService } from '../../domains/ledger/wallet.service';
|
import { WalletService } from '../../domains/ledger/wallet.service';
|
||||||
import { MatchesService } from '../../domains/catalog/matches.service';
|
import { MatchesService } from '../../domains/catalog/matches.service';
|
||||||
import { OutrightService } from '../../domains/catalog/outright.service';
|
import { OutrightService } from '../../domains/catalog/outright.service';
|
||||||
@@ -72,6 +73,14 @@ class UpdateProfileDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
avatarKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Player')
|
@ApiTags('Player')
|
||||||
@@ -87,12 +96,39 @@ export class PlayerController {
|
|||||||
private bets: BetsService,
|
private bets: BetsService,
|
||||||
private content: ContentService,
|
private content: ContentService,
|
||||||
private cashback: CashbackService,
|
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')
|
@Get('profile')
|
||||||
async profile(@CurrentUser('id') userId: bigint) {
|
async profile(@CurrentUser('id') userId: bigint) {
|
||||||
const user = await this.users.findById(userId);
|
const user = await this.users.findById(userId);
|
||||||
return jsonResponse(user);
|
if (!user) return jsonResponse(null);
|
||||||
|
return jsonResponse(await this.formatPlayerProfile(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('language')
|
@Post('language')
|
||||||
@@ -104,22 +140,25 @@ export class PlayerController {
|
|||||||
@Patch('profile')
|
@Patch('profile')
|
||||||
async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) {
|
async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) {
|
||||||
const user = await this.users.updateProfile(userId, dto);
|
const user = await this.users.updateProfile(userId, dto);
|
||||||
return jsonResponse(user);
|
if (!user) return jsonResponse(null);
|
||||||
|
return jsonResponse(await this.formatPlayerProfile(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('home')
|
@Get('home')
|
||||||
async home(@CurrentUser('locale') locale: string) {
|
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('BANNER', locale),
|
||||||
this.content.listActive('NOTICE', locale),
|
this.content.listActiveAnnouncements(locale),
|
||||||
this.content.listActive('TICKER', locale),
|
|
||||||
this.matches.listPublished(locale),
|
this.matches.listPublished(locale),
|
||||||
this.matches.listPublished(locale),
|
this.matches.listPublished(locale),
|
||||||
]);
|
]);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
banners,
|
banners,
|
||||||
notices,
|
announcements,
|
||||||
ticker,
|
/** @deprecated 使用 announcements */
|
||||||
|
ticker: announcements,
|
||||||
|
/** @deprecated 使用 announcements */
|
||||||
|
notices: announcements,
|
||||||
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
|
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
|
||||||
todayMatches,
|
todayMatches,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -626,6 +626,7 @@ export class AgentsService {
|
|||||||
locale,
|
locale,
|
||||||
phone: data.phone?.trim() || null,
|
phone: data.phone?.trim() || null,
|
||||||
email: data.email?.trim() || null,
|
email: data.email?.trim() || null,
|
||||||
|
managedPassword: data.password,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export class OutrightService {
|
|||||||
rank: sel.sortOrder + 1 || index + 1,
|
rank: sel.sortOrder + 1 || index + 1,
|
||||||
teamZh: teamZh || sel.selectionName,
|
teamZh: teamZh || sel.selectionName,
|
||||||
teamEn: teamEn || sel.selectionName,
|
teamEn: teamEn || sel.selectionName,
|
||||||
|
logoUrl: team?.logoUrl ?? null,
|
||||||
odds: sel.odds.toString(),
|
odds: sel.odds.toString(),
|
||||||
oddsVersion: sel.oddsVersion.toString(),
|
oddsVersion: sel.oddsVersion.toString(),
|
||||||
status: sel.status,
|
status: sel.status,
|
||||||
@@ -243,6 +244,7 @@ export class OutrightService {
|
|||||||
teamZh: string;
|
teamZh: string;
|
||||||
teamEn: string;
|
teamEn: string;
|
||||||
odds: number;
|
odds: number;
|
||||||
|
logoUrl?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (!data.teamCode?.trim()) {
|
if (!data.teamCode?.trim()) {
|
||||||
@@ -256,10 +258,19 @@ export class OutrightService {
|
|||||||
const market = await this.ensureOutrightMarket(match.id);
|
const market = await this.ensureOutrightMarket(match.id);
|
||||||
const code = data.teamCode.trim().toUpperCase();
|
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({
|
const team = await this.prisma.team.upsert({
|
||||||
where: { code },
|
where: { code },
|
||||||
create: { code },
|
create: {
|
||||||
update: {},
|
code,
|
||||||
|
...(logoUrl !== undefined ? { logoUrl } : {}),
|
||||||
|
},
|
||||||
|
update: logoUrl !== undefined ? { logoUrl } : {},
|
||||||
});
|
});
|
||||||
await this.upsertTeamTranslations(team.id, {
|
await this.upsertTeamTranslations(team.id, {
|
||||||
'zh-CN': data.teamZh.trim() || data.teamEn,
|
'zh-CN': data.teamZh.trim() || data.teamEn,
|
||||||
@@ -292,6 +303,77 @@ export class OutrightService {
|
|||||||
return this.getForAdmin(matchId);
|
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) {
|
async closeSelection(matchId: bigint, selectionId: bigint) {
|
||||||
const match = await this.getOutrightMatchOrThrow(matchId);
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||||
const market = await this.ensureOutrightMarket(match.id);
|
const market = await this.ensureOutrightMarket(match.id);
|
||||||
@@ -399,6 +481,7 @@ export class OutrightService {
|
|||||||
id: sel.id.toString(),
|
id: sel.id.toString(),
|
||||||
teamCode: sel.selectionCode,
|
teamCode: sel.selectionCode,
|
||||||
teamName,
|
teamName,
|
||||||
|
logoUrl: team?.logoUrl ?? null,
|
||||||
odds: sel.odds.toString(),
|
odds: sel.odds.toString(),
|
||||||
oddsVersion: sel.oddsVersion.toString(),
|
oddsVersion: sel.oddsVersion.toString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
|
|
||||||
const MAX_LOGIN_FAILS = 5;
|
const MAX_LOGIN_FAILS = 5;
|
||||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||||
@@ -20,6 +21,7 @@ export class AuthService {
|
|||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private jwt: JwtService,
|
private jwt: JwtService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */
|
||||||
@@ -107,6 +109,11 @@ export class AuthService {
|
|||||||
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
|
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
|
||||||
if (!auth) throw new UnauthorizedException('User not found');
|
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);
|
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
|
||||||
if (!valid) throw new UnauthorizedException('Invalid old password');
|
if (!valid) throw new UnauthorizedException('Invalid old password');
|
||||||
|
|
||||||
@@ -115,6 +122,10 @@ export class AuthService {
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
data: { passwordHash: hash },
|
data: { passwordHash: hash },
|
||||||
});
|
});
|
||||||
|
await this.prisma.userPreference.updateMany({
|
||||||
|
where: { userId },
|
||||||
|
data: { managedPassword: null },
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { SUPPORTED_LOCALES } from '@thebet365/shared';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { SUPPORTED_LOCALES, isValidAvatarKey } from '@thebet365/shared';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { AgentsService } from '../agent/agents.service';
|
import { AgentsService } from '../agent/agents.service';
|
||||||
|
|
||||||
export type PlayerListFilters = {
|
export type PlayerListFilters = {
|
||||||
@@ -14,6 +16,7 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private agents: AgentsService,
|
private agents: AgentsService,
|
||||||
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private formatPlayerRow(
|
private formatPlayerRow(
|
||||||
@@ -26,7 +29,11 @@ export class UsersService {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null;
|
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;
|
parent?: { username: string } | null;
|
||||||
auth?: { lastLoginAt: Date | null } | null;
|
auth?: { lastLoginAt: Date | null } | null;
|
||||||
},
|
},
|
||||||
@@ -41,6 +48,7 @@ export class UsersService {
|
|||||||
parentUsername: u.parent?.username ?? null,
|
parentUsername: u.parent?.username ?? null,
|
||||||
phone: u.preferences?.phone ?? null,
|
phone: u.preferences?.phone ?? null,
|
||||||
email: u.preferences?.email ?? null,
|
email: u.preferences?.email ?? null,
|
||||||
|
managedPassword: u.preferences?.managedPassword ?? null,
|
||||||
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
|
availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
|
||||||
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
|
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
|
||||||
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
||||||
@@ -81,13 +89,61 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(userId: bigint, data: { phone?: string; email?: string }) {
|
async updateProfile(
|
||||||
const phone = data.phone?.trim() || null;
|
userId: bigint,
|
||||||
const email = data.email?.trim() || null;
|
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({
|
await this.prisma.userPreference.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
create: { userId, phone, email },
|
create: {
|
||||||
update: { phone, email },
|
userId,
|
||||||
|
phone: phone ?? null,
|
||||||
|
email: email ?? null,
|
||||||
|
...(avatarKey !== undefined ? { avatarKey } : {}),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...(phone !== undefined ? { phone } : {}),
|
||||||
|
...(email !== undefined ? { email } : {}),
|
||||||
|
...(avatarKey !== undefined ? { avatarKey } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return this.findById(userId);
|
return this.findById(userId);
|
||||||
}
|
}
|
||||||
@@ -195,10 +251,13 @@ export class UsersService {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||||
|
include: { auth: true },
|
||||||
});
|
});
|
||||||
if (!user) throw new NotFoundException('玩家不存在');
|
if (!user) throw new NotFoundException('玩家不存在');
|
||||||
|
|
||||||
@@ -206,6 +265,35 @@ export class UsersService {
|
|||||||
throw new BadRequestException('无效状态');
|
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) {
|
if (data.status) {
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: playerId },
|
where: { id: playerId },
|
||||||
@@ -253,25 +341,43 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
const prefPatch: {
|
||||||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
locale?: string;
|
||||||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
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({
|
await this.prisma.userPreference.upsert({
|
||||||
where: { userId: playerId },
|
where: { userId: playerId },
|
||||||
create: {
|
create: {
|
||||||
userId: playerId,
|
userId: playerId,
|
||||||
locale: data.locale ?? user.locale,
|
locale: data.locale ?? user.locale,
|
||||||
phone: phone ?? null,
|
phone: prefPatch.phone ?? null,
|
||||||
email: email ?? null,
|
email: prefPatch.email ?? null,
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...(data.locale ? { locale: data.locale } : {}),
|
|
||||||
...(phone !== undefined ? { phone } : {}),
|
|
||||||
...(email !== undefined ? { email } : {}),
|
|
||||||
},
|
},
|
||||||
|
update: prefPatch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getPlayerAdminDetail(playerId);
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
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 }>(
|
function pickContentTranslation<T extends { locale: string }>(
|
||||||
translations: T[],
|
translations: T[],
|
||||||
locale: string,
|
locale: string,
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
const chain = [locale, 'en-US', 'zh-CN'];
|
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
|
||||||
for (const loc of chain) {
|
for (const loc of chain) {
|
||||||
const hit = translations.find((tr) => tr.locale === loc);
|
const hit = translations.find((tr) => tr.locale === loc);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
@@ -13,10 +39,194 @@ function pickContentTranslation<T extends { locale: string }>(
|
|||||||
return translations[0];
|
return translations[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalUrl(value?: string | null) {
|
||||||
|
const v = value?.trim();
|
||||||
|
return v || null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContentService {
|
export class ContentService {
|
||||||
constructor(private prisma: PrismaService) {}
|
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) {
|
async listActive(contentType: string, locale: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const items = await this.prisma.content.findMany({
|
const items = await this.prisma.content.findMany({
|
||||||
@@ -30,39 +240,208 @@ export class ContentService {
|
|||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return items.map((item) => {
|
return items
|
||||||
const t = pickContentTranslation(item.translations, locale);
|
.filter((item) => this.playerVisibility(item, now).playerVisible)
|
||||||
return { ...item, translation: t };
|
.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: {
|
async create(data: {
|
||||||
contentType: string;
|
contentType: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
linkType?: string;
|
status?: string;
|
||||||
linkTarget?: string;
|
linkType?: string | null;
|
||||||
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
|
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: {
|
data: {
|
||||||
contentType: data.contentType,
|
contentType,
|
||||||
sortOrder: data.sortOrder ?? 0,
|
sortOrder: data.sortOrder ?? 0,
|
||||||
linkType: data.linkType,
|
status,
|
||||||
linkTarget: data.linkTarget,
|
linkType: data.linkType?.trim() || null,
|
||||||
status: 'ACTIVE',
|
linkTarget: data.linkTarget?.trim() || null,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
translations: {
|
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 },
|
include: { translations: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.mapAdminItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listAll(contentType?: string) {
|
async update(
|
||||||
return this.prisma.content.findMany({
|
id: bigint,
|
||||||
where: contentType ? { contentType } : {},
|
data: {
|
||||||
include: { translations: true },
|
sortOrder?: number;
|
||||||
orderBy: { sortOrder: 'asc' },
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/player/src/assets/images/vs.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
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>
|
||||||
162
apps/player/src/components/PlayerAvatarPicker.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { BUILTIN_PLAYERS, playerAvatarUrl } from '@thebet365/shared';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | null];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const keyword = ref('');
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = keyword.value.trim().toLowerCase();
|
||||||
|
if (!q) return BUILTIN_PLAYERS;
|
||||||
|
return BUILTIN_PLAYERS.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.country.toLowerCase().includes(q) ||
|
||||||
|
p.position.toLowerCase().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="player.name" class="picker-photo" />
|
||||||
|
<span class="picker-name">{{ player.name }}</span>
|
||||||
|
<span class="picker-meta">{{ player.position }} · {{ player.country }}</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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { avatarUrl, loadProfile } = usePlayerProfile();
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
|
|
||||||
const initial = computed(() => {
|
const displayAvatarUrl = computed(() => {
|
||||||
const name = auth.user?.username ?? '?';
|
if (avatarUrl.value) return avatarUrl.value;
|
||||||
return name.charAt(0).toUpperCase();
|
const seed = auth.user?.username;
|
||||||
|
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
@@ -37,7 +45,7 @@ function logout() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="avatar-wrap">
|
<div class="avatar-wrap">
|
||||||
<button type="button" class="avatar-btn" :aria-expanded="open" @click="toggle">
|
<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>
|
</button>
|
||||||
|
|
||||||
<div v-if="open" class="avatar-menu">
|
<div v-if="open" class="avatar-menu">
|
||||||
@@ -68,6 +76,15 @@ function logout() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-letter {
|
.avatar-letter {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface OutrightSelection {
|
|||||||
id: string;
|
id: string;
|
||||||
teamCode: string;
|
teamCode: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
odds: string;
|
odds: string;
|
||||||
oddsVersion: string;
|
oddsVersion: string;
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,7 @@ const showLoadMore = computed(
|
|||||||
:key="sel.id"
|
:key="sel.id"
|
||||||
:team-code="sel.teamCode"
|
:team-code="sel.teamCode"
|
||||||
:team-name="sel.teamName"
|
:team-name="sel.teamName"
|
||||||
|
:logo-url="sel.logoUrl"
|
||||||
:odds="sel.odds"
|
:odds="sel.odds"
|
||||||
@pick="emit('pick', sel)"
|
@pick="emit('pick', sel)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ const props = defineProps<{
|
|||||||
teamCode: string;
|
teamCode: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
odds: string;
|
odds: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ pick: [] }>();
|
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);
|
const flagFailed = ref(false);
|
||||||
|
|
||||||
function onFlagError() {
|
function onFlagError() {
|
||||||
@@ -18,7 +21,7 @@ function onFlagError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.teamCode, props.teamName] as const,
|
() => [props.teamCode, props.teamName, props.logoUrl] as const,
|
||||||
() => {
|
() => {
|
||||||
flagFailed.value = false;
|
flagFailed.value = false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,38 +1,7 @@
|
|||||||
import { ref } from 'vue';
|
import { usePlayerHome } from './usePlayerHome';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** @deprecated 请使用 usePlayerHome */
|
||||||
export function useAnnouncements() {
|
export function useAnnouncements() {
|
||||||
const { t } = useI18n();
|
const { announcements, load } = usePlayerHome();
|
||||||
const items = ref<string[]>(resolveAnnouncements([], t('home.announcement_default')));
|
return { items: announcements, load };
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|||||||
74
apps/player/src/composables/usePlayerHome.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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;
|
||||||
|
homeTeamName: string;
|
||||||
|
awayTeamName: string;
|
||||||
|
homeTeamCode?: string;
|
||||||
|
awayTeamCode?: string;
|
||||||
|
startTime: string;
|
||||||
|
isHot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
const defaultSlide: BannerItem = {
|
||||||
...DEFAULT_BANNER,
|
...DEFAULT_BANNER,
|
||||||
translation: {
|
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 AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||||
import { computed, onMounted, watch } from '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 auth = useAuthStore();
|
||||||
const { initFromUser } = useAppLocale();
|
const { initFromUser } = useAppLocale();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const slip = useBetSlipStore();
|
const slip = useBetSlipStore();
|
||||||
|
|
||||||
const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
|
const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
|
||||||
const { items: announcements, load: loadAnnouncements } = useAnnouncements();
|
const { announcements, load: loadPlayerHome } = usePlayerHome();
|
||||||
|
const { loadProfile } = usePlayerProfile();
|
||||||
|
|
||||||
|
useOnLocaleChange(loadPlayerHome);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAnnouncements();
|
|
||||||
if (auth.user?.locale) initFromUser(auth.user.locale);
|
if (auth.user?.locale) initFromUser(auth.user.locale);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(locale, (next, prev) => {
|
watch(
|
||||||
if (prev && next !== prev) void loadAnnouncements();
|
() => auth.token,
|
||||||
});
|
(token) => {
|
||||||
|
if (token) {
|
||||||
|
void loadPlayerHome();
|
||||||
|
void loadProfile(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -174,6 +174,20 @@ const i18n = createI18n({
|
|||||||
profile: {
|
profile: {
|
||||||
edit: '修改资料',
|
edit: '修改资料',
|
||||||
language: '语言',
|
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: '手机号',
|
phone: '手机号',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
phone_placeholder: '请输入手机号',
|
phone_placeholder: '请输入手机号',
|
||||||
@@ -193,6 +207,10 @@ const i18n = createI18n({
|
|||||||
password_failed: '密码修改失败',
|
password_failed: '密码修改失败',
|
||||||
password_mismatch: '两次新密码不一致',
|
password_mismatch: '两次新密码不一致',
|
||||||
password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
|
password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
|
||||||
|
username_placeholder: '登录账号名',
|
||||||
|
username_readonly_hint: '账号名称由后台管理,如需修改请联系客服',
|
||||||
|
username_updated: '账号名称已更新',
|
||||||
|
password_disabled: '当前账号不允许自行修改密码,请联系客服',
|
||||||
rules_title: '投注规则',
|
rules_title: '投注规则',
|
||||||
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。',
|
||||||
rules_p2: '串关为 2 串 1 至 5 串 1,不可同场串关;冠军盘、四分盘让球/大小不可进入串关。',
|
rules_p2: '串关为 2 串 1 至 5 串 1,不可同场串关;冠军盘、四分盘让球/大小不可进入串关。',
|
||||||
@@ -365,6 +383,20 @@ const i18n = createI18n({
|
|||||||
profile: {
|
profile: {
|
||||||
edit: 'Edit Profile',
|
edit: 'Edit Profile',
|
||||||
language: 'Language',
|
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',
|
phone: 'Phone',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
phone_placeholder: 'Phone number',
|
phone_placeholder: 'Phone number',
|
||||||
@@ -384,6 +416,10 @@ const i18n = createI18n({
|
|||||||
password_failed: 'Password change failed',
|
password_failed: 'Password change failed',
|
||||||
password_mismatch: 'Passwords do not match',
|
password_mismatch: 'Passwords do not match',
|
||||||
password_incomplete: 'Fill current, new and confirm password to change password',
|
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_title: 'Betting Rules',
|
||||||
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
|
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.',
|
rules_p2: 'Parlays: 2–5 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.',
|
||||||
@@ -562,6 +598,20 @@ const i18n = createI18n({
|
|||||||
profile: {
|
profile: {
|
||||||
edit: 'Edit Profil',
|
edit: 'Edit Profil',
|
||||||
language: 'Bahasa',
|
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',
|
phone: 'Telefon',
|
||||||
email: 'E-mel',
|
email: 'E-mel',
|
||||||
phone_placeholder: 'Nombor telefon',
|
phone_placeholder: 'Nombor telefon',
|
||||||
@@ -581,6 +631,10 @@ const i18n = createI18n({
|
|||||||
password_failed: 'Gagal tukar kata laluan',
|
password_failed: 'Gagal tukar kata laluan',
|
||||||
password_mismatch: 'Kata laluan tidak sepadan',
|
password_mismatch: 'Kata laluan tidak sepadan',
|
||||||
password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar',
|
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_title: 'Peraturan Pertaruhan',
|
||||||
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
|
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.',
|
rules_p2: 'Parlay 2–5 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',
|
||||||
|
|||||||
@@ -1,67 +1,64 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
import api from '../api';
|
|
||||||
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
import emptyMatchesImg from '../assets/images/empty-matches.svg';
|
||||||
import BannerCarousel from '../components/BannerCarousel.vue';
|
import BannerCarousel from '../components/BannerCarousel.vue';
|
||||||
import { resolveBanners } from '../constants/defaultBanner';
|
import { usePlayerHome } from '../composables/usePlayerHome';
|
||||||
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
import { teamFlagUrl } from '../utils/teamFlag';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const home = ref<{
|
const { banners, hotMatches, loading } = usePlayerHome();
|
||||||
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);
|
|
||||||
|
|
||||||
function goMatch(id: string) {
|
function goMatch(id: string) {
|
||||||
router.push(`/match/${id}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<BannerCarousel :banners="displayBanners" />
|
<BannerCarousel :banners="banners" />
|
||||||
|
|
||||||
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
|
<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
|
||||||
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
|
v-for="match in hotMatches"
|
||||||
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div>
|
:key="match.id"
|
||||||
|
class="card 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>
|
||||||
|
<span class="vs">VS</span>
|
||||||
|
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
|
||||||
|
<span v-else class="flag-ph">⚽</span>
|
||||||
|
</div>
|
||||||
</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" />
|
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
|
||||||
<p>{{ t('home.no_matches') }}</p>
|
<p>{{ t('home.no_matches') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,10 +66,83 @@ function goMatch(id: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.match-card { cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; }
|
.match-card {
|
||||||
.match-card:active { border-color: var(--border-gold-soft); }
|
display: flex;
|
||||||
.match-teams { font-weight: 800; margin-bottom: 8px; font-size: 16px; }
|
align-items: center;
|
||||||
.match-time { font-size: 13px; color: var(--text-muted); font-weight: 500; }
|
justify-content: space-between;
|
||||||
.empty { text-align: center; color: var(--text-muted); padding: 40px 20px; font-weight: 600; }
|
gap: 12px;
|
||||||
.empty-icon { width: 96px; height: 96px; margin-bottom: 14px; }
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card:active {
|
||||||
|
border-color: var(--border-gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag {
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-ph {
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--primary-light);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 40px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import PlayerAvatarModal from '../components/PlayerAvatarModal.vue';
|
||||||
|
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const { loadProfile, setAvatarKey, profileRaw, avatarUrl, avatarKey } = usePlayerProfile();
|
||||||
|
|
||||||
const username = ref('');
|
const username = ref('');
|
||||||
|
const viewablePassword = ref('');
|
||||||
|
const passwordVisible = ref(false);
|
||||||
const phone = ref('');
|
const phone = ref('');
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
|
const avatarModalOpen = ref(false);
|
||||||
|
const passwordChangeOpen = ref(false);
|
||||||
const oldPassword = ref('');
|
const oldPassword = ref('');
|
||||||
const newPassword = ref('');
|
const newPassword = ref('');
|
||||||
const confirmPassword = ref('');
|
const confirmPassword = ref('');
|
||||||
@@ -17,14 +27,60 @@ const message = ref('');
|
|||||||
const error = ref('');
|
const error = ref('');
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
onMounted(async () => {
|
const allowPasswordChange = computed(
|
||||||
const { data } = await api.get('/player/profile');
|
() => profileRaw.value?.preferences?.allowPasswordChange ?? true,
|
||||||
const user = data.data;
|
);
|
||||||
username.value = user?.username ?? '';
|
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 ?? '';
|
phone.value = user?.preferences?.phone ?? '';
|
||||||
email.value = user?.preferences?.email ?? '';
|
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() {
|
function wantsPasswordChange() {
|
||||||
return !!(oldPassword.value || newPassword.value || confirmPassword.value);
|
return !!(oldPassword.value || newPassword.value || confirmPassword.value);
|
||||||
}
|
}
|
||||||
@@ -48,11 +104,21 @@ async function saveAll() {
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.patch('/player/profile', {
|
const profilePayload: { phone?: string; email?: string; username?: string } = {
|
||||||
phone: phone.value.trim() || undefined,
|
phone: phone.value.trim() || undefined,
|
||||||
email: email.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'));
|
parts.push(t('profile.saved'));
|
||||||
|
if (allowUsernameChange.value && profilePayload.username) {
|
||||||
|
parts.push(t('profile.username_updated'));
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value =
|
error.value =
|
||||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
@@ -62,14 +128,22 @@ async function saveAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wantsPasswordChange()) {
|
if (wantsPasswordChange()) {
|
||||||
|
if (!allowPasswordChange.value) {
|
||||||
|
error.value = t('profile.password_disabled');
|
||||||
|
saving.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api.post('/player/auth/change-password', {
|
await api.post('/player/auth/change-password', {
|
||||||
oldPassword: oldPassword.value,
|
oldPassword: oldPassword.value,
|
||||||
newPassword: newPassword.value,
|
newPassword: newPassword.value,
|
||||||
});
|
});
|
||||||
|
viewablePassword.value = newPassword.value;
|
||||||
|
passwordVisible.value = false;
|
||||||
oldPassword.value = '';
|
oldPassword.value = '';
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
confirmPassword.value = '';
|
confirmPassword.value = '';
|
||||||
|
passwordChangeOpen.value = false;
|
||||||
parts.push(t('profile.password_changed'));
|
parts.push(t('profile.password_changed'));
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value =
|
error.value =
|
||||||
@@ -96,57 +170,117 @@ function back() {
|
|||||||
<h2 class="page-title">{{ t('profile.edit') }}</h2>
|
<h2 class="page-title">{{ t('profile.edit') }}</h2>
|
||||||
</header>
|
</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">
|
<form class="form-card" @submit.prevent="saveAll">
|
||||||
|
<h3 class="section-title">{{ t('profile.section_account') }}</h3>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>{{ t('auth.username') }}</label>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<div class="field">
|
||||||
<label>{{ t('profile.phone') }}</label>
|
<label>{{ t('profile.phone') }}</label>
|
||||||
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
|
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field field-last">
|
||||||
<label>{{ t('profile.email') }}</label>
|
<label>{{ t('profile.email') }}</label>
|
||||||
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
|
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
|
||||||
</div>
|
</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">
|
<button type="submit" class="btn-action btn-gold-outline" :disabled="saving">
|
||||||
{{ t('profile.save') }}
|
{{ t('profile.save') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<PlayerAvatarModal
|
||||||
|
:open="avatarModalOpen"
|
||||||
|
:model-value="avatarKey"
|
||||||
|
@close="avatarModalOpen = false"
|
||||||
|
@confirm="confirmAvatar"
|
||||||
|
/>
|
||||||
|
|
||||||
<p v-if="message" class="msg ok">{{ message }}</p>
|
<p v-if="message" class="msg ok">{{ message }}</p>
|
||||||
<p v-if="error" class="msg err">{{ error }}</p>
|
<p v-if="error" class="msg err">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,11 +288,14 @@ function back() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.edit-page {
|
.edit-page {
|
||||||
padding: 8px 0 12px;
|
padding: 8px 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-head {
|
.page-head {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
@@ -177,22 +314,150 @@ function back() {
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-card,
|
||||||
.form-card {
|
.form-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
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 {
|
.field {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-last {
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-hint {
|
.field-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 4px 0 10px;
|
margin: 6px 0 0;
|
||||||
line-height: 1.4;
|
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 {
|
label {
|
||||||
@@ -200,14 +465,14 @@ label {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-input,
|
.field-input,
|
||||||
.readonly {
|
.readonly {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 9px 11px;
|
padding: 10px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -243,7 +508,7 @@ label {
|
|||||||
.btn-action {
|
.btn-action {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding: 10px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -256,7 +521,7 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
margin-top: 10px;
|
margin-top: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test": "pnpm -r run test",
|
"test": "pnpm -r run test",
|
||||||
"db:generate": "pnpm --filter @thebet365/api db:generate",
|
"db:generate": "pnpm --filter @thebet365/api db:generate",
|
||||||
"db:migrate": "pnpm --filter @thebet365/api db:migrate",
|
"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:seed": "pnpm --filter @thebet365/api db:seed",
|
||||||
"db:studio": "pnpm --filter @thebet365/api db:studio"
|
"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,7 @@ export const PARLAY_MAX_LEGS = 5;
|
|||||||
|
|
||||||
export * from './betting-rules';
|
export * from './betting-rules';
|
||||||
export * from './locale';
|
export * from './locale';
|
||||||
|
export * from './builtinPlayers';
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||