feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

@@ -0,0 +1,86 @@
/** 与后台 settlement 模板顺序一致,用于列内排序 */
export const FT_CORRECT_SCORE_ORDER = [
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
];
export const HT_CORRECT_SCORE_ORDER = [
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
];
export type CsColumn = 'home' | 'draw' | 'away';
export interface CsSelection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
scoreDisplay: string;
}
export function isCorrectScoreMarket(marketType: string) {
return marketType.includes('CORRECT_SCORE');
}
function orderForMarket(marketType: string) {
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_ORDER;
return HT_CORRECT_SCORE_ORDER;
}
export function parseScoreCode(code: string): { display: string; column: CsColumn } | null {
if (code === 'OTHER_HOME') return { display: '其它', column: 'home' };
if (code === 'OTHER_DRAW') return { display: '其它', column: 'draw' };
if (code === 'OTHER_AWAY') return { display: '其它', column: 'away' };
const m = code.match(/^SCORE_(\d+)_(\d+)$/);
if (!m) return null;
const h = Number(m[1]);
const a = Number(m[2]);
if (h > a) return { display: `${h}:${a}`, column: 'home' };
if (h < a) return { display: `${h}:${a}`, column: 'away' };
return { display: `${h}:${a}`, column: 'draw' };
}
function sortByTemplate(items: CsSelection[], template: string[]) {
return [...items].sort((a, b) => {
const ia = template.indexOf(a.selectionCode);
const ib = template.indexOf(b.selectionCode);
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib);
});
}
export function groupCorrectScoreSelections(
selections: Array<{
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
}>,
marketType: string,
) {
const template = orderForMarket(marketType);
const home: CsSelection[] = [];
const draw: CsSelection[] = [];
const away: CsSelection[] = [];
for (const sel of selections) {
const parsed = parseScoreCode(sel.selectionCode);
if (!parsed) continue;
const row: CsSelection = { ...sel, scoreDisplay: parsed.display };
if (parsed.column === 'home') home.push(row);
else if (parsed.column === 'draw') draw.push(row);
else away.push(row);
}
return {
home: sortByTemplate(home, template),
draw: sortByTemplate(draw, template),
away: sortByTemplate(away, template),
};
}

View File

@@ -0,0 +1,2 @@
// 兼容旧缓存:转发到 TypeScript 源文件
export { getLocaleDisplay, parseAmount, sumAmounts, formatMoney } from './localeDisplay.ts';

View File

@@ -0,0 +1,55 @@
export interface LocaleDisplay {
countryCode: 'cn' | 'us' | 'my';
currency: string;
label: string;
}
const LOCALE_DISPLAY: Record<string, LocaleDisplay> = {
'zh-CN': { countryCode: 'cn', currency: 'CNY', label: '中文' },
'en-US': { countryCode: 'us', currency: 'USD', label: 'English' },
'ms-MY': { countryCode: 'my', currency: 'MYR', label: 'BM' },
};
export function getLocaleDisplay(locale: string): LocaleDisplay {
return LOCALE_DISPLAY[locale] ?? LOCALE_DISPLAY['en-US'];
}
export function parseAmount(amount: unknown): number {
if (amount == null) return 0;
if (typeof amount === 'number') return Number.isFinite(amount) ? amount : 0;
if (typeof amount === 'string') {
const n = Number(amount);
return Number.isFinite(n) ? n : 0;
}
if (typeof amount === 'object') {
const value = amount as { toString?: () => string; d?: number[] };
if (typeof value.toString === 'function') {
const n = Number(value.toString());
if (Number.isFinite(n)) return n;
}
if (Array.isArray(value.d) && value.d.length) {
const n = Number(value.d.join(''));
return Number.isFinite(n) ? n : 0;
}
}
return 0;
}
export function sumAmounts(...amounts: unknown[]): number {
return amounts.reduce<number>((sum, item) => sum + parseAmount(item), 0);
}
export function formatMoney(amount: unknown, locale: string): string {
const value = parseAmount(amount);
const { currency } = getLocaleDisplay(locale);
try {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
} catch {
return `${getLocaleDisplay(locale).currency} ${value.toFixed(2)}`;
}
}

View File

