- Split admin users page into player/tier-1/tier-2 tabs with affiliation labels and context-specific create dialogs - Add media library with uploaded_files migration, list/delete unused files API, and admin nav route - Enforce player username format (alphanumeric 3-32) on frontend and backend via shared package - Improve admin dialog/panel styling; refine player parlay and match bet card kickoff display Co-authored-by: Cursor <cursoragent@cursor.com>
183 lines
5.1 KiB
TypeScript
183 lines
5.1 KiB
TypeScript
import { FormValidationError } from '../i18n/form-validation';
|
||
|
||
/** 玩家用户名:仅英文字母与数字,3–32 位 */
|
||
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
|
||
|
||
export function assertPlayerUsername(username: string): void {
|
||
const trimmed = username.trim();
|
||
if (!trimmed) throw new FormValidationError('err.username_required');
|
||
if (!PLAYER_USERNAME_PATTERN.test(trimmed)) {
|
||
throw new FormValidationError('err.username_player_invalid');
|
||
}
|
||
}
|
||
|
||
export interface PlayerCreateForm {
|
||
username: string;
|
||
password: string;
|
||
confirmPassword: string;
|
||
parentId: string;
|
||
phone: string;
|
||
email: string;
|
||
initialDeposit: number;
|
||
remark: string;
|
||
/** 创建为一级代理(非玩家) */
|
||
asTier1Agent: boolean;
|
||
creditLimit: number;
|
||
cashbackRate: number;
|
||
maxSingleDeposit: number;
|
||
maxDailyDeposit: number;
|
||
}
|
||
|
||
export interface PlayerEditForm {
|
||
id: string;
|
||
username: string;
|
||
status: string;
|
||
parentUsername: string | null;
|
||
affiliationAgents?: string[];
|
||
availableBalance: string;
|
||
frozenBalance: string;
|
||
betCount: number;
|
||
totalStake: string;
|
||
totalReturn: string;
|
||
createdAt: string;
|
||
lastLoginAt: string | null;
|
||
loginFailCount: number;
|
||
phone: string;
|
||
email: string;
|
||
managedPassword: string | null;
|
||
newPassword: string;
|
||
}
|
||
|
||
export interface PlayerRow {
|
||
id: string;
|
||
username: string;
|
||
status: string;
|
||
locale: string;
|
||
parentId: string | null;
|
||
parentUsername: string | null;
|
||
/** 归属代理链:一级代理、二级代理(如有) */
|
||
affiliationAgents?: string[];
|
||
phone: string | null;
|
||
email: string | null;
|
||
managedPassword: string | null;
|
||
availableBalance: string;
|
||
frozenBalance: string;
|
||
lastLoginAt: string | null;
|
||
betCount: number;
|
||
totalStake: string;
|
||
totalReturn: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
export interface PlayerDetail extends PlayerRow {
|
||
loginFailCount: number;
|
||
lockedUntil: string | null;
|
||
updatedAt: string;
|
||
}
|
||
|
||
/** 玩家归属标签,格式:玩家-平台 | 玩家-一级代理 | 玩家-一级代理-二级代理 */
|
||
export function formatPlayerAffiliationLabel(
|
||
row: Pick<PlayerRow, 'affiliationAgents'>,
|
||
playerLabel: string,
|
||
platformLabel: string,
|
||
): string {
|
||
const agents = row.affiliationAgents ?? [];
|
||
if (agents.length === 0) {
|
||
return [playerLabel, platformLabel].join('-');
|
||
}
|
||
return [playerLabel, ...agents].join('-');
|
||
}
|
||
|
||
export function emptyPlayerCreateForm(): PlayerCreateForm {
|
||
return {
|
||
username: '',
|
||
password: 'Player@123',
|
||
confirmPassword: 'Player@123',
|
||
parentId: '',
|
||
phone: '',
|
||
email: '',
|
||
initialDeposit: 0,
|
||
remark: '',
|
||
asTier1Agent: false,
|
||
creditLimit: 50000,
|
||
cashbackRate: 0,
|
||
maxSingleDeposit: 0,
|
||
maxDailyDeposit: 0,
|
||
};
|
||
}
|
||
|
||
export function emptyPlayerEditForm(): PlayerEditForm {
|
||
return {
|
||
id: '',
|
||
username: '',
|
||
status: 'ACTIVE',
|
||
parentUsername: null,
|
||
availableBalance: '0',
|
||
frozenBalance: '0',
|
||
betCount: 0,
|
||
totalStake: '0',
|
||
totalReturn: '0',
|
||
createdAt: '',
|
||
lastLoginAt: null,
|
||
loginFailCount: 0,
|
||
phone: '',
|
||
email: '',
|
||
managedPassword: null,
|
||
newPassword: '',
|
||
};
|
||
}
|
||
|
||
export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
||
return {
|
||
id: d.id,
|
||
username: d.username,
|
||
status: d.status,
|
||
parentUsername: d.parentUsername,
|
||
affiliationAgents: d.affiliationAgents,
|
||
availableBalance: d.availableBalance,
|
||
frozenBalance: d.frozenBalance,
|
||
betCount: d.betCount,
|
||
totalStake: d.totalStake,
|
||
totalReturn: d.totalReturn,
|
||
createdAt: d.createdAt,
|
||
lastLoginAt: d.lastLoginAt,
|
||
loginFailCount: d.loginFailCount,
|
||
phone: d.phone ?? '',
|
||
email: d.email ?? '',
|
||
managedPassword: d.managedPassword ?? null,
|
||
newPassword: '',
|
||
};
|
||
}
|
||
|
||
export function buildCreatePlayerPayload(form: PlayerCreateForm) {
|
||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
||
if (form.asTier1Agent) {
|
||
if (form.parentId) throw new FormValidationError('err.agent_no_parent');
|
||
if (form.initialDeposit > 0) throw new FormValidationError('err.agent_no_initial_deposit');
|
||
if (form.creditLimit < 0) throw new FormValidationError('err.credit_negative');
|
||
return {
|
||
username: form.username.trim(),
|
||
password: form.password,
|
||
phone: form.phone.trim() || undefined,
|
||
email: form.email.trim() || undefined,
|
||
asTier1Agent: true,
|
||
creditLimit: form.creditLimit,
|
||
cashbackRate: form.cashbackRate,
|
||
maxSingleDeposit: form.maxSingleDeposit > 0 ? form.maxSingleDeposit : undefined,
|
||
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
|
||
};
|
||
}
|
||
assertPlayerUsername(form.username);
|
||
return {
|
||
username: form.username.trim(),
|
||
password: form.password,
|
||
parentId: form.parentId || undefined,
|
||
phone: form.phone.trim() || undefined,
|
||
email: form.email.trim() || undefined,
|
||
initialDeposit: form.initialDeposit > 0 ? form.initialDeposit : undefined,
|
||
remark: form.remark.trim() || undefined,
|
||
};
|
||
}
|