feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情
新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
71
apps/admin/src/utils/agent-credit-context.ts
Normal file
71
apps/admin/src/utils/agent-credit-context.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { AgentDetail, AgentRow } from '../views/agent-form';
|
||||
import type { AgentSubAgentRow } from '../views/agent/agent-sub-agent-form';
|
||||
import api from '../api';
|
||||
|
||||
export type AgentCreditSnapshot = {
|
||||
username: string;
|
||||
level?: number;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
directPlayerLiability?: string;
|
||||
childAgentExposure?: string;
|
||||
};
|
||||
|
||||
export type AgentCreditAdjustContext = {
|
||||
target: AgentCreditSnapshot;
|
||||
parent?: AgentCreditSnapshot;
|
||||
};
|
||||
|
||||
function dec(value: string | number | null | undefined): number {
|
||||
const n = Number(value ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
export function snapshotFromAgentRow(
|
||||
row: Pick<
|
||||
AgentRow | AgentDetail | AgentSubAgentRow,
|
||||
'username' | 'creditLimit' | 'usedCredit' | 'availableCredit' | 'level'
|
||||
> & {
|
||||
directPlayerLiability?: string;
|
||||
childAgentExposure?: string;
|
||||
},
|
||||
): AgentCreditSnapshot {
|
||||
return {
|
||||
username: row.username,
|
||||
level: row.level,
|
||||
creditLimit: String(row.creditLimit),
|
||||
usedCredit: String(row.usedCredit),
|
||||
availableCredit: String(row.availableCredit),
|
||||
directPlayerLiability: row.directPlayerLiability,
|
||||
childAgentExposure: row.childAgentExposure,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAdminAgentCreditContext(userId: string): Promise<AgentCreditAdjustContext> {
|
||||
const { data } = await api.get(`/admin/agents/${userId}`);
|
||||
const detail = data.data as AgentDetail;
|
||||
const target = snapshotFromAgentRow(detail);
|
||||
if (!detail.parentAgentId) {
|
||||
return { target };
|
||||
}
|
||||
const { data: parentRes } = await api.get(`/admin/agents/${detail.parentAgentId}`);
|
||||
const parentDetail = parentRes.data as AgentDetail;
|
||||
return {
|
||||
target,
|
||||
parent: snapshotFromAgentRow(parentDetail),
|
||||
};
|
||||
}
|
||||
|
||||
/** 下级代理增信时,正数调整量上限约等于上级可用授信(与后端 exposure 校验一致) */
|
||||
export function maxCreditIncreaseAmount(ctx: AgentCreditAdjustContext | null): number | undefined {
|
||||
if (!ctx?.parent) return undefined;
|
||||
return Math.max(0, dec(ctx.parent.availableCredit));
|
||||
}
|
||||
|
||||
export function projectedCreditLimit(ctx: AgentCreditAdjustContext | null, adjustAmount: number): string | null {
|
||||
if (!ctx || !Number.isFinite(adjustAmount)) return null;
|
||||
const after = dec(ctx.target.creditLimit) + adjustAmount;
|
||||
if (after < 0) return null;
|
||||
return String(after);
|
||||
}
|
||||
17
apps/admin/src/utils/expandable-table.ts
Normal file
17
apps/admin/src/utils/expandable-table.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Skip row-click expand when the user interacts with controls inside the row. */
|
||||
export function isExpandRowInteractiveClick(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return !!target.closest(
|
||||
'.action-btns, .el-button, .el-link, a, input, textarea, .el-input, .el-select, .el-checkbox, .el-switch, .el-dropdown',
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldToggleExpandOnRowClick(event: MouseEvent): boolean {
|
||||
const el = event.target as HTMLElement;
|
||||
if (el.closest('.el-table__expand-icon')) return false;
|
||||
return !isExpandRowInteractiveClick(event.target);
|
||||
}
|
||||
|
||||
export function expandableTableRowClassName(): string {
|
||||
return 'row-expandable';
|
||||
}
|
||||
79
apps/admin/src/utils/session-hydrate.ts
Normal file
79
apps/admin/src/utils/session-hydrate.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
reconcileStaffSessionFromToken,
|
||||
useAuthStore,
|
||||
type StaffUser,
|
||||
type StaffUserType,
|
||||
} from '../stores/auth';
|
||||
|
||||
let hydratePromise: Promise<boolean> | null = null;
|
||||
|
||||
function isStaffUserType(value: unknown): value is StaffUserType {
|
||||
return value === 'ADMIN' || value === 'AGENT';
|
||||
}
|
||||
|
||||
export function resetStaffSessionHydration() {
|
||||
hydratePromise = null;
|
||||
}
|
||||
|
||||
function hasCompleteStaffUser(u: StaffUser | null | undefined): u is StaffUser {
|
||||
return !!(u?.id && u.username && u.userType);
|
||||
}
|
||||
|
||||
/** Sync manage_user from JWT + /manage/auth/me (fixes stale localStorage userType). */
|
||||
export async function hydrateStaffSession(): Promise<boolean> {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token.value) return false;
|
||||
if (hydratePromise) return hydratePromise;
|
||||
|
||||
hydratePromise = (async () => {
|
||||
reconcileStaffSessionFromToken();
|
||||
|
||||
if (!hasCompleteStaffUser(auth.user.value)) {
|
||||
auth.clearStaffSession();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { default: api } = await import('../api');
|
||||
const { data } = await api.get('/manage/auth/me');
|
||||
const raw = data.data as Partial<StaffUser>;
|
||||
if (!raw?.id || !raw.username || !isStaffUserType(raw.userType)) {
|
||||
return true;
|
||||
}
|
||||
auth.setSession(auth.token.value, {
|
||||
id: raw.id,
|
||||
username: raw.username,
|
||||
userType: raw.userType,
|
||||
locale: raw.locale,
|
||||
role: raw.role,
|
||||
agentLevel: typeof raw.agentLevel === 'number' ? raw.agentLevel : null,
|
||||
});
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 401) {
|
||||
auth.clearStaffSession();
|
||||
return false;
|
||||
}
|
||||
return hasCompleteStaffUser(auth.user.value);
|
||||
} finally {
|
||||
hydratePromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return hydratePromise;
|
||||
}
|
||||
|
||||
/** Run before any authenticated route — JWT reconcile + optional /me refresh. */
|
||||
export async function ensureStaffSession(): Promise<boolean> {
|
||||
reconcileStaffSessionFromToken();
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token.value) return false;
|
||||
if (!hasCompleteStaffUser(auth.user.value)) {
|
||||
reconcileStaffSessionFromToken();
|
||||
}
|
||||
if (!hasCompleteStaffUser(auth.user.value)) {
|
||||
return false;
|
||||
}
|
||||
return hydrateStaffSession();
|
||||
}
|
||||
51
apps/admin/src/utils/wallet-transfer-context.ts
Normal file
51
apps/admin/src/utils/wallet-transfer-context.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface WalletTransferCreditContext {
|
||||
agentId: string;
|
||||
agentUsername: string;
|
||||
agentLevel: number;
|
||||
creditLimit: string;
|
||||
usedCredit: string;
|
||||
availableCredit: string;
|
||||
maxSingleDeposit: string | null;
|
||||
maxDailyDeposit: string | null;
|
||||
dailyDepositUsed: string | null;
|
||||
appliesDepositLimits: boolean;
|
||||
}
|
||||
|
||||
export interface WalletTransferContext {
|
||||
player: {
|
||||
id: string;
|
||||
username: string;
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
};
|
||||
credit: WalletTransferCreditContext | null;
|
||||
}
|
||||
|
||||
export function parseCreditAvailable(ctx: WalletTransferContext | null): number {
|
||||
const n = Number(ctx?.credit?.availableCredit ?? NaN);
|
||||
return Number.isFinite(n) ? Math.max(0, n) : Infinity;
|
||||
}
|
||||
|
||||
export function parsePlayerAvailable(ctx: WalletTransferContext | null): number {
|
||||
const n = Number(ctx?.player?.availableBalance ?? NaN);
|
||||
return Number.isFinite(n) ? Math.max(0, n) : 0;
|
||||
}
|
||||
|
||||
/** 上分金额上限:可用授信;代理端再叠加单笔/日限 */
|
||||
export function depositAmountCap(ctx: WalletTransferContext | null): number | undefined {
|
||||
if (!ctx?.credit) return undefined;
|
||||
const available = parseCreditAvailable(ctx);
|
||||
if (!Number.isFinite(available)) return undefined;
|
||||
let cap = available;
|
||||
if (ctx.credit.appliesDepositLimits) {
|
||||
const single = ctx.credit.maxSingleDeposit ? Number(ctx.credit.maxSingleDeposit) : Infinity;
|
||||
if (Number.isFinite(single)) cap = Math.min(cap, single);
|
||||
if (ctx.credit.maxDailyDeposit) {
|
||||
const dailyRem =
|
||||
Number(ctx.credit.maxDailyDeposit) - Number(ctx.credit.dailyDepositUsed ?? 0);
|
||||
if (Number.isFinite(dailyRem)) cap = Math.min(cap, Math.max(0, dailyRem));
|
||||
}
|
||||
}
|
||||
if (cap <= 0) return 0;
|
||||
return cap;
|
||||
}
|
||||
Reference in New Issue
Block a user