@@ -0,0 +1,33 @@
/** 详情页展示顺序(与产品设计稿一致) */
export const FEATURED_MARKET_TYPES = [
'FT_CORRECT_SCORE',
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
] as const;
export const GRID_MARKET_TYPES = [
'FT_HANDICAP',
'FT_OVER_UNDER',
'FT_1X2',
'FT_ODD_EVEN',
'HT_HANDICAP',
'HT_OVER_UNDER',
'HT_1X2',
] as const;
export type CatalogMarketType =
| (typeof FEATURED_MARKET_TYPES)[number]
| (typeof GRID_MARKET_TYPES)[number];
export const MARKET_I18N_KEY: Record<string, string> = {
FT_CORRECT_SCORE: 'bet.market_cs',
HT_CORRECT_SCORE: 'bet.market_ht_cs',
SH_CORRECT_SCORE: 'bet.market_sh_cs',
FT_HANDICAP: 'bet.market_ft_handicap',
FT_OVER_UNDER: 'bet.market_ft_ou',
FT_1X2: 'bet.market_ft_1x2',
FT_ODD_EVEN: 'bet.market_ft_oe',
HT_HANDICAP: 'bet.market_ht_handicap',
HT_OVER_UNDER: 'bet.market_ht_ou',
HT_1X2: 'bet.market_ht_1x2',
};

View File

@@ -0,0 +1,23 @@
/** 串关列表表头与玩法类型 */
export const PARLAY_MARKET_TYPES = [
{ key: 'FT_HANDICAP', label: 'FT.HDP' },
{ key: 'FT_OVER_UNDER', label: 'FT.O/U' },
{ key: 'FT_1X2', label: 'FT.1X2' },
{ key: 'FT_ODD_EVEN', label: 'FT.O/E' },
{ key: 'HT_HANDICAP', label: '1H.HDP' },
{ key: 'HT_OVER_UNDER', label: '1H.O/U' },
{ key: 'HT_1X2', label: '1H.1X2' },
] as const;
export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key'];
/** 选项简称(串关格内展示) */
export const SELECTION_SHORT: Record<string, string> = {
HOME: '主',
AWAY: '客',
DRAW: '和',
OVER: '大',
UNDER: '小',
ODD: '单',
EVEN: '双',
};

View File

@@ -0,0 +1,94 @@
/** 球队 code / 名称 → ISO 3166-1 alpha-2用于 flagcdn 国旗图 */
const CODE_TO_ISO: Record<string, string> = {
MEX: 'mx',
USA: 'us',
CAN: 'ca',
BRA: 'br',
ARG: 'ar',
ENG: 'gb',
MUN: 'gb',
CHE: 'gb',
LIV: 'gb',
MCI: 'gb',
CZE: 'cz',
KOR: 'kr',
BIH: 'ba',
PAR: 'py',
RSA: 'za',
SUI: 'ch',
SCO: 'gb-sct',
TUR: 'tr',
CZE: 'cz',
BIH: 'ba',
FRA: 'fr',
ESP: 'es',
ENG: 'gb',
GER: 'de',
POR: 'pt',
NED: 'nl',
NOR: 'no',
BEL: 'be',
COL: 'co',
JPN: 'jp',
MAR: 'ma',
CRO: 'hr',
SEN: 'sn',
};
const NAME_TO_ISO: Record<string, string> = {
Mexico: 'mx',
'South Africa': 'za',
'United States': 'us',
USA: 'us',
Canada: 'ca',
Brazil: 'br',
Switzerland: 'ch',
Scotland: 'gb-sct',
Turkey: 'tr',
'South Korea': 'kr',
Paraguay: 'py',
西: 'mx',
: 'us',
: 'ca',
: 'gb',
西: 'gb',
西: 'mx',
: 'za',
: 'cz',
: 'kr',
: 'ba',
: 'py',
: 'ch',
西: 'br',
: 'gb-sct',
: 'tr',
: 'fr',
: 'ar',
: 'fr',
西: 'es',
: 'gb',
: 'de',
: 'pt',
: 'nl',
: 'no',
: 'be',
: 'co',
: 'jp',
: 'ma',
: 'hr',
: 'sn',
};
export function teamFlagUrl(code?: string, name?: string): string {
const key = (code ?? '').toUpperCase();
if (key && CODE_TO_ISO[key]) {
return `https://flagcdn.com/w40/${CODE_TO_ISO[key]}.png`;
}
if (name && NAME_TO_ISO[name]) {
return `https://flagcdn.com/w40/${NAME_TO_ISO[name]}.png`;
}
if (key.length === 2) {
return `https://flagcdn.com/w40/${key.toLowerCase()}.png`;
}
return '';
}