feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情

新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 15:34:12 +08:00
parent b2216abd0c
commit 414998ce36
54 changed files with 6641 additions and 481 deletions

View 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);
}

View 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';
}

View 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();
}

View 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;
}