Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
933 lines
32 KiB
TypeScript
933 lines
32 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { toast } from "sonner";
|
||
|
||
import {
|
||
deleteAgentNode,
|
||
getAgentNodeProfile,
|
||
getAgentTree,
|
||
postAgentNode,
|
||
putAgentNode,
|
||
putAgentNodeProfile,
|
||
} from "@/api/admin-agents";
|
||
import {
|
||
AgentLineDetailPanel,
|
||
type AgentDetailTab,
|
||
} from "@/modules/agents/agent-line-detail-panel";
|
||
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
|
||
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
|
||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||
import {
|
||
percentUiToRatio,
|
||
percentValueToUi,
|
||
parsePercentUi,
|
||
ratioToPercentUi,
|
||
} from "@/lib/admin-rate-percent";
|
||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||
import {
|
||
PRD_AGENT_MANAGE,
|
||
PRD_AGENT_PROFILE_MANAGE,
|
||
PRD_AGENTS_ACCESS_ANY,
|
||
PRD_USERS_MANAGE,
|
||
} from "@/lib/admin-prd";
|
||
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
|
||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||
import { useAdminProfile } from "@/stores/admin-session";
|
||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
|
||
import { LotteryApiBizError } from "@/types/api/errors";
|
||
|
||
function parseRiskTagsInput(text: string): string[] {
|
||
return Array.from(
|
||
new Set(
|
||
text
|
||
.split(/[,,\s]+/)
|
||
.map((tag) => tag.trim())
|
||
.filter((tag) => tag.length > 0),
|
||
),
|
||
);
|
||
}
|
||
|
||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||
const out: AgentNodeRow[] = [];
|
||
const walk = (list: AgentNodeRow[]) => {
|
||
for (const node of list) {
|
||
out.push(node);
|
||
if (node.children?.length) {
|
||
walk(node.children);
|
||
}
|
||
}
|
||
};
|
||
walk(nodes);
|
||
|
||
return out;
|
||
}
|
||
|
||
export function AgentsConsole(): React.ReactElement {
|
||
const { t } = useTranslation(["agents", "common"]);
|
||
const tRef = useTranslationRef(["agents", "common"]);
|
||
const profile = useAdminProfile();
|
||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||
|
||
const isSuperAdmin = profile?.is_super_admin === true;
|
||
const canManageNode =
|
||
isSuperAdmin || adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]);
|
||
const canViewAgents =
|
||
isSuperAdmin ||
|
||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENTS_ACCESS_ANY]);
|
||
const canManageProfile =
|
||
isSuperAdmin ||
|
||
adminHasAnyPermission(profile?.permissions, [
|
||
PRD_AGENT_PROFILE_MANAGE,
|
||
PRD_AGENT_MANAGE,
|
||
]);
|
||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||
const [tree, setTree] = useState<AgentNodeRow[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [keyword, setKeyword] = useState("");
|
||
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
|
||
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
|
||
const [profileSaving, setProfileSaving] = useState(false);
|
||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||
|
||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||
const [nodeName, setNodeName] = useState("");
|
||
const [nodeStatus, setNodeStatus] = useState(1);
|
||
const [nodeUsername, setNodeUsername] = useState("");
|
||
const [nodePassword, setNodePassword] = useState("");
|
||
const [nodeSaving, setNodeSaving] = useState(false);
|
||
const [profileShareRate, setProfileShareRate] = useState("0");
|
||
const [profileCreditLimit, setProfileCreditLimit] = useState("0");
|
||
const [profileRebateLimit, setProfileRebateLimit] = useState("0");
|
||
const [profileDefaultRebate, setProfileDefaultRebate] = useState("0");
|
||
const [profileSettlementCycle, setProfileSettlementCycle] = useState<
|
||
"daily" | "weekly" | "monthly"
|
||
>("weekly");
|
||
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
|
||
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
|
||
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
|
||
const [profileRiskTags, setProfileRiskTags] = useState("");
|
||
const [profileLoading, setProfileLoading] = useState(false);
|
||
const [profileLoaded, setProfileLoaded] = useState(true);
|
||
const [profileParentCaps, setProfileParentCaps] = useState<AgentParentCaps | null>(null);
|
||
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
||
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
||
|
||
const boundAgent = profile?.agent ?? null;
|
||
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
||
const canCreateChildAgent =
|
||
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
||
const hasUsersManagePermission =
|
||
isSuperAdmin ||
|
||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
|
||
|
||
const resetProfileForm = (mode: "create" | "edit" = "create") => {
|
||
setProfileShareRate("0");
|
||
setProfileCreditLimit("0");
|
||
setProfileRebateLimit("0");
|
||
setProfileDefaultRebate("0");
|
||
setProfileSettlementCycle("weekly");
|
||
setProfileExtraRebate(false);
|
||
setProfileCanCreateChild(mode === "create" ? false : false);
|
||
setProfileCanCreatePlayer(true);
|
||
setProfileRiskTags("");
|
||
setProfileParentCaps(null);
|
||
setProfileAvailableCredit(null);
|
||
};
|
||
|
||
const applyProfileRowToForm = (row: AgentProfileRow) => {
|
||
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
|
||
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
||
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
|
||
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
|
||
setProfileSettlementCycle(normalizeAgentSettlementCycle(row.settlement_cycle));
|
||
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
|
||
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
|
||
setProfileCanCreatePlayer(row.can_create_player !== false);
|
||
setProfileParentCaps(row.parent_caps ?? null);
|
||
setProfileAvailableCredit(row.available_credit ?? null);
|
||
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
||
};
|
||
|
||
const profilePayload = () => ({
|
||
total_share_rate: Number.parseFloat(profileShareRate) || 0,
|
||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||
rebate_limit: percentUiToRatio(profileRebateLimit),
|
||
default_player_rebate: percentUiToRatio(profileDefaultRebate),
|
||
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
|
||
can_grant_extra_rebate: profileExtraRebate,
|
||
can_create_child_agent: profileCanCreateChild,
|
||
can_create_player: profileCanCreatePlayer,
|
||
risk_tags: parseRiskTagsInput(profileRiskTags),
|
||
});
|
||
|
||
const validateProfileFields = (): string | null => {
|
||
const shareRate = Number.parseFloat(profileShareRate);
|
||
const creditLimit = Number.parseInt(profileCreditLimit, 10);
|
||
const rebateLimit = parsePercentUi(profileRebateLimit);
|
||
const defaultRebate = parsePercentUi(profileDefaultRebate);
|
||
|
||
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
|
||
return t("profile.validation.shareRange", {
|
||
defaultValue: "占成比例须在 0–100 之间",
|
||
});
|
||
}
|
||
|
||
if (Number.isNaN(creditLimit) || creditLimit < 0) {
|
||
return t("profile.validation.creditInvalid", {
|
||
defaultValue: "授信额度不能为负数",
|
||
});
|
||
}
|
||
|
||
if (rebateLimit === null || rebateLimit < 0 || rebateLimit > 100) {
|
||
return t("profile.validation.rebateLimitRange", {
|
||
defaultValue: "回水上限须在 0–100% 之间",
|
||
});
|
||
}
|
||
|
||
if (defaultRebate === null || defaultRebate < 0 || defaultRebate > 100) {
|
||
return t("profile.validation.defaultRebateRange", {
|
||
defaultValue: "默认玩家回水须在 0–100% 之间",
|
||
});
|
||
}
|
||
|
||
if (rebateLimit > 0 && defaultRebate > rebateLimit) {
|
||
return t("profile.validation.defaultExceedsLimit", {
|
||
defaultValue: "默认玩家回水不能超过回水上限",
|
||
});
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
|
||
const parentNameMap = useMemo(
|
||
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
|
||
[flatNodes],
|
||
);
|
||
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
|
||
const selectedSiteLabel = useMemo(
|
||
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
|
||
[adminSiteId, siteOptions],
|
||
);
|
||
const activeSiteCode = useMemo(() => {
|
||
const fromAgent = boundAgent?.site_code?.trim();
|
||
if (fromAgent) {
|
||
return fromAgent;
|
||
}
|
||
const fromSite = siteOptions.find((site) => site.id === adminSiteId)?.code?.trim();
|
||
if (fromSite) {
|
||
return fromSite;
|
||
}
|
||
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
|
||
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
|
||
const rootNode = useMemo(
|
||
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
|
||
[flatNodes],
|
||
);
|
||
|
||
const selectedNode = useMemo(
|
||
() =>
|
||
selectedNodeId !== null
|
||
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
|
||
: null,
|
||
[flatNodes, selectedNodeId],
|
||
);
|
||
|
||
const isOwnAgentNode =
|
||
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
|
||
|
||
const canEditSelectedProfile =
|
||
canManageProfile && selectedNode !== null && (isSuperAdmin || !isOwnAgentNode);
|
||
|
||
const selectedChildAgents = useMemo(() => {
|
||
if (selectedNode === null) {
|
||
return [];
|
||
}
|
||
|
||
return flatNodes.filter((node) => node.parent_id === selectedNode.id);
|
||
}, [flatNodes, selectedNode]);
|
||
|
||
const childCountById = useMemo(() => {
|
||
const counts = new Map<number, number>();
|
||
for (const node of flatNodes) {
|
||
if (node.parent_id === null) {
|
||
continue;
|
||
}
|
||
counts.set(node.parent_id, (counts.get(node.parent_id) ?? 0) + 1);
|
||
}
|
||
return counts;
|
||
}, [flatNodes]);
|
||
|
||
const filteredRows = useMemo(() => {
|
||
const normalized = keyword.trim().toLowerCase();
|
||
|
||
return businessRows.filter((node) => {
|
||
if (normalized === "") {
|
||
return true;
|
||
}
|
||
|
||
const parentName =
|
||
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
|
||
|
||
return [node.name, node.code, node.username ?? "", parentName]
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(normalized);
|
||
});
|
||
}, [businessRows, keyword, parentNameMap]);
|
||
|
||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||
setLoading(true);
|
||
setErr(null);
|
||
try {
|
||
const data = await getAgentTree(siteId ?? undefined);
|
||
setTree(data.tree);
|
||
setAdminSiteId(data.admin_site_id);
|
||
} catch (e) {
|
||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||
setTree([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [tRef]);
|
||
|
||
useEffect(() => {
|
||
if (!canViewAgents) {
|
||
return;
|
||
}
|
||
|
||
if (adminSiteId === null) {
|
||
if (profile?.agent?.admin_site_id) {
|
||
setAdminSiteId(profile.agent.admin_site_id);
|
||
return;
|
||
}
|
||
if (siteOptions.length > 0 && isSuperAdmin) {
|
||
setAdminSiteId(siteOptions[0]?.id ?? null);
|
||
}
|
||
}
|
||
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||
|
||
useAsyncEffect(() => {
|
||
if (selectedNode === null) {
|
||
setSelectedProfile(null);
|
||
setSelectedProfileLoading(false);
|
||
return;
|
||
}
|
||
|
||
setSelectedProfileLoading(true);
|
||
void getAgentNodeProfile(selectedNode.id)
|
||
.then((row) => {
|
||
setSelectedProfile(row);
|
||
if (!nodeDialogOpen) {
|
||
applyProfileRowToForm(row);
|
||
}
|
||
})
|
||
.catch(() => setSelectedProfile(null))
|
||
.finally(() => setSelectedProfileLoading(false));
|
||
}, [selectedNode?.id, nodeDialogOpen]);
|
||
|
||
useAsyncEffect(() => {
|
||
if (rootNode === null) {
|
||
setRootProfile(null);
|
||
return;
|
||
}
|
||
|
||
void getAgentNodeProfile(rootNode.id)
|
||
.then((p) => setRootProfile(p))
|
||
.catch(() => setRootProfile(null));
|
||
}, [rootNode?.id]);
|
||
|
||
/** 仅上级/平台维护下级占成授信;代理查看自己时不展示配置 Tab */
|
||
const canShowProfileTab = canEditSelectedProfile;
|
||
|
||
const canShowDownlineTab = useMemo(
|
||
() =>
|
||
selectedNode !== null &&
|
||
!selectedProfileLoading &&
|
||
selectedProfile?.can_create_child_agent === true,
|
||
[selectedNode, selectedProfile, selectedProfileLoading],
|
||
);
|
||
|
||
const canShowPlayersTab = useMemo(
|
||
() =>
|
||
selectedNode !== null &&
|
||
!selectedProfileLoading &&
|
||
selectedProfile?.can_create_player === true &&
|
||
hasUsersManagePermission,
|
||
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
|
||
);
|
||
|
||
const canCreateChildOnSelected = useMemo(
|
||
() => canManageNode && selectedProfile?.can_create_child_agent === true,
|
||
[canManageNode, selectedProfile?.can_create_child_agent],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (selectedProfileLoading || selectedNode === null) {
|
||
return;
|
||
}
|
||
|
||
if (detailTab === "profile" && !canShowProfileTab) {
|
||
setDetailTab("overview");
|
||
} else if (detailTab === "downline" && !canShowDownlineTab) {
|
||
setDetailTab(canShowPlayersTab ? "players" : "overview");
|
||
} else if (detailTab === "players" && !canShowPlayersTab) {
|
||
setDetailTab(canShowDownlineTab ? "downline" : "overview");
|
||
}
|
||
}, [
|
||
canShowDownlineTab,
|
||
canShowPlayersTab,
|
||
canShowProfileTab,
|
||
detailTab,
|
||
selectedNode,
|
||
selectedProfileLoading,
|
||
]);
|
||
|
||
useAsyncEffect(() => {
|
||
if (filteredRows.length === 0) {
|
||
setSelectedNodeId(null);
|
||
return;
|
||
}
|
||
|
||
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) {
|
||
setSelectedNodeId(filteredRows[0]?.id ?? null);
|
||
}
|
||
}, [filteredRows, selectedNodeId]);
|
||
|
||
useEffect(() => {
|
||
setDetailTab("overview");
|
||
}, [selectedNodeId]);
|
||
|
||
useEffect(() => {
|
||
if (isOwnAgentNode && detailTab === "profile") {
|
||
setDetailTab("overview");
|
||
}
|
||
}, [detailTab, isOwnAgentNode]);
|
||
|
||
useAsyncEffect(() => {
|
||
if (adminSiteId !== null) {
|
||
void loadTree(adminSiteId);
|
||
}
|
||
}, [adminSiteId, loadTree]);
|
||
|
||
const openCreateChildForNode = (node: AgentNodeRow) => {
|
||
setNodeDialogMode("create");
|
||
setTargetParentId(node.id);
|
||
setEditingNodeId(null);
|
||
setNodeName("");
|
||
setNodeStatus(1);
|
||
setNodeUsername("");
|
||
setNodePassword("");
|
||
resetProfileForm("create");
|
||
setProfileLoading(false);
|
||
setProfileLoaded(true);
|
||
setEditingNodeNeedsPrimaryAccount(false);
|
||
setNodeDialogOpen(true);
|
||
if (canManageProfile) {
|
||
void getAgentNodeProfile(node.id)
|
||
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
|
||
.catch(() => setProfileParentCaps(null));
|
||
}
|
||
};
|
||
|
||
const openEditForNode = (node: AgentNodeRow) => {
|
||
setNodeDialogMode("edit");
|
||
setTargetParentId(node.parent_id);
|
||
setEditingNodeId(node.id);
|
||
setNodeName(node.name);
|
||
setNodeStatus(node.status);
|
||
setNodeUsername(node.username ?? "");
|
||
setEditingNodeNeedsPrimaryAccount((node.username ?? "").trim() === "");
|
||
setNodePassword("");
|
||
resetProfileForm("edit");
|
||
setNodeDialogOpen(true);
|
||
|
||
if (!canManageProfile) {
|
||
setProfileLoading(false);
|
||
setProfileLoaded(true);
|
||
return;
|
||
}
|
||
|
||
setProfileLoading(true);
|
||
setProfileLoaded(false);
|
||
void getAgentNodeProfile(node.id)
|
||
.then((p) => {
|
||
applyProfileRowToForm(p);
|
||
setProfileLoaded(true);
|
||
})
|
||
.catch(() => {
|
||
toast.error(
|
||
t("profile.loadFailed", { defaultValue: "加载占成与授信失败,请关闭后重试" }),
|
||
);
|
||
setProfileLoaded(false);
|
||
})
|
||
.finally(() => {
|
||
setProfileLoading(false);
|
||
});
|
||
};
|
||
|
||
const canDeleteNode = (node: AgentNodeRow): boolean => {
|
||
const blockedByChildren = (node.children?.length ?? 0) > 0;
|
||
const blockedBySelf = profile?.agent?.id === node.id;
|
||
|
||
return canManageNode && !blockedByChildren && !blockedBySelf;
|
||
};
|
||
|
||
const handleDeleteNode = (node: AgentNodeRow): void => {
|
||
if (!canDeleteNode(node)) {
|
||
return;
|
||
}
|
||
|
||
requestConfirm({
|
||
title: t("deleteNode", { defaultValue: "删除代理" }),
|
||
description: t("deleteNodeConfirm", {
|
||
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
|
||
}),
|
||
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
|
||
confirmVariant: "destructive",
|
||
onConfirm: async () => {
|
||
await deleteAgentNode(node.id);
|
||
toast.success(t("deleteSuccess", { name: node.name }));
|
||
if (selectedNodeId === node.id) {
|
||
setSelectedNodeId(null);
|
||
}
|
||
await loadTree(adminSiteId);
|
||
},
|
||
});
|
||
};
|
||
|
||
const saveInlineProfile = async (): Promise<void> => {
|
||
if (selectedNode === null || !canEditSelectedProfile) {
|
||
return;
|
||
}
|
||
|
||
const validationError = validateProfileFields();
|
||
if (validationError) {
|
||
toast.error(validationError);
|
||
return;
|
||
}
|
||
|
||
setProfileSaving(true);
|
||
try {
|
||
const updated = await putAgentNodeProfile(selectedNode.id, profilePayload());
|
||
setSelectedProfile(updated);
|
||
applyProfileRowToForm(updated);
|
||
toast.success(t("profile.saveSuccess", { defaultValue: "占成与授信已保存" }));
|
||
await loadTree(adminSiteId);
|
||
} catch (e) {
|
||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||
} finally {
|
||
setProfileSaving(false);
|
||
}
|
||
};
|
||
|
||
const inlineProfileFields = useMemo(() => {
|
||
if (!canShowProfileTab) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
disabled: !canEditSelectedProfile,
|
||
loading: selectedProfileLoading,
|
||
parentCaps: profileParentCaps,
|
||
availableCredit: profileAvailableCredit,
|
||
canCreateChildAgent,
|
||
isSuperAdmin,
|
||
shareRate: profileShareRate,
|
||
onShareRateChange: setProfileShareRate,
|
||
creditLimit: profileCreditLimit,
|
||
onCreditLimitChange: setProfileCreditLimit,
|
||
rebateLimit: profileRebateLimit,
|
||
onRebateLimitChange: setProfileRebateLimit,
|
||
defaultRebate: profileDefaultRebate,
|
||
onDefaultRebateChange: setProfileDefaultRebate,
|
||
settlementCycle: profileSettlementCycle,
|
||
onSettlementCycleChange: setProfileSettlementCycle,
|
||
extraRebate: profileExtraRebate,
|
||
onExtraRebateChange: setProfileExtraRebate,
|
||
canCreatePlayer: profileCanCreatePlayer,
|
||
onCanCreatePlayerChange: setProfileCanCreatePlayer,
|
||
canCreateChild: profileCanCreateChild,
|
||
onCanCreateChildChange: setProfileCanCreateChild,
|
||
riskTags: profileRiskTags,
|
||
onRiskTagsChange: setProfileRiskTags,
|
||
};
|
||
}, [
|
||
canCreateChildAgent,
|
||
canEditSelectedProfile,
|
||
canShowProfileTab,
|
||
isSuperAdmin,
|
||
profileAvailableCredit,
|
||
profileCanCreateChild,
|
||
profileCanCreatePlayer,
|
||
profileCreditLimit,
|
||
profileDefaultRebate,
|
||
profileExtraRebate,
|
||
profileParentCaps,
|
||
profileRebateLimit,
|
||
profileRiskTags,
|
||
profileSettlementCycle,
|
||
profileShareRate,
|
||
selectedProfileLoading,
|
||
]);
|
||
|
||
const showAgentSidebar = businessRows.length > 1;
|
||
|
||
const openAddAgent = (): void => {
|
||
const parent = selectedNode ?? rootNode;
|
||
if (parent !== null) {
|
||
openCreateChildForNode(parent);
|
||
}
|
||
};
|
||
|
||
const saveNode = async () => {
|
||
if (!nodeName.trim()) {
|
||
toast.error(t("nameRequired", { defaultValue: "请填写代理名称" }));
|
||
return;
|
||
}
|
||
|
||
if (!nodeUsername.trim()) {
|
||
toast.error(t("usernameRequired", { defaultValue: "请填写登录名" }));
|
||
return;
|
||
}
|
||
|
||
if (nodeDialogMode === "create") {
|
||
if (targetParentId === null) {
|
||
return;
|
||
}
|
||
if (!nodePassword.trim()) {
|
||
toast.error(t("passwordRequired", { defaultValue: "请填写密码" }));
|
||
return;
|
||
}
|
||
if (nodePassword.trim().length < 8) {
|
||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||
return;
|
||
}
|
||
} else if (nodePassword.trim() && nodePassword.trim().length < 8) {
|
||
toast.error(t("passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||
return;
|
||
} else if (nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount && !nodePassword.trim()) {
|
||
toast.error(
|
||
t("bindAccountPasswordRequired", {
|
||
defaultValue: "该代理尚未绑定登录账号,请填写初始密码以创建账号",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const includeProfileInDialog =
|
||
canManageProfile &&
|
||
(nodeDialogMode === "create" ||
|
||
(editingNodeId !== null && boundAgent?.id !== editingNodeId));
|
||
|
||
if (includeProfileInDialog) {
|
||
if (nodeDialogMode === "edit" && !profileLoaded) {
|
||
toast.error(
|
||
t("profile.loadingBlocked", {
|
||
defaultValue: "占成与授信尚未加载完成,请稍候再保存",
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const profileError = validateProfileFields();
|
||
if (profileError !== null) {
|
||
toast.error(profileError);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setNodeSaving(true);
|
||
try {
|
||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||
await postAgentNode({
|
||
parent_id: targetParentId,
|
||
name: nodeName.trim(),
|
||
username: nodeUsername.trim(),
|
||
password: nodePassword,
|
||
status: nodeStatus,
|
||
...(canManageProfile ? profilePayload() : {}),
|
||
});
|
||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||
await putAgentNode(editingNodeId, {
|
||
name: nodeName.trim(),
|
||
username: nodeUsername.trim(),
|
||
password: editingNodeNeedsPrimaryAccount
|
||
? nodePassword.trim()
|
||
: nodePassword.trim() || undefined,
|
||
status: nodeStatus,
|
||
});
|
||
if (includeProfileInDialog) {
|
||
await putAgentNodeProfile(editingNodeId, profilePayload());
|
||
}
|
||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||
}
|
||
|
||
setNodeDialogOpen(false);
|
||
await loadTree(adminSiteId);
|
||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
|
||
const created = refreshed.find(
|
||
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
|
||
);
|
||
if (created) {
|
||
setSelectedNodeId(created.id);
|
||
}
|
||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||
setSelectedNodeId(editingNodeId);
|
||
if (canManageProfile) {
|
||
void getAgentNodeProfile(editingNodeId)
|
||
.then((p) => {
|
||
if (selectedNodeId === editingNodeId || selectedNodeId === null) {
|
||
setSelectedProfile(p);
|
||
}
|
||
})
|
||
.catch(() => {
|
||
/* 树已刷新,占成区可能短暂不可用 */
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||
} finally {
|
||
setNodeSaving(false);
|
||
}
|
||
};
|
||
|
||
const addParent = selectedNode ?? rootNode;
|
||
const parentProfileForAdd = useMemo(() => {
|
||
if (addParent === null) {
|
||
return null;
|
||
}
|
||
if (addParent.id === selectedNodeId && selectedProfile !== null) {
|
||
return selectedProfile;
|
||
}
|
||
if (rootNode !== null && addParent.id === rootNode.id) {
|
||
return rootProfile;
|
||
}
|
||
|
||
return null;
|
||
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
|
||
|
||
if (!canViewAgents) {
|
||
return (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("noAccess", { defaultValue: "您没有代理经营相关权限,请联系管理员开通。" })}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
if (loading && tree.length === 0) {
|
||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-[32rem] flex-col gap-0">
|
||
<ConfirmDialog />
|
||
|
||
{err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
|
||
{showAgentSidebar ? (
|
||
<AgentLineSidebar
|
||
siteLabel={selectedSiteLabel}
|
||
tree={tree}
|
||
parentNameMap={parentNameMap}
|
||
selectedId={selectedNodeId}
|
||
keyword={keyword}
|
||
agentCount={businessRows.length}
|
||
onKeywordChange={(value) => {
|
||
setKeyword(value);
|
||
}}
|
||
onSelect={(node) => {
|
||
setSelectedNodeId(node.id);
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<AgentLineDetailPanel
|
||
node={selectedNode}
|
||
profile={selectedProfile}
|
||
profileLoading={selectedProfileLoading}
|
||
childAgents={selectedChildAgents}
|
||
childCountById={childCountById}
|
||
siteCode={activeSiteCode}
|
||
siteLabel={selectedSiteLabel}
|
||
parentName={
|
||
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
|
||
? (parentNameMap.get(selectedNode.parent_id) ?? null)
|
||
: null
|
||
}
|
||
detailTab={detailTab}
|
||
onDetailTabChange={setDetailTab}
|
||
canViewProfileTab={canShowProfileTab}
|
||
canEditProfileTab={canEditSelectedProfile}
|
||
profileReadOnly={isOwnAgentNode}
|
||
canViewDownlineTab={canShowDownlineTab}
|
||
canViewPlayersTab={canShowPlayersTab}
|
||
canManageNode={canManageNode}
|
||
canCreateChild={canCreateChildOnSelected}
|
||
canDeleteChild={canDeleteNode}
|
||
onEditChild={(node) => openEditForNode(node)}
|
||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
||
onDeleteChild={(node) => handleDeleteNode(node)}
|
||
onSelectChild={(child) => {
|
||
setSelectedNodeId(child.id);
|
||
}}
|
||
profileFields={inlineProfileFields}
|
||
profileSaving={profileSaving}
|
||
onSaveProfile={() => void saveInlineProfile()}
|
||
/>
|
||
</div>
|
||
|
||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||
<DialogContent
|
||
showCloseButton
|
||
className="flex h-[min(90vh,760px)] !max-w-[min(520px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:!max-w-[min(520px,calc(100vw-2rem))]"
|
||
>
|
||
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-12">
|
||
<DialogTitle>
|
||
{nodeDialogMode === "create"
|
||
? t("createChild", { defaultValue: "添加下级代理" })
|
||
: t("editNode", { defaultValue: "编辑代理" })}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain px-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
|
||
<Input
|
||
id="agent-name"
|
||
value={nodeName}
|
||
placeholder={t("namePlaceholder")}
|
||
onChange={(e) => setNodeName(e.target.value)}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="agent-username">{t("users.username", { defaultValue: "登录名" })}</Label>
|
||
<Input
|
||
id="agent-username"
|
||
value={nodeUsername}
|
||
placeholder={t("usernamePlaceholder")}
|
||
onChange={(e) => setNodeUsername(e.target.value)}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="agent-password">
|
||
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
|
||
? t("users.password", { defaultValue: "密码" })
|
||
: t("resetPassword", { defaultValue: "重置密码" })}
|
||
</Label>
|
||
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t("bindAccountHint", {
|
||
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
|
||
})}
|
||
</p>
|
||
) : null}
|
||
<Input
|
||
id="agent-password"
|
||
type="password"
|
||
value={nodePassword}
|
||
onChange={(e) => setNodePassword(e.target.value)}
|
||
placeholder={
|
||
nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount
|
||
? t("passwordOptionalHint")
|
||
: t("passwordPlaceholder")
|
||
}
|
||
autoComplete="new-password"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
|
||
<Label>{t("status", { defaultValue: "状态" })}</Label>
|
||
</div>
|
||
|
||
{canManageProfile &&
|
||
(nodeDialogMode === "create" ||
|
||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
|
||
<div className="space-y-3 border-t pt-3">
|
||
<p className="text-sm font-medium">
|
||
{t("profile.section", { defaultValue: "占成与授信" })}
|
||
</p>
|
||
<AgentProfileFields
|
||
loading={profileLoading}
|
||
parentCaps={profileParentCaps}
|
||
availableCredit={profileAvailableCredit}
|
||
canCreateChildAgent={canCreateChildAgent}
|
||
isSuperAdmin={isSuperAdmin}
|
||
shareRate={profileShareRate}
|
||
onShareRateChange={setProfileShareRate}
|
||
creditLimit={profileCreditLimit}
|
||
onCreditLimitChange={setProfileCreditLimit}
|
||
rebateLimit={profileRebateLimit}
|
||
onRebateLimitChange={setProfileRebateLimit}
|
||
defaultRebate={profileDefaultRebate}
|
||
onDefaultRebateChange={setProfileDefaultRebate}
|
||
settlementCycle={profileSettlementCycle}
|
||
onSettlementCycleChange={setProfileSettlementCycle}
|
||
extraRebate={profileExtraRebate}
|
||
onExtraRebateChange={setProfileExtraRebate}
|
||
canCreatePlayer={profileCanCreatePlayer}
|
||
onCanCreatePlayerChange={setProfileCanCreatePlayer}
|
||
canCreateChild={profileCanCreateChild}
|
||
onCanCreateChildChange={setProfileCanCreateChild}
|
||
riskTags={profileRiskTags}
|
||
onRiskTagsChange={setProfileRiskTags}
|
||
idPrefix="dialog-agent-profile"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
|
||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={nodeSaving || (nodeDialogMode === "edit" && canManageProfile && profileLoading)}
|
||
onClick={() => void saveNode()}
|
||
>
|
||
{t("common:actions.save", { defaultValue: "保存" })}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
</div>
|
||
);
|
||
}
|