feat(api, agents): add agent node profile retrieval and update functionality
Implemented new API functions to fetch and update agent node profiles, enhancing the management capabilities for agent data. This addition improves the overall functionality of the admin agents console, allowing for better user interaction with agent profiles. Updated related types for improved type safety and clarity in the codebase.
This commit is contained in:
239
src/modules/agents/agent-line-provision-wizard.tsx
Normal file
239
src/modules/agents/agent-line-provision-wizard.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postAdminAgentLine } from "@/api/admin-agent-lines";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function AgentLineProvisionWizard(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [secrets, setSecrets] = useState<{ sso: string; wallet: string } | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
code: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
currency_code: "NPR",
|
||||
wallet_api_url: "",
|
||||
notes: "",
|
||||
total_share_rate: "0",
|
||||
credit_limit: "0",
|
||||
rebate_limit: "0",
|
||||
default_player_rebate: "0",
|
||||
settlement_cycle: "weekly" as "daily" | "weekly" | "monthly",
|
||||
can_grant_extra_rebate: false,
|
||||
});
|
||||
|
||||
async function onSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSecrets(null);
|
||||
try {
|
||||
const result = await postAdminAgentLine({
|
||||
code: form.code.trim().toLowerCase(),
|
||||
name: form.name.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
currency_code: form.currency_code,
|
||||
wallet_api_url: form.wallet_api_url.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
|
||||
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
|
||||
rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
|
||||
default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
|
||||
settlement_cycle: form.settlement_cycle,
|
||||
can_grant_extra_rebate: form.can_grant_extra_rebate,
|
||||
});
|
||||
if (result.secrets) {
|
||||
setSecrets({
|
||||
sso: result.secrets.sso_jwt_secret,
|
||||
wallet: result.secrets.wallet_api_key,
|
||||
});
|
||||
}
|
||||
toast.success(t("agents:lineProvision.success", { defaultValue: "线路已开通" }));
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "开通代理线路" })}>
|
||||
<form className="grid max-w-xl gap-4" onSubmit={onSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.code", { defaultValue: "站点 code" })}</Label>
|
||||
<Input
|
||||
value={form.code}
|
||||
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9_-]*"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.name", { defaultValue: "线路名称" })}</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.username", { defaultValue: "代理账号" })}</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.password", { defaultValue: "初始密码" })}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:lineProvision.walletUrl", { defaultValue: "钱包 API URL" })}</Label>
|
||||
<Input
|
||||
value={form.wallet_api_url}
|
||||
onChange={(e) => setForm((f) => ({ ...f, wallet_api_url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium">
|
||||
{t("agents:profile.section", { defaultValue: "占成与授信" })}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.totalShareRate", { defaultValue: "占成比例 (%)" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={form.total_share_rate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, total_share_rate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.creditLimit", { defaultValue: "授信额度" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.credit_limit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, credit_limit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={form.rebate_limit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={form.default_player_rebate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("agents:profile.settlementCycle", { defaultValue: "结算周期" })}</Label>
|
||||
<Select
|
||||
value={form.settlement_cycle}
|
||||
onValueChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
settlement_cycle: (value as "daily" | "weekly" | "monthly") ?? "weekly",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">
|
||||
{t("agents:profile.cycleDaily", { defaultValue: "日结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="weekly">
|
||||
{t("agents:profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
{t("agents:profile.cycleMonthly", { defaultValue: "月结" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={form.can_grant_extra_rebate}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((f) => ({ ...f, can_grant_extra_rebate: checked }))
|
||||
}
|
||||
/>
|
||||
<Label>
|
||||
{t("agents:profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("common:notes", { defaultValue: "备注" })}</Label>
|
||||
<Textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? t("common:submitting", { defaultValue: "提交中…" })
|
||||
: t("agents:lineProvision.submit", { defaultValue: "开通线路" })}
|
||||
</Button>
|
||||
</form>
|
||||
{secrets ? (
|
||||
<div className="mt-6 rounded-md border border-amber-500/40 bg-amber-500/5 p-4 text-sm">
|
||||
<p className="font-medium text-amber-700">
|
||||
{t("agents:lineProvision.secretsOnce", { defaultValue: "密钥仅显示一次,请妥善保存" })}
|
||||
</p>
|
||||
<p className="mt-2 break-all">
|
||||
SSO: <code>{secrets.sso}</code>
|
||||
</p>
|
||||
<p className="mt-1 break-all">
|
||||
Wallet API Key: <code>{secrets.wallet}</code>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, KeyRound, Pencil, Plus, Search, Trash2, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
deleteAgentAdminUser,
|
||||
deleteAgentNode,
|
||||
deleteAgentRole,
|
||||
getAgentNodeAdminUsers,
|
||||
getAgentNodeRoles,
|
||||
getAgentNodeProfile,
|
||||
getAgentTree,
|
||||
postAgentAdminUser,
|
||||
postAgentNode,
|
||||
postAgentRole,
|
||||
putAgentNode,
|
||||
putAgentRolePermissions,
|
||||
getAgentDelegationGrants,
|
||||
putAgentDelegationGrants,
|
||||
putAgentNodeProfile,
|
||||
} from "@/api/admin-agents";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -42,7 +30,6 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -59,35 +46,26 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_ROLE_MANAGE,
|
||||
PRD_AGENT_ROLE_VIEW,
|
||||
PRD_AGENT_USER_MANAGE,
|
||||
PRD_AGENT_USER_VIEW,
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_AGENT_SITES_ACCESS_ANY,
|
||||
PRD_INTEGRATION_ACCESS_ANY,
|
||||
PRD_USERS_MANAGE,
|
||||
} from "@/lib/admin-prd";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function permissionGroupLabel(key: string, fallback: string, t: (key: string, options?: Record<string, unknown>) => string): string {
|
||||
const translated = t(`adminUsers:permissionGroups.${key}`);
|
||||
return translated === `adminUsers:permissionGroups.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function permissionPackageLabel(
|
||||
key: string,
|
||||
fallback: string,
|
||||
t: (key: string, options?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
return t(`adminUsers:permissionLevels.${key}`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
const out: AgentNodeRow[] = [];
|
||||
const walk = (list: AgentNodeRow[]) => {
|
||||
@@ -103,262 +81,146 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterTree(nodes: AgentNodeRow[], keyword: string): AgentNodeRow[] {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const filterNode = (node: AgentNodeRow): AgentNodeRow | null => {
|
||||
const children = node.children
|
||||
?.map((child) => filterNode(child))
|
||||
.filter((child): child is AgentNodeRow => child !== null) ?? [];
|
||||
const selfMatch =
|
||||
node.name.toLowerCase().includes(normalized) || node.code.toLowerCase().includes(normalized);
|
||||
if (!selfMatch && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
return nodes.map((node) => filterNode(node)).filter((node): node is AgentNodeRow => node !== null);
|
||||
}
|
||||
|
||||
function AgentTreeNodes({
|
||||
nodes,
|
||||
depth,
|
||||
selectedId,
|
||||
expandedIds,
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
}: {
|
||||
nodes: AgentNodeRow[];
|
||||
depth: number;
|
||||
selectedId: number | null;
|
||||
expandedIds: Set<number>;
|
||||
onToggleExpand: (nodeId: number) => void;
|
||||
onSelect: (node: AgentNodeRow) => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ul className={depth === 0 ? "space-y-0.5" : "ml-3 border-l border-border pl-2"}>
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-2 transition-colors",
|
||||
selectedId === node.id
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "hover:bg-muted/60 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{node.children && node.children.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="toggle children"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className={cn(
|
||||
"ml-1 rounded-sm p-0.5 transition-colors",
|
||||
selectedId === node.id
|
||||
? "text-primary-foreground/80 hover:bg-primary-foreground/20 hover:text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform",
|
||||
expandedIds.has(node.id) && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="ml-1 size-4 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(node)}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 rounded-md py-1.5 text-left text-sm",
|
||||
selectedId === node.id ? "font-medium" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{node.name}</span>
|
||||
<span className={cn(
|
||||
"ml-auto font-mono text-[11px]",
|
||||
selectedId === node.id ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}>
|
||||
{node.code}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{node.children && node.children.length > 0 && expandedIds.has(node.id) ? (
|
||||
<AgentTreeNodes
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
function countBusinessAgents(nodes: AgentNodeRow[]): number {
|
||||
return nodes.filter((node) => !node.is_root).length;
|
||||
}
|
||||
|
||||
export function AgentsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "adminUsers", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "adminUsers", "common"]);
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const tRef = useTranslationRef(["agents", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||
|
||||
const canManageNode = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_MANAGE]);
|
||||
const canViewRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_VIEW, PRD_AGENT_ROLE_MANAGE]);
|
||||
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_ROLE_MANAGE]);
|
||||
const canViewUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_VIEW, PRD_AGENT_USER_MANAGE]);
|
||||
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_AGENT_USER_MANAGE]);
|
||||
const canViewAgents =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENTS_ACCESS_ANY]);
|
||||
const canManageProfile = adminHasAnyPermission(profile?.permissions, [
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
PRD_AGENT_MANAGE,
|
||||
]);
|
||||
const canProvision =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]);
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
const canViewSiteList = adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_SITES_ACCESS_ANY]);
|
||||
const canSwitchSite = isSuperAdmin || adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
|
||||
|
||||
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string }[]>([]);
|
||||
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string; code: string }[]>([]);
|
||||
const [globalVisibleNodeCount, setGlobalVisibleNodeCount] = useState<number | null>(null);
|
||||
const [globalBusinessAgentCount, setGlobalBusinessAgentCount] = useState<number | null>(null);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [tree, setTree] = useState<AgentNodeRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [treeKeyword, setTreeKeyword] = useState("");
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
||||
const [users, setUsers] = useState<AdminUserPermissionRow[]>([]);
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [operationsTab, setOperationsTab] = useState<"subordinates" | "players">("subordinates");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
|
||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||
const [nodeCode, setNodeCode] = useState("");
|
||||
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 [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||||
const [roleSlug, setRoleSlug] = useState("");
|
||||
const [roleName, setRoleName] = useState("");
|
||||
const [rolePerms, setRolePerms] = useState<string[]>([]);
|
||||
const [roleSaving, setRoleSaving] = useState(false);
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const canCreateChildAgent =
|
||||
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
||||
const canViewPlayersTab =
|
||||
isSuperAdmin ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
|
||||
const [permDialogOpen, setPermDialogOpen] = useState(false);
|
||||
const [permRoleId, setPermRoleId] = useState<number | null>(null);
|
||||
const [draftPerms, setDraftPerms] = useState<string[]>([]);
|
||||
const [permSaving, setPermSaving] = useState(false);
|
||||
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);
|
||||
};
|
||||
|
||||
const [userDialogOpen, setUserDialogOpen] = useState(false);
|
||||
const [userUsername, setUserUsername] = useState("");
|
||||
const [userNickname, setUserNickname] = useState("");
|
||||
const [userPassword, setUserPassword] = useState("");
|
||||
const [userRoleIds, setUserRoleIds] = useState<number[]>([]);
|
||||
const [userSaving, setUserSaving] = useState(false);
|
||||
|
||||
const [delegationGrants, setDelegationGrants] = useState<AgentDelegationGrantRow[]>([]);
|
||||
const [delegationSaving, setDelegationSaving] = useState(false);
|
||||
const profilePayload = () => ({
|
||||
total_share_rate: Number.parseFloat(profileShareRate) || 0,
|
||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
||||
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
||||
settlement_cycle: profileSettlementCycle,
|
||||
can_grant_extra_rebate: profileExtraRebate,
|
||||
can_create_child_agent: profileCanCreateChild,
|
||||
can_create_player: profileCanCreatePlayer,
|
||||
});
|
||||
|
||||
const flatNodes = useMemo(() => flattenTree(tree), [tree]);
|
||||
const filteredTree = useMemo(() => filterTree(tree, treeKeyword), [tree, treeKeyword]);
|
||||
const selected = useMemo(
|
||||
() => flatNodes.find((n) => n.id === selectedId) ?? null,
|
||||
[flatNodes, selectedId],
|
||||
const parentNameMap = useMemo(
|
||||
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
|
||||
[flatNodes],
|
||||
);
|
||||
const selectedChildrenCount = selected?.children?.length ?? 0;
|
||||
const selectedDescendantCount = useMemo(() => {
|
||||
if (!selected?.children?.length) {
|
||||
return 0;
|
||||
}
|
||||
return flattenTree(selected.children).length;
|
||||
}, [selected]);
|
||||
|
||||
const canManageDelegation =
|
||||
canManageNode &&
|
||||
selected !== null &&
|
||||
!selected.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
|
||||
const blockingCustomRoleCount = useMemo(
|
||||
() => roles.filter((role) => !role.is_read_only_template).length,
|
||||
[roles],
|
||||
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
|
||||
const currentSiteNodeCount = flatNodes.length;
|
||||
const currentSiteBusinessAgentCount = useMemo(() => countBusinessAgents(flatNodes), [flatNodes]);
|
||||
const selectedSiteLabel = useMemo(
|
||||
() => siteOptions.find((site) => site.id === adminSiteId)?.label ?? null,
|
||||
[adminSiteId, siteOptions],
|
||||
);
|
||||
const deleteBlockReasons = useMemo(() => {
|
||||
if (!selected || selected.is_root) {
|
||||
return [];
|
||||
const activeSiteCode = useMemo(() => {
|
||||
const fromAgent = boundAgent?.site_code?.trim();
|
||||
if (fromAgent) {
|
||||
return fromAgent;
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
if (selectedChildrenCount > 0) {
|
||||
reasons.push(
|
||||
t("deleteBlocked.children", {
|
||||
count: selectedChildrenCount,
|
||||
defaultValue: "仍有 {{count}} 个下级代理",
|
||||
}),
|
||||
);
|
||||
const fromSite = siteOptions.find((site) => site.id === adminSiteId)?.code?.trim();
|
||||
if (fromSite) {
|
||||
return fromSite;
|
||||
}
|
||||
if (blockingCustomRoleCount > 0) {
|
||||
reasons.push(
|
||||
t("deleteBlocked.roles", {
|
||||
count: blockingCustomRoleCount,
|
||||
defaultValue: "仍有 {{count}} 个可编辑角色需先删除",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (users.length > 0) {
|
||||
reasons.push(
|
||||
t("deleteBlocked.users", {
|
||||
count: users.length,
|
||||
defaultValue: "仍有 {{count}} 个绑定账号",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return reasons;
|
||||
}, [blockingCustomRoleCount, selected, selectedChildrenCount, t, users.length]);
|
||||
const canDeleteSelectedNode =
|
||||
canManageNode && selected !== null && !selected.is_root && deleteBlockReasons.length === 0;
|
||||
const defaultDetailTab = canViewRoles ? "roles" : canViewUsers ? "users" : canManageDelegation ? "delegation" : "roles";
|
||||
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
|
||||
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
|
||||
const playersPanelAgentId = useMemo(
|
||||
() => (isSuperAdmin ? null : (boundAgent?.id ?? null)),
|
||||
[boundAgent?.id, isSuperAdmin],
|
||||
);
|
||||
|
||||
const assignablePermissionSlugs = useMemo(() => {
|
||||
const mine = new Set(profile?.permissions ?? []);
|
||||
const slugs: string[] = [];
|
||||
for (const group of catalog?.permission_menu_groups ?? []) {
|
||||
for (const p of group.permissions) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalized = keyword.trim().toLowerCase();
|
||||
|
||||
return businessRows.filter((node) => {
|
||||
if (normalized === "") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (slugs.length === 0) {
|
||||
for (const p of catalog?.permissions ?? []) {
|
||||
if (mine.has(p.slug)) {
|
||||
slugs.push(p.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return slugs;
|
||||
}, [catalog, profile?.permissions]);
|
||||
const parentName =
|
||||
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
|
||||
|
||||
const selectedRoleCountText = useMemo(
|
||||
() => t("roles.selectedCount", {
|
||||
defaultValue: "已选 {{selected}} / {{total}} 项",
|
||||
selected: rolePerms.length,
|
||||
total: assignablePermissionSlugs.length,
|
||||
}),
|
||||
[assignablePermissionSlugs.length, rolePerms.length, t],
|
||||
);
|
||||
return [node.name, node.code, node.username ?? "", parentName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalized);
|
||||
});
|
||||
}, [businessRows, keyword, parentNameMap]);
|
||||
|
||||
const selectedDraftCountText = useMemo(
|
||||
() => t("roles.selectedCount", {
|
||||
defaultValue: "已选 {{selected}} / {{total}} 项",
|
||||
selected: draftPerms.length,
|
||||
total: assignablePermissionSlugs.length,
|
||||
}),
|
||||
[assignablePermissionSlugs.length, draftPerms.length, t],
|
||||
);
|
||||
const total = filteredRows.length;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
const currentPage = Math.min(page, lastPage);
|
||||
const pagedRows = useMemo(() => {
|
||||
const start = (currentPage - 1) * perPage;
|
||||
return filteredRows.slice(start, start + perPage);
|
||||
}, [currentPage, filteredRows, perPage]);
|
||||
|
||||
const loadTree = useCallback(async (siteId?: number | null) => {
|
||||
setLoading(true);
|
||||
@@ -367,131 +229,203 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const data = await getAgentTree(siteId ?? undefined);
|
||||
setTree(data.tree);
|
||||
setAdminSiteId(data.admin_site_id);
|
||||
setExpandedNodeIds(new Set(flattenTree(data.tree).map((node) => node.id)));
|
||||
if (selectedId === null && data.tree.length > 0) {
|
||||
const first = flattenTree(data.tree)[0];
|
||||
if (first) {
|
||||
setSelectedId(first.id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
setTree([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedId, tRef]);
|
||||
|
||||
const loadDetail = useCallback(async (nodeId: number) => {
|
||||
const needRoleRows = canViewRoles || canManageNode;
|
||||
const needUserRows = canViewUsers || canManageNode;
|
||||
if (needRoleRows) {
|
||||
const roleData = await getAgentNodeRoles(nodeId);
|
||||
setRoles(roleData.items);
|
||||
} else {
|
||||
setRoles([]);
|
||||
}
|
||||
if (needUserRows) {
|
||||
const userData = await getAgentNodeAdminUsers(nodeId);
|
||||
setUsers(userData.items);
|
||||
} else {
|
||||
setUsers([]);
|
||||
}
|
||||
const node = flattenTree(tree).find((n) => n.id === nodeId);
|
||||
const showDelegation =
|
||||
canManageNode &&
|
||||
node !== undefined &&
|
||||
!node.is_root &&
|
||||
(isSuperAdmin || profile?.agent?.id === node.parent_id);
|
||||
if (showDelegation) {
|
||||
const grantData = await getAgentDelegationGrants(nodeId);
|
||||
setDelegationGrants(grantData.grants);
|
||||
} else {
|
||||
setDelegationGrants([]);
|
||||
}
|
||||
}, [canManageNode, canViewRoles, canViewUsers, isSuperAdmin, profile?.agent?.id, tree]);
|
||||
}, [tRef]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
if (!canViewAgents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canSwitchSite) {
|
||||
void getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
setSiteOptions(
|
||||
data.items.map((row) => ({ id: row.id, label: `${row.name} (${row.code})` })),
|
||||
);
|
||||
if (data.items.length > 0 && adminSiteId === null) {
|
||||
setAdminSiteId(data.items[0]?.id ?? null);
|
||||
const options = data.items.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
label: `${row.name} (${row.code})`,
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
if (options.length > 0 && adminSiteId === null) {
|
||||
setAdminSiteId(options[0]?.id ?? null);
|
||||
}
|
||||
})
|
||||
.catch(() => setSiteOptions([]));
|
||||
} else if (profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
}
|
||||
void getAdminUserPermissionCatalog().then(setCatalog).catch(() => setCatalog(null));
|
||||
}, [isSuperAdmin, profile?.agent?.admin_site_id]);
|
||||
}, [adminSiteId, canSwitchSite, canViewAgents, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !isSuperAdmin && profile?.agent?.admin_site_id) {
|
||||
if (!canSwitchSite || siteOptions.length === 0) {
|
||||
setGlobalVisibleNodeCount(null);
|
||||
setGlobalBusinessAgentCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.all(siteOptions.map(async (site) => getAgentTree(site.id)))
|
||||
.then((results) => {
|
||||
const allNodes = results.flatMap((result) => flattenTree(result.tree));
|
||||
setGlobalVisibleNodeCount(allNodes.length);
|
||||
setGlobalBusinessAgentCount(countBusinessAgents(allNodes));
|
||||
})
|
||||
.catch(() => {
|
||||
setGlobalVisibleNodeCount(null);
|
||||
setGlobalBusinessAgentCount(null);
|
||||
});
|
||||
}, [canSwitchSite, siteOptions]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (adminSiteId === null && !canSwitchSite && profile?.agent?.admin_site_id) {
|
||||
setAdminSiteId(profile.agent.admin_site_id);
|
||||
return;
|
||||
}
|
||||
if (adminSiteId !== null || !isSuperAdmin) {
|
||||
|
||||
if (adminSiteId !== null || !canSwitchSite) {
|
||||
void loadTree(adminSiteId);
|
||||
}
|
||||
}, [adminSiteId, isSuperAdmin, loadTree, profile?.agent?.admin_site_id]);
|
||||
}, [adminSiteId, canSwitchSite, loadTree, profile?.agent?.admin_site_id]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (selectedId !== null) {
|
||||
void loadDetail(selectedId).catch(() => {
|
||||
toast.error(tRef.current("loadFailed"));
|
||||
});
|
||||
}
|
||||
}, [selectedId, loadDetail, tRef]);
|
||||
|
||||
const openCreateChild = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const openCreateChildForNode = (node: AgentNodeRow) => {
|
||||
setNodeDialogMode("create");
|
||||
setNodeCode("");
|
||||
setTargetParentId(node.id);
|
||||
setEditingNodeId(null);
|
||||
setNodeName("");
|
||||
setNodeStatus(1);
|
||||
setNodeUsername("");
|
||||
setNodePassword("");
|
||||
resetProfileForm("create");
|
||||
setNodeDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditNode = () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const openEditForNode = (node: AgentNodeRow) => {
|
||||
setNodeDialogMode("edit");
|
||||
setNodeCode(selected.code);
|
||||
setNodeName(selected.name);
|
||||
setNodeStatus(selected.status);
|
||||
setTargetParentId(node.parent_id);
|
||||
setEditingNodeId(node.id);
|
||||
setNodeName(node.name);
|
||||
setNodeStatus(node.status);
|
||||
setNodeUsername(node.username ?? "");
|
||||
setNodePassword("");
|
||||
resetProfileForm("edit");
|
||||
setNodeDialogOpen(true);
|
||||
if (canManageProfile) {
|
||||
void getAgentNodeProfile(node.id)
|
||||
.then((p) => {
|
||||
setProfileShareRate(String(p.total_share_rate ?? 0));
|
||||
setProfileCreditLimit(String(p.credit_limit ?? 0));
|
||||
setProfileRebateLimit(String(p.rebate_limit ?? 0));
|
||||
setProfileDefaultRebate(String(p.default_player_rebate ?? 0));
|
||||
setProfileSettlementCycle(p.settlement_cycle ?? "weekly");
|
||||
setProfileExtraRebate(Boolean(p.can_grant_extra_rebate));
|
||||
setProfileCanCreateChild(Boolean(p.can_create_child_agent));
|
||||
setProfileCanCreatePlayer(p.can_create_player !== false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRowActions = (node: AgentNodeRow) => {
|
||||
const rowDeleteBlockedByChildren = (node.children?.length ?? 0) > 0;
|
||||
const rowDeleteBlockedBySelf = profile?.agent?.id === node.id;
|
||||
const rowCanDelete =
|
||||
canManageNode && !rowDeleteBlockedByChildren && !rowDeleteBlockedBySelf;
|
||||
|
||||
return (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("editNode", { defaultValue: "编辑代理" }),
|
||||
icon: Pencil,
|
||||
hidden: !canManageNode,
|
||||
onClick: () => openEditForNode(node),
|
||||
},
|
||||
{
|
||||
key: "create-child",
|
||||
label: t("createChild", { defaultValue: "添加下级代理" }),
|
||||
icon: Plus,
|
||||
hidden: !canManageNode || !canCreateChildAgent,
|
||||
onClick: () => openCreateChildForNode(node),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("deleteNode", { defaultValue: "删除代理" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
hidden: !canManageNode,
|
||||
disabled: !rowCanDelete,
|
||||
onClick: () => {
|
||||
if (!rowCanDelete) {
|
||||
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 }));
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!nodeName.trim() || (nodeDialogMode === "create" && !nodeCode.trim())) {
|
||||
toast.error(t("codeRequired"));
|
||||
if (!nodeName.trim() || !nodeUsername.trim()) {
|
||||
toast.error(t("codeRequired", { defaultValue: "请填写代理名称和登录名" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (nodeDialogMode === "create") {
|
||||
if (targetParentId === null) {
|
||||
return;
|
||||
}
|
||||
if (!nodePassword.trim()) {
|
||||
toast.error(t("users.password", { defaultValue: "密码" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setNodeSaving(true);
|
||||
try {
|
||||
if (nodeDialogMode === "create" && selected) {
|
||||
if (nodeDialogMode === "create" && targetParentId !== null) {
|
||||
await postAgentNode({
|
||||
parent_id: selected.id,
|
||||
code: nodeCode.trim(),
|
||||
parent_id: targetParentId,
|
||||
name: nodeName.trim(),
|
||||
username: nodeUsername.trim(),
|
||||
password: nodePassword,
|
||||
status: nodeStatus,
|
||||
...(canManageProfile ? profilePayload() : {}),
|
||||
});
|
||||
toast.success(t("createSuccess", { name: nodeName.trim() }));
|
||||
} else if (selected) {
|
||||
await putAgentNode(selected.id, { name: nodeName.trim(), status: nodeStatus });
|
||||
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
|
||||
await putAgentNode(editingNodeId, {
|
||||
name: nodeName.trim(),
|
||||
username: nodeUsername.trim(),
|
||||
password: nodePassword.trim() || undefined,
|
||||
status: nodeStatus,
|
||||
});
|
||||
if (canManageProfile) {
|
||||
await putAgentNodeProfile(editingNodeId, profilePayload());
|
||||
}
|
||||
toast.success(t("updateSuccess", { name: nodeName.trim() }));
|
||||
}
|
||||
|
||||
setNodeDialogOpen(false);
|
||||
await loadTree(adminSiteId);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
@@ -499,629 +433,393 @@ export function AgentsConsole(): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewRole = async () => {
|
||||
if (!selected || !roleSlug.trim() || !roleName.trim()) {
|
||||
return;
|
||||
}
|
||||
setRoleSaving(true);
|
||||
try {
|
||||
await postAgentRole(selected.id, {
|
||||
slug: roleSlug.trim(),
|
||||
name: roleName.trim(),
|
||||
permission_slugs: rolePerms,
|
||||
});
|
||||
toast.success(t("roles.createSuccess", { name: roleName.trim() }));
|
||||
setRoleDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setRoleSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRolePermissions = async () => {
|
||||
if (permRoleId === null) {
|
||||
return;
|
||||
}
|
||||
setPermSaving(true);
|
||||
try {
|
||||
const result = await putAgentRolePermissions(permRoleId, draftPerms);
|
||||
setDraftPerms([...result.permission_slugs].sort());
|
||||
setRoles((prev) => prev.map((role) => (role.id === result.id ? result : role)));
|
||||
toast.success(t("roles.permissionSaveSuccess"));
|
||||
setPermDialogOpen(false);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setPermSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveDelegation = async () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
setDelegationSaving(true);
|
||||
try {
|
||||
const data = await putAgentDelegationGrants(selected.id, {
|
||||
grants: delegationGrants.map((g) => ({
|
||||
menu_action_id: g.menu_action_id,
|
||||
can_delegate: g.can_delegate,
|
||||
})),
|
||||
});
|
||||
setDelegationGrants(data.grants);
|
||||
toast.success(t("delegation.saveSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setDelegationSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNewUser = async () => {
|
||||
if (!selected || !userUsername.trim() || !userPassword.trim()) {
|
||||
return;
|
||||
}
|
||||
setUserSaving(true);
|
||||
try {
|
||||
await postAgentAdminUser(selected.id, {
|
||||
username: userUsername.trim(),
|
||||
nickname: userNickname.trim() || userUsername.trim(),
|
||||
password: userPassword,
|
||||
role_ids: userRoleIds,
|
||||
});
|
||||
toast.success(t("users.createSuccess", { name: userNickname.trim() || userUsername.trim() }));
|
||||
setUserDialogOpen(false);
|
||||
await loadDetail(selected.id);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
|
||||
} finally {
|
||||
setUserSaving(false);
|
||||
}
|
||||
};
|
||||
if (!canViewAgents) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noAccess", { defaultValue: "您没有代理经营相关权限,请联系管理员开通。" })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading && tree.length === 0) {
|
||||
return <AdminLoadingState label={t("treeTitle")} />;
|
||||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConfirmDialog />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
{isSuperAdmin && siteOptions.length > 0 ? (
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("title", { defaultValue: "代理经营" })}
|
||||
</h1>
|
||||
{canProvision ? (
|
||||
<Link
|
||||
href="/admin/agents/provision"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("lineProvision.link", { defaultValue: "开通线路" })}
|
||||
</Link>
|
||||
) : null}
|
||||
{canSwitchSite && siteOptions.length > 0 ? (
|
||||
<Select
|
||||
value={adminSiteId !== null ? String(adminSiteId) : undefined}
|
||||
onValueChange={(v) => setAdminSiteId(Number(v))}
|
||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder={t("siteLabel")}>
|
||||
{adminSiteId !== null
|
||||
? siteOptions.find((opt) => opt.id === adminSiteId)?.label ?? adminSiteId
|
||||
: undefined}
|
||||
<SelectValue placeholder={t("siteLabel", { defaultValue: "站点" })}>
|
||||
{selectedSiteLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
{canViewSiteList ? (
|
||||
<Link
|
||||
href="/admin/agents/sites"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-muted-foreground")}
|
||||
>
|
||||
{t("sitesListLink", { defaultValue: "站点列表" })}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,280px)_1fr]">
|
||||
<AdminPageCard title={t("treeTitle")}>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={treeKeyword}
|
||||
onChange={(e) => setTreeKeyword(e.target.value)}
|
||||
className="pl-8"
|
||||
placeholder={t("treeSearch", { defaultValue: "搜索代理编码/名称" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setExpandedNodeIds(new Set(flatNodes.map((node) => node.id)))}
|
||||
>
|
||||
{t("expandAll", { defaultValue: "展开全部" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
selected ? setExpandedNodeIds(new Set([selected.id])) : setExpandedNodeIds(new Set())
|
||||
}
|
||||
>
|
||||
{t("collapseAll", { defaultValue: "收起全部" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="mt-3 h-[min(66vh,620px)] pr-2">
|
||||
<AgentTreeNodes
|
||||
nodes={filteredTree}
|
||||
depth={0}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedNodeIds}
|
||||
onToggleExpand={(nodeId) => {
|
||||
setExpandedNodeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSelect={(node) => setSelectedId(node.id)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard title={selected ? selected.name : t("detailTitle")}>
|
||||
{!selected ? (
|
||||
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
|
||||
) : (
|
||||
<Tabs defaultValue={defaultDetailTab}>
|
||||
<div className="mb-4 grid gap-3 rounded-xl border bg-muted/20 p-3 md:grid-cols-4">
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("status")}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(selected.status)}>
|
||||
{selected.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
{selected.is_root ? (
|
||||
<Badge variant="secondary">{t("isRoot", { defaultValue: "Root" })}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("childrenCount", { defaultValue: "直属下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedChildrenCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("descendantsCount", { defaultValue: "全部下级" })}</p>
|
||||
<p className="text-lg font-semibold">{selectedDescendantCount}</p>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-background/80 p-3">
|
||||
<p className="text-xs text-muted-foreground">{t("nodeCode", { defaultValue: "节点编码" })}</p>
|
||||
<p className="truncate font-mono text-xs text-muted-foreground">{selected.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<TabsList>
|
||||
{canViewRoles ? <TabsTrigger value="roles">{t("tabs.roles")}</TabsTrigger> : null}
|
||||
{canViewUsers ? <TabsTrigger value="users">{t("tabs.users")}</TabsTrigger> : null}
|
||||
|
||||
</TabsList>
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button type="button" size="sm" variant="outline" onClick={openEditNode}>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
{t("editNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode && !selected.is_root ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={!canDeleteSelectedNode}
|
||||
title={
|
||||
canDeleteSelectedNode
|
||||
? undefined
|
||||
: deleteBlockReasons.join(";") ||
|
||||
t("deleteNodeBlockedHint", {
|
||||
defaultValue: "请先删除下级代理、角色与账号后再删除本节点",
|
||||
})
|
||||
}
|
||||
onClick={() => {
|
||||
if (!canDeleteSelectedNode) {
|
||||
return;
|
||||
}
|
||||
requestConfirm({
|
||||
title: t("deleteNode"),
|
||||
description: t("deleteNodeConfirm"),
|
||||
confirmLabel: t("deleteNode"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
const deletedId = selected.id;
|
||||
const deletedName = selected.name;
|
||||
const parentId = selected.parent_id;
|
||||
await deleteAgentNode(deletedId);
|
||||
toast.success(t("deleteSuccess", { name: deletedName }));
|
||||
setSelectedId(parentId);
|
||||
await loadTree(adminSiteId);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
{t("deleteNode")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageNode ? (
|
||||
<Button type="button" size="sm" onClick={openCreateChild}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("createChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{canManageNode && !selected.is_root && deleteBlockReasons.length > 0 ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
{t("deleteNodeBlockedPrefix", { defaultValue: "暂不可删除:" })}
|
||||
{deleteBlockReasons.join(";")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canViewRoles ? (
|
||||
<TabsContent value="roles">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageRoles ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRoleSlug("");
|
||||
setRoleName("");
|
||||
setRolePerms([]);
|
||||
setRoleDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("roles.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("roles.slug")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("roles.userCount")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
|
||||
<TableCell>{role.name}</TableCell>
|
||||
<TableCell>{role.user_count}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageRoles && !role.is_read_only_template ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{(role.user_count ?? 0) > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("roles.inUse", {
|
||||
count: role.user_count ?? 0,
|
||||
defaultValue: "{{count}} 人使用中",
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "permissions",
|
||||
label: t("roles.permissions"),
|
||||
icon: KeyRound,
|
||||
onClick: () => {
|
||||
setPermRoleId(role.id);
|
||||
setDraftPerms([...role.permission_slugs]);
|
||||
setPermDialogOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("common:actions.delete", { defaultValue: "Delete" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
disabled: (role.user_count ?? 0) > 0,
|
||||
onClick: () => {
|
||||
requestConfirm({
|
||||
title: role.name,
|
||||
description: t("common:confirm.deleteDescription", {
|
||||
defaultValue: "This cannot be undone.",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
await deleteAgentRole(role.id);
|
||||
toast.success(t("roles.deleteSuccess", { name: role.name }));
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : role.is_read_only_template ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("roles.readOnlyTemplate")}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{canViewUsers ? (
|
||||
<TabsContent value="users">
|
||||
<div className="mb-3 flex justify-end">
|
||||
{canManageUsers ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserUsername("");
|
||||
setUserNickname("");
|
||||
setUserPassword("");
|
||||
setUserRoleIds([]);
|
||||
setUserDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Users className="mr-1 size-3.5" />
|
||||
{t("users.create")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("users.username")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("users.roles")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.nickname}</TableCell>
|
||||
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManageUsers ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: t("common:actions.delete", { defaultValue: "Delete" }),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => {
|
||||
requestConfirm({
|
||||
title: user.username,
|
||||
description: t("users.deleteConfirm", {
|
||||
defaultValue: "删除后该管理员将无法登录,且不可恢复。",
|
||||
}),
|
||||
confirmLabel: t("common:actions.delete", {
|
||||
defaultValue: "Delete",
|
||||
}),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
await deleteAgentAdminUser(user.id);
|
||||
toast.success(
|
||||
t("users.deleteSuccess", { name: user.nickname }),
|
||||
);
|
||||
if (selectedId !== null) {
|
||||
await loadDetail(selectedId);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
|
||||
</Tabs>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.currentSiteNodes", { defaultValue: "当前站点节点总数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{currentSiteNodeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("summary.currentSiteAgents", { defaultValue: "当前站点经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">{currentSiteBusinessAgentCount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSuperAdmin
|
||||
? t("summary.globalNodes", { defaultValue: "全部站点节点总数" })
|
||||
: t("summary.visibleList", { defaultValue: "当前最上级代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{isSuperAdmin ? (globalVisibleNodeCount ?? "—") : filteredRows.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isSuperAdmin
|
||||
? t("summary.globalAgents", { defaultValue: "全部站点经营代理数" })
|
||||
: t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{isSuperAdmin ? (globalBusinessAgentCount ?? "—") : currentSiteBusinessAgentCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminPageCard title={t("listTitle", { defaultValue: "代理列表" })}>
|
||||
{canViewPlayersTab ? (
|
||||
<nav className="mb-4 flex flex-wrap gap-2 border-b border-border/60 pb-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={operationsTab === "subordinates" ? "default" : "outline"}
|
||||
onClick={() => setOperationsTab("subordinates")}
|
||||
>
|
||||
{t("tabs.subordinates", { defaultValue: "下级管理" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={operationsTab === "players" ? "default" : "outline"}
|
||||
onClick={() => setOperationsTab("players")}
|
||||
>
|
||||
{t("tabs.players", { defaultValue: "玩家管理" })}
|
||||
</Button>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{operationsTab === "subordinates" ? (
|
||||
<div className="relative mb-3 min-w-[16rem] max-w-md">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="pl-8"
|
||||
placeholder={t("listSearch", {
|
||||
defaultValue: "搜索代理名称 / 编码 / 登录名",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{operationsTab === "players" ? (
|
||||
<AgentsPlayersPanel siteCode={activeSiteCode} agentNodeId={playersPanelAgentId} />
|
||||
) : null}
|
||||
|
||||
<div className={cn("admin-table-shell mt-3", operationsTab === "players" ? "hidden" : "")}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
|
||||
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
|
||||
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
|
||||
<TableHead>{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
|
||||
<TableHead className="w-16">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
|
||||
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pagedRows.map((node) => (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell className="font-medium">{node.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.code}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{node.username ?? "—"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "—") : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{node.depth}</TableCell>
|
||||
<TableCell>{node.children?.length ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)}>
|
||||
{node.status === 1
|
||||
? t("common:status.enabled", { defaultValue: "Enabled" })
|
||||
: t("common:status.disabled", { defaultValue: "Disabled" })}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>{renderRowActions(node)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{operationsTab === "subordinates" ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="agents-operations-per-page"
|
||||
total={total}
|
||||
page={currentPage}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(value) => {
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</AdminPageCard>
|
||||
|
||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{nodeDialogMode === "create" ? t("createChild") : t("editNode")}
|
||||
{nodeDialogMode === "create"
|
||||
? t("createChild", { defaultValue: "添加下级代理" })
|
||||
: t("editNode", { defaultValue: "编辑代理" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{nodeDialogMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-code">{t("code")}</Label>
|
||||
<Input
|
||||
id="agent-code"
|
||||
value={nodeCode}
|
||||
onChange={(e) => setNodeCode(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-name">{t("name")}</Label>
|
||||
<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="flex items-center gap-2">
|
||||
<Switch checked={nodeStatus === 1} onCheckedChange={(v) => setNodeStatus(v ? 1 : 0)} />
|
||||
<Label>{t("status")}</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("roles.slug")}</Label>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
<p>{t("roles.permissionSubsetHint")}</p>
|
||||
<p className="mt-2 font-medium text-foreground">{selectedRoleCountText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={rolePerms}
|
||||
onChange={setRolePerms}
|
||||
selectableSlugs={assignablePermissionSlugs}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("roles.permissionSubsetHint")}
|
||||
summaryText={selectedRoleCountText}
|
||||
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={roleSaving} onClick={() => void saveNewRole()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("roles.permissions")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
{selectedDraftCountText}
|
||||
</div>
|
||||
<AdminPermissionPackageSelector
|
||||
catalog={catalog}
|
||||
selectedSlugs={draftPerms}
|
||||
onChange={setDraftPerms}
|
||||
selectableSlugs={assignablePermissionSlugs}
|
||||
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
|
||||
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
|
||||
helperText={t("roles.permissionSubsetHint")}
|
||||
summaryText={selectedDraftCountText}
|
||||
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={permSaving} onClick={() => void saveRolePermissions()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={userDialogOpen} onOpenChange={setUserDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("users.create")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.username")}</Label>
|
||||
<Input value={userUsername} onChange={(e) => setUserUsername(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("name")}</Label>
|
||||
<Input value={userNickname} onChange={(e) => setUserNickname(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.password")}</Label>
|
||||
<Label htmlFor="agent-username">{t("users.username", { defaultValue: "登录名" })}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={userPassword}
|
||||
onChange={(e) => setUserPassword(e.target.value)}
|
||||
id="agent-username"
|
||||
value={nodeUsername}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
onChange={(e) => setNodeUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{roles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.roles")}</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded-md border p-2">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={userRoleIds.includes(role.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setUserRoleIds((prev) =>
|
||||
checked
|
||||
? [...prev, role.id]
|
||||
: prev.filter((id) => id !== role.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{role.name}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-password">
|
||||
{nodeDialogMode === "create"
|
||||
? t("users.password", { defaultValue: "密码" })
|
||||
: t("resetPassword", { defaultValue: "重置密码" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-password"
|
||||
type="password"
|
||||
value={nodePassword}
|
||||
onChange={(e) => setNodePassword(e.target.value)}
|
||||
placeholder={
|
||||
nodeDialogMode === "edit"
|
||||
? 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 ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("profile.section", { defaultValue: "占成与授信" })}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-share-rate">
|
||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-share-rate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={profileShareRate}
|
||||
onChange={(e) => setProfileShareRate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-credit-limit">
|
||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-credit-limit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={profileCreditLimit}
|
||||
onChange={(e) => setProfileCreditLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-rebate-limit">
|
||||
{t("profile.rebateLimit", { defaultValue: "回水上限" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-rebate-limit"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={profileRebateLimit}
|
||||
onChange={(e) => setProfileRebateLimit(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-default-rebate">
|
||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-default-rebate"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step="0.0001"
|
||||
value={profileDefaultRebate}
|
||||
onChange={(e) => setProfileDefaultRebate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-settlement-cycle">
|
||||
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
|
||||
</Label>
|
||||
<Select
|
||||
value={profileSettlementCycle}
|
||||
onValueChange={(value) =>
|
||||
setProfileSettlementCycle((value as "daily" | "weekly" | "monthly") ?? "weekly")
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="agent-settlement-cycle">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">
|
||||
{t("profile.cycleDaily", { defaultValue: "日结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="weekly">
|
||||
{t("profile.cycleWeekly", { defaultValue: "周结" })}
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
{t("profile.cycleMonthly", { defaultValue: "月结" })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={profileExtraRebate}
|
||||
onCheckedChange={setProfileExtraRebate}
|
||||
/>
|
||||
<Label>
|
||||
{t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={profileCanCreatePlayer}
|
||||
onCheckedChange={setProfileCanCreatePlayer}
|
||||
/>
|
||||
<Label>
|
||||
{t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={profileCanCreateChild}
|
||||
onCheckedChange={setProfileCanCreateChild}
|
||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||
/>
|
||||
<Label>
|
||||
{t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setUserDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "Cancel" })}
|
||||
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={userSaving} onClick={() => void saveNewUser()}>
|
||||
{t("common:actions.save", { defaultValue: "Save" })}
|
||||
<Button type="button" disabled={nodeSaving} onClick={() => void saveNode()}>
|
||||
{t("common:actions.save", { defaultValue: "保存" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
257
src/modules/agents/agents-players-panel.tsx
Normal file
257
src/modules/agents/agents-players-panel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAdminPlayers, postAdminPlayer } from "@/api/admin-player";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
type AgentsPlayersPanelProps = {
|
||||
siteCode: string;
|
||||
/** 筛选直属玩家时的代理节点;null 表示当前登录代理或不过滤 */
|
||||
agentNodeId: number | null;
|
||||
};
|
||||
|
||||
export function AgentsPlayersPanel({
|
||||
siteCode,
|
||||
agentNodeId,
|
||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "players", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
|
||||
const canCreatePlayer =
|
||||
isSuperAdmin ||
|
||||
(boundAgent?.can_create_player !== false &&
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
|
||||
|
||||
const effectiveAgentId = useMemo(() => {
|
||||
if (agentNodeId !== null) {
|
||||
return agentNodeId;
|
||||
}
|
||||
return boundAgent?.id ?? null;
|
||||
}, [agentNodeId, boundAgent?.id]);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [items, setItems] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sitePlayerId, setSitePlayerId] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [nickname, setNickname] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteCode.trim() === "") {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminPlayers({
|
||||
page,
|
||||
per_page: perPage,
|
||||
site_code: siteCode.trim(),
|
||||
...(effectiveAgentId !== null ? { agent_node_id: effectiveAgentId } : {}),
|
||||
});
|
||||
setItems(data.items);
|
||||
setTotal(data.meta.total);
|
||||
setLastPage(Math.max(1, data.meta.last_page));
|
||||
} catch {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [effectiveAgentId, page, perPage, siteCode]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function savePlayer(): Promise<void> {
|
||||
if (siteCode.trim() === "" || sitePlayerId.trim() === "") {
|
||||
toast.error(t("players:sitePlayerIdRequired", { defaultValue: "请填写站点玩家 ID" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await postAdminPlayer({
|
||||
site_code: siteCode.trim(),
|
||||
site_player_id: sitePlayerId.trim(),
|
||||
username: username.trim() || null,
|
||||
nickname: nickname.trim() || null,
|
||||
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
|
||||
});
|
||||
toast.success(t("players:createSuccess", { name: sitePlayerId.trim() }));
|
||||
setDialogOpen(false);
|
||||
setSitePlayerId("");
|
||||
setUsername("");
|
||||
setNickname("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canCreatePlayer ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
{t("playersPanel.create", { defaultValue: "创建玩家" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState minHeight="6rem" />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}</TableHead>
|
||||
<TableHead>{t("players:username", { defaultValue: "用户名" })}</TableHead>
|
||||
<TableHead>{t("players:nickname", { defaultValue: "昵称" })}</TableHead>
|
||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.site_player_id}</TableCell>
|
||||
<TableCell>{row.username ?? "—"}</TableCell>
|
||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
|
||||
{row.status === 0
|
||||
? t("players:statusNormal", { defaultValue: "正常" })
|
||||
: String(row.status)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="agents-players-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(value) => {
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("players:siteCode", { defaultValue: "站点" })}</Label>
|
||||
<Input value={siteCode} readOnly disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-site-id">
|
||||
{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-site-id"
|
||||
value={sitePlayerId}
|
||||
onChange={(e) => setSitePlayerId(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-username">
|
||||
{t("players:username", { defaultValue: "用户名" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="agent-player-nickname">
|
||||
{t("players:nickname", { defaultValue: "昵称" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-player-nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void savePlayer()}>
|
||||
{t("common:actions.save", { defaultValue: "保存" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/modules/agents/agents-subnav.tsx
Normal file
110
src/modules/agents/agents-subnav.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import {
|
||||
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
PRD_AGENT_SITES_ACCESS_ANY,
|
||||
PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
const tabs: {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
matchPrefix: string;
|
||||
requiredAny: readonly string[];
|
||||
}[] = [
|
||||
{
|
||||
href: "/admin/agents",
|
||||
labelKey: "subnav.operations",
|
||||
matchPrefix: "/admin/agents",
|
||||
requiredAny: PRD_AGENTS_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/provision",
|
||||
labelKey: "subnav.provision",
|
||||
matchPrefix: "/admin/agents/provision",
|
||||
requiredAny: PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/sites",
|
||||
labelKey: "subnav.sites",
|
||||
matchPrefix: "/admin/agents/sites",
|
||||
requiredAny: PRD_AGENT_SITES_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
href: "/admin/agents/settlement-bills",
|
||||
labelKey: "subnav.settlementBills",
|
||||
matchPrefix: "/admin/agents/settlement",
|
||||
requiredAny: PRD_SETTLEMENT_AGENT_ACCESS_ANY,
|
||||
},
|
||||
];
|
||||
|
||||
function isTabActive(pathname: string, href: string, matchPrefix: string): boolean {
|
||||
if (href === "/admin/agents") {
|
||||
return (
|
||||
pathname === "/admin/agents" ||
|
||||
pathname === "/admin/agents/list" ||
|
||||
(pathname.startsWith("/admin/agents/") &&
|
||||
!pathname.startsWith("/admin/agents/provision") &&
|
||||
!pathname.startsWith("/admin/agents/sites") &&
|
||||
!pathname.startsWith("/admin/agents/settlement"))
|
||||
);
|
||||
}
|
||||
|
||||
return pathname === href || pathname.startsWith(`${matchPrefix}/`) || pathname === matchPrefix;
|
||||
}
|
||||
|
||||
export function AgentsSubnav(): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const perms = profile?.permissions;
|
||||
|
||||
const visibleTabs = useMemo(
|
||||
() =>
|
||||
tabs.filter(
|
||||
(tab) =>
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(perms, [...tab.requiredAny]),
|
||||
),
|
||||
[perms, profile?.is_super_admin],
|
||||
);
|
||||
|
||||
if (visibleTabs.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={t("subnav.label", { defaultValue: "代理线路导航" })}
|
||||
className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1"
|
||||
>
|
||||
{visibleTabs.map((tab) => {
|
||||
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
|
||||
active
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user