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:
13
src/api/admin-agent-lines.ts
Normal file
13
src/api/admin-agent-lines.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { adminRequest } from "@/lib/admin-http";
|
||||
import type {
|
||||
AdminAgentLineProvisionPayload,
|
||||
AdminAgentLineProvisionResult,
|
||||
} from "@/types/api/admin-agent-line";
|
||||
|
||||
const A = `/admin`;
|
||||
|
||||
export async function postAdminAgentLine(
|
||||
payload: AdminAgentLineProvisionPayload,
|
||||
): Promise<AdminAgentLineProvisionResult> {
|
||||
return adminRequest.post<AdminAgentLineProvisionResult>(`${A}/agent-lines`, payload);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
AgentNodeCreatePayload,
|
||||
AgentNodeRow,
|
||||
AgentNodeUpdatePayload,
|
||||
AgentProfilePayload,
|
||||
AgentProfileRow,
|
||||
AgentRoleCreatePayload,
|
||||
AgentRoleListData,
|
||||
AgentTreeData,
|
||||
@@ -39,6 +41,17 @@ export async function deleteAgentNode(agentNodeId: number): Promise<null> {
|
||||
return adminRequest.delete<null>(`${A}/agent-nodes/${agentNodeId}`);
|
||||
}
|
||||
|
||||
export async function getAgentNodeProfile(agentNodeId: number): Promise<AgentProfileRow> {
|
||||
return adminRequest.get<AgentProfileRow>(`${A}/agent-nodes/${agentNodeId}/profile`);
|
||||
}
|
||||
|
||||
export async function putAgentNodeProfile(
|
||||
agentNodeId: number,
|
||||
body: AgentProfilePayload,
|
||||
): Promise<AgentProfileRow> {
|
||||
return adminRequest.put<AgentProfileRow>(`${A}/agent-nodes/${agentNodeId}/profile`, body);
|
||||
}
|
||||
|
||||
export async function getAgentNodeRoles(agentNodeId: number): Promise<AgentRoleListData> {
|
||||
return adminRequest.get<AgentRoleListData>(`${A}/agent-nodes/${agentNodeId}/roles`);
|
||||
}
|
||||
|
||||
14
src/app/admin/(shell)/agents/layout.tsx
Normal file
14
src/app/admin/(shell)/agents/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AgentsSubnav } from "@/modules/agents/agents-subnav";
|
||||
|
||||
export default function AdminAgentsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
|
||||
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<AgentsSubnav />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/admin/(shell)/agents/list/page.tsx
Normal file
5
src/app/admin/(shell)/agents/list/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AgentsListPage() {
|
||||
redirect("/admin/agents");
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const metadata: Metadata = buildPageMetadata("agents", "title");
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<ModuleScaffold embedded>
|
||||
<AdminPermissionGate requiredAny={PRD_AGENTS_ACCESS_ANY}>
|
||||
<AgentsConsole />
|
||||
</AdminPermissionGate>
|
||||
|
||||
18
src/app/admin/(shell)/agents/provision/page.tsx
Normal file
18
src/app/admin/(shell)/agents/provision/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-wizard";
|
||||
import { PRD_AGENT_LINE_PROVISION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("agents", "lineProvision.title");
|
||||
|
||||
export default function AgentLineProvisionPage(): React.ReactElement {
|
||||
return (
|
||||
<ModuleScaffold embedded>
|
||||
<AdminPermissionGate requiredAny={PRD_AGENT_LINE_PROVISION_ACCESS_ANY}>
|
||||
<AgentLineProvisionWizard />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
18
src/app/admin/(shell)/agents/settlement-bills/page.tsx
Normal file
18
src/app/admin/(shell)/agents/settlement-bills/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { AgentBillsConsole } from "@/modules/settlement/agent-bills-console";
|
||||
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("agents", "subnav.settlementBills");
|
||||
|
||||
export default function AgentSettlementBillsPage(): React.ReactElement {
|
||||
return (
|
||||
<ModuleScaffold embedded>
|
||||
<AdminPermissionGate requiredAny={PRD_SETTLEMENT_AGENT_ACCESS_ANY}>
|
||||
<AgentBillsConsole />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
15
src/app/admin/(shell)/agents/sites/page.tsx
Normal file
15
src/app/admin/(shell)/agents/sites/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
|
||||
import { PRD_AGENT_SITES_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("agents", "sitesTitle");
|
||||
|
||||
export default function AgentLineSitesPage() {
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_AGENT_SITES_ACCESS_ANY}>
|
||||
<IntegrationSitesConsole restrictCreateToSuperAdmin />
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,6 @@
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("config", "integrationSites.title");
|
||||
|
||||
export default function AdminIntegrationSitesPage() {
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<AdminPermissionGate requiredAny={PRD_INTEGRATION_ACCESS_ANY}>
|
||||
<IntegrationSitesConsole />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
/** @deprecated 接入配置已并入「代理线路」目录 */
|
||||
export default function LegacyIntegrationSitesPage() {
|
||||
redirect("/admin/agents/sites");
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** 兼容旧链接:热门号码已并入风险池 Tab(筛选 >80%)。 */
|
||||
export default async function AdminDrawRiskHotPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
titleKey="hotPageTitle"
|
||||
soldOutOnly={false}
|
||||
defaultSort="usage_desc"
|
||||
/>
|
||||
);
|
||||
redirect(`/admin/draws/${drawId}/risk/pools?filter=high_risk`);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
import {
|
||||
RiskPoolsConsole,
|
||||
type RiskPoolListFilter,
|
||||
} from "@/modules/risk/risk-pools-console";
|
||||
|
||||
function parsePoolFilter(raw: string | undefined): RiskPoolListFilter {
|
||||
if (raw === "sold_out" || raw === "high_risk") {
|
||||
return raw;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
export default async function AdminDrawRiskPoolsPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
searchParams: Promise<{ filter?: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const { filter: filterRaw } = await props.searchParams;
|
||||
const id = Number(drawId);
|
||||
const filter = parsePoolFilter(filterRaw);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
titleKey="allPoolsPageTitle"
|
||||
soldOutOnly={false}
|
||||
defaultSort="number_asc"
|
||||
initialFilter={filter}
|
||||
defaultSort={filter === "high_risk" ? "usage_desc" : "number_asc"}
|
||||
allowSortChange
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** 兼容旧链接:售罄号码已并入风险池 Tab(筛选售罄)。 */
|
||||
export default async function AdminDrawRiskSoldOutPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
titleKey="soldOutPageTitle"
|
||||
soldOutOnly
|
||||
defaultSort="number_asc"
|
||||
/>
|
||||
);
|
||||
redirect(`/admin/draws/${drawId}/risk/pools?filter=sold_out`);
|
||||
}
|
||||
|
||||
28
src/app/admin/(shell)/players/[playerId]/page.tsx
Normal file
28
src/app/admin/(shell)/players/[playerId]/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { PRD_PLAYERS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { InvalidPlayerId } from "@/modules/players/invalid-player-id";
|
||||
import { PlayerDetailConsole } from "@/modules/players/player-detail-console";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("players", "detailTitle");
|
||||
|
||||
export default async function AdminPlayerDetailPage(props: {
|
||||
params: Promise<{ playerId: string }>;
|
||||
}) {
|
||||
const { playerId } = await props.params;
|
||||
const id = Number(playerId);
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<AdminPermissionGate requiredAny={PRD_PLAYERS_ACCESS_ANY}>
|
||||
{Number.isFinite(id) && id > 0 ? (
|
||||
<PlayerDetailConsole playerId={id} />
|
||||
) : (
|
||||
<InvalidPlayerId />
|
||||
)}
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskHotPage(props: {
|
||||
export default async function AdminRiskHotRedirectPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
redirect(`/admin/draws/${drawId}/risk/hot`);
|
||||
redirect(`/admin/draws/${drawId}/risk/pools?filter=high_risk`);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskSoldOutPage(props: {
|
||||
export default async function AdminRiskSoldOutRedirectPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
redirect(`/admin/draws/${drawId}/risk/sold-out`);
|
||||
redirect(`/admin/draws/${drawId}/risk/pools?filter=sold_out`);
|
||||
}
|
||||
|
||||
5
src/app/admin/(shell)/settlement/agent-bills/page.tsx
Normal file
5
src/app/admin/(shell)/settlement/agent-bills/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LegacyAgentBillsPage() {
|
||||
redirect("/admin/agents/settlement-bills");
|
||||
}
|
||||
@@ -1,15 +1,6 @@
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { PlayerWalletPanel } from "@/modules/wallet/wallet-console";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("wallet", "playerWalletQuery");
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/** 玩家钱包已并入玩家详情;旧路径重定向到玩家列表 */
|
||||
export default function AdminWalletPlayerPage() {
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_WALLET_PLAYER_ACCESS_ANY}>
|
||||
<PlayerWalletPanel />
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
redirect("/admin/players");
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
}
|
||||
|
||||
.admin-list-toolbar {
|
||||
@apply flex w-full flex-row flex-wrap items-center gap-3 border-t border-border/60 pt-4;
|
||||
@apply flex w-full flex-row flex-wrap items-center gap-3;
|
||||
}
|
||||
|
||||
.admin-list-field {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { adminNavLabel } from "@/lib/admin-nav-label";
|
||||
import { resolveAdminPageTitle } from "@/lib/admin-page-title";
|
||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import React from "react";
|
||||
@@ -39,6 +40,13 @@ const TOP_ROUTE_LABELS: Record<string, string> = {
|
||||
agents: "agents.title",
|
||||
};
|
||||
|
||||
const AGENT_ROUTE_LABELS: Record<string, string> = {
|
||||
list: "agents.directoryTitle",
|
||||
provision: "agents.subnav.provision",
|
||||
sites: "agents.sitesTitle",
|
||||
"settlement-bills": "agents.subnav.settlementBills",
|
||||
};
|
||||
|
||||
const CONFIG_ROUTE_LABELS: Record<string, string> = {
|
||||
"integration-sites": "integrationSites.title",
|
||||
plays: "nav.items.plays",
|
||||
@@ -119,10 +127,15 @@ export function AdminBreadcrumb() {
|
||||
navItem != null &&
|
||||
(pathname === navItem.href || pathname.startsWith(`${navItem.href}/`));
|
||||
|
||||
if (segments.length > 2 && !navCoversPath) {
|
||||
if (segments.length > 2 && (!navCoversPath || businessSegment === "agents")) {
|
||||
const subSegment = segments[2];
|
||||
let subLabel = "";
|
||||
if (businessSegment === "rules" && subSegment) {
|
||||
if (businessSegment === "agents" && subSegment) {
|
||||
const key = AGENT_ROUTE_LABELS[subSegment];
|
||||
subLabel = key
|
||||
? t(key, { ns: "agents", defaultValue: titleCase(subSegment) })
|
||||
: titleCase(subSegment);
|
||||
} else if (businessSegment === "rules" && subSegment) {
|
||||
const key = RULES_ROUTE_LABELS[subSegment];
|
||||
subLabel = key
|
||||
? t(key, { ns: "config", defaultValue: titleCase(subSegment) })
|
||||
@@ -143,12 +156,20 @@ export function AdminBreadcrumb() {
|
||||
? t(key, { ns: "config", defaultValue: titleCase(subSegment) })
|
||||
: titleCase(subSegment);
|
||||
} else {
|
||||
subLabel = subSegment
|
||||
? t(`subnav.${subSegment}`, {
|
||||
ns: "draws",
|
||||
defaultValue: DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment),
|
||||
})
|
||||
: "";
|
||||
const pageTitle = resolveAdminPageTitle(pathname);
|
||||
if (pageTitle) {
|
||||
subLabel = t(pageTitle.key, {
|
||||
ns: pageTitle.ns,
|
||||
...(pageTitle.params ?? {}),
|
||||
});
|
||||
} else if (subSegment) {
|
||||
subLabel = t(`subnav.${subSegment}`, {
|
||||
ns: "draws",
|
||||
defaultValue: DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment),
|
||||
});
|
||||
} else {
|
||||
subLabel = "";
|
||||
}
|
||||
}
|
||||
if (subLabel) {
|
||||
breadcrumbs.push({
|
||||
|
||||
@@ -5,14 +5,18 @@ import { cn } from "@/lib/utils";
|
||||
type ModuleScaffoldProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
/** 已处于带外边距的 layout(如代理线路子导航)内时为 true */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
/** 内容区容器;模块标题由侧栏导航体现,此处不再重复大标题与说明。 */
|
||||
export function ModuleScaffold({ children, className }: ModuleScaffoldProps) {
|
||||
export function ModuleScaffold({ children, className, embedded = false }: ModuleScaffoldProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6",
|
||||
embedded
|
||||
? "flex w-full min-w-0 flex-col gap-6"
|
||||
: "mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { PRD_INTEGRATION_ACCESS_ANY, PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
|
||||
export type AdminSiteCodeOption = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -32,6 +33,7 @@ async function fetchSiteCodeOptions(): Promise<AdminSiteCodeOption[]> {
|
||||
inflightSites = getAdminIntegrationSites()
|
||||
.then((data) => {
|
||||
cachedSites = data.items.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
}));
|
||||
@@ -58,7 +60,10 @@ export function useAdminSiteCodeOptions(): {
|
||||
reload: () => Promise<void>;
|
||||
} {
|
||||
const profile = useAdminProfile();
|
||||
const canLoad = adminHasAnyPermission(profile?.permissions, PRD_INTEGRATION_ACCESS_ANY);
|
||||
const canLoad = adminHasAnyPermission(profile?.permissions, [
|
||||
...PRD_INTEGRATION_ACCESS_ANY,
|
||||
PRD_USERS_MANAGE,
|
||||
]);
|
||||
|
||||
const [sites, setSites] = useState<AdminSiteCodeOption[]>(cachedSites ?? []);
|
||||
const [loading, setLoading] = useState(canLoad && cachedSites === null);
|
||||
|
||||
@@ -103,8 +103,11 @@
|
||||
"editTitle": "Edit Role",
|
||||
"description": "Roles group backend function permissions and are then assigned to admin accounts.",
|
||||
"slug": "Role code",
|
||||
"slugPlaceholder": "Enter role identifier, for example super_admin",
|
||||
"name": "Role name",
|
||||
"namePlaceholder": "Enter role name",
|
||||
"descriptionLabel": "Role description",
|
||||
"descriptionPlaceholder": "Enter role description",
|
||||
"status": "Status"
|
||||
},
|
||||
"accountDialog": {
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
{
|
||||
"title": "Agents",
|
||||
"title": "Agent lines",
|
||||
"sitesTitle": "Sites",
|
||||
"sitesListHint": "For the full site table (keys, callbacks, etc.), go to",
|
||||
"sitesListLink": "Sites",
|
||||
"subnav": {
|
||||
"label": "Agent line navigation",
|
||||
"noPermission": "No permission",
|
||||
"operations": "Operations",
|
||||
"provision": "Provision line",
|
||||
"sites": "Sites",
|
||||
"settlementBills": "Agent bills"
|
||||
},
|
||||
"includeRoots": "Include root nodes",
|
||||
"includeRootsHint": "Root nodes represent site boundaries and are excluded from operating agent counts by default.",
|
||||
"directoryStatus": {
|
||||
"all": "All statuses",
|
||||
"enabled": "Enabled only",
|
||||
"disabled": "Disabled only"
|
||||
},
|
||||
"tabs": {
|
||||
"subordinates": "Subordinates",
|
||||
"accounts": "Primary account",
|
||||
"players": "Players",
|
||||
"overview": "Overview",
|
||||
"roles": "Roles",
|
||||
"users": "Accounts",
|
||||
"delegation": "Delegation ceiling"
|
||||
},
|
||||
"filterParent": "Parent agent",
|
||||
"filterParentAll": "All subordinates",
|
||||
"listFlatHint": "All operating agents in a flat list. Use row actions to add a child under a specific agent.",
|
||||
"addChildNeedParent": "Select a parent agent before adding a subordinate",
|
||||
"treeTitle": "Agent tree",
|
||||
"detailTitle": "Node details",
|
||||
"selectNode": "Select an agent node from the tree",
|
||||
@@ -18,6 +49,7 @@
|
||||
},
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Enter agent name",
|
||||
"depth": "Depth",
|
||||
"path": "Path",
|
||||
"status": "Status",
|
||||
@@ -28,11 +60,61 @@
|
||||
"saveFailed": "Save failed",
|
||||
"codeRequired": "Code and name are required",
|
||||
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"roles": "Roles",
|
||||
"users": "Accounts",
|
||||
"delegation": "Delegation ceiling"
|
||||
"pageGuide": "Manage the agent tree, agent roles, agent accounts, and delegation ceilings here. Platform accounts and platform roles stay in platform governance pages.",
|
||||
"summary": {
|
||||
"currentSiteNodes": "Current site nodes",
|
||||
"currentSiteAgents": "Current site operating agents",
|
||||
"visibleList": "Current flat list rows",
|
||||
"visibleAgents": "Current visible operating agents",
|
||||
"globalNodes": "All-site node total",
|
||||
"globalAgents": "All-site operating agents",
|
||||
"enabledAgents": "Enabled operating agents",
|
||||
"rootNodes": "Root node count"
|
||||
},
|
||||
"profile": {
|
||||
"section": "Share & credit",
|
||||
"totalShareRate": "Share rate (%)",
|
||||
"creditLimit": "Credit limit",
|
||||
"rebateLimit": "Rebate ceiling",
|
||||
"defaultPlayerRebate": "Default player rebate",
|
||||
"settlementCycle": "Settlement cycle",
|
||||
"canGrantExtraRebate": "Allow extra rebate",
|
||||
"canCreatePlayer": "Allow creating players",
|
||||
"canCreateChildAgent": "Allow creating sub-agents",
|
||||
"cycleDaily": "Daily",
|
||||
"cycleWeekly": "Weekly",
|
||||
"cycleMonthly": "Monthly"
|
||||
},
|
||||
"settlementBills": {
|
||||
"title": "Agent bills",
|
||||
"description": "Player/agent bills generated after a period is closed",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"type": "Type",
|
||||
"net": "Net",
|
||||
"unpaid": "Unpaid",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"lineProvision": {
|
||||
"title": "Provision agent line",
|
||||
"description": "Creates site, root agent, and admin account in one step (site_code matches agent code).",
|
||||
"code": "Site code",
|
||||
"name": "Line name",
|
||||
"username": "Agent login",
|
||||
"password": "Initial password",
|
||||
"walletUrl": "Wallet API URL",
|
||||
"submit": "Provision",
|
||||
"success": "Line provisioned",
|
||||
"secretsOnce": "Secrets are shown once — save them now",
|
||||
"link": "Provision line"
|
||||
},
|
||||
"noAccess": "You do not have permission to manage agents. Contact an administrator.",
|
||||
"playersPanel": {
|
||||
"create": "Create player",
|
||||
"scopedTo": "Direct players: {{agent}}",
|
||||
"allUnderSite": "Players visible on this site",
|
||||
"filterHint": "Filter direct players by parent agent."
|
||||
},
|
||||
"delegation": {
|
||||
"title": "Delegation ceiling",
|
||||
@@ -62,11 +144,15 @@
|
||||
"title": "Agent accounts",
|
||||
"create": "Create account",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"roles": "Roles",
|
||||
"createSuccess": "Created account {{name}}",
|
||||
"roleSaveSuccess": "Roles updated for {{name}}",
|
||||
"deleteConfirm": "This admin will no longer be able to sign in. This cannot be undone.",
|
||||
"deleteSuccess": "Deleted account {{name}}"
|
||||
}
|
||||
},
|
||||
"usernamePlaceholder": "Enter login username",
|
||||
"passwordPlaceholder": "Enter an 8-character password",
|
||||
"passwordOptionalHint": "Leave empty to keep unchanged, or enter an 8-character password"
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@
|
||||
"audit": "Audit Logs",
|
||||
"settings": "Settings",
|
||||
"account": "Account settings",
|
||||
"integration": "Integration sites",
|
||||
"agents": "Agents",
|
||||
"integration": "Integration",
|
||||
"agents": "Agent lines",
|
||||
"config": "Operations config"
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -99,6 +99,16 @@
|
||||
"notes": "Notes",
|
||||
"ssoSecret": "SSO secret",
|
||||
"walletApiKey": "Wallet API key"
|
||||
},
|
||||
"placeholders": {
|
||||
"code": "Enter site identifier, for example partner-a",
|
||||
"name": "Enter site name",
|
||||
"currency": "Enter currency code, for example NPR",
|
||||
"walletApiUrl": "Enter wallet API URL",
|
||||
"lotteryH5BaseUrl": "Enter H5 URL",
|
||||
"iframeOrigins": "Enter allowed origins, for example https://www.example.com",
|
||||
"notes": "Enter notes",
|
||||
"connectivityPlayerId": "Enter player ID, for example 10001"
|
||||
}
|
||||
},
|
||||
"versionStatus": {
|
||||
@@ -211,6 +221,17 @@
|
||||
"playRulesHtml": "Play rules HTML (i18n)",
|
||||
"playRulesHtmlDesc": "Rendered on the player play-rules page per locale. Leave empty to fall back to another language or the default empty state."
|
||||
},
|
||||
"placeholders": {
|
||||
"defaultCurrency": "Enter default currency code, for example NPR",
|
||||
"drawIntervalMinutes": "Enter draw interval in minutes",
|
||||
"drawBettingWindowSeconds": "Enter betting window in seconds",
|
||||
"drawCloseBeforeDrawSeconds": "Enter seconds to close before draw",
|
||||
"drawBufferDrawsAhead": "Enter pre-generated draw count",
|
||||
"cooldownMinutes": "Enter cooldown minutes",
|
||||
"currencyDisplayDecimals": "Enter display decimal places, for example 2",
|
||||
"currencyDecimalSeparator": "Enter decimal separator, for example .",
|
||||
"currencyThousandsSeparator": "Enter thousands separator, for example ,"
|
||||
},
|
||||
"hints": {
|
||||
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.",
|
||||
"cooldownMinutes": "How long to wait after publishing before entering settling. Use 0 to settle immediately.",
|
||||
@@ -277,6 +298,9 @@
|
||||
"code": "Currency code",
|
||||
"name": "Currency name",
|
||||
"decimals": "Decimal places",
|
||||
"codePlaceholder": "Enter currency code, for example NPR",
|
||||
"namePlaceholder": "Enter currency name",
|
||||
"decimalsPlaceholder": "Enter decimal places, for example 2",
|
||||
"enabled": "Enabled status",
|
||||
"enabledHint": "Disabled currencies should not be used for new business.",
|
||||
"bettable": "Allow betting",
|
||||
@@ -347,6 +371,11 @@
|
||||
"maxBet": "Max bet",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"placeholders": {
|
||||
"displayOrder": "Order",
|
||||
"minBetAmount": "Minimum amount",
|
||||
"maxBetAmount": "Maximum amount"
|
||||
},
|
||||
"states": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
@@ -411,6 +440,10 @@
|
||||
"missingScopeRow": "Missing {{scope}} row. Check seed or version data.",
|
||||
"rebateRate": "Rebate rate (%)",
|
||||
"rebateRateHint": "Writes rebate_rate to all prize scopes under this play type.",
|
||||
"placeholders": {
|
||||
"multiplier": "Enter odds multiplier",
|
||||
"rebateRate": "Enter rebate rate"
|
||||
},
|
||||
"publishFailed": "Publish failed",
|
||||
"createDraftSuccess": "Created draft v{{version}}",
|
||||
"createDraftFailed": "Failed to create draft",
|
||||
@@ -457,6 +490,11 @@
|
||||
"d3": "3D rebate rate (%)",
|
||||
"d4": "4D rebate rate (%)"
|
||||
},
|
||||
"placeholders": {
|
||||
"d2": "Enter 2D rebate",
|
||||
"d3": "Enter 3D rebate",
|
||||
"d4": "Enter 4D rebate"
|
||||
},
|
||||
"winEnjoy": {
|
||||
"label": "Deduct rebate on winning payouts",
|
||||
"description": "Maps to settlement.apply_rebate_to_payout: when enabled, winning payout uses gross win × (1 - rebate_rate_snapshot).",
|
||||
@@ -467,6 +505,11 @@
|
||||
"effectiveTime": "Effective time (current active odds version)"
|
||||
},
|
||||
"riskCap": {
|
||||
"placeholders": {
|
||||
"defaultCap": "Enter default cap amount",
|
||||
"number": "4-digit number",
|
||||
"capAmount": "Enter cap amount"
|
||||
},
|
||||
"validation": {
|
||||
"requireAtLeastOne": "At least one cap row is required",
|
||||
"defaultGreaterThanZero": "Default cap amount must be greater than 0",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"title": "Create draw manually",
|
||||
"description": "Enter date and time in {{tz}} (not your browser local zone). If only draw time is set, start/close are derived from server config.",
|
||||
"hint": "Start < close < draw. Draw number optional; sequence auto-assigned by UTC business date.",
|
||||
"drawNoPlaceholder": "Enter draw number, for example 20260526-008",
|
||||
"drawTimeRequired": "Draw time is required",
|
||||
"submit": "Create",
|
||||
"saving": "Creating…",
|
||||
@@ -34,6 +35,7 @@
|
||||
"action": "Edit",
|
||||
"title": "Edit draw",
|
||||
"description": "Draw {{drawNo}} · edit times in {{tz}}",
|
||||
"drawNoPlaceholder": "Enter draw number, for example 20260526-008",
|
||||
"submit": "Save",
|
||||
"saving": "Saving…",
|
||||
"success": "Draw updated",
|
||||
@@ -55,6 +57,14 @@
|
||||
"invalidDrawId": "Invalid draw ID",
|
||||
"loadFailed": "Failed to load. Check login and API configuration.",
|
||||
"drawDetail": "Draw details",
|
||||
"detailSubtitle": "{{date}} · Round {{seq}}",
|
||||
"scheduleTitle": "Schedule",
|
||||
"resultBatchesTitle": "Result batches",
|
||||
"batchSummaryTotal": "{{count}} batch(es)",
|
||||
"batchSummaryPending": "{{count}} pending",
|
||||
"batchSummaryPublished": "{{count}} published",
|
||||
"noResultBatchesYet": "No result batches yet.",
|
||||
"goToReviewTab": "Review & publish",
|
||||
"businessDate": "Business date",
|
||||
"sequenceNo": "Sequence no.",
|
||||
"plannedDraw": "Planned draw",
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"adjustmentIncrease": "Increase",
|
||||
"adjustmentDecrease": "Decrease",
|
||||
"adjustmentAmount": "Amount (major units)",
|
||||
"adjustmentAmountPlaceholder": "Enter adjustment amount",
|
||||
"adjustmentReason": "Reason (required)",
|
||||
"adjustmentReasonPlaceholder": "Enter adjustment reason",
|
||||
"submitAdjustment": "Submit adjustment",
|
||||
"adjustmentSuccess": "Pool balance adjusted",
|
||||
"adjustmentFailed": "Adjustment failed",
|
||||
@@ -30,11 +32,17 @@
|
||||
"confirmAdjustmentDescription": "This writes a ledger entry and updates the pool balance. Verify amount and reason.",
|
||||
"recentAdjustments": "Recent adjustments",
|
||||
"contributionRate": "Contribution rate 0-1",
|
||||
"contributionRatePlaceholder": "Enter contribution rate, for example 0.02",
|
||||
"triggerThreshold": "Burst threshold (minor unit)",
|
||||
"triggerThresholdPlaceholder": "Enter burst threshold",
|
||||
"payoutRate": "Burst payout rate 0-1",
|
||||
"payoutRatePlaceholder": "Enter payout rate, for example 0.05",
|
||||
"forceTriggerGap": "Force burst gap (settled draws)",
|
||||
"forceTriggerGapPlaceholder": "Enter forced burst gap in draws",
|
||||
"minBetAmount": "Minimum bet amount (minor unit)",
|
||||
"minBetAmountPlaceholder": "Enter minimum bet amount",
|
||||
"comboTriggerPlays": "Combo trigger plays (comma separated)",
|
||||
"comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox",
|
||||
"status": "Status",
|
||||
"disabled": "Disabled",
|
||||
"enabled": "Enabled",
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"title": "Players",
|
||||
"detailTitle": "Player details",
|
||||
"listTitle": "Player list",
|
||||
"viewDetail": "View details",
|
||||
"backToList": "Back to player list",
|
||||
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
||||
"tabOverview": "Overview",
|
||||
"tabTickets": "Tickets",
|
||||
"tabWalletTxns": "Wallet transactions",
|
||||
"tabTransferOrders": "Transfer orders",
|
||||
"profileSection": "Profile",
|
||||
"walletsSection": "Wallets",
|
||||
"createdAt": "Registered at",
|
||||
"agent": "Agent",
|
||||
"frozen": "Frozen",
|
||||
"txnAmount": "Amount",
|
||||
"balanceAfterTxn": "Balance after",
|
||||
"invalidPlayerId": "Invalid player ID",
|
||||
"createPlayer": "Create player",
|
||||
"searchPlaceholder": "Search by player ID / username / nickname",
|
||||
"filterSite": "Site",
|
||||
@@ -11,6 +27,10 @@
|
||||
"siteCodeRequired": "Enter the site code",
|
||||
"sitePlayerIdRequired": "Enter the site player ID",
|
||||
"createFailed": "Failed to create player",
|
||||
"createAgentRequired": "Your account is not bound to an agent node. Sign in with an agent account, or as super admin pick a valid site and agent.",
|
||||
"createAgentNode": "Agent node",
|
||||
"createAgentNodePlaceholder": "Select agent node",
|
||||
"createAgentAutoHint": "Player will be assigned to your agent: {{name}} ({{code}})",
|
||||
"createSuccess": "Created player {{name}}",
|
||||
"noChanges": "No changes",
|
||||
"updateFailed": "Failed to update player",
|
||||
|
||||
@@ -103,8 +103,11 @@
|
||||
"editTitle": "भूमिका सम्पादन",
|
||||
"description": "भूमिकाले ब्याकएन्ड कार्य अनुमति समेट्छ र पछि प्रशासक खातालाई बाँडिन्छ।",
|
||||
"slug": "भूमिका कोड",
|
||||
"slugPlaceholder": "भूमिका चिन्ह प्रविष्ट गर्नुहोस्, जस्तै super_admin",
|
||||
"name": "भूमिका नाम",
|
||||
"namePlaceholder": "भूमिका नाम प्रविष्ट गर्नुहोस्",
|
||||
"descriptionLabel": "भूमिका विवरण",
|
||||
"descriptionPlaceholder": "भूमिका विवरण प्रविष्ट गर्नुहोस्",
|
||||
"status": "स्थिति"
|
||||
},
|
||||
"accountDialog": {
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
{
|
||||
"title": "Agents",
|
||||
"title": "एजेन्ट लाइन",
|
||||
"sitesTitle": "साइट सूची",
|
||||
"sitesListHint": "पूर्ण साइट तालिका (कुञ्जी, कलब्याक) को लागि",
|
||||
"sitesListLink": "साइट सूची",
|
||||
"subnav": {
|
||||
"label": "एजेन्ट लाइन नेभ",
|
||||
"noPermission": "अनुमति छैन",
|
||||
"operations": "सञ्चालन",
|
||||
"provision": "लाइन खोल्नुहोस्",
|
||||
"sites": "साइट सूची",
|
||||
"settlementBills": "एजेन्ट बिल"
|
||||
},
|
||||
"tabs": {
|
||||
"subordinates": "अधीनस्थ व्यवस्थापन",
|
||||
"accounts": "मुख्य खाता",
|
||||
"players": "प्लेयर व्यवस्थापन",
|
||||
"overview": "Overview",
|
||||
"roles": "Roles",
|
||||
"users": "Accounts",
|
||||
"delegation": "Delegation ceiling"
|
||||
},
|
||||
"filterParent": "माथिल्लो एजेन्ट",
|
||||
"filterParentAll": "सबै अधीनस्थ",
|
||||
"listFlatHint": "सबै सञ्चालन एजेन्ट सूचीमा; अधीनस्थ थप्न पङ्क्ति मेनु प्रयोग गर्नुहोस्।",
|
||||
"addChildNeedParent": "अधीनस्थ थप्न पहिले माथिल्लो एजेन्ट छान्नुहोस्",
|
||||
"includeRoots": "रुट नोड समावेश गर्नुहोस्",
|
||||
"includeRootsHint": "रुट नोडले साइट सिमाना जनाउँछ, त्यसैले सामान्यतया सञ्चालन एजेन्ट गणनामा समावेश हुँदैन।",
|
||||
"directoryStatus": {
|
||||
"all": "सबै स्थिति",
|
||||
"enabled": "सक्रिय मात्र",
|
||||
"disabled": "निष्क्रिय मात्र"
|
||||
},
|
||||
"treeTitle": "Agent tree",
|
||||
"detailTitle": "Node details",
|
||||
"selectNode": "Select an agent node from the tree",
|
||||
@@ -18,6 +49,7 @@
|
||||
},
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "एजेन्ट नाम प्रविष्ट गर्नुहोस्",
|
||||
"depth": "Depth",
|
||||
"path": "Path",
|
||||
"status": "Status",
|
||||
@@ -28,11 +60,61 @@
|
||||
"saveFailed": "Save failed",
|
||||
"codeRequired": "Code and name are required",
|
||||
"modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"roles": "Roles",
|
||||
"users": "Accounts",
|
||||
"delegation": "Delegation ceiling"
|
||||
"pageGuide": "यहाँ एजेन्ट ट्री, एजेन्ट भूमिका, एजेन्ट खाता र delegation ceiling व्यवस्थापन गरिन्छ। प्लेटफर्म खाता र प्लेटफर्म भूमिका अलग पृष्ठमा राखिन्छ।",
|
||||
"summary": {
|
||||
"currentSiteNodes": "हालको साइट नोड संख्या",
|
||||
"currentSiteAgents": "हालको साइट सञ्चालन एजेन्ट",
|
||||
"visibleList": "हालको सूची पंक्ति",
|
||||
"visibleAgents": "हाल देखिने सञ्चालन एजेन्ट",
|
||||
"globalNodes": "सबै साइट नोड कुल",
|
||||
"globalAgents": "सबै साइट सञ्चालन एजेन्ट",
|
||||
"enabledAgents": "सक्रिय सञ्चालन एजेन्ट",
|
||||
"rootNodes": "रुट नोड संख्या"
|
||||
},
|
||||
"profile": {
|
||||
"section": "शेयर र क्रेडिट",
|
||||
"totalShareRate": "शेयर दर (%)",
|
||||
"creditLimit": "क्रेडिट सीमा",
|
||||
"rebateLimit": "रिबेट सीमा",
|
||||
"defaultPlayerRebate": "प्लेयर पूर्वनिर्धारित रिबेट",
|
||||
"settlementCycle": "सेटलमेन्ट चक्र",
|
||||
"canGrantExtraRebate": "अतिरिक्त रिबेट अनुमति",
|
||||
"canCreatePlayer": "प्लेयर सिर्जना अनुमति",
|
||||
"canCreateChildAgent": "अधीनस्थ एजेन्ट सिर्जना अनुमति",
|
||||
"cycleDaily": "दैनिक",
|
||||
"cycleWeekly": "साप्ताहिक",
|
||||
"cycleMonthly": "मासिक"
|
||||
},
|
||||
"settlementBills": {
|
||||
"title": "एजेन्ट बिल",
|
||||
"description": "अवधि बन्द पछि बनेका प्लेयर/एजेन्ट बिल",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"type": "प्रकार",
|
||||
"net": "नेट",
|
||||
"unpaid": "बाँकी",
|
||||
"status": "स्थिति"
|
||||
}
|
||||
},
|
||||
"lineProvision": {
|
||||
"title": "एजेन्ट लाइन खोल्नुहोस्",
|
||||
"description": "एकै चरणमा साइट, रुट एजेन्ट र खाता सिर्जना (site_code = agent code)।",
|
||||
"code": "साइट code",
|
||||
"name": "लाइन नाम",
|
||||
"username": "एजेन्ट लगइन",
|
||||
"password": "प्रारम्भिक पासवर्ड",
|
||||
"walletUrl": "वालेट API URL",
|
||||
"submit": "खोल्नुहोस्",
|
||||
"success": "लाइन खोलियो",
|
||||
"secretsOnce": "कुञ्जी एक पटक मात्र देखाइन्छ",
|
||||
"link": "लाइन खोल्नुहोस्"
|
||||
},
|
||||
"noAccess": "एजेन्ट सञ्चालन अनुमति छैन। प्रशासकलाई सम्पर्क गर्नुहोस्।",
|
||||
"playersPanel": {
|
||||
"create": "प्लेयर सिर्जना",
|
||||
"scopedTo": "प्रत्यक्ष प्लेयर: {{agent}}",
|
||||
"allUnderSite": "हालको साइटका प्लेयर",
|
||||
"filterHint": "माथिल्लो एजेन्ट अनुसार प्लेयर हेर्नुहोस्।"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "Delegation ceiling",
|
||||
@@ -68,5 +150,8 @@
|
||||
"roleSaveSuccess": "Roles updated for {{name}}",
|
||||
"deleteConfirm": "यो खाता अब लगइन गर्न सक्दैन।",
|
||||
"deleteSuccess": "खाता {{name}} मेटियो"
|
||||
}
|
||||
},
|
||||
"usernamePlaceholder": "लगइन नाम प्रविष्ट गर्नुहोस्",
|
||||
"passwordPlaceholder": "८-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्",
|
||||
"passwordOptionalHint": "परिवर्तन नगर्ने भए खाली छोड्नुहोस्, परिवर्तन गर्न ८-अक्षरको पासवर्ड प्रविष्ट गर्नुहोस्"
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"settings": "सेटिङ",
|
||||
"account": "खाता सेटिङ",
|
||||
"integration": "मुख्य साइट एकीकरण",
|
||||
"agents": "एजेन्ट व्यवस्थापन",
|
||||
"agents": "एजेन्ट लाइन",
|
||||
"config": "सञ्चालन कन्फिगरेसन"
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -99,6 +99,16 @@
|
||||
"notes": "टिप्पणी",
|
||||
"ssoSecret": "SSO गोप्य",
|
||||
"walletApiKey": "वालेट API कुञ्जी"
|
||||
},
|
||||
"placeholders": {
|
||||
"code": "साइट चिन्ह प्रविष्ट गर्नुहोस्, जस्तै partner-a",
|
||||
"name": "साइट नाम प्रविष्ट गर्नुहोस्",
|
||||
"currency": "मुद्रा कोड प्रविष्ट गर्नुहोस्, जस्तै NPR",
|
||||
"walletApiUrl": "वालेट API ठेगाना प्रविष्ट गर्नुहोस्",
|
||||
"lotteryH5BaseUrl": "H5 ठेगाना प्रविष्ट गर्नुहोस्",
|
||||
"iframeOrigins": "अनुमत origin प्रविष्ट गर्नुहोस्, जस्तै https://www.example.com",
|
||||
"notes": "टिप्पणी प्रविष्ट गर्नुहोस्",
|
||||
"connectivityPlayerId": "खेलाडी ID प्रविष्ट गर्नुहोस्, जस्तै 10001"
|
||||
}
|
||||
},
|
||||
"versionStatus": {
|
||||
@@ -200,6 +210,17 @@
|
||||
"playRulesHtml": "खेल नियम HTML (बहुभाषी)",
|
||||
"playRulesHtmlDesc": "खेलाडीको नियम पृष्ठमा भाषा अनुसार HTML देखिन्छ। खाली छोड्दा अर्को भाषा वा पूर्वनिर्धारित खाली सूचना देखिन्छ।"
|
||||
},
|
||||
"placeholders": {
|
||||
"defaultCurrency": "पूर्वनिर्धारित मुद्रा कोड प्रविष्ट गर्नुहोस्, जस्तै NPR",
|
||||
"drawIntervalMinutes": "ड्रअ अन्तराल मिनेट प्रविष्ट गर्नुहोस्",
|
||||
"drawBettingWindowSeconds": "बेटिङ विन्डो सेकेन्ड प्रविष्ट गर्नुहोस्",
|
||||
"drawCloseBeforeDrawSeconds": "ड्रअ अघि बन्द हुने सेकेन्ड प्रविष्ट गर्नुहोस्",
|
||||
"drawBufferDrawsAhead": "अग्रिम सिर्जना गरिने ड्रअ संख्या प्रविष्ट गर्नुहोस्",
|
||||
"cooldownMinutes": "कूलडाउन मिनेट प्रविष्ट गर्नुहोस्",
|
||||
"currencyDisplayDecimals": "प्रदर्शन दशमलव स्थान प्रविष्ट गर्नुहोस्, जस्तै 2",
|
||||
"currencyDecimalSeparator": "दशमलव विभाजक प्रविष्ट गर्नुहोस्, जस्तै .",
|
||||
"currencyThousandsSeparator": "हजार विभाजक प्रविष्ट गर्नुहोस्, जस्तै ,"
|
||||
},
|
||||
"hints": {
|
||||
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।",
|
||||
"cooldownMinutes": "प्रकाशनपछि settling मा जानुअघि कति समय पर्खने। 0 राखे तुरुन्त सेटलमेन्ट सुरु हुन्छ।",
|
||||
@@ -260,6 +281,9 @@
|
||||
"code": "मुद्रा कोड",
|
||||
"name": "मुद्रा नाम",
|
||||
"decimals": "दशमलव स्थान",
|
||||
"codePlaceholder": "मुद्रा कोड प्रविष्ट गर्नुहोस्, जस्तै NPR",
|
||||
"namePlaceholder": "मुद्रा नाम प्रविष्ट गर्नुहोस्",
|
||||
"decimalsPlaceholder": "दशमलव स्थान प्रविष्ट गर्नुहोस्, जस्तै 2",
|
||||
"enabled": "सक्रिय स्थिति",
|
||||
"enabledHint": "निष्क्रिय मुद्रा नयाँ व्यवसायमा प्रयोग गर्नु हुँदैन।",
|
||||
"bettable": "बेटिङ अनुमति",
|
||||
@@ -330,6 +354,11 @@
|
||||
"maxBet": "अधिकतम बेट",
|
||||
"actions": "कार्य"
|
||||
},
|
||||
"placeholders": {
|
||||
"displayOrder": "क्रम",
|
||||
"minBetAmount": "न्यूनतम रकम",
|
||||
"maxBetAmount": "अधिकतम रकम"
|
||||
},
|
||||
"states": {
|
||||
"enabled": "सक्रिय",
|
||||
"disabled": "बन्द",
|
||||
@@ -394,6 +423,10 @@
|
||||
"missingScopeRow": "{{scope}} को row हराइरहेको छ। seed वा version data जाँच गर्नुहोस्।",
|
||||
"rebateRate": "रिबेट दर (%)",
|
||||
"rebateRateHint": "यसले यो खेल प्रकारअन्तर्गत सबै prize scope मा rebate_rate लेख्छ।",
|
||||
"placeholders": {
|
||||
"multiplier": "अड्स गुणक प्रविष्ट गर्नुहोस्",
|
||||
"rebateRate": "रिबेट दर प्रविष्ट गर्नुहोस्"
|
||||
},
|
||||
"publishFailed": "प्रकाशन असफल भयो",
|
||||
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
|
||||
"createDraftFailed": "ड्राफ्ट सिर्जना असफल भयो",
|
||||
@@ -440,6 +473,11 @@
|
||||
"d3": "3D रिबेट दर (%)",
|
||||
"d4": "4D रिबेट दर (%)"
|
||||
},
|
||||
"placeholders": {
|
||||
"d2": "2D रिबेट प्रविष्ट गर्नुहोस्",
|
||||
"d3": "3D रिबेट प्रविष्ट गर्नुहोस्",
|
||||
"d4": "4D रिबेट प्रविष्ट गर्नुहोस्"
|
||||
},
|
||||
"winEnjoy": {
|
||||
"label": "जितेको टिकटको पेआउटमा पुनः रिबेट घटाउने",
|
||||
"description": "settlement.apply_rebate_to_payout सँग जोडिएको: सक्रिय हुँदा जित पेआउटमा rebate_rate_snapshot अनुसार घटाउँछ।",
|
||||
@@ -450,6 +488,11 @@
|
||||
"effectiveTime": "लागू समय (हाल सक्रिय अड्स संस्करण)"
|
||||
},
|
||||
"riskCap": {
|
||||
"placeholders": {
|
||||
"defaultCap": "पूर्वनिर्धारित सीमा प्रविष्ट गर्नुहोस्",
|
||||
"number": "४-अङ्कको नम्बर",
|
||||
"capAmount": "सीमा रकम प्रविष्ट गर्नुहोस्"
|
||||
},
|
||||
"validation": {
|
||||
"requireAtLeastOne": "कम्तीमा एक क्याप row आवश्यक छ",
|
||||
"defaultGreaterThanZero": "पूर्वनिर्धारित क्याप रकम 0 भन्दा ठूलो हुनुपर्छ",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"title": "म्यानुअल ड्रअ सिर्जना",
|
||||
"description": "{{tz}} मा मिति र समय प्रविष्ट गर्नुहोस् (ब्राउजर स्थानीय समय होइन)।",
|
||||
"hint": "सुरु < बन्द < ड्रअ। ड्रअ नम्बर वैकल्पिक।",
|
||||
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
|
||||
"drawTimeRequired": "ड्रअ समय आवश्यक छ",
|
||||
"submit": "सिर्जना",
|
||||
"saving": "सिर्जना हुँदैछ…",
|
||||
@@ -34,6 +35,7 @@
|
||||
"action": "सम्पादन",
|
||||
"title": "ड्रअ सम्पादन",
|
||||
"description": "ड्रअ {{drawNo}} · {{tz}}",
|
||||
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
|
||||
"submit": "सेभ",
|
||||
"saving": "सेभ हुँदैछ…",
|
||||
"success": "ड्रअ अद्यावधिक भयो",
|
||||
@@ -55,6 +57,14 @@
|
||||
"invalidDrawId": "अवैध ड्रअ ID",
|
||||
"loadFailed": "लोड असफल भयो। लगइन र API कन्फिग जाँच गर्नुहोस्।",
|
||||
"drawDetail": "ड्रअ विवरण",
|
||||
"detailSubtitle": "{{date}} · राउन्ड {{seq}}",
|
||||
"scheduleTitle": "तालिका",
|
||||
"resultBatchesTitle": "नतिजा ब्याच",
|
||||
"batchSummaryTotal": "जम्मा {{count}}",
|
||||
"batchSummaryPending": "समीक्षा {{count}}",
|
||||
"batchSummaryPublished": "प्रकाशित {{count}}",
|
||||
"noResultBatchesYet": "अहिलेसम्म कुनै नतिजा ब्याच छैन।",
|
||||
"goToReviewTab": "समीक्षा र प्रकाशन",
|
||||
"businessDate": "व्यवसाय मिति",
|
||||
"sequenceNo": "क्रम संख्या",
|
||||
"plannedDraw": "योजनाबद्ध ड्रअ",
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"adjustmentIncrease": "बढाउनु",
|
||||
"adjustmentDecrease": "घटाउनु",
|
||||
"adjustmentAmount": "समायोजन रकम (मुख्य एकाइ)",
|
||||
"adjustmentAmountPlaceholder": "समायोजन रकम प्रविष्ट गर्नुहोस्",
|
||||
"adjustmentReason": "कारण (अनिवार्य)",
|
||||
"adjustmentReasonPlaceholder": "समायोजन कारण प्रविष्ट गर्नुहोस्",
|
||||
"submitAdjustment": "समायोजन पेश गर्नुहोस्",
|
||||
"adjustmentSuccess": "पूल ब्यालेन्स समायोजन भयो",
|
||||
"adjustmentFailed": "समायोजन असफल",
|
||||
@@ -30,11 +32,17 @@
|
||||
"confirmAdjustmentDescription": "यसले लेजर प्रविष्टि लेख्छ र पूल ब्यालेन्स अद्यावधिक गर्छ। रकम र कारण जाँच गर्नुहोस्।",
|
||||
"recentAdjustments": "भर्खरका समायोजन",
|
||||
"contributionRate": "योगदान अनुपात 0-1",
|
||||
"contributionRatePlaceholder": "योगदान अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.02",
|
||||
"triggerThreshold": "बर्स्ट थ्रेसहोल्ड (सानो एकाइ)",
|
||||
"triggerThresholdPlaceholder": "ट्रिगर थ्रेसहोल्ड प्रविष्ट गर्नुहोस्",
|
||||
"payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1",
|
||||
"payoutRatePlaceholder": "पेआउट अनुपात प्रविष्ट गर्नुहोस्, जस्तै 0.05",
|
||||
"forceTriggerGap": "बलपूर्वक बर्स्ट अन्तर (सेटल ड्रअ)",
|
||||
"forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्",
|
||||
"minBetAmount": "न्यूनतम बेट रकम (सानो एकाइ)",
|
||||
"minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्",
|
||||
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)",
|
||||
"comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox",
|
||||
"status": "स्थिति",
|
||||
"disabled": "बन्द",
|
||||
"enabled": "खुला",
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"title": "खेलाडी",
|
||||
"detailTitle": "खेलाडी विवरण",
|
||||
"listTitle": "खेलाडी सूची",
|
||||
"viewDetail": "विवरण हेर्नुहोस्",
|
||||
"backToList": "खेलाडी सूचीमा फर्कनुहोस्",
|
||||
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
||||
"tabOverview": "सारांश",
|
||||
"tabTickets": "टिकट",
|
||||
"tabWalletTxns": "वालेट लेनदेन",
|
||||
"tabTransferOrders": "ट्रान्सफर अर्डर",
|
||||
"profileSection": "प्रोफाइल",
|
||||
"walletsSection": "वालेट",
|
||||
"createdAt": "दर्ता समय",
|
||||
"agent": "एजेन्ट",
|
||||
"frozen": "फ्रोजन",
|
||||
"txnAmount": "रकम",
|
||||
"balanceAfterTxn": "पछिको ब्यालेन्स",
|
||||
"invalidPlayerId": "अवैध खेलाडी ID",
|
||||
"createPlayer": "खेलाडी सिर्जना",
|
||||
"searchPlaceholder": "खेलाडी ID / प्रयोगकर्ता नाम / उपनामबाट खोज्नुहोस्",
|
||||
"search": "खोज",
|
||||
@@ -9,6 +25,10 @@
|
||||
"siteCodeRequired": "साइट कोड लेख्नुहोस्",
|
||||
"sitePlayerIdRequired": "साइट खेलाडी ID लेख्नुहोस्",
|
||||
"createFailed": "खेलाडी सिर्जना असफल भयो",
|
||||
"createAgentRequired": "तपाईंको खाता एजेन्ट नोडसँग जोडिएको छैन। एजेन्ट खाताबाट लगइन गर्नुहोस्, वा सुपर एडमिनले मान्य साइट र एजेन्ट छान्नुहोस्।",
|
||||
"createAgentNode": "एजेन्ट नोड",
|
||||
"createAgentNodePlaceholder": "एजेन्ट नोड छान्नुहोस्",
|
||||
"createAgentAutoHint": "खेलाडी तपाईंको एजेन्टमा तोकिनेछ: {{name}} ({{code}})",
|
||||
"createSuccess": "खेलाडी {{name}} सिर्जना भयो",
|
||||
"noChanges": "कुनै परिवर्तन छैन",
|
||||
"updateFailed": "खेलाडी अपडेट असफल भयो",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"title": "管理员",
|
||||
"listTitle": "管理员用户列表",
|
||||
"createAdmin": "新建管理员",
|
||||
"title": "平台账号",
|
||||
"listTitle": "平台账号列表",
|
||||
"createAdmin": "新建平台账号",
|
||||
"searchPlaceholder": "按用户名 / 昵称 / 邮箱搜索",
|
||||
"loadFailed": "加载管理员列表失败",
|
||||
"roleLoadFailed": "加载角色列表失败",
|
||||
@@ -15,8 +15,8 @@
|
||||
"saveAccountFailed": "保存账号失败",
|
||||
"deleteSuccess": "已删除 {{name}}",
|
||||
"deleteFailed": "删除失败",
|
||||
"roleListTitle": "角色管理",
|
||||
"createRole": "新增角色",
|
||||
"roleListTitle": "平台角色管理",
|
||||
"createRole": "新增平台角色",
|
||||
"roleCreateSuccess": "已创建角色 {{name}}",
|
||||
"roleUpdateSuccess": "已更新角色 {{name}}",
|
||||
"roleSaveFailed": "保存角色失败",
|
||||
@@ -103,8 +103,11 @@
|
||||
"editTitle": "编辑角色",
|
||||
"description": "角色用于归拢后台功能权限,再分配给管理员账号。",
|
||||
"slug": "角色编码",
|
||||
"slugPlaceholder": "请输入角色标识,如 super_admin",
|
||||
"name": "角色名称",
|
||||
"namePlaceholder": "请输入角色名称",
|
||||
"descriptionLabel": "角色说明",
|
||||
"descriptionPlaceholder": "请输入角色说明",
|
||||
"status": "状态"
|
||||
},
|
||||
"accountDialog": {
|
||||
|
||||
@@ -1,15 +1,46 @@
|
||||
{
|
||||
"title": "代理管理",
|
||||
"title": "代理线路",
|
||||
"sitesTitle": "站点列表",
|
||||
"sitesListHint": "完整站点表格(密钥、回调等)请前往",
|
||||
"sitesListLink": "站点列表",
|
||||
"subnav": {
|
||||
"label": "代理线路导航",
|
||||
"noPermission": "无权限",
|
||||
"operations": "代理经营",
|
||||
"provision": "开通线路",
|
||||
"sites": "站点列表",
|
||||
"settlementBills": "代理账单"
|
||||
},
|
||||
"includeRoots": "包含根节点",
|
||||
"includeRootsHint": "根节点用于表示站点边界,默认不计入经营代理列表。",
|
||||
"directoryStatus": {
|
||||
"all": "全部状态",
|
||||
"enabled": "仅启用",
|
||||
"disabled": "仅停用"
|
||||
},
|
||||
"treeTitle": "代理树",
|
||||
"detailTitle": "节点详情",
|
||||
"selectNode": "请从左侧选择代理节点",
|
||||
"loadFailed": "加载代理树失败",
|
||||
"tabs": {
|
||||
"subordinates": "下级管理",
|
||||
"accounts": "主账号",
|
||||
"players": "玩家管理",
|
||||
"overview": "概况",
|
||||
"roles": "角色",
|
||||
"users": "账号",
|
||||
"delegation": "授权上限"
|
||||
},
|
||||
"filterParent": "上级代理",
|
||||
"filterParentAll": "全部下级",
|
||||
"listFlatHint": "列表平铺展示所有经营代理;添加下级请使用行内「添加下级代理」。",
|
||||
"addChildNeedParent": "请先在「上级代理」中选择要为谁添加下级",
|
||||
"detailTitle": "代理详情",
|
||||
"selectNode": "请选择代理",
|
||||
"loadFailed": "加载代理列表失败",
|
||||
"siteLabel": "站点",
|
||||
"createChild": "添加下级代理",
|
||||
"editNode": "编辑节点",
|
||||
"deleteNode": "删除节点",
|
||||
"deleteNodeConfirm": "删除后不可恢复,请确认该节点无下级、无账号、无角色绑定。",
|
||||
"deleteNodeBlockedHint": "请先删除下级代理、角色与账号后再删除本节点",
|
||||
"editNode": "编辑代理",
|
||||
"deleteNode": "删除代理",
|
||||
"deleteNodeConfirm": "删除后不可恢复,请确认该代理无下级、无账号、无角色绑定。",
|
||||
"deleteNodeBlockedHint": "请先删除下级代理、角色与账号后再删除该代理",
|
||||
"deleteNodeBlockedPrefix": "暂不可删除:",
|
||||
"deleteBlocked": {
|
||||
"children": "仍有 {{count}} 个下级代理",
|
||||
@@ -18,6 +49,7 @@
|
||||
},
|
||||
"code": "编码",
|
||||
"name": "名称",
|
||||
"namePlaceholder": "请输入代理名称",
|
||||
"depth": "层级",
|
||||
"path": "路径",
|
||||
"status": "状态",
|
||||
@@ -26,13 +58,63 @@
|
||||
"updateSuccess": "已更新 {{name}}",
|
||||
"deleteSuccess": "已删除代理 {{name}}",
|
||||
"saveFailed": "保存失败",
|
||||
"codeRequired": "请填写编码与名称",
|
||||
"codeRequired": "请填写代理名称和登录名",
|
||||
"modelGuide": "代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。",
|
||||
"tabs": {
|
||||
"overview": "概况",
|
||||
"roles": "角色",
|
||||
"users": "账号",
|
||||
"delegation": "授权上限"
|
||||
"pageGuide": "这里统一管理代理树、代理角色、代理账号与下放上限。平台账号和平台角色请到各自的平台治理页面维护。",
|
||||
"summary": {
|
||||
"currentSiteNodes": "当前站点节点总数",
|
||||
"currentSiteAgents": "当前站点经营代理数",
|
||||
"visibleList": "当前平铺列表条数",
|
||||
"visibleAgents": "当前可见经营代理数",
|
||||
"globalNodes": "全部站点节点总数",
|
||||
"globalAgents": "全部站点经营代理数",
|
||||
"enabledAgents": "启用中的经营代理数",
|
||||
"rootNodes": "根节点数量"
|
||||
},
|
||||
"profile": {
|
||||
"section": "占成与授信",
|
||||
"totalShareRate": "占成比例 (%)",
|
||||
"creditLimit": "授信额度",
|
||||
"rebateLimit": "回水上限",
|
||||
"defaultPlayerRebate": "默认玩家回水",
|
||||
"settlementCycle": "结算周期",
|
||||
"canGrantExtraRebate": "允许额外回水",
|
||||
"canCreatePlayer": "允许创建玩家",
|
||||
"canCreateChildAgent": "允许创建下级代理",
|
||||
"cycleDaily": "日结",
|
||||
"cycleWeekly": "周结",
|
||||
"cycleMonthly": "月结"
|
||||
},
|
||||
"settlementBills": {
|
||||
"title": "代理账单",
|
||||
"description": "账期关闭后生成的玩家/代理账单",
|
||||
"columns": {
|
||||
"id": "ID",
|
||||
"type": "类型",
|
||||
"net": "净额",
|
||||
"unpaid": "未结",
|
||||
"status": "状态"
|
||||
}
|
||||
},
|
||||
"lineProvision": {
|
||||
"title": "开通代理线路",
|
||||
"description": "一次创建站点、根代理与后台账号(site_code 与代理 code 一致)。",
|
||||
"code": "站点 code",
|
||||
"name": "线路名称",
|
||||
"username": "代理账号",
|
||||
"password": "初始密码",
|
||||
"walletUrl": "钱包 API URL",
|
||||
"submit": "开通线路",
|
||||
"success": "线路已开通",
|
||||
"secretsOnce": "密钥仅显示一次,请妥善保存",
|
||||
"link": "开通线路"
|
||||
},
|
||||
"noAccess": "您没有代理经营相关权限,请联系管理员开通。",
|
||||
"playersPanel": {
|
||||
"create": "创建玩家",
|
||||
"scopedTo": "直属玩家:{{agent}}",
|
||||
"allUnderSite": "当前站点下可见玩家",
|
||||
"filterHint": "可按上级代理查看其直属玩家。"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "下放权限上限",
|
||||
@@ -66,11 +148,15 @@
|
||||
"title": "代理账号",
|
||||
"create": "创建账号",
|
||||
"username": "登录名",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"roles": "角色",
|
||||
"createSuccess": "已创建账号 {{name}}",
|
||||
"roleSaveSuccess": "已更新 {{name}} 的角色",
|
||||
"deleteConfirm": "删除后该管理员将无法登录,且不可恢复。",
|
||||
"deleteSuccess": "已删除账号 {{name}}"
|
||||
}
|
||||
},
|
||||
"usernamePlaceholder": "请输入登录名",
|
||||
"passwordPlaceholder": "请输入8位数密码",
|
||||
"passwordOptionalHint": "留空则不修改,修改请输入8位数密码"
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@
|
||||
"audit": "审计日志",
|
||||
"settings": "系统设置",
|
||||
"account": "账号设置",
|
||||
"integration": "接入站点",
|
||||
"agents": "代理管理",
|
||||
"integration": "接入配置",
|
||||
"agents": "代理线路",
|
||||
"config": "运营配置"
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -99,6 +99,16 @@
|
||||
"notes": "备注",
|
||||
"ssoSecret": "SSO 密钥",
|
||||
"walletApiKey": "钱包 API 密钥"
|
||||
},
|
||||
"placeholders": {
|
||||
"code": "请输入站点标识,如 partner-a",
|
||||
"name": "请输入站点名称",
|
||||
"currency": "请输入币种代码,如 NPR",
|
||||
"walletApiUrl": "请输入钱包接口地址",
|
||||
"lotteryH5BaseUrl": "请输入 H5 地址",
|
||||
"iframeOrigins": "请输入允许的来源地址,如 https://www.example.com",
|
||||
"notes": "请输入备注说明",
|
||||
"connectivityPlayerId": "请输入玩家 ID,如 10001"
|
||||
}
|
||||
},
|
||||
"versionStatus": {
|
||||
@@ -211,6 +221,17 @@
|
||||
"playRulesHtml": "玩法规则 HTML(多语言)",
|
||||
"playRulesHtmlDesc": "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。按语言分别配置;留空则回退其它语言或显示默认提示。"
|
||||
},
|
||||
"placeholders": {
|
||||
"defaultCurrency": "请输入默认币种代码,如 NPR",
|
||||
"drawIntervalMinutes": "请输入开奖间隔分钟数",
|
||||
"drawBettingWindowSeconds": "请输入投注窗口秒数",
|
||||
"drawCloseBeforeDrawSeconds": "请输入封盘提前秒数",
|
||||
"drawBufferDrawsAhead": "请输入预生成期数",
|
||||
"cooldownMinutes": "请输入冷却分钟数",
|
||||
"currencyDisplayDecimals": "请输入显示小数位数,如 2",
|
||||
"currencyDecimalSeparator": "请输入小数分隔符,如 .",
|
||||
"currencyThousandsSeparator": "请输入千分位分隔符,如 ,"
|
||||
},
|
||||
"hints": {
|
||||
"manualReview": "开启后,RNG 开奖结果会先进入待审核,必须由后台人工发布。",
|
||||
"cooldownMinutes": "结果发布后等待多久再进入 settling。填 0 表示发布后直接进入结算。",
|
||||
@@ -277,6 +298,9 @@
|
||||
"code": "币种代码",
|
||||
"name": "币种名称",
|
||||
"decimals": "小数位",
|
||||
"codePlaceholder": "请输入币种代码,如 NPR",
|
||||
"namePlaceholder": "请输入币种名称",
|
||||
"decimalsPlaceholder": "请输入小数位数,如 2",
|
||||
"enabled": "启用状态",
|
||||
"enabledHint": "关闭后,新业务不应继续使用该币种。",
|
||||
"bettable": "允许下注",
|
||||
@@ -347,6 +371,11 @@
|
||||
"maxBet": "最大下注",
|
||||
"actions": "操作"
|
||||
},
|
||||
"placeholders": {
|
||||
"displayOrder": "顺序",
|
||||
"minBetAmount": "最小金额",
|
||||
"maxBetAmount": "最大金额"
|
||||
},
|
||||
"states": {
|
||||
"enabled": "开启",
|
||||
"disabled": "关闭",
|
||||
@@ -411,6 +440,10 @@
|
||||
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
|
||||
"rebateRate": "回水比例 (%)",
|
||||
"rebateRateHint": "会把 rebate_rate 写入该玩法下所有奖级范围。",
|
||||
"placeholders": {
|
||||
"multiplier": "请输入赔率倍数",
|
||||
"rebateRate": "请输入返点比例"
|
||||
},
|
||||
"publishFailed": "发布失败",
|
||||
"createDraftSuccess": "已创建草稿 v{{version}}",
|
||||
"createDraftFailed": "创建草稿失败",
|
||||
@@ -457,6 +490,11 @@
|
||||
"d3": "3D 回水比例 (%)",
|
||||
"d4": "4D 回水比例 (%)"
|
||||
},
|
||||
"placeholders": {
|
||||
"d2": "请输入 2D 返点",
|
||||
"d3": "请输入 3D 返点",
|
||||
"d4": "请输入 4D 返点"
|
||||
},
|
||||
"winEnjoy": {
|
||||
"label": "中奖注单结算时再扣回水",
|
||||
"description": "对应系统参数 settlement.apply_rebate_to_payout:开启后中奖派彩在毛赢基础上再乘 (1 - 回水率快照)。",
|
||||
@@ -467,6 +505,11 @@
|
||||
"effectiveTime": "生效时间(当前赔率生效版本)"
|
||||
},
|
||||
"riskCap": {
|
||||
"placeholders": {
|
||||
"defaultCap": "请输入默认限额",
|
||||
"number": "4位号码",
|
||||
"capAmount": "请输入限额"
|
||||
},
|
||||
"validation": {
|
||||
"requireAtLeastOne": "至少需要一条封顶配置",
|
||||
"defaultGreaterThanZero": "默认封顶金额必须大于 0",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"title": "手动创建期号",
|
||||
"description": "日期与时间按 {{tz}} 填写(勿用浏览器本地时区)。仅填开奖时间时,开始/封盘按系统配置自动推算。",
|
||||
"hint": "开始 < 封盘 < 开奖。期号可留空,将按 UTC 业务日自动生成流水号。",
|
||||
"drawNoPlaceholder": "请输入期号,如 20260526-008",
|
||||
"drawTimeRequired": "请填写开奖时间",
|
||||
"submit": "创建",
|
||||
"saving": "创建中…",
|
||||
@@ -34,6 +35,7 @@
|
||||
"action": "编辑",
|
||||
"title": "编辑期号",
|
||||
"description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑",
|
||||
"drawNoPlaceholder": "请输入期号,如 20260526-008",
|
||||
"submit": "保存",
|
||||
"saving": "保存中…",
|
||||
"success": "期号已更新",
|
||||
@@ -55,6 +57,14 @@
|
||||
"invalidDrawId": "无效的期号 ID",
|
||||
"loadFailed": "加载失败,请检查登录与 API 配置",
|
||||
"drawDetail": "开奖详情",
|
||||
"detailSubtitle": "{{date}} · 第 {{seq}} 期",
|
||||
"scheduleTitle": "时间安排",
|
||||
"resultBatchesTitle": "开奖批次",
|
||||
"batchSummaryTotal": "共 {{count}} 批",
|
||||
"batchSummaryPending": "待审 {{count}}",
|
||||
"batchSummaryPublished": "已发 {{count}}",
|
||||
"noResultBatchesYet": "尚无开奖批次。",
|
||||
"goToReviewTab": "去审核与发布",
|
||||
"businessDate": "业务日",
|
||||
"sequenceNo": "流水序号",
|
||||
"plannedDraw": "计划开奖",
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"adjustmentIncrease": "增加",
|
||||
"adjustmentDecrease": "减少",
|
||||
"adjustmentAmount": "调整金额(主币单位)",
|
||||
"adjustmentAmountPlaceholder": "请输入调整金额",
|
||||
"adjustmentReason": "调整原因(必填)",
|
||||
"adjustmentReasonPlaceholder": "请输入调整原因",
|
||||
"submitAdjustment": "提交余额调整",
|
||||
"adjustmentSuccess": "余额调整已入账",
|
||||
"adjustmentFailed": "余额调整失败",
|
||||
@@ -30,11 +32,17 @@
|
||||
"confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。",
|
||||
"recentAdjustments": "最近调整记录",
|
||||
"contributionRate": "蓄水比例 0–1",
|
||||
"contributionRatePlaceholder": "请输入贡献比例,如 0.02",
|
||||
"triggerThreshold": "爆池阈值(最小单位)",
|
||||
"triggerThresholdPlaceholder": "请输入触发阈值",
|
||||
"payoutRate": "爆池派彩比例 0–1",
|
||||
"payoutRatePlaceholder": "请输入派彩比例,如 0.05",
|
||||
"forceTriggerGap": "强制爆池间隔(已结算期数)",
|
||||
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
|
||||
"minBetAmount": "最低下注额(最小单位)",
|
||||
"minBetAmountPlaceholder": "请输入最低下注金额",
|
||||
"comboTriggerPlays": "组合触发玩法(逗号分隔)",
|
||||
"comboTriggerPlaysPlaceholder": "请输入玩法编码,多个用逗号分隔,如 straight,ibox",
|
||||
"status": "开关",
|
||||
"disabled": "关闭",
|
||||
"enabled": "开启",
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
{
|
||||
"title": "玩家",
|
||||
"detailTitle": "玩家详情",
|
||||
"listTitle": "玩家列表",
|
||||
"viewDetail": "查看详情",
|
||||
"backToList": "返回玩家列表",
|
||||
"detailSubtitle": "{{site}} · {{sitePlayerId}} · ID {{playerId}}",
|
||||
"tabOverview": "概览",
|
||||
"tabTickets": "注单",
|
||||
"tabWalletTxns": "钱包流水",
|
||||
"tabTransferOrders": "转账单",
|
||||
"profileSection": "基本资料",
|
||||
"walletsSection": "钱包余额",
|
||||
"createdAt": "注册时间",
|
||||
"agent": "代理",
|
||||
"frozen": "冻结",
|
||||
"txnAmount": "变动金额",
|
||||
"balanceAfterTxn": "变动后余额",
|
||||
"invalidPlayerId": "无效的玩家 ID",
|
||||
"createPlayer": "新建玩家",
|
||||
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
|
||||
"filterSite": "主站站点",
|
||||
@@ -11,6 +27,10 @@
|
||||
"siteCodeRequired": "请填写主站编号",
|
||||
"sitePlayerIdRequired": "请填写主站玩家 ID",
|
||||
"createFailed": "创建玩家失败",
|
||||
"createAgentRequired": "当前账号未绑定代理,无法创建玩家。请使用代理账号登录,或由超管选择有效主站及代理节点。",
|
||||
"createAgentNode": "归属代理",
|
||||
"createAgentNodePlaceholder": "选择代理节点",
|
||||
"createAgentAutoHint": "将归属到您绑定的代理:{{name}}({{code}})",
|
||||
"createSuccess": "已创建玩家 {{name}}",
|
||||
"noChanges": "没有变更",
|
||||
"updateFailed": "更新玩家失败",
|
||||
|
||||
@@ -15,6 +15,12 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
||||
"/admin/audit-logs": { ns: "audit", key: "title" },
|
||||
"/admin/admin-users": { ns: "adminUsers", key: "title" },
|
||||
"/admin/admin-roles": { ns: "adminRoles", key: "title" },
|
||||
"/admin/agents": { ns: "agents", key: "title" },
|
||||
"/admin/agents/list": { ns: "agents", key: "directoryTitle" },
|
||||
"/admin/agents/provision": { ns: "agents", key: "subnav.provision" },
|
||||
"/admin/agents/sites": { ns: "agents", key: "sitesTitle" },
|
||||
"/admin/agents/settlement-bills": { ns: "agents", key: "subnav.settlementBills" },
|
||||
"/admin/config/integration-sites": { ns: "agents", key: "sitesTitle" },
|
||||
"/admin/wallet": { ns: "wallet", key: "title" },
|
||||
"/admin/wallet/transactions": { ns: "wallet", key: "walletTransactions" },
|
||||
"/admin/wallet/transfer-orders": { ns: "wallet", key: "transferOrders" },
|
||||
@@ -24,7 +30,6 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
||||
"/admin/settings/currencies": { ns: "config", key: "currencies.title" },
|
||||
"/admin/currencies": { ns: "config", key: "currencies.title" },
|
||||
"/admin/config": { ns: "config", key: "hub.title" },
|
||||
"/admin/config/integration-sites": { ns: "config", key: "integrationSites.title" },
|
||||
"/admin/rules/plays": { ns: "config", key: "nav.rulesPlaysTitle" },
|
||||
"/admin/rules/odds": { ns: "config", key: "nav.rulesOddsTitle" },
|
||||
"/admin/jackpot": { ns: "jackpot", key: "configTitle" },
|
||||
@@ -39,6 +44,10 @@ type RoutePattern = {
|
||||
};
|
||||
|
||||
const ROUTE_PATTERNS: RoutePattern[] = [
|
||||
{
|
||||
test: (p) => /^\/admin\/players\/\d+$/.test(p),
|
||||
resolve: () => ({ ns: "players", key: "detailTitle" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/finance$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.finance" }),
|
||||
@@ -60,15 +69,13 @@ const ROUTE_PATTERNS: RoutePattern[] = [
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskLockLogs" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/hot$/.test(p) || /^\/admin\/risk\/draws\/\d+\/hot$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskHot" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/sold-out$/.test(p) || /^\/admin\/risk\/draws\/\d+\/sold-out$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskSoldOut" }),
|
||||
},
|
||||
{
|
||||
test: (p) => /^\/admin\/draws\/\d+\/risk\/pools$/.test(p) || /^\/admin\/risk\/draws\/\d+\/pools$/.test(p),
|
||||
test: (p) =>
|
||||
/^\/admin\/draws\/\d+\/risk\/pools$/.test(p)
|
||||
|| /^\/admin\/risk\/draws\/\d+\/pools$/.test(p)
|
||||
|| /^\/admin\/draws\/\d+\/risk\/hot$/.test(p)
|
||||
|| /^\/admin\/risk\/draws\/\d+\/hot$/.test(p)
|
||||
|| /^\/admin\/draws\/\d+\/risk\/sold-out$/.test(p)
|
||||
|| /^\/admin\/risk\/draws\/\d+\/sold-out$/.test(p),
|
||||
resolve: () => ({ ns: "draws", key: "subnav.riskPools" }),
|
||||
},
|
||||
{
|
||||
|
||||
4
src/lib/admin-player-paths.ts
Normal file
4
src/lib/admin-player-paths.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** 后台玩家详情页(单页聚合资料、注单、钱包)。 */
|
||||
export function adminPlayerDetailPath(playerId: number): string {
|
||||
return `/admin/players/${playerId}`;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export const PRD_PLAYER_FREEZE_MANAGE = "prd.player_freeze.manage" as const;
|
||||
|
||||
export const PRD_CURRENCY_MANAGE = "prd.currency.manage" as const;
|
||||
|
||||
/** 接入站点(integration-sites) */
|
||||
/** 接入站点(与 {@link AdminPermissionLanguage} / config admin_permission_language 对齐) */
|
||||
export const PRD_INTEGRATION_VIEW = "prd.integration.view" as const;
|
||||
export const PRD_INTEGRATION_MANAGE = "prd.integration.manage" as const;
|
||||
|
||||
@@ -140,6 +140,17 @@ export const PRD_AGENT_ROLE_MANAGE = "prd.agent.role.manage" as const;
|
||||
export const PRD_AGENT_USER_VIEW = "prd.agent.user.view" as const;
|
||||
export const PRD_AGENT_USER_MANAGE = "prd.agent.user.manage" as const;
|
||||
|
||||
export const PRD_AGENT_LINE_PROVISION = "prd.agent-line.provision" as const;
|
||||
export const PRD_AGENT_PROFILE_MANAGE = "prd.agent.profile.manage" as const;
|
||||
|
||||
/** 代理线路内「站点列表」入口(接入权限或线路经营权限) */
|
||||
export const PRD_AGENT_SITES_ACCESS_ANY = [
|
||||
...PRD_INTEGRATION_ACCESS_ANY,
|
||||
PRD_AGENT_LINE_PROVISION,
|
||||
PRD_AGENT_MANAGE,
|
||||
PRD_AGENT_VIEW,
|
||||
] as const;
|
||||
|
||||
export const PRD_AGENTS_ACCESS_ANY = [
|
||||
PRD_AGENT_VIEW,
|
||||
PRD_AGENT_MANAGE,
|
||||
@@ -147,4 +158,24 @@ export const PRD_AGENTS_ACCESS_ANY = [
|
||||
PRD_AGENT_ROLE_MANAGE,
|
||||
PRD_AGENT_USER_VIEW,
|
||||
PRD_AGENT_USER_MANAGE,
|
||||
PRD_AGENT_PROFILE_MANAGE,
|
||||
] as const;
|
||||
|
||||
export const PRD_AGENT_LINE_PROVISION_ACCESS_ANY = [
|
||||
PRD_AGENT_LINE_PROVISION,
|
||||
PRD_AGENT_MANAGE,
|
||||
] as const;
|
||||
|
||||
export const PRD_SETTLEMENT_AGENT_ACCESS_ANY = [
|
||||
"prd.settlement.agent.view",
|
||||
"prd.settlement.agent.manage",
|
||||
] as const;
|
||||
|
||||
/** 侧栏「代理线路」分组:含经营、开通、接入配置、代理账单任一权限即可见入口 */
|
||||
export const PRD_AGENT_HUB_ACCESS_ANY = [
|
||||
...PRD_AGENTS_ACCESS_ANY,
|
||||
PRD_AGENT_LINE_PROVISION,
|
||||
...PRD_INTEGRATION_ACCESS_ANY,
|
||||
"prd.settlement.agent.view",
|
||||
"prd.settlement.agent.manage",
|
||||
] as const;
|
||||
|
||||
@@ -248,10 +248,10 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("roleListTitle")}</CardTitle>
|
||||
<CardTitle>{t("roleListTitle", { defaultValue: "平台角色管理" })}</CardTitle>
|
||||
{canManageRoles ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
||||
{t("createRole")}
|
||||
{t("createRole", { defaultValue: "新增平台角色" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -414,15 +414,28 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.slug")}</div>
|
||||
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} disabled={roleMode === "edit"} />
|
||||
<Input
|
||||
value={roleSlug}
|
||||
placeholder={t("roleDialog.slugPlaceholder")}
|
||||
onChange={(e) => setRoleSlug(e.target.value)}
|
||||
disabled={roleMode === "edit"}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.name")}</div>
|
||||
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
||||
<Input
|
||||
value={roleName}
|
||||
placeholder={t("roleDialog.namePlaceholder")}
|
||||
onChange={(e) => setRoleName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium leading-none">{t("roleDialog.descriptionLabel")}</div>
|
||||
<Input value={roleDescription} onChange={(e) => setRoleDescription(e.target.value)} />
|
||||
<Input
|
||||
value={roleDescription}
|
||||
placeholder={t("roleDialog.descriptionPlaceholder")}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -313,17 +313,19 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">
|
||||
{t("listTitle", { defaultValue: "平台账号列表" })}
|
||||
</CardTitle>
|
||||
{canManageUsers ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createAdmin")}
|
||||
{t("createAdmin", { defaultValue: "新建平台账号" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("modelGuide", {
|
||||
{t("modelGuidePlatform", {
|
||||
defaultValue:
|
||||
"账号层只绑定角色,不直接分配功能权限;具体权限请到“角色管理”维护。",
|
||||
"这里只管理平台账号与平台角色。代理账号请到「代理经营」中创建和维护;账号层只绑定角色,不直接分配功能权限。",
|
||||
})}
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
@@ -545,12 +547,19 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{accountMode === "create" ? t("accountDialog.createTitle") : t("accountDialog.editTitle")}
|
||||
{accountMode === "create"
|
||||
? t("accountDialog.createTitle", { defaultValue: "新建平台账号" })
|
||||
: t("accountDialog.editTitle", { defaultValue: "编辑平台账号" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{accountMode === "create"
|
||||
? t("accountDialog.createDescription")
|
||||
: t("accountDialog.editDescription")}
|
||||
? t("accountDialog.createDescriptionPlatform", {
|
||||
defaultValue:
|
||||
"须为平台账号指定至少一个平台角色。登录账号仅可使用字母、数字、点、下划线与连字符,保存后为小写。",
|
||||
})
|
||||
: t("accountDialog.editDescriptionPlatform", {
|
||||
defaultValue: "这里只编辑平台账号。登录账号不可修改,留空密码表示不修改。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
@@ -600,7 +609,11 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{accountMode === "create" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
|
||||
<p className="text-xs text-muted-foreground">{t("accountDialog.rolesDescription")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("accountDialog.rolesDescriptionPlatform", {
|
||||
defaultValue: "这里只能选择平台角色;代理角色请到「代理经营」中分配。",
|
||||
})}
|
||||
</p>
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
|
||||
{(catalog?.roles ?? []).length === 0 ? (
|
||||
<p className="col-span-full text-xs text-muted-foreground">
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const HUB_CARDS: HubCard[] = [
|
||||
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
|
||||
},
|
||||
{
|
||||
href: "/admin/config/integration-sites",
|
||||
href: "/admin/agents/sites",
|
||||
titleKey: "hub.integrationTitle",
|
||||
descKey: "hub.integrationDesc",
|
||||
requiredAny: PRD_INTEGRATION_ACCESS_ANY,
|
||||
|
||||
@@ -669,6 +669,7 @@ export function OddsConfigDocScreen({
|
||||
className="h-9 w-full font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={oddsMultiplierLabel(row.odds_value)}
|
||||
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateOddsForScope(scope, {
|
||||
odds_value: parseOddsMultiplierInput(e.target.value),
|
||||
@@ -697,6 +698,7 @@ export function OddsConfigDocScreen({
|
||||
className="h-9 w-full font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={rebatePercentUi}
|
||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -631,6 +631,7 @@ export function PlayConfigDocScreen() {
|
||||
className="mx-auto h-8 w-16 font-mono tabular-nums text-center"
|
||||
value={row.display_order}
|
||||
disabled={saving}
|
||||
placeholder={t("play.placeholders.displayOrder", { ns: "config" })}
|
||||
onChange={(e) => {
|
||||
const n = Number.parseInt(e.target.value, 10);
|
||||
if (Number.isFinite(n)) {
|
||||
@@ -652,6 +653,7 @@ export function PlayConfigDocScreen() {
|
||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
||||
placeholder={t("play.placeholders.minBetAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(row.play_code, {
|
||||
min_bet_amount:
|
||||
@@ -673,6 +675,7 @@ export function PlayConfigDocScreen() {
|
||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
||||
placeholder={t("play.placeholders.maxBetAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateConfigRow(row.play_code, {
|
||||
max_bet_amount:
|
||||
|
||||
@@ -556,6 +556,7 @@ export function RebateConfigDocScreen({
|
||||
className="font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={p2}
|
||||
placeholder={t("rebate.placeholders.d2", { ns: "config" })}
|
||||
onChange={(e) => setP2(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -572,6 +573,7 @@ export function RebateConfigDocScreen({
|
||||
className="font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={p3}
|
||||
placeholder={t("rebate.placeholders.d3", { ns: "config" })}
|
||||
onChange={(e) => setP3(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -588,6 +590,7 @@ export function RebateConfigDocScreen({
|
||||
className="font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={p4}
|
||||
placeholder={t("rebate.placeholders.d4", { ns: "config" })}
|
||||
onChange={(e) => setP4(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -471,6 +471,7 @@ export function RiskCapDocScreen() {
|
||||
className="w-[220px] font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={defaultCapStr}
|
||||
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
||||
onChange={(e) => setDefaultCapStr(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
@@ -525,6 +526,7 @@ export function RiskCapDocScreen() {
|
||||
maxLength={4}
|
||||
disabled={saving}
|
||||
value={r.normalized_number}
|
||||
placeholder={t("riskCap.placeholders.number", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
normalized_number: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
@@ -543,6 +545,7 @@ export function RiskCapDocScreen() {
|
||||
className="h-8 font-mono tabular-nums"
|
||||
disabled={saving}
|
||||
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, {
|
||||
cap_amount:
|
||||
|
||||
@@ -149,11 +149,17 @@ export function RiskCapRuntimePanel() {
|
||||
<Link href={`${riskBase}/pools`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskPools", { ns: "draws" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/hot`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskHot", { ns: "draws" })}
|
||||
<Link
|
||||
href={`${riskBase}/pools?filter=high_risk`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("filterHighRisk", { ns: "risk" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/sold-out`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskSoldOut", { ns: "draws" })}
|
||||
<Link
|
||||
href={`${riskBase}/pools?filter=sold_out`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("filterSoldOut", { ns: "risk" })}
|
||||
</Link>
|
||||
<Link href={`${riskBase}/occupancy`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
{t("subnav.riskLockLogs", { ns: "draws" })}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function DrawCreateDialog({
|
||||
<Label htmlFor="draw-create-draw-no">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="draw-create-draw-no"
|
||||
placeholder="20260526-008"
|
||||
placeholder={t("createDraw.drawNoPlaceholder")}
|
||||
value={form.drawNo}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, drawNo: e.target.value }))}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useState } from "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";
|
||||
@@ -15,25 +15,18 @@ import {
|
||||
postAdminRunDrawRng,
|
||||
} from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
drawResultSourceLabel,
|
||||
drawStatusLabel,
|
||||
hallPreviewDiffersFromDbStatus,
|
||||
} from "./draw-display";
|
||||
import { drawStatusLabel, hallPreviewDiffersFromDbStatus } from "./draw-display";
|
||||
import { DrawStatusBadge } from "./draw-status-badge";
|
||||
import {
|
||||
PRD_DRAW_REOPEN_MANAGE,
|
||||
@@ -42,12 +35,31 @@ import {
|
||||
PRD_PAYOUT_REVIEW,
|
||||
} from "./draw-prd";
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
type ScheduleStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
at: string | null | undefined;
|
||||
};
|
||||
|
||||
function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
|
||||
<Label className="text-muted-foreground">{label}</Label>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
<ol className="grid gap-3 sm:grid-cols-3">
|
||||
{steps.map((step, index) => (
|
||||
<li
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"relative rounded-lg border bg-muted/20 px-3 py-2.5",
|
||||
index < steps.length - 1 &&
|
||||
"sm:after:absolute sm:after:top-1/2 sm:after:left-full sm:after:h-px sm:after:w-3 sm:after:-translate-y-1/2 sm:after:bg-border",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">{step.label}</p>
|
||||
<p className="mt-1 font-mono text-sm tabular-nums">{formatDt(step.at)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +74,6 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
PRD_PAYOUT_MANAGE,
|
||||
PRD_PAYOUT_REVIEW,
|
||||
]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [data, setData] = useState<AdminDrawShowData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -105,6 +116,99 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
void load();
|
||||
}, [idNum]);
|
||||
|
||||
const scheduleSteps = useMemo((): ScheduleStep[] => {
|
||||
if (!data) return [];
|
||||
const steps: ScheduleStep[] = [
|
||||
{ key: "start", label: t("startTime"), at: data.start_time },
|
||||
{ key: "close", label: t("closeTime"), at: data.close_time },
|
||||
{ key: "draw", label: t("plannedDraw"), at: data.draw_time },
|
||||
];
|
||||
if (data.cooling_end_time) {
|
||||
steps.push({
|
||||
key: "cooling",
|
||||
label: t("coolingEndTime"),
|
||||
at: data.cooling_end_time,
|
||||
});
|
||||
}
|
||||
return steps;
|
||||
}, [data, t]);
|
||||
|
||||
const availableActions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
type ActionDef = {
|
||||
key: string;
|
||||
label: string;
|
||||
variant: "outline" | "destructive";
|
||||
enabled: boolean;
|
||||
onConfirm: () => Promise<void>;
|
||||
confirmTitle: string;
|
||||
confirmDescription: string;
|
||||
confirmVariant?: "default" | "destructive";
|
||||
};
|
||||
|
||||
const defs: ActionDef[] = [];
|
||||
|
||||
if (canManageDraw) {
|
||||
defs.push(
|
||||
{
|
||||
key: "manualClose",
|
||||
label: t("manualClose"),
|
||||
variant: "outline",
|
||||
enabled: ["pending", "open"].includes(data.status),
|
||||
onConfirm: () => postAdminManualCloseDraw(idNum),
|
||||
confirmTitle: t("confirm.manualCloseTitle"),
|
||||
confirmDescription: t("confirm.manualCloseDescription"),
|
||||
},
|
||||
{
|
||||
key: "cancel",
|
||||
label: t("cancelBeforeDraw"),
|
||||
variant: "outline",
|
||||
enabled: ["pending", "open", "closing", "closed"].includes(data.status),
|
||||
onConfirm: () => postAdminCancelDraw(idNum),
|
||||
confirmTitle: t("confirm.cancelDrawTitle"),
|
||||
confirmDescription: t("confirm.cancelDrawDescription"),
|
||||
},
|
||||
{
|
||||
key: "rng",
|
||||
label: t("rngAutoGenerate"),
|
||||
variant: "outline",
|
||||
enabled: data.status === "closed",
|
||||
onConfirm: () => postAdminRunDrawRng(idNum),
|
||||
confirmTitle: t("confirm.rngDrawTitle"),
|
||||
confirmDescription: t("confirm.rngDrawDescription"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (canReopenDraw) {
|
||||
defs.push({
|
||||
key: "reopen",
|
||||
label: t("cooldownReopen"),
|
||||
variant: "destructive",
|
||||
enabled: data.status === "cooldown",
|
||||
onConfirm: () => postAdminReopenDraw(idNum),
|
||||
confirmTitle: t("confirm.reopenTitle"),
|
||||
confirmDescription: t("confirm.reopenDescription"),
|
||||
confirmVariant: "destructive",
|
||||
});
|
||||
}
|
||||
|
||||
if (canRunSettlement) {
|
||||
defs.push({
|
||||
key: "settlement",
|
||||
label: t("runSettlement"),
|
||||
variant: "outline",
|
||||
enabled: data.status === "settling",
|
||||
onConfirm: () => postAdminRunDrawSettlement(idNum),
|
||||
confirmTitle: t("confirm.runSettlementTitle"),
|
||||
confirmDescription: t("confirm.runSettlementDescription"),
|
||||
});
|
||||
}
|
||||
|
||||
return defs.filter((d) => d.enabled);
|
||||
}, [canManageDraw, canReopenDraw, canRunSettlement, data, idNum, t]);
|
||||
|
||||
if (loading && !data) {
|
||||
return <AdminLoadingState minHeight="6rem" className="py-6" />;
|
||||
}
|
||||
@@ -113,164 +217,122 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
|
||||
}
|
||||
|
||||
const batch = data.result_batch_counts;
|
||||
const hasResultActivity = batch.total > 0 || batch.pending_review > 0 || batch.published > 0;
|
||||
const showActions =
|
||||
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="border-b bg-muted/30 pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("drawDetail")}</p>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="font-mono text-xl tracking-tight">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("detailSubtitle", {
|
||||
date: data.business_date,
|
||||
seq: data.sequence_no,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DrawStatusBadge
|
||||
status={data.status}
|
||||
label={drawStatusLabel(data.status, t)}
|
||||
/>
|
||||
{hallPreviewDiffersFromDbStatus(data.status, data.hall_preview_status) ? (
|
||||
<p className="flex flex-wrap items-center justify-end gap-2 text-sm text-muted-foreground">
|
||||
<span>{t("hallPreviewStatusLabel")}</span>
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">{t("hallPreviewStatusLabel")}</span>
|
||||
<DrawStatusBadge
|
||||
status={data.hall_preview_status}
|
||||
label={drawStatusLabel(data.hall_preview_status, t)}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t("businessDate")}>{data.business_date}</Field>
|
||||
<Field label={t("sequenceNo")}>{data.sequence_no}</Field>
|
||||
<Field label={t("startTime")}>{formatDt(data.start_time)}</Field>
|
||||
<Field label={t("closeTime")}>{formatDt(data.close_time)}</Field>
|
||||
<Field label={t("plannedDraw")}>{formatDt(data.draw_time)}</Field>
|
||||
<Field label={t("coolingEndTime")}>{formatDt(data.cooling_end_time)}</Field>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t("resultSource")}>{drawResultSourceLabel(data.result_source, t)}</Field>
|
||||
<Field label={t("currentResultVersion")}>{data.current_result_version}</Field>
|
||||
<Field label={t("settleVersion")}>{data.settle_version}</Field>
|
||||
<Field label={t("isReopened")}>{data.is_reopened ? t("yes") : t("no")}</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-muted/20 p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("batchStats")}</p>
|
||||
<div className="mt-3 grid gap-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2">
|
||||
<span>{t("batchTotal")}</span>
|
||||
<span className="font-semibold">{data.result_batch_counts.total}</span>
|
||||
<CardContent className="space-y-6 border-t pt-6">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("scheduleTitle")}</h3>
|
||||
<ScheduleTimeline steps={scheduleSteps} />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t("resultBatchesTitle")}</h3>
|
||||
{hasResultActivity ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="rounded-md bg-muted px-2.5 py-1">
|
||||
{t("batchSummaryTotal", { count: batch.total })}
|
||||
</span>
|
||||
{batch.pending_review > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t("batchSummaryPending", { count: batch.pending_review })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPending", { count: 0 })}
|
||||
</span>
|
||||
)}
|
||||
{batch.published > 0 ? (
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/results`}
|
||||
className="rounded-md bg-emerald-500/15 px-2.5 py-1 font-medium text-emerald-800 dark:text-emerald-200"
|
||||
>
|
||||
{t("batchSummaryPublished", { count: batch.published })}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
|
||||
{t("batchSummaryPublished", { count: 0 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-amber-600 dark:text-amber-400">
|
||||
<span>{t("pendingReview")}</span>
|
||||
<span className="font-semibold">{data.result_batch_counts.pending_review}</span>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noResultBatchesYet")}{" "}
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/review`}
|
||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("goToReviewTab")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showActions ? (
|
||||
<section className="space-y-3 border-t pt-6">
|
||||
<h3 className="text-sm font-medium">{t("drawActions")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={action.variant}
|
||||
disabled={acting !== null}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: action.confirmTitle,
|
||||
description: action.confirmDescription,
|
||||
confirmVariant: action.confirmVariant,
|
||||
onConfirm: () => runAction(action.label, action.onConfirm),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === action.label ? t("processing") : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg bg-background px-3 py-2 text-emerald-600 dark:text-emerald-400">
|
||||
<span>{t("published")}</span>
|
||||
<span className="font-semibold">{data.result_batch_counts.published}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/draws/${drawId}/finance`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "mt-4 w-full")}
|
||||
>
|
||||
{t("viewFinance")}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(canManageDraw || canReopenDraw || canRunSettlement) ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("drawActions")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || !["pending", "open"].includes(data.status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.manualCloseTitle"),
|
||||
description: t("confirm.manualCloseDescription"),
|
||||
onConfirm: () => runAction(t("manualClose"), () => postAdminManualCloseDraw(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("manualClose") ? t("processing") : t("manualClose")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || !["pending", "open", "closing", "closed"].includes(data.status)}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.cancelDrawTitle"),
|
||||
description: t("confirm.cancelDrawDescription"),
|
||||
onConfirm: () => runAction(t("cancelDraw"), () => postAdminCancelDraw(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("cancelDraw") ? t("processing") : t("cancelBeforeDraw")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageDraw || acting !== null || data.status !== "closed"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.rngDrawTitle"),
|
||||
description: t("confirm.rngDrawDescription"),
|
||||
onConfirm: () => runAction(t("rngDraw"), () => postAdminRunDrawRng(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("rngDraw") ? t("generating") : t("rngAutoGenerate")}
|
||||
</Button>
|
||||
{canReopenDraw ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={acting !== null || data.status !== "cooldown"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.reopenTitle"),
|
||||
description: t("confirm.reopenDescription"),
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: () => runAction(t("reopen"), () => postAdminReopenDraw(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("reopen") ? t("processing") : t("cooldownReopen")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canRunSettlement || acting !== null || data.status !== "settling"}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirm.runSettlementTitle"),
|
||||
description: t("confirm.runSettlementDescription"),
|
||||
onConfirm: () => runAction(t("runSettlement"), () => postAdminRunDrawSettlement(idNum)),
|
||||
})
|
||||
}
|
||||
>
|
||||
{acting === t("runSettlement") ? t("processing") : t("runSettlement")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -128,6 +128,7 @@ export function DrawEditDialog({
|
||||
<Input
|
||||
id="draw-edit-draw-no"
|
||||
value={drawNo}
|
||||
placeholder={t("editDraw.drawNoPlaceholder")}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,11 +13,24 @@ const segments = [
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review" },
|
||||
{ suffix: "/risk/occupancy", key: "riskLockLogs", label: "subnav.riskLockLogs" },
|
||||
{ suffix: "/risk/hot", key: "riskHot", label: "subnav.riskHot" },
|
||||
{ suffix: "/risk/sold-out", key: "riskSoldOut", label: "subnav.riskSoldOut" },
|
||||
{ suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
|
||||
] as const;
|
||||
|
||||
function isRiskPoolsTabActive(pathname: string, base: string): boolean {
|
||||
const riskPrefix = `${base}/risk/`;
|
||||
if (!pathname.startsWith(riskPrefix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rest = pathname.slice(riskPrefix.length);
|
||||
return (
|
||||
rest === "pools"
|
||||
|| rest.startsWith("pools/")
|
||||
|| rest === "hot"
|
||||
|| rest === "sold-out"
|
||||
);
|
||||
}
|
||||
|
||||
function isReviewTabActive(pathname: string, base: string): boolean {
|
||||
const reviewPrefix = `${base}/review`;
|
||||
const publishPrefix = `${base}/publish`;
|
||||
@@ -42,7 +55,9 @@ export function DrawSubnav({ drawId }: { drawId: string }) {
|
||||
? pathname === base || pathname === `${base}/`
|
||||
: suffix === "/review"
|
||||
? isReviewTabActive(pathname, base)
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
: key === "riskPools"
|
||||
? isRiskPoolsTabActive(pathname, base)
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -139,7 +139,14 @@ function formToPayload(
|
||||
return base;
|
||||
}
|
||||
|
||||
export function IntegrationSitesConsole() {
|
||||
type IntegrationSitesConsoleProps = {
|
||||
/** 代理线路内站点列表:仅超管可新建站点,普通账号走「开通线路」。 */
|
||||
restrictCreateToSuperAdmin?: boolean;
|
||||
};
|
||||
|
||||
export function IntegrationSitesConsole({
|
||||
restrictCreateToSuperAdmin = false,
|
||||
}: IntegrationSitesConsoleProps = {}) {
|
||||
const { t } = useTranslation("config");
|
||||
const tRef = useTranslationRef("config");
|
||||
const profile = useAdminProfile();
|
||||
@@ -147,6 +154,9 @@ export function IntegrationSitesConsole() {
|
||||
profile?.permissions,
|
||||
getAdminPageBundle("integration-sites", "manage"),
|
||||
);
|
||||
const canCreate =
|
||||
canManage &&
|
||||
(!restrictCreateToSuperAdmin || profile?.is_super_admin === true);
|
||||
|
||||
const [items, setItems] = useState<AdminIntegrationSiteRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -346,7 +356,7 @@ export function IntegrationSitesConsole() {
|
||||
title={t("integrationSites.title")}
|
||||
description={t("integrationSites.description")}
|
||||
actions={
|
||||
canManage ? (
|
||||
canCreate ? (
|
||||
<Button type="button" onClick={openCreate}>
|
||||
{t("integrationSites.create")}
|
||||
</Button>
|
||||
@@ -445,7 +455,7 @@ export function IntegrationSitesConsole() {
|
||||
value={form.code}
|
||||
disabled={mode === "edit"}
|
||||
onChange={(e) => updateForm("code", e.target.value)}
|
||||
placeholder="partner-a"
|
||||
placeholder={t("integrationSites.placeholders.code")}
|
||||
/>
|
||||
{mode === "edit" ? (
|
||||
<p className="text-xs text-muted-foreground">{t("integrationSites.codeImmutable")}</p>
|
||||
@@ -456,6 +466,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-name"
|
||||
value={form.name}
|
||||
placeholder={t("integrationSites.placeholders.name")}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -465,6 +476,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-currency"
|
||||
value={form.currency_code}
|
||||
placeholder={t("integrationSites.placeholders.currency")}
|
||||
onChange={(e) => updateForm("currency_code", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -486,6 +498,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-wallet-url"
|
||||
value={form.wallet_api_url}
|
||||
placeholder={t("integrationSites.placeholders.walletApiUrl")}
|
||||
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -494,6 +507,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="is-h5"
|
||||
value={form.lottery_h5_base_url}
|
||||
placeholder={t("integrationSites.placeholders.lotteryH5BaseUrl")}
|
||||
onChange={(e) => updateForm("lottery_h5_base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -504,7 +518,7 @@ export function IntegrationSitesConsole() {
|
||||
rows={3}
|
||||
value={form.iframe_allowed_origins}
|
||||
onChange={(e) => updateForm("iframe_allowed_origins", e.target.value)}
|
||||
placeholder="https://www.example.com"
|
||||
placeholder={t("integrationSites.placeholders.iframeOrigins")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -513,6 +527,7 @@ export function IntegrationSitesConsole() {
|
||||
id="is-notes"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
placeholder={t("integrationSites.placeholders.notes")}
|
||||
onChange={(e) => updateForm("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -581,7 +596,7 @@ export function IntegrationSitesConsole() {
|
||||
id="ct-player"
|
||||
value={connectivityPlayerId}
|
||||
onChange={(e) => setConnectivityPlayerId(e.target.value)}
|
||||
placeholder="10001"
|
||||
placeholder={t("integrationSites.placeholders.connectivityPlayerId")}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
@@ -589,6 +604,7 @@ export function IntegrationSitesConsole() {
|
||||
<Input
|
||||
id="ct-currency"
|
||||
value={connectivityCurrency}
|
||||
placeholder={t("integrationSites.placeholders.currency")}
|
||||
onChange={(e) => setConnectivityCurrency(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -318,6 +318,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
placeholder={t("adjustmentAmountPlaceholder")}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -327,6 +328,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={1}
|
||||
value={adj.reason}
|
||||
placeholder={t("adjustmentReasonPlaceholder")}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -361,6 +363,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`th-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.trigger_threshold}
|
||||
placeholder={t("triggerThresholdPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -370,6 +373,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`min-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.min_bet_amount}
|
||||
placeholder={t("minBetAmountPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -379,6 +383,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`pr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.payout_rate}
|
||||
placeholder={t("payoutRatePlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -388,6 +393,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`gap-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.force_trigger_draw_gap}
|
||||
placeholder={t("forceTriggerGapPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,6 +403,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`cr-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.contribution_rate}
|
||||
placeholder={t("contributionRatePlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
@@ -406,7 +413,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder="straight,ibox"
|
||||
placeholder={t("comboTriggerPlaysPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
9
src/modules/players/invalid-player-id.tsx
Normal file
9
src/modules/players/invalid-player-id.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function InvalidPlayerId(): React.ReactElement {
|
||||
const { t } = useTranslation("players");
|
||||
|
||||
return <p className="text-destructive text-sm">{t("invalidPlayerId")}</p>;
|
||||
}
|
||||
562
src/modules/players/player-detail-console.tsx
Normal file
562
src/modules/players/player-detail-console.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
|
||||
import { getAdminPlayer } from "@/api/admin-player";
|
||||
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
||||
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
||||
|
||||
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
||||
if (status === 0) return t("statusNormal");
|
||||
if (status === 1) return t("statusFrozen");
|
||||
if (status === 2) return t("statusBanned");
|
||||
return String(status);
|
||||
}
|
||||
|
||||
function ticketStatusText(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
|
||||
const key = `statusOptions.${status}`;
|
||||
const translated = t(key, { ns: "tickets" });
|
||||
return translated === key ? status : translated;
|
||||
}
|
||||
|
||||
function walletStatusLabel(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
|
||||
switch (status) {
|
||||
case "processing":
|
||||
return t("statusProcessing", { ns: "wallet" });
|
||||
case "success":
|
||||
return t("statusSuccess", { ns: "wallet" });
|
||||
case "failed":
|
||||
return t("statusFailed", { ns: "wallet" });
|
||||
case "pending_reconcile":
|
||||
return t("statusPendingReconcile", { ns: "wallet" });
|
||||
case "reversed":
|
||||
return t("statusReversed", { ns: "wallet" });
|
||||
case "manually_processed":
|
||||
return t("statusCaseClosed", { ns: "wallet" });
|
||||
case "posted":
|
||||
return t("statusPosted", { ns: "wallet" });
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function PlayerStatusBadge({ status, t }: { status: number; t: (key: string) => string }) {
|
||||
return (
|
||||
<AdminStatusBadge status={String(status)} tone={resolvePlayerStatusTone(status)}>
|
||||
{playerStatusLabel(status, t)}
|
||||
</AdminStatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function playerDisplayName(row: AdminPlayerRow): string {
|
||||
return row.nickname?.trim() || row.username?.trim() || row.site_player_id;
|
||||
}
|
||||
|
||||
function ProfileField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[7.5rem_1fr] sm:items-start">
|
||||
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
||||
const { t } = useTranslation(["players", "tickets", "wallet", "common"]);
|
||||
const tRef = useTranslationRef(["players", "tickets", "wallet", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
useAdminCurrencyCatalog();
|
||||
|
||||
const [player, setPlayer] = useState<AdminPlayerRow | null>(null);
|
||||
const [playerLoading, setPlayerLoading] = useState(true);
|
||||
const [playerErr, setPlayerErr] = useState<string | null>(null);
|
||||
|
||||
const [ticketPage, setTicketPage] = useState(1);
|
||||
const [ticketPerPage, setTicketPerPage] = useState(10);
|
||||
const [tickets, setTickets] = useState<AdminPlayerTicketItemRow[]>([]);
|
||||
const [ticketTotal, setTicketTotal] = useState(0);
|
||||
const [ticketLastPage, setTicketLastPage] = useState(1);
|
||||
const [ticketsLoading, setTicketsLoading] = useState(false);
|
||||
|
||||
const [txnPage, setTxnPage] = useState(1);
|
||||
const [txnPerPage, setTxnPerPage] = useState(10);
|
||||
const [txns, setTxns] = useState<AdminWalletTxnItem[]>([]);
|
||||
const [txnTotal, setTxnTotal] = useState(0);
|
||||
const [txnLastPage, setTxnLastPage] = useState(1);
|
||||
const [txnsLoading, setTxnsLoading] = useState(false);
|
||||
|
||||
const [transferPage, setTransferPage] = useState(1);
|
||||
const [transferPerPage, setTransferPerPage] = useState(10);
|
||||
const [transfers, setTransfers] = useState<AdminTransferOrderItem[]>([]);
|
||||
const [transferTotal, setTransferTotal] = useState(0);
|
||||
const [transferLastPage, setTransferLastPage] = useState(1);
|
||||
const [transfersLoading, setTransfersLoading] = useState(false);
|
||||
|
||||
const loadPlayer = useCallback(async () => {
|
||||
setPlayerLoading(true);
|
||||
setPlayerErr(null);
|
||||
try {
|
||||
setPlayer(await getAdminPlayer(playerId));
|
||||
} catch (e) {
|
||||
setPlayer(null);
|
||||
setPlayerErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
||||
} finally {
|
||||
setPlayerLoading(false);
|
||||
}
|
||||
}, [playerId]);
|
||||
|
||||
const loadTickets = useCallback(async () => {
|
||||
setTicketsLoading(true);
|
||||
try {
|
||||
const d = await getAdminPlayerTicketItems(playerId, {
|
||||
page: ticketPage,
|
||||
per_page: ticketPerPage,
|
||||
});
|
||||
setTickets(d.items);
|
||||
setTicketTotal(d.total);
|
||||
setTicketLastPage(Math.max(1, d.last_page));
|
||||
} catch {
|
||||
setTickets([]);
|
||||
setTicketTotal(0);
|
||||
setTicketLastPage(1);
|
||||
} finally {
|
||||
setTicketsLoading(false);
|
||||
}
|
||||
}, [playerId, ticketPage, ticketPerPage]);
|
||||
|
||||
const loadTxns = useCallback(async () => {
|
||||
setTxnsLoading(true);
|
||||
try {
|
||||
const d = await getAdminWalletTransactions({
|
||||
player_id: playerId,
|
||||
page: txnPage,
|
||||
per_page: txnPerPage,
|
||||
});
|
||||
setTxns(d.items);
|
||||
setTxnTotal(d.total);
|
||||
setTxnLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
||||
} catch {
|
||||
setTxns([]);
|
||||
setTxnTotal(0);
|
||||
setTxnLastPage(1);
|
||||
} finally {
|
||||
setTxnsLoading(false);
|
||||
}
|
||||
}, [playerId, txnPage, txnPerPage]);
|
||||
|
||||
const loadTransfers = useCallback(async () => {
|
||||
setTransfersLoading(true);
|
||||
try {
|
||||
const d = await getAdminTransferOrders({
|
||||
player_id: playerId,
|
||||
page: transferPage,
|
||||
per_page: transferPerPage,
|
||||
});
|
||||
setTransfers(d.items);
|
||||
setTransferTotal(d.total);
|
||||
setTransferLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
||||
} catch {
|
||||
setTransfers([]);
|
||||
setTransferTotal(0);
|
||||
setTransferLastPage(1);
|
||||
} finally {
|
||||
setTransfersLoading(false);
|
||||
}
|
||||
}, [playerId, transferPage, transferPerPage]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void loadPlayer();
|
||||
}, [loadPlayer]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
void loadTickets();
|
||||
}, [player, loadTickets]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
void loadTxns();
|
||||
}, [player, loadTxns]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!player) return;
|
||||
void loadTransfers();
|
||||
}, [player, loadTransfers]);
|
||||
|
||||
if (playerLoading && !player) {
|
||||
return <AdminLoadingState minHeight="8rem" className="py-8" />;
|
||||
}
|
||||
|
||||
if (playerErr || !player) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link href="/admin/players" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1")}>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
<p className="text-sm text-destructive">{playerErr ?? t("states.noData", { ns: "common" })}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/admin/players"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "-ml-2 gap-1")}
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{playerDisplayName(player)}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("detailSubtitle", {
|
||||
site: player.site_code,
|
||||
sitePlayerId: player.site_player_id,
|
||||
playerId: player.id,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="gap-4">
|
||||
<TabsList variant="line" className="w-full justify-start border-b rounded-none bg-transparent p-0">
|
||||
<TabsTrigger value="overview" className="rounded-none px-3">
|
||||
{t("tabOverview")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tickets" className="rounded-none px-3">
|
||||
{t("tabTickets")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="wallet" className="rounded-none px-3">
|
||||
{t("tabWalletTxns")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfers" className="rounded-none px-3">
|
||||
{t("tabTransferOrders")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("profileSection")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<dl className="space-y-3">
|
||||
<ProfileField label={t("site")}>{player.site_code}</ProfileField>
|
||||
<ProfileField label={t("sitePlayerId")}>
|
||||
<span className="font-mono">{player.site_player_id}</span>
|
||||
</ProfileField>
|
||||
<ProfileField label="ID">
|
||||
<span className="font-mono">{player.id}</span>
|
||||
</ProfileField>
|
||||
<ProfileField label={t("username")}>{player.username ?? "—"}</ProfileField>
|
||||
<ProfileField label={t("nickname")}>{player.nickname ?? "—"}</ProfileField>
|
||||
</dl>
|
||||
<dl className="space-y-3">
|
||||
<ProfileField label={t("currency")}>{player.default_currency}</ProfileField>
|
||||
<ProfileField label={t("status")}>
|
||||
<PlayerStatusBadge status={player.status} t={t} />
|
||||
</ProfileField>
|
||||
<ProfileField label={t("lastLogin")}>
|
||||
{player.last_login_at ? formatDt(player.last_login_at) : "—"}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("createdAt")}>
|
||||
{formatDt(player.created_at)}
|
||||
</ProfileField>
|
||||
<ProfileField label={t("agent")}>
|
||||
{player.agent_name ?? player.agent_code ?? "—"}
|
||||
{player.agent_code && player.agent_name ? (
|
||||
<span className="ml-1 font-mono text-xs text-muted-foreground">
|
||||
({player.agent_code})
|
||||
</span>
|
||||
) : null}
|
||||
</ProfileField>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("walletsSection")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{player.wallets.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{player.wallets.map((w: AdminPlayerWalletRow) => (
|
||||
<div
|
||||
key={`${w.wallet_type}-${w.currency_code}`}
|
||||
className="rounded-lg border bg-muted/20 px-3 py-2.5"
|
||||
>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{w.wallet_type} · {w.currency_code}
|
||||
</p>
|
||||
<p className="mt-1 text-sm">
|
||||
{t("balance")}{" "}
|
||||
<span className="font-semibold tabular-nums">
|
||||
{formatAdminMinorUnits(w.balance, w.currency_code)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("available")}{" "}
|
||||
<span className="tabular-nums">
|
||||
{formatAdminMinorUnits(w.available_balance, w.currency_code)}
|
||||
</span>
|
||||
{w.frozen_balance > 0 ? (
|
||||
<>
|
||||
{" · "}
|
||||
{t("frozen", { defaultValue: "冻结" })}{" "}
|
||||
<span className="tabular-nums">
|
||||
{formatAdminMinorUnits(w.frozen_balance, w.currency_code)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tickets" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
||||
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
||||
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ticketsLoading && tickets.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={7} />
|
||||
) : null}
|
||||
{tickets.map((row) => (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{row.actual_deduct_amount_formatted}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{ticketStatusText(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!ticketsLoading && tickets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-tickets"
|
||||
total={ticketTotal}
|
||||
page={ticketPage}
|
||||
lastPage={ticketLastPage}
|
||||
perPage={ticketPerPage}
|
||||
loading={ticketsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTicketPerPage(n);
|
||||
setTicketPage(1);
|
||||
}}
|
||||
onPageChange={setTicketPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wallet" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabWalletTxns")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("txnNo", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("bizType", { ns: "wallet" })}</TableHead>
|
||||
<TableHead className="text-center">{t("txnAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("balanceAfterTxn")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("createdAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{txnsLoading && txns.length === 0 ? <AdminTableLoadingRow colSpan={6} /> : null}
|
||||
{txns.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell className="text-xs">{row.biz_type}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, player.default_currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.balance_after, player.default_currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{walletStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!txnsLoading && txns.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-txns"
|
||||
total={txnTotal}
|
||||
page={txnPage}
|
||||
lastPage={txnLastPage}
|
||||
perPage={txnPerPage}
|
||||
loading={txnsLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTxnPerPage(n);
|
||||
setTxnPage(1);
|
||||
}}
|
||||
onPageChange={setTxnPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfers" className="mt-0">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("tabTransferOrders")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-table-shell">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("localTransferNo", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("direction", { ns: "wallet" })}</TableHead>
|
||||
<TableHead className="text-center">{t("amount", { ns: "wallet" })}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("requestTime", { ns: "wallet" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transfersLoading && transfers.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={5} />
|
||||
) : null}
|
||||
{transfers.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.transfer_no}</TableCell>
|
||||
<TableCell className="text-xs">{row.direction}</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, row.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{walletStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!transfersLoading && transfers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<AdminListPaginationFooter
|
||||
selectId="player-detail-transfers"
|
||||
total={transferTotal}
|
||||
page={transferPage}
|
||||
lastPage={transferLastPage}
|
||||
perPage={transferPerPage}
|
||||
loading={transfersLoading}
|
||||
onPerPageChange={(n) => {
|
||||
setTransferPerPage(n);
|
||||
setTransferPage(1);
|
||||
}}
|
||||
onPageChange={setTransferPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
@@ -11,6 +11,7 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAgentTree } from "@/api/admin-agents";
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayers,
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
postAdminPlayerUnfreeze,
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
||||
@@ -90,6 +94,9 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const exportLabels = useExportLabels("players");
|
||||
const profile = useAdminProfile();
|
||||
const isSuperAdmin = profile?.is_super_admin === true;
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -118,6 +125,9 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [formNickname, setFormNickname] = useState("");
|
||||
const [formDefaultCurrency, setFormDefaultCurrency] = useState("NPR");
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
|
||||
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
|
||||
const [createAgentLoading, setCreateAgentLoading] = useState(false);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminPlayerRow | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
@@ -164,7 +174,13 @@ export function PlayersConsole(): React.ReactElement {
|
||||
function openCreateAccount(): void {
|
||||
setAccountMode("create");
|
||||
setEditingAccountId(null);
|
||||
setFormSiteCode("");
|
||||
const agentSite = boundAgent?.site_code?.trim() ?? "";
|
||||
const defaultSite =
|
||||
agentSite !== ""
|
||||
? agentSite
|
||||
: siteOptions[0]?.code ?? "";
|
||||
setFormSiteCode(defaultSite);
|
||||
setFormAgentNodeId(boundAgent?.id);
|
||||
setFormSitePlayerId("");
|
||||
setFormUsername("");
|
||||
setFormNickname("");
|
||||
@@ -173,6 +189,52 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setAccountOpen(true);
|
||||
}
|
||||
|
||||
useAsyncEffect(() => {
|
||||
if (!accountOpen || accountMode !== "create" || !isSuperAdmin) {
|
||||
return;
|
||||
}
|
||||
const siteCode = formSiteCode.trim();
|
||||
const site = siteOptions.find((s) => s.code === siteCode);
|
||||
if (!site) {
|
||||
setCreateAgentOptions([]);
|
||||
setFormAgentNodeId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setCreateAgentLoading(true);
|
||||
getAgentTree(site.id)
|
||||
.then((data) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const flat = flattenAgentTree(data.tree);
|
||||
setCreateAgentOptions(flat);
|
||||
const root = flat.find((o) => o.depth === 0) ?? flat[0];
|
||||
setFormAgentNodeId((prev) => {
|
||||
if (prev !== undefined && flat.some((o) => o.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return root?.id;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCreateAgentOptions([]);
|
||||
setFormAgentNodeId(undefined);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setCreateAgentLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accountOpen, accountMode, formSiteCode, isSuperAdmin, siteOptions]);
|
||||
|
||||
function openEditAccount(row: AdminPlayerRow): void {
|
||||
setAccountMode("edit");
|
||||
setEditingAccountId(row.id);
|
||||
@@ -194,6 +256,10 @@ export function PlayersConsole(): React.ReactElement {
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
if (accountMode === "create") {
|
||||
if (!isSuperAdmin && !boundAgent) {
|
||||
toast.error(t("createAgentRequired"));
|
||||
return;
|
||||
}
|
||||
if (formSiteCode.trim() === "") {
|
||||
toast.error(t("siteCodeRequired"));
|
||||
return;
|
||||
@@ -202,6 +268,10 @@ export function PlayersConsole(): React.ReactElement {
|
||||
toast.error(t("sitePlayerIdRequired"));
|
||||
return;
|
||||
}
|
||||
if (isSuperAdmin && (formAgentNodeId === undefined || formAgentNodeId <= 0)) {
|
||||
toast.error(t("createAgentRequired"));
|
||||
return;
|
||||
}
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
const created = await postAdminPlayer({
|
||||
@@ -211,6 +281,9 @@ export function PlayersConsole(): React.ReactElement {
|
||||
nickname: formNickname.trim() || null,
|
||||
default_currency: formDefaultCurrency,
|
||||
status: formStatus,
|
||||
...(isSuperAdmin && formAgentNodeId
|
||||
? { agent_node_id: formAgentNodeId }
|
||||
: {}),
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
setTotal((t) => t + 1);
|
||||
@@ -468,37 +541,33 @@ export function PlayersConsole(): React.ReactElement {
|
||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{canManagePlayers ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "edit",
|
||||
label: t("edit"),
|
||||
icon: Pencil,
|
||||
onClick: () => openEditAccount(row),
|
||||
},
|
||||
{
|
||||
key: "tickets",
|
||||
label: t("viewTickets", { defaultValue: "查看注单" }),
|
||||
href: `/admin/tickets?player_id=${row.id}`,
|
||||
},
|
||||
{
|
||||
key: "wallet",
|
||||
label: t("viewWallet", { defaultValue: "查看钱包流水" }),
|
||||
href: `/admin/wallet/transactions?player_id=${row.id}`,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("delete"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => setDeleteTarget(row),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("viewDetail"),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.id),
|
||||
},
|
||||
...(canManagePlayers
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: t("edit"),
|
||||
icon: Pencil,
|
||||
onClick: () => openEditAccount(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: t("delete"),
|
||||
icon: Trash2,
|
||||
destructive: true,
|
||||
onClick: () => setDeleteTarget(row),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -536,13 +605,73 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-site-code">{t("siteCode")}</Label>
|
||||
<Input
|
||||
id="player-site-code"
|
||||
value={formSiteCode}
|
||||
placeholder={t("siteCodePlaceholder")}
|
||||
onChange={(e) => setFormSiteCode(e.target.value)}
|
||||
/>
|
||||
{boundAgent && boundAgent.site_code ? (
|
||||
<Input id="player-site-code" value={boundAgent.site_code} disabled readOnly />
|
||||
) : canChooseSite && siteOptions.length > 0 ? (
|
||||
<Select
|
||||
value={formSiteCode}
|
||||
onValueChange={(code) => {
|
||||
setFormSiteCode(code);
|
||||
setFormAgentNodeId(undefined);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="player-site-code">
|
||||
<SelectValue placeholder={t("siteCodePlaceholder")}>
|
||||
{siteOptions.find((s) => s.code === formSiteCode)?.name ?? formSiteCode}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={site.code}>
|
||||
{site.name} ({site.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="player-site-code"
|
||||
value={formSiteCode}
|
||||
placeholder={t("siteCodePlaceholder")}
|
||||
onChange={(e) => setFormSiteCode(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{boundAgent ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("createAgentAutoHint", {
|
||||
name: boundAgent.name,
|
||||
code: boundAgent.code,
|
||||
})}
|
||||
</p>
|
||||
) : isSuperAdmin ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-agent-node">{t("createAgentNode")}</Label>
|
||||
<Select
|
||||
value={
|
||||
formAgentNodeId != null && formAgentNodeId > 0
|
||||
? String(formAgentNodeId)
|
||||
: ""
|
||||
}
|
||||
onValueChange={(v) => setFormAgentNodeId(Number(v))}
|
||||
disabled={createAgentLoading || createAgentOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger id="player-agent-node">
|
||||
<SelectValue placeholder={t("createAgentNodePlaceholder")}>
|
||||
{createAgentOptions.find((o) => o.id === formAgentNodeId)?.label ??
|
||||
t("createAgentNodePlaceholder")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{createAgentOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-site-id">{t("sitePlayerIdLabel")}</Label>
|
||||
<Input
|
||||
@@ -591,7 +720,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-status">
|
||||
<SelectValue />
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
@@ -612,7 +741,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onValueChange={(v) => setFormStatus(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="player-edit-status">
|
||||
<SelectValue />
|
||||
<SelectValue>{playerStatusLabelT(formStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYER_STATUS_OPTIONS.map((o) => (
|
||||
|
||||
@@ -111,7 +111,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={draftNumber}
|
||||
className="w-full sm:w-32"
|
||||
className="h-8 w-full font-mono sm:w-32"
|
||||
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
@@ -127,7 +127,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
if (v) setDraftAction(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="w-full sm:w-40">
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="h-8 w-full sm:w-40">
|
||||
<SelectValue>{riskActionFilterLabel(draftAction, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -177,21 +177,23 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null}
|
||||
{(data?.items ?? []).map((row: AdminRiskLockLogRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{row.normalized_number}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{riskActionTypeLabel(row.action_type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{riskSourceReasonLabel(row.source_reason, t)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm">{playCodeLabel(row.play_code)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -63,25 +63,41 @@ function riskSortLabel(
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
type RiskFilter = "all" | "sold_out" | "high_risk";
|
||||
export type RiskPoolListFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
type RiskPoolsConsoleProps = {
|
||||
drawId: number;
|
||||
/** @deprecated 优先使用 titleKey */
|
||||
title?: string;
|
||||
titleKey?: RiskPoolsPageTitleKey;
|
||||
soldOutOnly: boolean;
|
||||
defaultSort: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
||||
/** @deprecated 使用 initialFilter */
|
||||
soldOutOnly?: boolean;
|
||||
initialFilter?: RiskPoolListFilter;
|
||||
defaultSort?: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc";
|
||||
allowSortChange?: boolean;
|
||||
};
|
||||
|
||||
function resolveInitialFilter(
|
||||
initialFilter: RiskPoolListFilter | undefined,
|
||||
soldOutOnly: boolean | undefined,
|
||||
): RiskPoolListFilter {
|
||||
if (initialFilter) {
|
||||
return initialFilter;
|
||||
}
|
||||
if (soldOutOnly) {
|
||||
return "sold_out";
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
export function RiskPoolsConsole({
|
||||
drawId,
|
||||
title,
|
||||
titleKey,
|
||||
soldOutOnly,
|
||||
defaultSort,
|
||||
allowSortChange = false,
|
||||
initialFilter: initialFilterProp,
|
||||
defaultSort = "number_asc",
|
||||
allowSortChange = true,
|
||||
}: RiskPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const tRef = useTranslationRef(["risk", "common"]);
|
||||
@@ -91,11 +107,12 @@ export function RiskPoolsConsole({
|
||||
PRD_RISK_MANAGE,
|
||||
PRD_DRAW_RESULT_MANAGE,
|
||||
]);
|
||||
const initialFilter = resolveInitialFilter(initialFilterProp, soldOutOnly);
|
||||
const pageTitle = titleKey ? t(titleKey) : (title ?? t("poolsTitle"));
|
||||
const exportLabels = useExportLabels("riskPools");
|
||||
useAdminCurrencyCatalog();
|
||||
const [sort, setSort] = useState(defaultSort);
|
||||
const [filter, setFilter] = useState<RiskFilter>(soldOutOnly ? "sold_out" : "all");
|
||||
const [filter, setFilter] = useState<RiskPoolListFilter>(initialFilter);
|
||||
const [number, setNumber] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
@@ -162,19 +179,22 @@ export function RiskPoolsConsole({
|
||||
return (
|
||||
<>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-3">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{pageTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-16 sm:shrink-0">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-20 sm:shrink-0">
|
||||
{t("searchNumber")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-pool-number"
|
||||
inputMode="numeric"
|
||||
value={number}
|
||||
maxLength={4}
|
||||
placeholder={t("searchNumberPlaceholder")}
|
||||
className="h-8 w-full font-mono sm:w-52"
|
||||
className="h-8 w-full font-mono sm:w-32"
|
||||
onChange={(event) => {
|
||||
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
setPage(1);
|
||||
@@ -182,31 +202,35 @@ export function RiskPoolsConsole({
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-16 sm:shrink-0">{t("riskFilter")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["all", t("filterAll")],
|
||||
["sold_out", t("filterSoldOut")],
|
||||
["high_risk", t("filterHighRisk")],
|
||||
].map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={filter === value ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setFilter(value as RiskFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Label className="sm:w-20 sm:shrink-0">{t("riskFilter")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={filter}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return;
|
||||
setFilter(v as RiskPoolListFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-pool-filter" size="sm" className="h-8 w-full sm:w-40">
|
||||
<SelectValue>
|
||||
{filter === "all"
|
||||
? t("filterAll")
|
||||
: filter === "sold_out"
|
||||
? t("filterSoldOut")
|
||||
: t("filterHighRisk")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filterAll")}</SelectItem>
|
||||
<SelectItem value="sold_out">{t("filterSoldOut")}</SelectItem>
|
||||
<SelectItem value="high_risk">{t("filterHighRisk")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{allowSortChange ? (
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-sort" className="sm:w-10 sm:shrink-0">
|
||||
<Label htmlFor="risk-pool-sort" className="sm:w-20 sm:shrink-0">
|
||||
{t("sort")}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -218,7 +242,7 @@ export function RiskPoolsConsole({
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-pool-sort" size="sm" className="w-full sm:w-52">
|
||||
<SelectTrigger id="risk-pool-sort" size="sm" className="h-8 w-full sm:w-44">
|
||||
<SelectValue>{riskSortLabel(sort, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -239,8 +263,7 @@ export function RiskPoolsConsole({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
@@ -253,7 +276,7 @@ export function RiskPoolsConsole({
|
||||
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
|
||||
<TableHead className="text-center">{t("usageRatio")}</TableHead>
|
||||
<TableHead>{t("poolStatus")}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 text-center">{t("table.actions", { ns: "common" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -301,7 +324,7 @@ export function RiskPoolsConsole({
|
||||
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<TableCell className="sticky right-0 z-10 text-center">
|
||||
<AdminRowActionsMenu
|
||||
busy={acting}
|
||||
actions={[
|
||||
@@ -345,7 +368,7 @@ export function RiskPoolsConsole({
|
||||
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId={`risk-pools-${drawId}-${soldOutOnly ? "so" : "all"}`}
|
||||
selectId={`risk-pools-${drawId}-${filter}`}
|
||||
total={data.meta.total}
|
||||
page={data.meta.current_page}
|
||||
lastPage={data.meta.last_page}
|
||||
|
||||
@@ -9,11 +9,19 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const segments = [
|
||||
{ suffix: "/occupancy", key: "occupancy", label: "subnavOccupancy" },
|
||||
{ suffix: "/hot", key: "hot", label: "subnavHot" },
|
||||
{ suffix: "/sold-out", key: "sold-out", label: "subnavSoldOut" },
|
||||
{ suffix: "/pools", key: "pools", label: "subnavPools" },
|
||||
] as const;
|
||||
|
||||
function isPoolsTabActive(pathname: string, base: string): boolean {
|
||||
const poolsPrefix = `${base}/pools`;
|
||||
return (
|
||||
pathname === poolsPrefix
|
||||
|| pathname.startsWith(`${poolsPrefix}/`)
|
||||
|| pathname === `${base}/hot`
|
||||
|| pathname === `${base}/sold-out`
|
||||
);
|
||||
}
|
||||
|
||||
export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation("risk");
|
||||
const pathname = usePathname();
|
||||
@@ -24,8 +32,7 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
{segments.map(({ suffix, key, label }) => {
|
||||
const href = `${base}${suffix}`;
|
||||
const active =
|
||||
pathname === href ||
|
||||
(key === "pools" && pathname?.startsWith(`${base}/pools`));
|
||||
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -315,6 +315,7 @@ export function CurrencySettingsPanel() {
|
||||
<Input
|
||||
id="currency-code"
|
||||
value={form.code}
|
||||
placeholder={t("currencies.form.codePlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("code", e.target.value.toUpperCase())}
|
||||
disabled={saving || mode === "edit"}
|
||||
/>
|
||||
@@ -325,6 +326,7 @@ export function CurrencySettingsPanel() {
|
||||
<Input
|
||||
id="currency-name"
|
||||
value={form.name}
|
||||
placeholder={t("currencies.form.namePlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("name", e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
@@ -339,6 +341,7 @@ export function CurrencySettingsPanel() {
|
||||
max="12"
|
||||
step="1"
|
||||
value={form.decimal_places}
|
||||
placeholder={t("currencies.form.decimalsPlaceholder", { ns: "config" })}
|
||||
onChange={(e) => updateForm("decimal_places", e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
|
||||
@@ -91,6 +91,7 @@ export function CurrencyFormatSettingsPanel() {
|
||||
max="12"
|
||||
step="1"
|
||||
value={draft.currencyDisplayDecimals}
|
||||
placeholder={t("system.placeholders.currencyDisplayDecimals", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyDisplayDecimals", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -102,6 +103,7 @@ export function CurrencyFormatSettingsPanel() {
|
||||
<Input
|
||||
id="currency-decimal-separator"
|
||||
value={draft.currencyDecimalSeparator}
|
||||
placeholder={t("system.placeholders.currencyDecimalSeparator", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyDecimalSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
@@ -114,6 +116,7 @@ export function CurrencyFormatSettingsPanel() {
|
||||
<Input
|
||||
id="currency-thousands-separator"
|
||||
value={draft.currencyThousandsSeparator}
|
||||
placeholder={t("system.placeholders.currencyThousandsSeparator", { ns: "config" })}
|
||||
onChange={(e) => updateField("currencyThousandsSeparator", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
maxLength={1}
|
||||
|
||||
@@ -129,6 +129,7 @@ export function DrawSettingsPanel() {
|
||||
<Input
|
||||
id="default-currency"
|
||||
value={draft.defaultCurrency}
|
||||
placeholder={t("system.placeholders.defaultCurrency", { ns: "config" })}
|
||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
||||
disabled={loading || saving}
|
||||
maxLength={16}
|
||||
@@ -145,6 +146,7 @@ export function DrawSettingsPanel() {
|
||||
max="1440"
|
||||
step="1"
|
||||
value={draft.drawIntervalMinutes}
|
||||
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -159,6 +161,7 @@ export function DrawSettingsPanel() {
|
||||
min="10"
|
||||
step="1"
|
||||
value={draft.drawBettingWindowSeconds}
|
||||
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -173,6 +176,7 @@ export function DrawSettingsPanel() {
|
||||
min="5"
|
||||
step="1"
|
||||
value={draft.drawCloseBeforeDrawSeconds}
|
||||
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -187,6 +191,7 @@ export function DrawSettingsPanel() {
|
||||
min="1"
|
||||
step="1"
|
||||
value={draft.drawBufferDrawsAhead}
|
||||
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
|
||||
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
@@ -201,6 +206,7 @@ export function DrawSettingsPanel() {
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
|
||||
79
src/modules/settlement/agent-bills-console.tsx
Normal file
79
src/modules/settlement/agent-bills-console.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { adminRequest } from "@/lib/admin-http";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type BillRow = {
|
||||
id: number;
|
||||
bill_type: string;
|
||||
net_amount: number;
|
||||
unpaid_amount: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function AgentBillsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const [rows, setRows] = useState<BillRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await adminRequest.get<{ items: BillRow[] }>("/admin/settlement-bills");
|
||||
setRows(data.items ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("agents:settlementBills.title", { defaultValue: "代理账单" })}>
|
||||
{loading ? (
|
||||
<AdminLoadingState />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("common:states.noData", { defaultValue: "暂无数据" })}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("agents:settlementBills.columns.id", { defaultValue: "ID" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.type", { defaultValue: "类型" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.net", { defaultValue: "净额" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.unpaid", { defaultValue: "未结" })}</TableHead>
|
||||
<TableHead>{t("agents:settlementBills.columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.id}</TableCell>
|
||||
<TableCell>{row.bill_type}</TableCell>
|
||||
<TableCell>{row.net_amount}</TableCell>
|
||||
<TableCell>{row.unpaid_amount}</TableCell>
|
||||
<TableCell>{row.status}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -36,9 +36,10 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
|
||||
/** 与玩家端、注项表 status 字段对齐(不含无效的 success) */
|
||||
const TICKET_STATUS_OPTIONS = [
|
||||
@@ -95,6 +96,25 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
|
||||
return t("statusSelectedCount", { count: statuses.length });
|
||||
}
|
||||
|
||||
function TicketFilterField({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const tRef = useTranslationRef(["tickets", "common"]);
|
||||
@@ -184,52 +204,55 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-player" className="sm:shrink-0">
|
||||
{t("playerId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="pt-player"
|
||||
className="font-mono"
|
||||
placeholder={t("playerIdPlaceholder")}
|
||||
value={draft.playerQuery}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-draw" className="sm:shrink-0">
|
||||
{t("drawNoOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="font-mono text-sm sm:w-44"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={draft.drawNo}
|
||||
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
|
||||
<Label htmlFor="pt-number" className="sm:shrink-0">
|
||||
{t("numberKeyword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="pt-number"
|
||||
className="font-mono text-sm"
|
||||
placeholder={t("numberKeywordPlaceholder")}
|
||||
value={draft.numberKeyword}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-date-range" className="sm:shrink-0">
|
||||
{t("placedDateRange")}
|
||||
</Label>
|
||||
<div className="min-w-0 w-full sm:w-56">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<TicketFilterField id="pt-player" label={t("playerId")}>
|
||||
<Input
|
||||
id="pt-player"
|
||||
className="h-8 w-full font-mono"
|
||||
placeholder={t("playerIdPlaceholder")}
|
||||
value={draft.playerQuery}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-draw" label={t("drawNoOptional")}>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="h-8 w-full font-mono"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={draft.drawNo}
|
||||
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-number" label={t("numberKeyword")}>
|
||||
<Input
|
||||
id="pt-number"
|
||||
className="h-8 w-full font-mono"
|
||||
placeholder={t("numberKeywordPlaceholder")}
|
||||
value={draft.numberKeyword}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
<TicketFilterField id="pt-date-range" label={t("placedDateRange")}>
|
||||
<AdminDateRangeField
|
||||
id="pt-date-range"
|
||||
from={draft.startDate}
|
||||
@@ -242,50 +265,50 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</TicketFilterField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between">
|
||||
<TicketFilterField id="pt-status" label={t("statusFilterLabel")}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
id="pt-status"
|
||||
title={t("statusHint")}
|
||||
className="inline-flex h-8 w-full min-w-0 items-center justify-between rounded-md border border-border bg-card px-3 text-left text-sm font-normal shadow-sm outline-none transition-all hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:min-w-[12rem] sm:max-w-xs"
|
||||
>
|
||||
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{TICKET_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={draft.statuses.includes(status)}
|
||||
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
|
||||
>
|
||||
{ticketStatusText(status, t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TicketFilterField>
|
||||
<div className="admin-list-actions w-full sm:ml-auto sm:w-auto">
|
||||
<AdminTableExportButton
|
||||
tableId="tickets-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-status" className="sm:shrink-0">
|
||||
{t("statusFilterLabel")}
|
||||
</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
id="pt-status"
|
||||
title={t("statusHint")}
|
||||
className="inline-flex h-8 w-full min-w-0 items-center justify-between rounded-md border border-border bg-card px-3 text-left text-sm font-normal shadow-sm outline-none transition-all hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:w-44"
|
||||
>
|
||||
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{TICKET_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={draft.statuses.includes(status)}
|
||||
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
|
||||
>
|
||||
{ticketStatusText(status, t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="tickets-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
|
||||
@@ -372,13 +395,18 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewPlayer", { defaultValue: "查看玩家" }),
|
||||
href: `/admin/players?keyword=${encodeURIComponent(String(row.player_id ?? ""))}&site_code=${encodeURIComponent(String(row.site_code ?? ""))}${row.agent_node_id ? `&agent_node_id=${row.agent_node_id}` : ""}`,
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
row.player_id
|
||||
? [
|
||||
{
|
||||
key: "view-player",
|
||||
label: t("viewDetail", { ns: "players" }),
|
||||
icon: Eye,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
@@ -18,7 +17,6 @@ const RECONCILE_PERMS = [
|
||||
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
|
||||
{ href: "/admin/wallet/transactions", label: "subnavTransactions", requiredAny: RECONCILE_PERMS },
|
||||
{ href: "/admin/wallet/transfer-orders", label: "subnavTransferOrders", requiredAny: RECONCILE_PERMS },
|
||||
{ href: "/admin/wallet/player", label: "subnavPlayerWallet", requiredAny: PRD_WALLET_PLAYER_ACCESS_ANY },
|
||||
];
|
||||
|
||||
export function WalletSubnav(): React.ReactElement {
|
||||
|
||||
45
src/types/api/admin-agent-line.ts
Normal file
45
src/types/api/admin-agent-line.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export type AdminAgentLineProvisionPayload = {
|
||||
code: string;
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string | null;
|
||||
currency_code?: string;
|
||||
status?: number;
|
||||
wallet_api_url?: string | null;
|
||||
wallet_debit_path?: string;
|
||||
wallet_credit_path?: string;
|
||||
wallet_balance_path?: string;
|
||||
wallet_timeout_seconds?: number;
|
||||
iframe_allowed_origins?: string[];
|
||||
lottery_h5_base_url?: string | null;
|
||||
notes?: string | null;
|
||||
total_share_rate?: number;
|
||||
credit_limit?: number;
|
||||
rebate_limit?: number;
|
||||
default_player_rebate?: number;
|
||||
settlement_cycle?: "daily" | "weekly" | "monthly";
|
||||
can_grant_extra_rebate?: boolean;
|
||||
};
|
||||
|
||||
export type AdminAgentLineProvisionResult = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
agent_node: {
|
||||
id: number;
|
||||
code: string;
|
||||
site_code: string | null;
|
||||
is_line_root: boolean;
|
||||
};
|
||||
line_root: {
|
||||
agent_node_id: number;
|
||||
site_code: string;
|
||||
is_line_root: boolean;
|
||||
};
|
||||
secrets?: {
|
||||
sso_jwt_secret: string;
|
||||
wallet_api_key: string;
|
||||
};
|
||||
secrets_display_once?: boolean;
|
||||
};
|
||||
@@ -3,10 +3,14 @@ import type { AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-use
|
||||
export type AdminAgentContext = {
|
||||
id: number;
|
||||
admin_site_id: number;
|
||||
/** 主站编号(admin_sites.code),创建玩家时预填 */
|
||||
site_code: string;
|
||||
path: string;
|
||||
code: string;
|
||||
name: string;
|
||||
depth: number;
|
||||
can_create_child_agent: boolean;
|
||||
can_create_player: boolean;
|
||||
};
|
||||
|
||||
export type AgentNodeRow = {
|
||||
@@ -19,6 +23,8 @@ export type AgentNodeRow = {
|
||||
name: string;
|
||||
status: number;
|
||||
is_root: boolean;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
children?: AgentNodeRow[];
|
||||
};
|
||||
|
||||
@@ -27,15 +33,39 @@ export type AgentTreeData = {
|
||||
tree: AgentNodeRow[];
|
||||
};
|
||||
|
||||
export type AgentProfilePayload = {
|
||||
total_share_rate?: number;
|
||||
credit_limit?: number;
|
||||
rebate_limit?: number;
|
||||
default_player_rebate?: number;
|
||||
settlement_cycle?: "daily" | "weekly" | "monthly";
|
||||
can_grant_extra_rebate?: boolean;
|
||||
can_create_child_agent?: boolean;
|
||||
can_create_player?: boolean;
|
||||
};
|
||||
|
||||
export type AgentProfileRow = AgentProfilePayload & {
|
||||
agent_node_id: number;
|
||||
allocated_credit: number;
|
||||
used_credit: number;
|
||||
available_credit: number;
|
||||
};
|
||||
|
||||
export type AgentNodeCreatePayload = {
|
||||
parent_id: number;
|
||||
code: string;
|
||||
code?: string | null;
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string | null;
|
||||
status?: number;
|
||||
};
|
||||
} & AgentProfilePayload;
|
||||
|
||||
export type AgentNodeUpdatePayload = {
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
email?: string | null;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export type AdminPlayerCreatePayload = {
|
||||
nickname?: string | null;
|
||||
default_currency?: string;
|
||||
status?: number;
|
||||
/** 超管可选;未传时后端使用该主站根代理 */
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
export type AdminPlayerUpdatePayload = {
|
||||
|
||||
@@ -6,6 +6,7 @@ export type AdminUserPermissionRow = {
|
||||
nickname: string;
|
||||
email: string | null;
|
||||
status: number;
|
||||
account_kind?: "platform" | "agent";
|
||||
roles: string[];
|
||||
direct_permissions: string[];
|
||||
effective_permissions: string[];
|
||||
@@ -77,6 +78,7 @@ export type AdminUserPermissionSyncData = {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
account_kind?: "platform" | "agent";
|
||||
roles: string[];
|
||||
direct_permissions: string[];
|
||||
effective_permissions: string[];
|
||||
|
||||
Reference in New Issue
Block a user