refactor: update agent API schemas, standardize UI text styling, and enhance settlement credit ledger components
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
|||||||
AgentAdminUserListData,
|
AgentAdminUserListData,
|
||||||
AgentAdminUserRoleSyncPayload,
|
AgentAdminUserRoleSyncPayload,
|
||||||
AgentNodeCreatePayload,
|
AgentNodeCreatePayload,
|
||||||
|
AgentNodeListData,
|
||||||
AgentNodeRow,
|
AgentNodeRow,
|
||||||
AgentNodeUpdatePayload,
|
AgentNodeUpdatePayload,
|
||||||
AgentProfilePayload,
|
AgentProfilePayload,
|
||||||
@@ -26,6 +27,12 @@ export async function getAgentTree(adminSiteId?: number): Promise<AgentTreeData>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAgentNodes(adminSiteId?: number): Promise<AgentNodeListData> {
|
||||||
|
return adminRequest.get<AgentNodeListData>(`${A}/agent-nodes`, {
|
||||||
|
params: adminSiteId ? { admin_site_id: adminSiteId } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function postAgentNode(body: AgentNodeCreatePayload): Promise<AgentNodeRow> {
|
export async function postAgentNode(body: AgentNodeCreatePayload): Promise<AgentNodeRow> {
|
||||||
return adminRequest.post<AgentNodeRow>(`${A}/agent-nodes`, body);
|
return adminRequest.post<AgentNodeRow>(`${A}/agent-nodes`, body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { redirect } from "next/navigation";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console";
|
||||||
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
|
|
||||||
|
export const metadata: Metadata = buildPageMetadata("agents", "directoryTitle");
|
||||||
|
|
||||||
export default function AgentsListPage() {
|
export default function AgentsListPage() {
|
||||||
redirect("/admin/agents");
|
return <AgentsDirectoryConsole />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { AgentsConsole } from "@/modules/agents/agents-console";
|
import { AgentsConsole } from "@/modules/agents/agents-console";
|
||||||
import { PRD_AGENTS_ACCESS_ANY } from "@/lib/admin-prd";
|
import {
|
||||||
|
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||||
|
PRD_AGENTS_ACCESS_ANY,
|
||||||
|
} from "@/lib/admin-prd";
|
||||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
@@ -10,7 +13,9 @@ export const metadata: Metadata = buildPageMetadata("agents", "title");
|
|||||||
export default function AgentsPage() {
|
export default function AgentsPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold embedded>
|
<ModuleScaffold embedded>
|
||||||
<AdminPermissionGate requiredAny={PRD_AGENTS_ACCESS_ANY}>
|
<AdminPermissionGate
|
||||||
|
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
|
||||||
|
>
|
||||||
<AgentsConsole />
|
<AgentsConsole />
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
</ModuleScaffold>
|
</ModuleScaffold>
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { redirect } from "next/navigation";
|
||||||
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 AgentProvisionRedirectPage(): never {
|
||||||
|
redirect("/admin/agents");
|
||||||
export default function AgentLineProvisionPage(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<ModuleScaffold embedded>
|
|
||||||
<AdminPermissionGate
|
|
||||||
requiredAny={PRD_AGENT_LINE_PROVISION_ACCESS_ANY}
|
|
||||||
denyWhenBoundLineAgent
|
|
||||||
>
|
|
||||||
<AgentLineProvisionWizard />
|
|
||||||
</AdminPermissionGate>
|
|
||||||
</ModuleScaffold>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,11 +93,13 @@ export function AdminBreadcrumb() {
|
|||||||
const navItem = navItems
|
const navItem = navItems
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
pathname === item.href ||
|
(item.activeExact === true
|
||||||
pathname.startsWith(`${item.href}/`) ||
|
? pathname === item.href
|
||||||
(item.activeMatchPrefix != null &&
|
: pathname === item.href ||
|
||||||
(pathname === item.activeMatchPrefix ||
|
pathname.startsWith(`${item.href}/`) ||
|
||||||
pathname.startsWith(`${item.activeMatchPrefix}/`))),
|
(item.activeMatchPrefix != null &&
|
||||||
|
(pathname === item.activeMatchPrefix ||
|
||||||
|
pathname.startsWith(`${item.activeMatchPrefix}/`)))),
|
||||||
)
|
)
|
||||||
.sort((a, b) => b.href.length - a.href.length)[0];
|
.sort((a, b) => b.href.length - a.href.length)[0];
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function AdminTableNoResourceRow({
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
return (
|
return (
|
||||||
<TableRow className={className}>
|
<TableRow className={cn("hover:bg-transparent", className)}>
|
||||||
<TableCell colSpan={colSpan} className={cn("text-muted-foreground", cellClassName)}>
|
<TableCell colSpan={colSpan} className={cn("text-muted-foreground", cellClassName)}>
|
||||||
<AdminNoResourceState message={message} compact={compact} />
|
<AdminNoResourceState message={message} compact={compact} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ const SUB_NAV =
|
|||||||
|
|
||||||
function isActive(
|
function isActive(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
item: { href: string; activeMatchPrefix?: string; segment?: string },
|
item: { href: string; activeMatchPrefix?: string; activeExact?: boolean; segment?: string },
|
||||||
): boolean {
|
): boolean {
|
||||||
const { href, activeMatchPrefix, segment } = item;
|
const { href, activeMatchPrefix, activeExact, segment } = item;
|
||||||
|
if (activeExact) {
|
||||||
|
return pathname === href;
|
||||||
|
}
|
||||||
const prefix = activeMatchPrefix ?? href;
|
const prefix = activeMatchPrefix ?? href;
|
||||||
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
|
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
|
||||||
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
|
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
|
||||||
|
|||||||
@@ -54,16 +54,7 @@ export function PlayerLedgerSourceBadge({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceClass =
|
const sourceClass = "border-border bg-muted/30 text-muted-foreground";
|
||||||
ledgerSource === "wallet_txn"
|
|
||||||
? "border-sky-200 bg-sky-50 text-sky-900"
|
|
||||||
: ledgerSource === "payment_record"
|
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-900"
|
|
||||||
: ledgerSource === "settlement_adjustment"
|
|
||||||
? "border-amber-200 bg-amber-50 text-amber-900"
|
|
||||||
: ledgerSource === "share_ledger"
|
|
||||||
? "border-indigo-200 bg-indigo-50 text-indigo-900"
|
|
||||||
: "border-violet-200 bg-violet-50 text-violet-900";
|
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
ledgerSource === "wallet_txn"
|
ledgerSource === "wallet_txn"
|
||||||
|
|||||||
@@ -11,14 +11,17 @@
|
|||||||
"selectAgentHint": "Settlement boundaries follow the agent tree; share, credit and rebate are configured per node.",
|
"selectAgentHint": "Settlement boundaries follow the agent tree; share, credit and rebate are configured per node.",
|
||||||
"allocatedCredit": "Allocated",
|
"allocatedCredit": "Allocated",
|
||||||
"availableCredit": "Available",
|
"availableCredit": "Available",
|
||||||
"profileFootnote": "Rebate cap {{rebate}}% · Default {{defaultRebate}}% · {{cycle}}",
|
"profileFootnote": "Rebate cap {{rebate}}% · Default {{defaultRebate}}%",
|
||||||
"tabOverview": "Overview",
|
"tabOverview": "Overview",
|
||||||
"tabProfile": "Share & credit",
|
"tabProfile": "Share & credit",
|
||||||
"tabProfileReadOnly": "Share & credit (read-only)",
|
"tabProfileReadOnly": "Share & credit (read-only)",
|
||||||
"currentSite": "Site",
|
"currentSite": "Site",
|
||||||
"viewAll": "View all",
|
"viewAll": "View all",
|
||||||
"shareRebateCap": "Rebate cap {{rate}}%",
|
"shareRebateCap": "Rebate cap {{rate}}%",
|
||||||
"overviewDownlineCard": "{{count}} direct downline — manage in the Downline tab.",
|
"overviewDownlineCard": "View and manage direct downline agents",
|
||||||
|
"overviewDownlineCount": "{{count}}",
|
||||||
|
"overviewPlayersHint": "View direct players and credit status",
|
||||||
|
"overviewPlayersSummary": "Player management",
|
||||||
"downlineEmptyTitle": "No direct downline yet",
|
"downlineEmptyTitle": "No direct downline yet",
|
||||||
"editAccount": "Account & status",
|
"editAccount": "Account & status",
|
||||||
"saveProfile": "Save share & credit",
|
"saveProfile": "Save share & credit",
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
},
|
},
|
||||||
"listTitle": "Agents",
|
"listTitle": "Agents",
|
||||||
"listSearch": "Search name / code / login",
|
"listSearch": "Search name / code / login",
|
||||||
|
"siteSearch": "Search site name",
|
||||||
"parentAgent": "Parent",
|
"parentAgent": "Parent",
|
||||||
"childrenCount": "Direct downline",
|
"childrenCount": "Direct downline",
|
||||||
"subnav": {
|
"subnav": {
|
||||||
@@ -189,7 +193,7 @@
|
|||||||
"externalIdHint": "Leave blank to auto-generate",
|
"externalIdHint": "Leave blank to auto-generate",
|
||||||
"creditLimit": "Credit limit",
|
"creditLimit": "Credit limit",
|
||||||
"rebateRate": "Rebate rate (%)",
|
"rebateRate": "Rebate rate (%)",
|
||||||
"rebateRateHint": "Enter percent, e.g. 0.5 = 0.5%",
|
"rebateRateHint": "Enter percent, e.g. 5 = 5%",
|
||||||
"availableToGrant": "Agent available to grant: {{amount}}",
|
"availableToGrant": "Agent available to grant: {{amount}}",
|
||||||
"riskTags": "Risk tags",
|
"riskTags": "Risk tags",
|
||||||
"riskTagsPlaceholder": "Comma-separated",
|
"riskTagsPlaceholder": "Comma-separated",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
"account": "Account settings",
|
"account": "Account settings",
|
||||||
"integration": "Integration",
|
"integration": "Integration",
|
||||||
"agents": "Agent lines",
|
"agents": "Agent lines",
|
||||||
|
"agent_list": "Agent list",
|
||||||
"settlement_center": "Settlement center",
|
"settlement_center": "Settlement center",
|
||||||
"config": "Operations config"
|
"config": "Operations config"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"loadFailed": "Failed to load integration sites",
|
"loadFailed": "Failed to load integration sites",
|
||||||
"saveFailed": "Save failed",
|
"saveFailed": "Save failed",
|
||||||
"createSuccess": "Created site {{code}}",
|
"createSuccess": "Created site {{code}}",
|
||||||
|
"adminAccountCreated": "Created site admin account {{username}}",
|
||||||
"updateSuccess": "Updated site {{code}}",
|
"updateSuccess": "Updated site {{code}}",
|
||||||
"connectivityTest": "Test connectivity",
|
"connectivityTest": "Test connectivity",
|
||||||
"connectivityTitle": "Partner wallet connectivity",
|
"connectivityTitle": "Partner wallet connectivity",
|
||||||
@@ -85,7 +86,10 @@
|
|||||||
"dialogDescription": "Default wallet paths are fine unless the partner uses custom URLs.",
|
"dialogDescription": "Default wallet paths are fine unless the partner uses custom URLs.",
|
||||||
"form": {
|
"form": {
|
||||||
"required": "Site name is required",
|
"required": "Site name is required",
|
||||||
"codeRequired": "site_code is required"
|
"codeRequired": "site_code is required",
|
||||||
|
"adminUsernameRequired": "Site admin username is required",
|
||||||
|
"adminNicknameRequired": "Site admin nickname is required",
|
||||||
|
"adminPasswordRequired": "Initial site admin password must be at least 8 characters"
|
||||||
},
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
"code": "site_code",
|
"code": "site_code",
|
||||||
@@ -97,6 +101,10 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"code": "site_code",
|
"code": "site_code",
|
||||||
"name": "Site name",
|
"name": "Site name",
|
||||||
|
"adminUsername": "Admin username",
|
||||||
|
"adminNickname": "Admin nickname",
|
||||||
|
"adminPassword": "Initial password",
|
||||||
|
"adminEmail": "Email (optional)",
|
||||||
"currency": "Default currency",
|
"currency": "Default currency",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"walletApiUrl": "Partner wallet base URL",
|
"walletApiUrl": "Partner wallet base URL",
|
||||||
@@ -109,13 +117,19 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"code": "Enter site identifier, for example partner-a",
|
"code": "Enter site identifier, for example partner-a",
|
||||||
"name": "Enter site name",
|
"name": "Enter site name",
|
||||||
|
"adminUsername": "Enter admin username",
|
||||||
|
"adminNickname": "Enter account nickname",
|
||||||
|
"adminPassword": "At least 8 characters",
|
||||||
|
"adminEmail": "Enter email",
|
||||||
"currency": "Enter currency code, for example NPR",
|
"currency": "Enter currency code, for example NPR",
|
||||||
"walletApiUrl": "Enter wallet API URL",
|
"walletApiUrl": "Enter wallet API URL",
|
||||||
"lotteryH5BaseUrl": "Enter H5 URL",
|
"lotteryH5BaseUrl": "Enter H5 URL",
|
||||||
"iframeOrigins": "Enter allowed origins, for example https://www.example.com",
|
"iframeOrigins": "Enter allowed origins, for example https://www.example.com",
|
||||||
"notes": "Enter notes",
|
"notes": "Enter notes",
|
||||||
"connectivityPlayerId": "Enter player ID, for example 10001"
|
"connectivityPlayerId": "Enter player ID, for example 10001"
|
||||||
}
|
},
|
||||||
|
"adminAccountSectionTitle": "Site admin account",
|
||||||
|
"adminAccountSectionDescription": "Creating a site will also create one admin account bound to that site."
|
||||||
},
|
},
|
||||||
"versionStatus": {
|
"versionStatus": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|||||||
@@ -11,21 +11,25 @@
|
|||||||
"selectAgentHint": "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
|
"selectAgentHint": "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
|
||||||
"allocatedCredit": "已下发",
|
"allocatedCredit": "已下发",
|
||||||
"availableCredit": "可下发",
|
"availableCredit": "可下发",
|
||||||
"profileFootnote": "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
|
"profileFootnote": "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}%",
|
||||||
"tabOverview": "概览",
|
"tabOverview": "概览",
|
||||||
"currentSite": "当前站点",
|
"currentSite": "当前站点",
|
||||||
"viewAll": "查看全部",
|
"viewAll": "查看全部",
|
||||||
"shareRebateCap": "回水上限 {{rate}}%",
|
"shareRebateCap": "回水上限 {{rate}}%",
|
||||||
"overviewDownlineCard": "{{count}} 个,可在对应 Tab 管理下级代理。",
|
"overviewDownlineCard": "查看并管理直属下级代理",
|
||||||
|
"overviewDownlineCount": "{{count}} 个",
|
||||||
"downlineEmptyTitle": "暂无直属下级",
|
"downlineEmptyTitle": "暂无直属下级",
|
||||||
"tabProfile": "占成与授信",
|
"tabProfile": "占成与授信",
|
||||||
"tabProfileReadOnly": "占成与授信(只读)",
|
"tabProfileReadOnly": "占成与授信(只读)",
|
||||||
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
|
||||||
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
|
||||||
"overviewDownlineHint": "直属下级 {{count}} 个,可在「直属下级」Tab 管理。",
|
"overviewDownlineHint": "直属下级 {{count}} 个,可在「直属下级」Tab 管理。",
|
||||||
"overviewPlayersHint": "直属玩家请在「直属玩家」Tab 维护。",
|
"overviewPlayersHint": "查看直属玩家与授信情况",
|
||||||
|
"overviewPlayersSummary": "玩家管理",
|
||||||
"tabDownline": "直属下级",
|
"tabDownline": "直属下级",
|
||||||
"tabPlayers": "直属玩家",
|
"tabPlayers": "直属玩家",
|
||||||
|
"playersUnavailableHint": "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
|
||||||
|
"playersNoPermissionHint": "当前账号没有该节点的玩家管理权限。",
|
||||||
"downlineColumns": {
|
"downlineColumns": {
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"downlineCount": "下级数"
|
"downlineCount": "下级数"
|
||||||
@@ -41,11 +45,14 @@
|
|||||||
"downlineEmpty": "暂无直属下级。创建下级代理后将在此展示。",
|
"downlineEmpty": "暂无直属下级。创建下级代理后将在此展示。",
|
||||||
"downlineEmptyShort": "暂无直属下级。",
|
"downlineEmptyShort": "暂无直属下级。",
|
||||||
"noDelegatedTabs": "该代理未开放创建下级或玩家。请使用「编辑本代理」维护占成、授信与风控标签。",
|
"noDelegatedTabs": "该代理未开放创建下级或玩家。请使用「编辑本代理」维护占成、授信与风控标签。",
|
||||||
|
"sidebarShareRate": "占成 {{rate}}%",
|
||||||
|
"sidebarAvailableCredit": "可下发 {{amount}}",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"collapse": "收起"
|
"collapse": "收起"
|
||||||
},
|
},
|
||||||
"listTitle": "代理列表",
|
"listTitle": "代理列表",
|
||||||
"listSearch": "搜索代理名称 / 编码 / 登录名",
|
"listSearch": "搜索代理名称 / 编码 / 登录名",
|
||||||
|
"siteSearch": "搜索站点名称",
|
||||||
"parentAgent": "上级代理",
|
"parentAgent": "上级代理",
|
||||||
"childrenCount": "直属下级",
|
"childrenCount": "直属下级",
|
||||||
"subnav": {
|
"subnav": {
|
||||||
@@ -130,6 +137,8 @@
|
|||||||
"profile": {
|
"profile": {
|
||||||
"section": "占成与授信",
|
"section": "占成与授信",
|
||||||
"totalShareRate": "占成比例 (%)",
|
"totalShareRate": "占成比例 (%)",
|
||||||
|
"relativeShareRate": "占成比例(占上级 %)",
|
||||||
|
"relativeShareRateValue": "占上级 {{rate}}%",
|
||||||
"creditLimit": "授信额度",
|
"creditLimit": "授信额度",
|
||||||
"rebateLimit": "回水上限 (%)",
|
"rebateLimit": "回水上限 (%)",
|
||||||
"defaultPlayerRebate": "默认玩家回水 (%)",
|
"defaultPlayerRebate": "默认玩家回水 (%)",
|
||||||
@@ -292,12 +301,15 @@
|
|||||||
"siteCode": "接入站点",
|
"siteCode": "接入站点",
|
||||||
"siteCodePlaceholder": "选择站点",
|
"siteCodePlaceholder": "选择站点",
|
||||||
"siteRequired": "请选择接入站点",
|
"siteRequired": "请选择接入站点",
|
||||||
|
"codeRequired": "请填写代理编码",
|
||||||
|
"codePatternInvalid": "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
|
||||||
"noUnboundSite": "暂无未绑定一级代理的站点",
|
"noUnboundSite": "暂无未绑定一级代理的站点",
|
||||||
"openIntegrationSites": "前往接入站点",
|
"openIntegrationSites": "前往接入站点",
|
||||||
"code": "代理编码",
|
"code": "代理编码",
|
||||||
"name": "一级代理名称",
|
"name": "一级代理名称",
|
||||||
"username": "后台登录账号",
|
"username": "后台登录账号",
|
||||||
"password": "初始密码",
|
"password": "初始密码",
|
||||||
|
"passwordHint": "至少 8 位",
|
||||||
"submit": "创建一级代理",
|
"submit": "创建一级代理",
|
||||||
"success": "一级代理已创建",
|
"success": "一级代理已创建",
|
||||||
"link": "创建一级代理"
|
"link": "创建一级代理"
|
||||||
@@ -305,18 +317,24 @@
|
|||||||
"noAccess": "您没有代理经营相关权限,请联系管理员开通。",
|
"noAccess": "您没有代理经营相关权限,请联系管理员开通。",
|
||||||
"playersPanel": {
|
"playersPanel": {
|
||||||
"create": "创建玩家",
|
"create": "创建玩家",
|
||||||
|
"siteCode": "所属线路",
|
||||||
"scopedTo": "直属玩家:{{agent}}",
|
"scopedTo": "直属玩家:{{agent}}",
|
||||||
"allUnderSite": "当前一级代理线路下可见玩家",
|
"allUnderSite": "当前一级代理线路下可见玩家",
|
||||||
"filterHint": "可按上级代理查看其直属玩家。",
|
"filterHint": "可按上级代理查看其直属玩家。",
|
||||||
"loginRequired": "请填写登录账号与初始密码",
|
"loginRequired": "请填写登录账号与初始密码",
|
||||||
"loginUsername": "登录账号",
|
"loginUsername": "登录账号",
|
||||||
"initialPassword": "初始密码",
|
"initialPassword": "初始密码",
|
||||||
|
"passwordHint": "至少 8 位",
|
||||||
|
"passwordMinLength": "初始密码至少 8 位",
|
||||||
"externalIdOptional": "外部 ID(可选)",
|
"externalIdOptional": "外部 ID(可选)",
|
||||||
"externalIdHint": "留空则系统自动生成",
|
"externalIdHint": "留空则系统自动生成",
|
||||||
"creditLimit": "授信额度",
|
"creditLimit": "授信额度",
|
||||||
"rebateRate": "回水比例 (%)",
|
"rebateRate": "回水比例 (%)",
|
||||||
"rebateRateHint": "填写百分比,如 0.5 表示 0.5%",
|
"rebateRateHint": "填写百分比,如 5 表示 5%",
|
||||||
"availableToGrant": "代理剩余可下发:{{amount}}",
|
"availableToGrant": "代理剩余可下发:{{amount}}",
|
||||||
|
"creditLimitInvalid": "授信额度必须为不小于 0 的整数",
|
||||||
|
"creditLimitExceeded": "授信额度不能超过当前代理可下发额度",
|
||||||
|
"rebateRateInvalid": "回水比例须在 0–100% 之间",
|
||||||
"riskTags": "风控标签",
|
"riskTags": "风控标签",
|
||||||
"riskTagsPlaceholder": "逗号分隔",
|
"riskTagsPlaceholder": "逗号分隔",
|
||||||
"fundingMode": "资金模式",
|
"fundingMode": "资金模式",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
"account": "账号设置",
|
"account": "账号设置",
|
||||||
"integration": "接入配置",
|
"integration": "接入配置",
|
||||||
"agents": "代理线路",
|
"agents": "代理线路",
|
||||||
|
"agent_list": "代理列表",
|
||||||
"settlement_center": "结算中心",
|
"settlement_center": "结算中心",
|
||||||
"config": "运营配置"
|
"config": "运营配置"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"loadFailed": "加载接入站点失败",
|
"loadFailed": "加载接入站点失败",
|
||||||
"saveFailed": "保存失败",
|
"saveFailed": "保存失败",
|
||||||
"createSuccess": "已创建站点 {{code}}",
|
"createSuccess": "已创建站点 {{code}}",
|
||||||
|
"adminAccountCreated": "已同时创建站点后台账号 {{username}}",
|
||||||
"updateSuccess": "已更新站点 {{code}}",
|
"updateSuccess": "已更新站点 {{code}}",
|
||||||
"connectivityTest": "联通检测",
|
"connectivityTest": "联通检测",
|
||||||
"connectivityTitle": "主站钱包联通检测",
|
"connectivityTitle": "主站钱包联通检测",
|
||||||
@@ -85,7 +86,10 @@
|
|||||||
"dialogDescription": "钱包路径使用默认值即可,除非主站 URL 规范不同。",
|
"dialogDescription": "钱包路径使用默认值即可,除非主站 URL 规范不同。",
|
||||||
"form": {
|
"form": {
|
||||||
"required": "请填写站点名称",
|
"required": "请填写站点名称",
|
||||||
"codeRequired": "请填写 site_code"
|
"codeRequired": "请填写 site_code",
|
||||||
|
"adminUsernameRequired": "请填写站点后台登录名",
|
||||||
|
"adminNicknameRequired": "请填写站点后台账号昵称",
|
||||||
|
"adminPasswordRequired": "请填写至少 8 位的站点后台初始密码"
|
||||||
},
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
"code": "site_code",
|
"code": "site_code",
|
||||||
@@ -106,6 +110,10 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"code": "site_code",
|
"code": "site_code",
|
||||||
"name": "站点名称",
|
"name": "站点名称",
|
||||||
|
"adminUsername": "后台登录名",
|
||||||
|
"adminNickname": "账号昵称",
|
||||||
|
"adminPassword": "初始密码",
|
||||||
|
"adminEmail": "邮箱(可选)",
|
||||||
"currency": "默认币种",
|
"currency": "默认币种",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"walletApiUrl": "主站钱包根 URL",
|
"walletApiUrl": "主站钱包根 URL",
|
||||||
@@ -118,13 +126,19 @@
|
|||||||
"placeholders": {
|
"placeholders": {
|
||||||
"code": "请输入站点标识,如 partner-a",
|
"code": "请输入站点标识,如 partner-a",
|
||||||
"name": "请输入站点名称",
|
"name": "请输入站点名称",
|
||||||
|
"adminUsername": "请输入后台登录名",
|
||||||
|
"adminNickname": "请输入账号昵称",
|
||||||
|
"adminPassword": "至少 8 位",
|
||||||
|
"adminEmail": "请输入邮箱",
|
||||||
"currency": "请输入币种代码,如 NPR",
|
"currency": "请输入币种代码,如 NPR",
|
||||||
"walletApiUrl": "请输入钱包接口地址",
|
"walletApiUrl": "请输入钱包接口地址",
|
||||||
"lotteryH5BaseUrl": "请输入 H5 地址",
|
"lotteryH5BaseUrl": "请输入 H5 地址",
|
||||||
"iframeOrigins": "请输入允许的来源地址,如 https://www.example.com",
|
"iframeOrigins": "请输入允许的来源地址,如 https://www.example.com",
|
||||||
"notes": "请输入备注说明",
|
"notes": "请输入备注说明",
|
||||||
"connectivityPlayerId": "请输入玩家 ID,如 10001"
|
"connectivityPlayerId": "请输入玩家 ID,如 10001"
|
||||||
}
|
},
|
||||||
|
"adminAccountSectionTitle": "站点后台管理账号",
|
||||||
|
"adminAccountSectionDescription": "创建站点时将同步创建一个绑定该站点的后台管理账号。"
|
||||||
},
|
},
|
||||||
"versionStatus": {
|
"versionStatus": {
|
||||||
"active": "生效中",
|
"active": "生效中",
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
"reason": "业务类型",
|
"reason": "业务类型",
|
||||||
"ref": "关联",
|
"ref": "关联",
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
"channel": "渠道",
|
"channel": "来源",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"time": "时间"
|
"time": "时间"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
|||||||
settings: "settings",
|
settings: "settings",
|
||||||
integration: "integration",
|
integration: "integration",
|
||||||
agents: "agents",
|
agents: "agents",
|
||||||
|
agent_list: "agent_list",
|
||||||
config: "config",
|
config: "config",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** API / 存储用小数比例(0–1);后台表单统一用百分比(0–100)展示与录入。 */
|
/** API 统一用百分比(0–100);后台表单统一用百分比(0–100)展示与录入。 */
|
||||||
|
|
||||||
const RATIO_TO_PERCENT = 100;
|
const RATIO_TO_PERCENT = 100;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export function percentValueToUi(
|
|||||||
return formatPercentNumber(n, decimals);
|
return formatPercentNumber(n, decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 存库小数 0–1 → 表单百分比,如 0.2 → "20",0.005 → "0.5" */
|
/** @deprecated API 已统一为百分比,不再需要此转换 */
|
||||||
export function ratioToPercentUi(
|
export function ratioToPercentUi(
|
||||||
ratio: number | string | null | undefined,
|
ratio: number | string | null | undefined,
|
||||||
decimals = 2,
|
decimals = 2,
|
||||||
@@ -32,7 +32,7 @@ export function ratioToPercentUi(
|
|||||||
return formatPercentNumber(n * RATIO_TO_PERCENT, decimals);
|
return formatPercentNumber(n * RATIO_TO_PERCENT, decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "0.5" → 0.005 */
|
/** @deprecated API 已统一为百分比,不再需要此转换 */
|
||||||
export function percentUiToRatio(percent: number | string | null | undefined): number {
|
export function percentUiToRatio(percent: number | string | null | undefined): number {
|
||||||
const n = typeof percent === "string" ? Number.parseFloat(percent.trim()) : percent;
|
const n = typeof percent === "string" ? Number.parseFloat(percent.trim()) : percent;
|
||||||
if (n == null || !Number.isFinite(n)) {
|
if (n == null || !Number.isFinite(n)) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
{
|
{
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
agents: Network,
|
agents: Network,
|
||||||
|
agent_list: Users,
|
||||||
players: Users,
|
players: Users,
|
||||||
draws: CalendarClock,
|
draws: CalendarClock,
|
||||||
rules_plays: ClipboardList,
|
rules_plays: ClipboardList,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AdminNavGroup =
|
|||||||
export type AdminNavSegment =
|
export type AdminNavSegment =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "agents"
|
| "agents"
|
||||||
|
| "agent_list"
|
||||||
| "players"
|
| "players"
|
||||||
| "draws"
|
| "draws"
|
||||||
| "rules_plays"
|
| "rules_plays"
|
||||||
@@ -39,5 +40,6 @@ export type AdminNavItem = {
|
|||||||
platform_only?: boolean;
|
platform_only?: boolean;
|
||||||
agent_hidden?: boolean;
|
agent_hidden?: boolean;
|
||||||
activeMatchPrefix?: string;
|
activeMatchPrefix?: string;
|
||||||
|
activeExact?: boolean;
|
||||||
requiredAny?: readonly string[];
|
requiredAny?: readonly string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import {
|
import {
|
||||||
@@ -26,17 +26,16 @@ import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
|
import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
|
||||||
|
|
||||||
function settlementCycleLabel(
|
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
|
||||||
cycle: AgentNodeProfileSummary["settlement_cycle"] | undefined,
|
if (
|
||||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
totalShareRate == null ||
|
||||||
): string {
|
parentShareRate == null ||
|
||||||
if (cycle === "daily") {
|
parentShareRate <= 0
|
||||||
return t("profile.cycleDaily", { defaultValue: "日结" });
|
) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (cycle === "monthly") {
|
|
||||||
return t("profile.cycleMonthly", { defaultValue: "月结" });
|
return percentValueToUi((totalShareRate / parentShareRate) * 100);
|
||||||
}
|
|
||||||
return t("profile.cycleWeekly", { defaultValue: "周结" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
|
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
|
||||||
@@ -57,18 +56,22 @@ export type AgentLineDetailPanelProps = {
|
|||||||
profileReadOnly: boolean;
|
profileReadOnly: boolean;
|
||||||
canViewDownlineTab: boolean;
|
canViewDownlineTab: boolean;
|
||||||
canViewPlayersTab: boolean;
|
canViewPlayersTab: boolean;
|
||||||
|
playersTabHint?: string | null;
|
||||||
canManageNode: boolean;
|
canManageNode: boolean;
|
||||||
canCreateChild: boolean;
|
canCreateChild: boolean;
|
||||||
canCreateChildAgent: boolean;
|
canCreateChildAgent: boolean;
|
||||||
|
canCreatePlayerAction: boolean;
|
||||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||||
onEditChild: (node: AgentNodeRow) => void;
|
onEditChild: (node: AgentNodeRow) => void;
|
||||||
onDeleteChild: (node: AgentNodeRow) => void;
|
onDeleteChild: (node: AgentNodeRow) => void;
|
||||||
onAddChild: () => void;
|
onAddChild: () => void;
|
||||||
|
onAddPlayer: () => void;
|
||||||
onEditCurrent: () => void;
|
onEditCurrent: () => void;
|
||||||
onSelectChild: (node: AgentNodeRow) => void;
|
onSelectChild: (node: AgentNodeRow) => void;
|
||||||
profileFields: AgentProfileFieldsProps | null;
|
profileFields: AgentProfileFieldsProps | null;
|
||||||
profileSaving: boolean;
|
profileSaving: boolean;
|
||||||
onSaveProfile: () => void;
|
onSaveProfile: () => void;
|
||||||
|
playerCreateRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentLineDetailPanel({
|
export function AgentLineDetailPanel({
|
||||||
@@ -87,18 +90,22 @@ export function AgentLineDetailPanel({
|
|||||||
profileReadOnly,
|
profileReadOnly,
|
||||||
canViewDownlineTab,
|
canViewDownlineTab,
|
||||||
canViewPlayersTab,
|
canViewPlayersTab,
|
||||||
|
playersTabHint,
|
||||||
canManageNode,
|
canManageNode,
|
||||||
canCreateChild,
|
canCreateChild,
|
||||||
canCreateChildAgent,
|
canCreateChildAgent,
|
||||||
|
canCreatePlayerAction,
|
||||||
canDeleteChild,
|
canDeleteChild,
|
||||||
onEditChild,
|
onEditChild,
|
||||||
onDeleteChild,
|
onDeleteChild,
|
||||||
onAddChild,
|
onAddChild,
|
||||||
|
onAddPlayer,
|
||||||
onEditCurrent,
|
onEditCurrent,
|
||||||
onSelectChild,
|
onSelectChild,
|
||||||
profileFields,
|
profileFields,
|
||||||
profileSaving,
|
profileSaving,
|
||||||
onSaveProfile,
|
onSaveProfile,
|
||||||
|
playerCreateRequestKey = 0,
|
||||||
}: AgentLineDetailPanelProps): React.ReactElement {
|
}: AgentLineDetailPanelProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
|
||||||
@@ -120,13 +127,6 @@ export function AgentLineDetailPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleLabel =
|
|
||||||
profile?.settlement_cycle === "daily"
|
|
||||||
? t("profile.cycleDaily", { defaultValue: "日结" })
|
|
||||||
: profile?.settlement_cycle === "monthly"
|
|
||||||
? t("profile.cycleMonthly", { defaultValue: "月结" })
|
|
||||||
: t("profile.cycleWeekly", { defaultValue: "周结" });
|
|
||||||
|
|
||||||
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
|
const tabs: { key: AgentDetailTab; label: string; count?: number; visible: boolean }[] = [
|
||||||
{
|
{
|
||||||
key: "overview",
|
key: "overview",
|
||||||
@@ -157,8 +157,6 @@ export function AgentLineDetailPanel({
|
|||||||
siteLabel && siteCode.trim() !== ""
|
siteLabel && siteCode.trim() !== ""
|
||||||
? `${siteLabel} (${siteCode})`
|
? `${siteLabel} (${siteCode})`
|
||||||
: siteLabel ?? siteCode;
|
: siteLabel ?? siteCode;
|
||||||
const codeText = typeof node.code === "string" ? node.code.trim() : "";
|
|
||||||
const usernameText = typeof node.username === "string" ? node.username.trim() : "";
|
|
||||||
const childActionHint = canCreateChild
|
const childActionHint = canCreateChild
|
||||||
? null
|
? null
|
||||||
: canCreateChildAgent
|
: canCreateChildAgent
|
||||||
@@ -168,11 +166,22 @@ export function AgentLineDetailPanel({
|
|||||||
: t("lineUi.addChildNoPermissionHint", {
|
: t("lineUi.addChildNoPermissionHint", {
|
||||||
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
defaultValue: "当前账号没有为该节点创建下级代理的权限。",
|
||||||
});
|
});
|
||||||
|
const playerActionHint =
|
||||||
|
canViewPlayersTab && !canCreatePlayerAction ? playersTabHint ?? null : null;
|
||||||
|
const showPrimaryAction = detailTab === "downline" || detailTab === "players";
|
||||||
|
const primaryActionEnabled =
|
||||||
|
detailTab === "players" ? canCreatePlayerAction : canCreateChild;
|
||||||
|
const primaryActionLabel =
|
||||||
|
detailTab === "players"
|
||||||
|
? t("lineUi.createDirectPlayer", { defaultValue: "创建直属玩家" })
|
||||||
|
: t("createChild", { defaultValue: "添加下级代理" });
|
||||||
|
const primaryActionHint =
|
||||||
|
detailTab === "players" ? playerActionHint : childActionHint;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
<div className="flex min-h-[28rem] min-w-0 flex-1 flex-col bg-background">
|
||||||
<header className="border-b border-border/60 bg-card px-5 py-5 sm:px-6">
|
<header className="border-b border-border/60 bg-card px-5 py-4 sm:px-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
|
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
|
||||||
@@ -184,60 +193,38 @@ export function AgentLineDetailPanel({
|
|||||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</div>
|
</div>
|
||||||
{(codeText !== "" || usernameText !== "" || parentName) ? (
|
{siteDisplay ? (
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<p className="mt-1 truncate text-sm text-muted-foreground" title={siteDisplay}>
|
||||||
{codeText !== "" ? (
|
{siteDisplay}
|
||||||
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono text-xs text-foreground/80">
|
</p>
|
||||||
{t("lineUi.agentCode", { defaultValue: "编码" })} {codeText}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{usernameText !== "" ? (
|
|
||||||
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
|
||||||
{t("lineUi.agentUsername", { defaultValue: "账号" })} {usernameText}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{parentName ? (
|
|
||||||
<span className="rounded-md bg-muted/50 px-2 py-1 text-xs">
|
|
||||||
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||||
{siteDisplay ? (
|
|
||||||
<div
|
|
||||||
className="rounded-lg border border-border/70 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground"
|
|
||||||
title={siteDisplay}
|
|
||||||
>
|
|
||||||
<span className="font-medium text-foreground/90">
|
|
||||||
{t("lineUi.currentSite", { defaultValue: "当前站点" })}
|
|
||||||
</span>
|
|
||||||
<span className="mx-1.5 text-border">|</span>
|
|
||||||
<span className="truncate">{siteDisplay}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{canManageNode ? (
|
{canManageNode ? (
|
||||||
<div className="flex max-w-[28rem] flex-col items-end gap-2">
|
<>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
|
||||||
<Pencil className="mr-1.5 size-3.5" />
|
<Pencil className="mr-1.5 size-3.5" />
|
||||||
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
|
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
|
||||||
</Button>
|
</Button>
|
||||||
{canCreateChild ? (
|
{showPrimaryAction && primaryActionEnabled ? (
|
||||||
<Button type="button" size="sm" onClick={onAddChild}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={detailTab === "players" ? onAddPlayer : onAddChild}
|
||||||
|
>
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
{t("createChild", { defaultValue: "添加下级代理" })}
|
{primaryActionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{childActionHint ? (
|
{primaryActionHint ? (
|
||||||
<p className="text-right text-xs leading-5 text-muted-foreground">
|
<p className="max-w-[26rem] text-right text-xs leading-5 text-muted-foreground">
|
||||||
{childActionHint}
|
{primaryActionHint}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,10 +253,10 @@ export function AgentLineDetailPanel({
|
|||||||
<OverviewTab
|
<OverviewTab
|
||||||
profile={profile}
|
profile={profile}
|
||||||
profileLoading={profileLoading}
|
profileLoading={profileLoading}
|
||||||
cycleLabel={cycleLabel}
|
|
||||||
profileReadOnly={profileReadOnly}
|
profileReadOnly={profileReadOnly}
|
||||||
canViewDownlineTab={canViewDownlineTab}
|
canViewDownlineTab={canViewDownlineTab}
|
||||||
canViewPlayersTab={canViewPlayersTab}
|
canViewPlayersTab={canViewPlayersTab}
|
||||||
|
playersTabHint={playersTabHint}
|
||||||
childCount={childAgents.length}
|
childCount={childAgents.length}
|
||||||
onGoToDownline={() => onDetailTabChange("downline")}
|
onGoToDownline={() => onDetailTabChange("downline")}
|
||||||
onGoToPlayers={() => onDetailTabChange("players")}
|
onGoToPlayers={() => onDetailTabChange("players")}
|
||||||
@@ -319,6 +306,7 @@ export function AgentLineDetailPanel({
|
|||||||
<DownlineTable
|
<DownlineTable
|
||||||
childAgents={childAgents}
|
childAgents={childAgents}
|
||||||
childCountById={childCountById}
|
childCountById={childCountById}
|
||||||
|
parentTotalShareRate={profile?.total_share_rate}
|
||||||
canManageNode={canManageNode}
|
canManageNode={canManageNode}
|
||||||
canCreateChild={canCreateChild}
|
canCreateChild={canCreateChild}
|
||||||
canDeleteChild={canDeleteChild}
|
canDeleteChild={canDeleteChild}
|
||||||
@@ -335,6 +323,7 @@ export function AgentLineDetailPanel({
|
|||||||
agentNodeId={node.id}
|
agentNodeId={node.id}
|
||||||
allowCreatePlayer={profile?.can_create_player === true}
|
allowCreatePlayer={profile?.can_create_player === true}
|
||||||
embedded
|
embedded
|
||||||
|
createRequestKey={playerCreateRequestKey}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -345,20 +334,20 @@ export function AgentLineDetailPanel({
|
|||||||
function OverviewTab({
|
function OverviewTab({
|
||||||
profile,
|
profile,
|
||||||
profileLoading,
|
profileLoading,
|
||||||
cycleLabel,
|
|
||||||
profileReadOnly,
|
profileReadOnly,
|
||||||
canViewDownlineTab,
|
canViewDownlineTab,
|
||||||
canViewPlayersTab,
|
canViewPlayersTab,
|
||||||
|
playersTabHint,
|
||||||
childCount,
|
childCount,
|
||||||
onGoToDownline,
|
onGoToDownline,
|
||||||
onGoToPlayers,
|
onGoToPlayers,
|
||||||
}: {
|
}: {
|
||||||
profile: AgentProfileRow | null;
|
profile: AgentProfileRow | null;
|
||||||
profileLoading: boolean;
|
profileLoading: boolean;
|
||||||
cycleLabel: string;
|
|
||||||
profileReadOnly: boolean;
|
profileReadOnly: boolean;
|
||||||
canViewDownlineTab: boolean;
|
canViewDownlineTab: boolean;
|
||||||
canViewPlayersTab: boolean;
|
canViewPlayersTab: boolean;
|
||||||
|
playersTabHint?: string | null;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
onGoToDownline: () => void;
|
onGoToDownline: () => void;
|
||||||
onGoToPlayers: () => void;
|
onGoToPlayers: () => void;
|
||||||
@@ -367,6 +356,10 @@ function OverviewTab({
|
|||||||
|
|
||||||
const rebateCap =
|
const rebateCap =
|
||||||
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
|
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
|
||||||
|
const parentRelativeShare = relativeShareRate(
|
||||||
|
profile?.total_share_rate,
|
||||||
|
profile?.parent_caps?.total_share_rate,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
@@ -392,12 +385,17 @@ function OverviewTab({
|
|||||||
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
|
||||||
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
|
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
|
||||||
subtitle={
|
subtitle={
|
||||||
rebateCap !== null
|
parentRelativeShare
|
||||||
? t("lineUi.shareRebateCap", {
|
? t("profile.relativeShareRateValue", {
|
||||||
defaultValue: "回水上限 {{rate}}%",
|
defaultValue: "占上级 {{rate}}%",
|
||||||
rate: rebateCap,
|
rate: parentRelativeShare,
|
||||||
})
|
})
|
||||||
: undefined
|
: rebateCap !== null
|
||||||
|
? t("lineUi.shareRebateCap", {
|
||||||
|
defaultValue: "回水上限 {{rate}}%",
|
||||||
|
rate: rebateCap,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
accent
|
accent
|
||||||
/>
|
/>
|
||||||
@@ -418,17 +416,24 @@ function OverviewTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!profileReadOnly && !profileLoading && profile ? (
|
{!profileReadOnly && !profileLoading && profile ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{t("lineUi.profileFootnote", {
|
<MetricCard
|
||||||
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
|
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||||
rebate: percentValueToUi(profile.rebate_limit ?? 0),
|
value={`${percentValueToUi(profile.rebate_limit ?? 0)}%`}
|
||||||
defaultRebate: percentValueToUi(profile.default_player_rebate ?? 0),
|
/>
|
||||||
cycle: cycleLabel,
|
<MetricCard
|
||||||
})}
|
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||||
{(profile.risk_tags?.length ?? 0) > 0
|
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
|
||||||
? ` · ${t("profile.riskTags", { defaultValue: "风控" })}: ${profile.risk_tags?.join(", ")}`
|
/>
|
||||||
: ""}
|
<MetricCard
|
||||||
</p>
|
label={t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||||
|
value={
|
||||||
|
(profile.risk_tags?.length ?? 0) > 0
|
||||||
|
? profile.risk_tags!.join(", ")
|
||||||
|
: t("common:states.none", { defaultValue: "无" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{profileReadOnly ? (
|
{profileReadOnly ? (
|
||||||
@@ -440,14 +445,18 @@ function OverviewTab({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canViewDownlineTab || canViewPlayersTab ? (
|
{canViewDownlineTab || canViewPlayersTab || playersTabHint ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{canViewDownlineTab ? (
|
{canViewDownlineTab ? (
|
||||||
<OverviewLinkCard
|
<OverviewLinkCard
|
||||||
icon={Network}
|
icon={Network}
|
||||||
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
|
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
|
||||||
description={t("lineUi.overviewDownlineCard", {
|
summary={t("lineUi.overviewDownlineCount", {
|
||||||
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
|
defaultValue: "{{count}} 个",
|
||||||
|
count: childCount,
|
||||||
|
})}
|
||||||
|
description={t("lineUi.overviewDownlineHint", {
|
||||||
|
defaultValue: "直属下级 {{count}} 个,可在对应 Tab 管理下级代理。",
|
||||||
count: childCount,
|
count: childCount,
|
||||||
})}
|
})}
|
||||||
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
|
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
|
||||||
@@ -458,6 +467,9 @@ function OverviewTab({
|
|||||||
<OverviewLinkCard
|
<OverviewLinkCard
|
||||||
icon={Users}
|
icon={Users}
|
||||||
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
||||||
|
summary={t("lineUi.overviewPlayersSummary", {
|
||||||
|
defaultValue: "玩家管理",
|
||||||
|
})}
|
||||||
description={t("lineUi.overviewPlayersHint", {
|
description={t("lineUi.overviewPlayersHint", {
|
||||||
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
|
||||||
})}
|
})}
|
||||||
@@ -465,6 +477,21 @@ function OverviewTab({
|
|||||||
onAction={onGoToPlayers}
|
onAction={onGoToPlayers}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!canViewPlayersTab && playersTabHint ? (
|
||||||
|
<Card className="border-border/70 shadow-sm">
|
||||||
|
<CardContent className="flex items-start gap-3 pt-5">
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||||
|
<Users className="size-5" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{playersTabHint}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -474,38 +501,55 @@ function OverviewTab({
|
|||||||
function OverviewLinkCard({
|
function OverviewLinkCard({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
title,
|
title,
|
||||||
|
summary,
|
||||||
description,
|
description,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
onAction,
|
onAction,
|
||||||
}: {
|
}: {
|
||||||
icon: ComponentType<{ className?: string }>;
|
icon: ComponentType<{ className?: string }>;
|
||||||
title: string;
|
title: string;
|
||||||
|
summary: string;
|
||||||
description: string;
|
description: string;
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
onAction: () => void;
|
onAction: () => void;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/70 shadow-sm">
|
<Card
|
||||||
<CardContent className="flex items-start justify-between gap-4 pt-5">
|
className="group relative cursor-pointer overflow-hidden border-border/70 shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||||
<div className="flex min-w-0 gap-3">
|
onClick={onAction}
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/8 text-primary">
|
>
|
||||||
<Icon className="size-5" aria-hidden />
|
<CardContent className="flex flex-col p-5">
|
||||||
</div>
|
<div className="flex items-start justify-between">
|
||||||
<div className="min-w-0">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
|
||||||
<p className="font-medium text-foreground">{title}</p>
|
<Icon className="size-5.5" aria-hidden />
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0 text-primary -mr-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
<ChevronRight className="ml-0.5 size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
|
<p className={cn(
|
||||||
|
"mt-1 font-semibold tracking-tight text-foreground",
|
||||||
|
summary.length > 5 ? "text-xl" : "text-2xl tabular-nums"
|
||||||
|
)}>
|
||||||
|
{summary}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="shrink-0 text-primary hover:text-primary"
|
|
||||||
onClick={onAction}
|
|
||||||
>
|
|
||||||
{actionLabel}
|
|
||||||
<ChevronRight className="ml-0.5 size-3.5" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -514,6 +558,7 @@ function OverviewLinkCard({
|
|||||||
function DownlineTable({
|
function DownlineTable({
|
||||||
childAgents,
|
childAgents,
|
||||||
childCountById,
|
childCountById,
|
||||||
|
parentTotalShareRate,
|
||||||
canManageNode,
|
canManageNode,
|
||||||
canCreateChild,
|
canCreateChild,
|
||||||
canDeleteChild,
|
canDeleteChild,
|
||||||
@@ -524,6 +569,7 @@ function DownlineTable({
|
|||||||
}: {
|
}: {
|
||||||
childAgents: AgentNodeRow[];
|
childAgents: AgentNodeRow[];
|
||||||
childCountById: Map<number, number>;
|
childCountById: Map<number, number>;
|
||||||
|
parentTotalShareRate?: number;
|
||||||
canManageNode: boolean;
|
canManageNode: boolean;
|
||||||
canCreateChild: boolean;
|
canCreateChild: boolean;
|
||||||
canDeleteChild: (node: AgentNodeRow) => boolean;
|
canDeleteChild: (node: AgentNodeRow) => boolean;
|
||||||
@@ -537,122 +583,120 @@ function DownlineTable({
|
|||||||
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
const editChildLabel = t("lineUi.editDownline", { defaultValue: "编辑代理" });
|
||||||
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
const deleteChildLabel = t("lineUi.deleteDownline", { defaultValue: "删除代理" });
|
||||||
|
|
||||||
if (childAgents.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border/70 bg-card px-6 py-16 text-center shadow-sm">
|
|
||||||
<AdminNoResourceState className="py-4">
|
|
||||||
{canManageNode && canCreateChild ? (
|
|
||||||
<Button type="button" className="mt-2" onClick={onAddChild}>
|
|
||||||
<Plus className="mr-1.5 size-4" />
|
|
||||||
{createChildLabel}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</AdminNoResourceState>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||||
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
|
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
|
||||||
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
|
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
|
||||||
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
|
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
|
||||||
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
|
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
|
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
|
||||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
|
||||||
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="whitespace-nowrap">
|
|
||||||
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-center whitespace-nowrap">
|
|
||||||
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
|
|
||||||
{canManageNode ? (
|
|
||||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
|
|
||||||
{t("common:table.actions", { defaultValue: "操作" })}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
) : null}
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
</TableRow>
|
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||||
</TableHeader>
|
</TableHead>
|
||||||
<TableBody>
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{childAgents.map((child) => {
|
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
|
||||||
const summary = child.profile_summary;
|
</TableHead>
|
||||||
return (
|
<TableHead className="text-center whitespace-nowrap">
|
||||||
<TableRow
|
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
|
||||||
key={child.id}
|
</TableHead>
|
||||||
className="cursor-pointer"
|
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
|
||||||
onClick={() => onSelectChild(child)}
|
{canManageNode ? (
|
||||||
>
|
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
<TableCell className="font-mono text-xs">{child.code}</TableCell>
|
{t("common:table.actions", { defaultValue: "操作" })}
|
||||||
<TableCell className="font-medium">{child.name}</TableCell>
|
</TableHead>
|
||||||
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
|
) : null}
|
||||||
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
|
</TableRow>
|
||||||
{child.email ?? "—"}
|
</TableHeader>
|
||||||
</TableCell>
|
<TableBody>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
{childAgents.length === 0 ? (
|
||||||
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
|
<AdminTableNoResourceRow colSpan={canManageNode ? 10 : 9} cellClassName="py-12 text-center" />
|
||||||
</TableCell>
|
) : (
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
childAgents.map((child) => {
|
||||||
{summary ? formatCredit(summary.credit_limit) : "—"}
|
const summary = child.profile_summary;
|
||||||
</TableCell>
|
return (
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableRow
|
||||||
{summary ? formatCredit(summary.allocated_credit) : "—"}
|
key={child.id}
|
||||||
</TableCell>
|
className="cursor-pointer"
|
||||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
onClick={() => onSelectChild(child)}
|
||||||
{summary ? settlementCycleLabel(summary.settlement_cycle, t) : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center tabular-nums text-xs">
|
|
||||||
{childCountById.get(child.id) ?? 0}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
|
|
||||||
{child.status === 1
|
|
||||||
? t("common:status.enabled", { defaultValue: "启用" })
|
|
||||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
|
||||||
</AdminStatusBadge>
|
|
||||||
</TableCell>
|
|
||||||
{canManageNode ? (
|
|
||||||
<TableCell
|
|
||||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
>
|
||||||
<AdminRowActionsMenu
|
<TableCell className="font-mono text-xs">{child.code}</TableCell>
|
||||||
actions={[
|
<TableCell className="font-medium">{child.name}</TableCell>
|
||||||
{
|
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
|
||||||
key: "edit",
|
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
|
||||||
label: editChildLabel,
|
{child.email ?? "—"}
|
||||||
icon: Pencil,
|
</TableCell>
|
||||||
onClick: () => onEditChild(child),
|
<TableCell className="text-right tabular-nums text-xs">
|
||||||
},
|
{summary ? (
|
||||||
{
|
<div className="space-y-0.5">
|
||||||
key: "delete",
|
<div>{`${summary.total_share_rate ?? 0}%`}</div>
|
||||||
label: deleteChildLabel,
|
{parentTotalShareRate && parentTotalShareRate > 0 ? (
|
||||||
icon: Trash2,
|
<div className="text-[11px] text-muted-foreground">
|
||||||
destructive: true,
|
{t("profile.relativeShareRateValue", {
|
||||||
disabled: !canDeleteChild(child),
|
defaultValue: "占上级 {{rate}}%",
|
||||||
onClick: () => onDeleteChild(child),
|
rate: relativeShareRate(
|
||||||
},
|
summary.total_share_rate,
|
||||||
]}
|
parentTotalShareRate,
|
||||||
/>
|
) ?? "0",
|
||||||
</TableCell>
|
})}
|
||||||
) : null}
|
</div>
|
||||||
</TableRow>
|
) : null}
|
||||||
);
|
</div>
|
||||||
})}
|
) : "—"}
|
||||||
</TableBody>
|
</TableCell>
|
||||||
</Table>
|
<TableCell className="text-right tabular-nums text-xs">
|
||||||
</div>
|
{summary ? formatCredit(summary.credit_limit) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums text-xs">
|
||||||
|
{summary ? formatCredit(summary.allocated_credit) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
|
{childCountById.get(child.id) ?? 0}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
|
||||||
|
{child.status === 1
|
||||||
|
? t("common:status.enabled", { defaultValue: "启用" })
|
||||||
|
: t("common:status.disabled", { defaultValue: "停用" })}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
</TableCell>
|
||||||
|
{canManageNode ? (
|
||||||
|
<TableCell
|
||||||
|
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AdminRowActionsMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: editChildLabel,
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => onEditChild(child),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: deleteChildLabel,
|
||||||
|
icon: Trash2,
|
||||||
|
destructive: true,
|
||||||
|
disabled: !canDeleteChild(child),
|
||||||
|
onClick: () => onDeleteChild(child),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,20 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
|
||||||
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
||||||
|
import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line";
|
||||||
|
|
||||||
export function AgentLineProvisionWizard(): React.ReactElement {
|
type AgentLineProvisionWizardProps = {
|
||||||
|
embedded?: boolean;
|
||||||
|
onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AgentLineProvisionWizard({
|
||||||
|
embedded = false,
|
||||||
|
onSuccess,
|
||||||
|
}: AgentLineProvisionWizardProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [sitesLoading, setSitesLoading] = useState(true);
|
const [sitesLoading, setSitesLoading] = useState(true);
|
||||||
@@ -40,7 +48,6 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
credit_limit: "0",
|
credit_limit: "0",
|
||||||
rebate_limit: "0",
|
rebate_limit: "0",
|
||||||
default_player_rebate: "0",
|
default_player_rebate: "0",
|
||||||
settlement_cycle: "weekly" as "daily" | "weekly" | "monthly",
|
|
||||||
can_grant_extra_rebate: false,
|
can_grant_extra_rebate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,32 +70,114 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
|
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!form.code.trim()) {
|
||||||
|
toast.error(t("agents:lineProvision.codeRequired", { defaultValue: "请填写代理编码" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(form.code.trim())) {
|
||||||
|
toast.error(
|
||||||
|
t("agents:lineProvision.codePatternInvalid", {
|
||||||
|
defaultValue: "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.username.trim()) {
|
||||||
|
toast.error(t("agents:usernameRequired", { defaultValue: "请填写登录名" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.password.trim()) {
|
||||||
|
toast.error(t("agents:passwordRequired", { defaultValue: "请填写密码" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form.password.trim().length < 8) {
|
||||||
|
toast.error(t("agents:passwordMinLength", { defaultValue: "密码至少 8 位" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareRate = Number.parseFloat(form.total_share_rate);
|
||||||
|
const creditLimit = Number.parseInt(form.credit_limit, 10);
|
||||||
|
const rebateLimit = Number.parseFloat(form.rebate_limit);
|
||||||
|
const defaultPlayerRebate = Number.parseFloat(form.default_player_rebate);
|
||||||
|
|
||||||
|
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
|
||||||
|
toast.error(
|
||||||
|
t("agents:profile.validation.shareRange", {
|
||||||
|
defaultValue: "占成比例须在 0–100 之间",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(creditLimit) || creditLimit < 0) {
|
||||||
|
toast.error(
|
||||||
|
t("agents:profile.validation.creditInvalid", {
|
||||||
|
defaultValue: "授信额度不能为负数",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(rebateLimit) || rebateLimit < 0 || rebateLimit > 100) {
|
||||||
|
toast.error(
|
||||||
|
t("agents:profile.validation.rebateLimitRange", {
|
||||||
|
defaultValue: "回水上限须在 0–100% 之间",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
Number.isNaN(defaultPlayerRebate) ||
|
||||||
|
defaultPlayerRebate < 0 ||
|
||||||
|
defaultPlayerRebate > 100
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
t("agents:profile.validation.defaultRebateRange", {
|
||||||
|
defaultValue: "默认玩家回水须在 0–100% 之间",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (defaultPlayerRebate > rebateLimit) {
|
||||||
|
toast.error(
|
||||||
|
t("agents:profile.validation.defaultExceedsLimit", {
|
||||||
|
defaultValue: "默认玩家回水不能超过回水上限",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await postAdminAgentLine({
|
const result = await postAdminAgentLine({
|
||||||
site_code: form.site_code.trim().toLowerCase(),
|
site_code: form.site_code.trim().toLowerCase(),
|
||||||
code: form.code.trim().toLowerCase(),
|
code: form.code.trim().toLowerCase(),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
|
total_share_rate: shareRate,
|
||||||
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
|
credit_limit: creditLimit,
|
||||||
rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
|
rebate_limit: rebateLimit,
|
||||||
default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
|
default_player_rebate: defaultPlayerRebate,
|
||||||
settlement_cycle: form.settlement_cycle,
|
|
||||||
can_grant_extra_rebate: form.can_grant_extra_rebate,
|
can_grant_extra_rebate: form.can_grant_extra_rebate,
|
||||||
});
|
});
|
||||||
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
|
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
|
||||||
site_code: "",
|
site_code: "",
|
||||||
code: "",
|
code: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
total_share_rate: "0",
|
||||||
|
credit_limit: "0",
|
||||||
|
rebate_limit: "0",
|
||||||
|
default_player_rebate: "0",
|
||||||
|
can_grant_extra_rebate: false,
|
||||||
}));
|
}));
|
||||||
const data = await getAdminIntegrationSites();
|
const data = await getAdminIntegrationSites();
|
||||||
setSites(data.items);
|
setSites(data.items);
|
||||||
|
await onSuccess?.(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg =
|
const msg =
|
||||||
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
|
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
|
||||||
@@ -98,18 +187,20 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
|
<>
|
||||||
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
|
{!embedded ? (
|
||||||
{t("agents:subnav.provisionHint", {
|
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
|
||||||
defaultValue:
|
{t("agents:subnav.provisionHint", {
|
||||||
|
defaultValue:
|
||||||
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
|
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
|
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
|
||||||
{t("agents:lineProvision.description", {
|
{t("agents:lineProvision.description", {
|
||||||
defaultValue:
|
defaultValue:
|
||||||
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
|
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。",
|
||||||
})}{" "}
|
})}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/admin/config/integration-sites"
|
href="/admin/config/integration-sites"
|
||||||
@@ -190,6 +281,9 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("agents:lineProvision.passwordHint", { defaultValue: "至少 8 位" })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
@@ -224,7 +318,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={form.rebate_limit}
|
value={form.rebate_limit}
|
||||||
placeholder="0.5"
|
placeholder="50"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -236,46 +330,11 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={form.default_player_rebate}
|
value={form.default_player_rebate}
|
||||||
placeholder="0.5"
|
placeholder="50"
|
||||||
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
{(v) =>
|
|
||||||
v === "daily"
|
|
||||||
? t("agents:profile.cycleDaily", { defaultValue: "日结" })
|
|
||||||
: v === "monthly"
|
|
||||||
? t("agents:profile.cycleMonthly", { defaultValue: "月结" })
|
|
||||||
: t("agents:profile.cycleWeekly", { defaultValue: "周结" })
|
|
||||||
}
|
|
||||||
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.can_grant_extra_rebate}
|
checked={form.can_grant_extra_rebate}
|
||||||
@@ -294,6 +353,16 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
|
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <div className="space-y-0">{content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
|
||||||
|
{content}
|
||||||
</AdminPageCard>
|
</AdminPageCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import { ChevronRight, Search } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
||||||
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||||
@@ -66,10 +64,6 @@ function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapSiteRoots(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
|
||||||
return nodes.flatMap((node) => (node.is_root ? (node.children ?? []) : [node]));
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentLineSidebarProps = {
|
export type AgentLineSidebarProps = {
|
||||||
siteLabel: string | null;
|
siteLabel: string | null;
|
||||||
/** API 返回的嵌套树(含 children) */
|
/** API 返回的嵌套树(含 children) */
|
||||||
@@ -110,8 +104,8 @@ function TreeRow({
|
|||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-start gap-0.5 rounded-lg py-1.5 pr-2 transition-colors",
|
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
|
||||||
active ? "bg-primary/12 ring-1 ring-primary/30 shadow-sm" : "hover:bg-background/80",
|
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-background/80",
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: `${6 + indent}px` }}
|
style={{ paddingLeft: `${6 + indent}px` }}
|
||||||
>
|
>
|
||||||
@@ -120,7 +114,7 @@ function TreeRow({
|
|||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
aria-label={expanded ? t("lineUi.collapse", { defaultValue: "收起" }) : t("lineUi.expand", { defaultValue: "展开" })}
|
aria-label={expanded ? t("lineUi.collapse", { defaultValue: "收起" }) : t("lineUi.expand", { defaultValue: "展开" })}
|
||||||
className="mt-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleExpand(node.id);
|
onToggleExpand(node.id);
|
||||||
@@ -132,7 +126,7 @@ function TreeRow({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="mt-1 inline-block size-6 shrink-0" aria-hidden />
|
<span className="mt-0.5 inline-block size-5 shrink-0" aria-hidden />
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -141,18 +135,8 @@ function TreeRow({
|
|||||||
className="min-w-0 flex-1 px-1 py-0.5 text-left"
|
className="min-w-0 flex-1 px-1 py-0.5 text-left"
|
||||||
onClick={() => onSelect(node)}
|
onClick={() => onSelect(node)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="truncate text-sm font-medium leading-5">{node.name}</div>
|
||||||
<span className="truncate text-sm font-medium">{node.name}</span>
|
<p className="truncate text-xs leading-5 text-muted-foreground">
|
||||||
<AdminStatusBadge
|
|
||||||
tone={resolveRoleStatusTone(node.status)}
|
|
||||||
className="shrink-0 px-1.5 py-0 text-[10px]"
|
|
||||||
>
|
|
||||||
{node.status === 1
|
|
||||||
? t("common:status.enabled", { defaultValue: "启用" })
|
|
||||||
: t("common:status.disabled", { defaultValue: "停用" })}
|
|
||||||
</AdminStatusBadge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
|
||||||
{node.username ?? node.code}
|
{node.username ?? node.code}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -192,9 +176,7 @@ export function AgentLineSidebar({
|
|||||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||||
|
|
||||||
const displayForest = useMemo(() => {
|
const displayForest = useMemo(() => {
|
||||||
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
|
return pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
|
||||||
|
|
||||||
return unwrapSiteRoots(pruned);
|
|
||||||
}, [normalizedKeyword, parentNameMap, tree]);
|
}, [normalizedKeyword, parentNameMap, tree]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { formatAdminCreditMajorDecimal } from "@/lib/money";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AgentParentCaps } from "@/types/api/admin-agent";
|
import type { AgentParentCaps } from "@/types/api/admin-agent";
|
||||||
|
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
|
||||||
export type AgentProfileFieldsProps = {
|
export type AgentProfileFieldsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -31,8 +33,6 @@ export type AgentProfileFieldsProps = {
|
|||||||
onRebateLimitChange: (value: string) => void;
|
onRebateLimitChange: (value: string) => void;
|
||||||
defaultRebate: string;
|
defaultRebate: string;
|
||||||
onDefaultRebateChange: (value: string) => void;
|
onDefaultRebateChange: (value: string) => void;
|
||||||
settlementCycle: "daily" | "weekly" | "monthly";
|
|
||||||
onSettlementCycleChange: (value: "daily" | "weekly" | "monthly") => void;
|
|
||||||
extraRebate: boolean;
|
extraRebate: boolean;
|
||||||
onExtraRebateChange: (value: boolean) => void;
|
onExtraRebateChange: (value: boolean) => void;
|
||||||
canCreatePlayer: boolean;
|
canCreatePlayer: boolean;
|
||||||
@@ -62,8 +62,6 @@ export function AgentProfileFields({
|
|||||||
onRebateLimitChange,
|
onRebateLimitChange,
|
||||||
defaultRebate,
|
defaultRebate,
|
||||||
onDefaultRebateChange,
|
onDefaultRebateChange,
|
||||||
settlementCycle,
|
|
||||||
onSettlementCycleChange,
|
|
||||||
extraRebate,
|
extraRebate,
|
||||||
onExtraRebateChange,
|
onExtraRebateChange,
|
||||||
canCreatePlayer,
|
canCreatePlayer,
|
||||||
@@ -81,47 +79,48 @@ export function AgentProfileFields({
|
|||||||
const isCard = variant === "card";
|
const isCard = variant === "card";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
{(parentCaps || availableCredit !== null) && !loading ? (
|
{(parentCaps || availableCredit !== null) && !loading ? (
|
||||||
<div
|
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
|
||||||
className={cn(
|
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
|
||||||
"rounded-lg text-xs text-muted-foreground",
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
isCard ? "border border-border/60 bg-muted/25 px-3 py-2.5 space-y-1" : "space-y-1",
|
{parentCaps ? (
|
||||||
)}
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
>
|
<p className="text-sm font-medium leading-snug">
|
||||||
{parentCaps ? (
|
{t("profile.parentCaps", {
|
||||||
<p>
|
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
|
||||||
{t("profile.parentCaps", {
|
share: parentCaps.total_share_rate,
|
||||||
defaultValue: "上级占成 {{share}}%,可下发额度 {{credit}}",
|
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
||||||
share: parentCaps.total_share_rate,
|
})}
|
||||||
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
|
</p>
|
||||||
})}
|
</div>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
{availableCredit !== null ? (
|
||||||
{availableCredit !== null ? (
|
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
|
||||||
<p>
|
{t("profile.availableCredit", {
|
||||||
{t("profile.availableCredit", {
|
defaultValue: "可下发额度 {{amount}}",
|
||||||
defaultValue: "可下发额度:{{amount}}",
|
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
||||||
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
|
})}
|
||||||
})}
|
</p>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground animate-pulse">
|
||||||
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
|
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-4 sm:grid-cols-2",
|
"grid gap-x-6 gap-y-5 sm:grid-cols-2",
|
||||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-share-rate`}>
|
<Label htmlFor={`${idPrefix}-share-rate`} className="text-muted-foreground">
|
||||||
{parentCaps
|
{parentCaps
|
||||||
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
||||||
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||||
@@ -132,11 +131,12 @@ export function AgentProfileFields({
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
value={shareRate}
|
value={shareRate}
|
||||||
onChange={(e) => onShareRateChange(e.target.value)}
|
onChange={(e) => onShareRateChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{parentCaps && shareRate ? (
|
{parentCaps && shareRate ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("profile.actualShareRate", {
|
{t("profile.actualShareRate", {
|
||||||
defaultValue: "实际占成 {{rate}}%",
|
defaultValue: "实际占成 {{rate}}%",
|
||||||
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
|
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
|
||||||
@@ -145,19 +145,20 @@ export function AgentProfileFields({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-credit-limit`}>
|
<Label htmlFor={`${idPrefix}-credit-limit`} className="text-muted-foreground">
|
||||||
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${idPrefix}-credit-limit`}
|
id={`${idPrefix}-credit-limit`}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
value={creditLimit}
|
value={creditLimit}
|
||||||
onChange={(e) => onCreditLimitChange(e.target.value)}
|
onChange={(e) => onCreditLimitChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-rebate-limit`}>
|
<Label htmlFor={`${idPrefix}-rebate-limit`} className="text-muted-foreground">
|
||||||
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -166,13 +167,14 @@ export function AgentProfileFields({
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
value={rebateLimit}
|
value={rebateLimit}
|
||||||
onChange={(e) => onRebateLimitChange(e.target.value)}
|
onChange={(e) => onRebateLimitChange(e.target.value)}
|
||||||
placeholder="0.5"
|
placeholder="50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-default-rebate`}>
|
<Label htmlFor={`${idPrefix}-default-rebate`} className="text-muted-foreground">
|
||||||
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -181,17 +183,19 @@ export function AgentProfileFields({
|
|||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
step="0.01"
|
step="0.01"
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
value={defaultRebate}
|
value={defaultRebate}
|
||||||
onChange={(e) => onDefaultRebateChange(e.target.value)}
|
onChange={(e) => onDefaultRebateChange(e.target.value)}
|
||||||
placeholder="0.5"
|
placeholder="50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
<div className="space-y-2 sm:col-span-2">
|
||||||
<Label htmlFor={`${idPrefix}-risk-tags`}>
|
<Label htmlFor={`${idPrefix}-risk-tags`} className="text-muted-foreground">
|
||||||
{t("profile.riskTags", { defaultValue: "风控标签" })}
|
{t("profile.riskTags", { defaultValue: "风控标签" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${idPrefix}-risk-tags`}
|
id={`${idPrefix}-risk-tags`}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
value={riskTags}
|
value={riskTags}
|
||||||
onChange={(e) => onRiskTagsChange(e.target.value)}
|
onChange={(e) => onRiskTagsChange(e.target.value)}
|
||||||
placeholder={t("profile.riskTagsPlaceholder", {
|
placeholder={t("profile.riskTagsPlaceholder", {
|
||||||
@@ -199,49 +203,15 @@ export function AgentProfileFields({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 sm:col-span-2">
|
|
||||||
<Label htmlFor={`${idPrefix}-settlement-cycle`}>
|
|
||||||
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={settlementCycle}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onSettlementCycleChange((value as "daily" | "weekly" | "monthly") ?? "weekly")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`${idPrefix}-settlement-cycle`}>
|
|
||||||
<SelectValue>
|
|
||||||
{settlementCycle === "daily"
|
|
||||||
? t("profile.cycleDaily", { defaultValue: "日结" })
|
|
||||||
: settlementCycle === "monthly"
|
|
||||||
? t("profile.cycleMonthly", { defaultValue: "月结" })
|
|
||||||
: t("profile.cycleWeekly", { defaultValue: "周结" })}
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-4 border-t border-border/60 pt-4",
|
"pt-2",
|
||||||
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
fieldDisabled ? "pointer-events-none opacity-50" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isCard ? (
|
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("profile.capabilityHint", {
|
|
||||||
defaultValue:
|
|
||||||
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-1">
|
|
||||||
<SwitchRow
|
<SwitchRow
|
||||||
checked={extraRebate}
|
checked={extraRebate}
|
||||||
onCheckedChange={onExtraRebateChange}
|
onCheckedChange={onExtraRebateChange}
|
||||||
@@ -257,8 +227,17 @@ export function AgentProfileFields({
|
|||||||
onCheckedChange={onCanCreateChildChange}
|
onCheckedChange={onCanCreateChildChange}
|
||||||
disabled={!canCreateChildAgent && !isSuperAdmin}
|
disabled={!canCreateChildAgent && !isSuperAdmin}
|
||||||
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
|
||||||
|
isLast
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!isCard ? (
|
||||||
|
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
|
||||||
|
{t("profile.capabilityHint", {
|
||||||
|
defaultValue:
|
||||||
|
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -269,15 +248,20 @@ function SwitchRow({
|
|||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
label,
|
label,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
isLast = false,
|
||||||
}: {
|
}: {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onCheckedChange: (value: boolean) => void;
|
onCheckedChange: (value: boolean) => void;
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
|
<div className={cn(
|
||||||
<Label className="font-normal">{label}</Label>
|
"flex items-center justify-between gap-4 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30",
|
||||||
|
!isLast && "border-b border-border/50"
|
||||||
|
)}>
|
||||||
|
<Label className={cn("font-medium cursor-pointer", disabled && "opacity-50")} onClick={() => !disabled && onCheckedChange(!checked)}>{label}</Label>
|
||||||
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -40,11 +41,11 @@ import {
|
|||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import {
|
import {
|
||||||
PRD_AGENT_MANAGE,
|
PRD_AGENT_MANAGE,
|
||||||
|
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
|
||||||
PRD_AGENT_PROFILE_MANAGE,
|
PRD_AGENT_PROFILE_MANAGE,
|
||||||
PRD_AGENTS_ACCESS_ANY,
|
PRD_AGENTS_ACCESS_ANY,
|
||||||
PRD_USERS_MANAGE,
|
PRD_USERS_MANAGE,
|
||||||
} from "@/lib/admin-prd";
|
} from "@/lib/admin-prd";
|
||||||
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
|
|
||||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||||
@@ -80,8 +81,10 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
|
|||||||
export function AgentsConsole(): React.ReactElement {
|
export function AgentsConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const tRef = useTranslationRef(["agents", "common"]);
|
const tRef = useTranslationRef(["agents", "common"]);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
|
const boundAgent = profile?.agent ?? null;
|
||||||
|
|
||||||
const isSuperAdmin = profile?.is_super_admin === true;
|
const isSuperAdmin = profile?.is_super_admin === true;
|
||||||
const canManageNode =
|
const canManageNode =
|
||||||
@@ -95,6 +98,10 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
PRD_AGENT_PROFILE_MANAGE,
|
PRD_AGENT_PROFILE_MANAGE,
|
||||||
PRD_AGENT_MANAGE,
|
PRD_AGENT_MANAGE,
|
||||||
]);
|
]);
|
||||||
|
const canProvisionLine =
|
||||||
|
boundAgent === null &&
|
||||||
|
(isSuperAdmin ||
|
||||||
|
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]));
|
||||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||||||
@@ -107,7 +114,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const [profileSaving, setProfileSaving] = useState(false);
|
const [profileSaving, setProfileSaving] = useState(false);
|
||||||
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
|
||||||
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
|
||||||
|
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
|
||||||
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
|
||||||
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
|
||||||
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
const [targetParentId, setTargetParentId] = useState<number | null>(null);
|
||||||
@@ -121,9 +128,6 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const [profileCreditLimit, setProfileCreditLimit] = useState("0");
|
const [profileCreditLimit, setProfileCreditLimit] = useState("0");
|
||||||
const [profileRebateLimit, setProfileRebateLimit] = useState("0");
|
const [profileRebateLimit, setProfileRebateLimit] = useState("0");
|
||||||
const [profileDefaultRebate, setProfileDefaultRebate] = useState("0");
|
const [profileDefaultRebate, setProfileDefaultRebate] = useState("0");
|
||||||
const [profileSettlementCycle, setProfileSettlementCycle] = useState<
|
|
||||||
"daily" | "weekly" | "monthly"
|
|
||||||
>("weekly");
|
|
||||||
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
|
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
|
||||||
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
|
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
|
||||||
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
|
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
|
||||||
@@ -134,7 +138,6 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
|
||||||
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
|
||||||
|
|
||||||
const boundAgent = profile?.agent ?? null;
|
|
||||||
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
/** 登录账号是否可向子代理下放「允许创建下级」 */
|
||||||
const canCreateChildAgent =
|
const canCreateChildAgent =
|
||||||
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
|
||||||
@@ -142,13 +145,13 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
isSuperAdmin ||
|
isSuperAdmin ||
|
||||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||||
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
|
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
|
||||||
|
const selectedNodeIdFromUrl = Number.parseInt(searchParams.get("agent_node_id") ?? "", 10);
|
||||||
|
|
||||||
const resetProfileForm = (mode: "create" | "edit" = "create") => {
|
const resetProfileForm = (mode: "create" | "edit" = "create") => {
|
||||||
setProfileShareRate("0");
|
setProfileShareRate("0");
|
||||||
setProfileCreditLimit("0");
|
setProfileCreditLimit("0");
|
||||||
setProfileRebateLimit("0");
|
setProfileRebateLimit("0");
|
||||||
setProfileDefaultRebate("0");
|
setProfileDefaultRebate("0");
|
||||||
setProfileSettlementCycle("weekly");
|
|
||||||
setProfileExtraRebate(false);
|
setProfileExtraRebate(false);
|
||||||
setProfileCanCreateChild(mode === "create" ? false : false);
|
setProfileCanCreateChild(mode === "create" ? false : false);
|
||||||
setProfileCanCreatePlayer(true);
|
setProfileCanCreatePlayer(true);
|
||||||
@@ -168,7 +171,6 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
||||||
setProfileRebateLimit(percentValueToUi(row.rebate_limit ?? 0));
|
setProfileRebateLimit(percentValueToUi(row.rebate_limit ?? 0));
|
||||||
setProfileDefaultRebate(percentValueToUi(row.default_player_rebate ?? 0));
|
setProfileDefaultRebate(percentValueToUi(row.default_player_rebate ?? 0));
|
||||||
setProfileSettlementCycle(normalizeAgentSettlementCycle(row.settlement_cycle));
|
|
||||||
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
|
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
|
||||||
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
|
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
|
||||||
setProfileCanCreatePlayer(row.can_create_player !== false);
|
setProfileCanCreatePlayer(row.can_create_player !== false);
|
||||||
@@ -183,7 +185,6 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||||
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
|
||||||
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
|
||||||
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
|
|
||||||
can_grant_extra_rebate: profileExtraRebate,
|
can_grant_extra_rebate: profileExtraRebate,
|
||||||
can_create_child_agent: profileCanCreateChild,
|
can_create_child_agent: profileCanCreateChild,
|
||||||
can_create_player: profileCanCreatePlayer,
|
can_create_player: profileCanCreatePlayer,
|
||||||
@@ -239,7 +240,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
|
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
|
||||||
[flatNodes],
|
[flatNodes],
|
||||||
);
|
);
|
||||||
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
|
const visibleAgentRows = flatNodes;
|
||||||
const selectedSiteLabel = useMemo(
|
const selectedSiteLabel = useMemo(
|
||||||
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
|
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
|
||||||
[adminSiteId, siteOptions],
|
[adminSiteId, siteOptions],
|
||||||
@@ -324,6 +325,16 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isInteger(selectedNodeIdFromUrl) || selectedNodeIdFromUrl <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!flatNodes.some((node) => node.id === selectedNodeIdFromUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedNodeId(selectedNodeIdFromUrl);
|
||||||
|
}, [flatNodes, selectedNodeIdFromUrl]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (selectedNode === null) {
|
if (selectedNode === null) {
|
||||||
setSelectedProfile(null);
|
setSelectedProfile(null);
|
||||||
@@ -374,6 +385,26 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
|
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const playersTabHint = useMemo(() => {
|
||||||
|
if (selectedNode === null || selectedProfileLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProfile?.can_create_player !== true) {
|
||||||
|
return t("lineUi.playersUnavailableHint", {
|
||||||
|
defaultValue: "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUsersManagePermission) {
|
||||||
|
return t("lineUi.playersNoPermissionHint", {
|
||||||
|
defaultValue: "当前账号没有该节点的玩家管理权限。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading, t]);
|
||||||
|
|
||||||
const canCreateChildOnSelected = useMemo(
|
const canCreateChildOnSelected = useMemo(
|
||||||
() => canManageNode && selectedProfile?.can_create_child_agent === true,
|
() => canManageNode && selectedProfile?.can_create_child_agent === true,
|
||||||
[canManageNode, selectedProfile?.can_create_child_agent],
|
[canManageNode, selectedProfile?.can_create_child_agent],
|
||||||
@@ -401,15 +432,15 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
if (businessRows.length === 0) {
|
if (visibleAgentRows.length === 0) {
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) {
|
if (selectedNodeId === null || !visibleAgentRows.some((row) => row.id === selectedNodeId)) {
|
||||||
setSelectedNodeId(businessRows[0]?.id ?? null);
|
setSelectedNodeId(visibleAgentRows[0]?.id ?? null);
|
||||||
}
|
}
|
||||||
}, [businessRows, selectedNodeId]);
|
}, [visibleAgentRows, selectedNodeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetailTab("overview");
|
setDetailTab("overview");
|
||||||
@@ -422,10 +453,14 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
}, [detailTab, isOwnAgentNode]);
|
}, [detailTab, isOwnAgentNode]);
|
||||||
|
|
||||||
useAsyncEffect(() => {
|
useAsyncEffect(() => {
|
||||||
|
if (!canViewAgents) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (adminSiteId !== null) {
|
if (adminSiteId !== null) {
|
||||||
void loadTree(adminSiteId);
|
void loadTree(adminSiteId);
|
||||||
}
|
}
|
||||||
}, [adminSiteId, loadTree]);
|
}, [adminSiteId, canViewAgents, loadTree]);
|
||||||
|
|
||||||
const openCreateChildForNode = (node: AgentNodeRow) => {
|
const openCreateChildForNode = (node: AgentNodeRow) => {
|
||||||
setNodeDialogMode("create");
|
setNodeDialogMode("create");
|
||||||
@@ -435,9 +470,10 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
setNodeStatus(1);
|
setNodeStatus(1);
|
||||||
setNodeUsername("");
|
setNodeUsername("");
|
||||||
setNodePassword("");
|
setNodePassword("");
|
||||||
setProfileLoading(false);
|
setProfileLoading(canManageProfile);
|
||||||
setProfileLoaded(true);
|
setProfileLoaded(!canManageProfile);
|
||||||
setEditingNodeNeedsPrimaryAccount(false);
|
setEditingNodeNeedsPrimaryAccount(false);
|
||||||
|
resetProfileForm("create");
|
||||||
setNodeDialogOpen(true);
|
setNodeDialogOpen(true);
|
||||||
if (canManageProfile) {
|
if (canManageProfile) {
|
||||||
void getAgentNodeProfile(node.id)
|
void getAgentNodeProfile(node.id)
|
||||||
@@ -450,15 +486,18 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
available_credit: p.available_credit ?? 0,
|
available_credit: p.available_credit ?? 0,
|
||||||
});
|
});
|
||||||
setProfileAvailableCredit(p.available_credit ?? null);
|
setProfileAvailableCredit(p.available_credit ?? null);
|
||||||
resetProfileForm("create");
|
setProfileLoaded(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setProfileParentCaps(null);
|
setProfileParentCaps(null);
|
||||||
setProfileAvailableCredit(null);
|
setProfileAvailableCredit(null);
|
||||||
resetProfileForm("create");
|
setProfileLoaded(false);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setProfileLoading(false);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resetProfileForm("create");
|
setProfileLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -573,8 +612,6 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
onRebateLimitChange: setProfileRebateLimit,
|
onRebateLimitChange: setProfileRebateLimit,
|
||||||
defaultRebate: profileDefaultRebate,
|
defaultRebate: profileDefaultRebate,
|
||||||
onDefaultRebateChange: setProfileDefaultRebate,
|
onDefaultRebateChange: setProfileDefaultRebate,
|
||||||
settlementCycle: profileSettlementCycle,
|
|
||||||
onSettlementCycleChange: setProfileSettlementCycle,
|
|
||||||
extraRebate: profileExtraRebate,
|
extraRebate: profileExtraRebate,
|
||||||
onExtraRebateChange: setProfileExtraRebate,
|
onExtraRebateChange: setProfileExtraRebate,
|
||||||
canCreatePlayer: profileCanCreatePlayer,
|
canCreatePlayer: profileCanCreatePlayer,
|
||||||
@@ -598,12 +635,11 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
profileParentCaps,
|
profileParentCaps,
|
||||||
profileRebateLimit,
|
profileRebateLimit,
|
||||||
profileRiskTags,
|
profileRiskTags,
|
||||||
profileSettlementCycle,
|
|
||||||
profileShareRate,
|
profileShareRate,
|
||||||
selectedProfileLoading,
|
selectedProfileLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showAgentSidebar = businessRows.length > 1;
|
const showAgentSidebar = visibleAgentRows.length > 0;
|
||||||
|
|
||||||
const openAddAgent = (): void => {
|
const openAddAgent = (): void => {
|
||||||
const parent = selectedNode ?? rootNode;
|
const parent = selectedNode ?? rootNode;
|
||||||
@@ -742,7 +778,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
return null;
|
return null;
|
||||||
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
|
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
|
||||||
|
|
||||||
if (!canViewAgents) {
|
if (!canViewAgents && !canProvisionLine) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("noAccess", { defaultValue: "您没有代理经营相关权限,请联系管理员开通。" })}
|
{t("noAccess", { defaultValue: "您没有代理经营相关权限,请联系管理员开通。" })}
|
||||||
@@ -750,7 +786,7 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading && tree.length === 0) {
|
if (canViewAgents && loading && tree.length === 0) {
|
||||||
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,17 +794,18 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
<div className="flex min-h-[32rem] flex-col gap-0">
|
<div className="flex min-h-[32rem] flex-col gap-0">
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
{err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
{canViewAgents && err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
|
{canViewAgents ? (
|
||||||
{showAgentSidebar ? (
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm lg:flex-row">
|
||||||
|
{showAgentSidebar ? (
|
||||||
<AgentLineSidebar
|
<AgentLineSidebar
|
||||||
siteLabel={selectedSiteLabel}
|
siteLabel={selectedSiteLabel}
|
||||||
tree={tree}
|
tree={tree}
|
||||||
parentNameMap={parentNameMap}
|
parentNameMap={parentNameMap}
|
||||||
selectedId={selectedNodeId}
|
selectedId={selectedNodeId}
|
||||||
keyword={keyword}
|
keyword={keyword}
|
||||||
agentCount={businessRows.length}
|
agentCount={visibleAgentRows.length}
|
||||||
onKeywordChange={(value) => {
|
onKeywordChange={(value) => {
|
||||||
setKeyword(value);
|
setKeyword(value);
|
||||||
}}
|
}}
|
||||||
@@ -798,12 +835,19 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
profileReadOnly={isOwnAgentNode}
|
profileReadOnly={isOwnAgentNode}
|
||||||
canViewDownlineTab={canShowDownlineTab}
|
canViewDownlineTab={canShowDownlineTab}
|
||||||
canViewPlayersTab={canShowPlayersTab}
|
canViewPlayersTab={canShowPlayersTab}
|
||||||
|
playersTabHint={playersTabHint}
|
||||||
canManageNode={canManageNode}
|
canManageNode={canManageNode}
|
||||||
canCreateChild={canCreateChildOnSelected}
|
canCreateChild={canCreateChildOnSelected}
|
||||||
canCreateChildAgent={canCreateChildAgent}
|
canCreateChildAgent={canCreateChildAgent}
|
||||||
|
canCreatePlayerAction={
|
||||||
|
isSuperAdmin ||
|
||||||
|
(selectedProfile?.can_create_player === true &&
|
||||||
|
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
|
||||||
|
}
|
||||||
canDeleteChild={canDeleteNode}
|
canDeleteChild={canDeleteNode}
|
||||||
onEditChild={(node) => openEditForNode(node)}
|
onEditChild={(node) => openEditForNode(node)}
|
||||||
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
|
||||||
|
onAddPlayer={() => setPlayerCreateRequestKey((value) => value + 1)}
|
||||||
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
|
||||||
onDeleteChild={(node) => handleDeleteNode(node)}
|
onDeleteChild={(node) => handleDeleteNode(node)}
|
||||||
onSelectChild={(child) => {
|
onSelectChild={(child) => {
|
||||||
@@ -812,8 +856,16 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
profileFields={inlineProfileFields}
|
profileFields={inlineProfileFields}
|
||||||
profileSaving={profileSaving}
|
profileSaving={profileSaving}
|
||||||
onSaveProfile={() => void saveInlineProfile()}
|
onSaveProfile={() => void saveInlineProfile()}
|
||||||
/>
|
playerCreateRequestKey={playerCreateRequestKey}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-border/70 bg-card px-5 py-8 text-sm text-muted-foreground shadow-sm">
|
||||||
|
{t("lineUi.provisionOnlyHint", {
|
||||||
|
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
@@ -828,96 +880,103 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain px-4 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-5">
|
||||||
<div className="space-y-2">
|
<div className="grid gap-5">
|
||||||
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="agent-name" className="text-muted-foreground">{t("name", { defaultValue: "名称" })}</Label>
|
||||||
id="agent-name"
|
<Input
|
||||||
value={nodeName}
|
id="agent-name"
|
||||||
placeholder={t("namePlaceholder")}
|
value={nodeName}
|
||||||
onChange={(e) => setNodeName(e.target.value)}
|
placeholder={t("namePlaceholder")}
|
||||||
autoComplete="off"
|
onChange={(e) => setNodeName(e.target.value)}
|
||||||
/>
|
autoComplete="off"
|
||||||
</div>
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||||
<Label htmlFor="agent-username">{t("users.username", { defaultValue: "登录名" })}</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="agent-username" className="text-muted-foreground">{t("users.username", { defaultValue: "登录名" })}</Label>
|
||||||
id="agent-username"
|
<Input
|
||||||
value={nodeUsername}
|
id="agent-username"
|
||||||
placeholder={t("usernamePlaceholder")}
|
value={nodeUsername}
|
||||||
onChange={(e) => setNodeUsername(e.target.value)}
|
placeholder={t("usernamePlaceholder")}
|
||||||
autoComplete="off"
|
onChange={(e) => setNodeUsername(e.target.value)}
|
||||||
/>
|
autoComplete="off"
|
||||||
</div>
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="agent-password">
|
<Label htmlFor="agent-password" className="text-muted-foreground">
|
||||||
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
|
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
|
||||||
? t("users.password", { defaultValue: "密码" })
|
? t("users.password", { defaultValue: "密码" })
|
||||||
: t("resetPassword", { defaultValue: "重置密码" })}
|
: t("resetPassword", { defaultValue: "重置密码" })}
|
||||||
</Label>
|
</Label>
|
||||||
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
|
<Input
|
||||||
<p className="text-xs text-muted-foreground">
|
id="agent-password"
|
||||||
{t("bindAccountHint", {
|
type="password"
|
||||||
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
|
value={nodePassword}
|
||||||
})}
|
onChange={(e) => setNodePassword(e.target.value)}
|
||||||
</p>
|
placeholder={
|
||||||
) : null}
|
nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount
|
||||||
<Input
|
? t("passwordOptionalHint")
|
||||||
id="agent-password"
|
: t("passwordPlaceholder")
|
||||||
type="password"
|
}
|
||||||
value={nodePassword}
|
autoComplete="new-password"
|
||||||
onChange={(e) => setNodePassword(e.target.value)}
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
placeholder={
|
/>
|
||||||
nodeDialogMode === "edit" && !editingNodeNeedsPrimaryAccount
|
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
|
||||||
? t("passwordOptionalHint")
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
: t("passwordPlaceholder")
|
{t("bindAccountHint", {
|
||||||
}
|
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
|
||||||
autoComplete="new-password"
|
})}
|
||||||
/>
|
</p>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between rounded-xl border border-border/70 bg-card px-4 py-3 shadow-sm">
|
||||||
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
|
<Label className="font-medium cursor-pointer" onClick={() => setNodeStatus(nodeStatus === 1 ? 0 : 1)}>
|
||||||
<Label>{t("status", { defaultValue: "状态" })}</Label>
|
{t("status", { defaultValue: "状态" })}
|
||||||
</div>
|
</Label>
|
||||||
|
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{canManageProfile &&
|
{canManageProfile &&
|
||||||
(nodeDialogMode === "create" ||
|
(nodeDialogMode === "create" ||
|
||||||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
|
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
|
||||||
<div className="space-y-3 border-t pt-3">
|
<div className="space-y-4 border-t border-border/60 pt-5 mt-1">
|
||||||
<p className="text-sm font-medium">
|
<h3 className="text-sm font-semibold tracking-tight">
|
||||||
{t("profile.section", { defaultValue: "占成与授信" })}
|
{t("profile.section", { defaultValue: "占成与授信配置" })}
|
||||||
</p>
|
</h3>
|
||||||
<AgentProfileFields
|
<AgentProfileFields
|
||||||
loading={profileLoading}
|
loading={profileLoading}
|
||||||
parentCaps={profileParentCaps}
|
parentCaps={profileParentCaps}
|
||||||
availableCredit={profileAvailableCredit}
|
availableCredit={profileAvailableCredit}
|
||||||
canCreateChildAgent={canCreateChildAgent}
|
canCreateChildAgent={canCreateChildAgent}
|
||||||
isSuperAdmin={isSuperAdmin}
|
isSuperAdmin={isSuperAdmin}
|
||||||
shareRate={profileShareRate}
|
shareRate={profileShareRate}
|
||||||
onShareRateChange={setProfileShareRate}
|
onShareRateChange={setProfileShareRate}
|
||||||
creditLimit={profileCreditLimit}
|
creditLimit={profileCreditLimit}
|
||||||
onCreditLimitChange={setProfileCreditLimit}
|
onCreditLimitChange={setProfileCreditLimit}
|
||||||
rebateLimit={profileRebateLimit}
|
rebateLimit={profileRebateLimit}
|
||||||
onRebateLimitChange={setProfileRebateLimit}
|
onRebateLimitChange={setProfileRebateLimit}
|
||||||
defaultRebate={profileDefaultRebate}
|
defaultRebate={profileDefaultRebate}
|
||||||
onDefaultRebateChange={setProfileDefaultRebate}
|
onDefaultRebateChange={setProfileDefaultRebate}
|
||||||
settlementCycle={profileSettlementCycle}
|
extraRebate={profileExtraRebate}
|
||||||
onSettlementCycleChange={setProfileSettlementCycle}
|
onExtraRebateChange={setProfileExtraRebate}
|
||||||
extraRebate={profileExtraRebate}
|
canCreatePlayer={profileCanCreatePlayer}
|
||||||
onExtraRebateChange={setProfileExtraRebate}
|
onCanCreatePlayerChange={setProfileCanCreatePlayer}
|
||||||
canCreatePlayer={profileCanCreatePlayer}
|
canCreateChild={profileCanCreateChild}
|
||||||
onCanCreatePlayerChange={setProfileCanCreatePlayer}
|
onCanCreateChildChange={setProfileCanCreateChild}
|
||||||
canCreateChild={profileCanCreateChild}
|
riskTags={profileRiskTags}
|
||||||
onCanCreateChildChange={setProfileCanCreateChild}
|
onRiskTagsChange={setProfileRiskTags}
|
||||||
riskTags={profileRiskTags}
|
idPrefix="dialog-agent-profile"
|
||||||
onRiskTagsChange={setProfileRiskTags}
|
/>
|
||||||
idPrefix="dialog-agent-profile"
|
</div>
|
||||||
/>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
|
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
|
||||||
|
|||||||
308
src/modules/agents/agents-directory-console.tsx
Normal file
308
src/modules/agents/agents-directory-console.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { RefreshCw, Search } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { getAgentNodes } from "@/api/admin-agents";
|
||||||
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
|
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||||
|
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||||
|
import type { AgentNodeRow } from "@/types/api/admin-agent";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function formatPercent(value: number | null | undefined): string {
|
||||||
|
if (value == null || Number.isNaN(value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Number(value).toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCredit(value: number | null | undefined): string {
|
||||||
|
if (value == null || Number.isNaN(value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {
|
||||||
|
return status === 1
|
||||||
|
? t("statusEnabled", { defaultValue: "启用" })
|
||||||
|
: t("statusDisabled", { defaultValue: "停用" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentsDirectoryConsole(): React.ReactElement {
|
||||||
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
|
const tRef = useTranslationRef(["agents", "common"]);
|
||||||
|
|
||||||
|
const [items, setItems] = useState<AgentNodeRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
|
||||||
|
const [includeRoots, setIncludeRoots] = useState(false);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
const parentNameMap = useMemo(
|
||||||
|
() => new Map(items.map((item) => [item.id, item.name])),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const data = await getAgentNodes();
|
||||||
|
setItems(data.items);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: tRef.current("agents:loadFailed", { defaultValue: "加载代理列表失败" });
|
||||||
|
setErr(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tRef]);
|
||||||
|
|
||||||
|
useAsyncEffect(load, [load, reloadKey]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const normalized = keyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (!includeRoots && item.is_root) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status === "enabled" && item.status !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status === "disabled" && item.status === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!normalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentName = item.parent_id != null ? parentNameMap.get(item.parent_id) ?? "" : "";
|
||||||
|
return [item.name, item.code, item.username ?? "", item.email ?? "", parentName]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalized);
|
||||||
|
});
|
||||||
|
}, [includeRoots, items, keyword, parentNameMap, status]);
|
||||||
|
|
||||||
|
const totalOperatingAgents = useMemo(
|
||||||
|
() => items.filter((item) => !item.is_root).length,
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
const enabledOperatingAgents = useMemo(
|
||||||
|
() => items.filter((item) => !item.is_root && item.status === 1).length,
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalOperatingAgents}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("summary.enabledAgents", { defaultValue: "启用中的经营代理数" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{enabledOperatingAgents}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border/70 bg-card px-4 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("summary.visibleList", { defaultValue: "当前平铺列表条数" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{filteredItems.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminPageCard
|
||||||
|
title={t("listTitle", { defaultValue: "代理列表" })}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReloadKey((value) => value + 1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{t("common:actions.refresh", { defaultValue: "刷新" })}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder={t("listSearch", { defaultValue: "搜索代理名称 / 编码 / 登录名" })}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Select value={status} onValueChange={(value) => setStatus(value as typeof status)}>
|
||||||
|
<SelectTrigger className="h-9 w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">
|
||||||
|
{t("directoryStatus.all", { defaultValue: "全部状态" })}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="enabled">
|
||||||
|
{t("directoryStatus.enabled", { defaultValue: "仅启用" })}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="disabled">
|
||||||
|
{t("directoryStatus.disabled", { defaultValue: "仅停用" })}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Label className="flex h-9 items-center gap-2 rounded-md border border-border/70 px-3 text-sm font-normal">
|
||||||
|
<Checkbox
|
||||||
|
checked={includeRoots}
|
||||||
|
onCheckedChange={(checked) => setIncludeRoots(checked === true)}
|
||||||
|
/>
|
||||||
|
{t("includeRoots", { defaultValue: "包含根节点" })}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err ? (
|
||||||
|
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-[140px]">{t("name", { defaultValue: "名称" })}</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">{t("code", { defaultValue: "编码" })}</TableHead>
|
||||||
|
<TableHead className="w-[90px]">{t("depth", { defaultValue: "层级" })}</TableHead>
|
||||||
|
<TableHead className="w-[90px]">{t("status", { defaultValue: "状态" })}</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">{t("username", { defaultValue: "登录名" })}</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">
|
||||||
|
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">
|
||||||
|
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[130px] text-right">
|
||||||
|
{t("profile.creditLimit", { defaultValue: "授信额度" })}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[130px] text-right">
|
||||||
|
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[110px] text-right">
|
||||||
|
{t("common:actions.title", { defaultValue: "操作" })}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<AdminTableLoadingRow colSpan={11} />
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<AdminTableNoResourceRow colSpan={11} />
|
||||||
|
) : (
|
||||||
|
filteredItems.map((item) => {
|
||||||
|
const parentName =
|
||||||
|
item.parent_id != null ? parentNameMap.get(item.parent_id) ?? "-" : "-";
|
||||||
|
const profile = item.profile_summary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-semibold">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-mono">{item.code}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{item.depth}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.is_root ? (
|
||||||
|
<AdminStatusBadge tone="info">
|
||||||
|
{t("isRoot", { defaultValue: "根节点" })}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
) : (
|
||||||
|
<AdminStatusBadge tone={item.status === 1 ? "success" : "neutral"}>
|
||||||
|
{statusLabel(item.status, t)}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{parentName}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">{item.username ?? "-"}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="tabular-nums">{formatPercent(profile?.total_share_rate)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="tabular-nums">{formatPercent(profile?.rebate_limit)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="tabular-nums">{formatCredit(profile?.credit_limit)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/agents?agent_node_id=${item.id}`}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
|
||||||
|
>
|
||||||
|
{t("common:actions.view", { defaultValue: "查看" })}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</AdminPageCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
import { Eye, Pencil, Plus, ReceiptText, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
|||||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display";
|
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } from "@/lib/admin-rate-percent";
|
import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
|
||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
|
||||||
@@ -136,7 +136,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
|
|||||||
currency: row.default_currency ?? "",
|
currency: row.default_currency ?? "",
|
||||||
status: row.status,
|
status: row.status,
|
||||||
creditLimit: row.credit_limit ?? 0,
|
creditLimit: row.credit_limit ?? 0,
|
||||||
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
|
rebateRate: rebate != null ? percentValueToUi(rebate) : "",
|
||||||
riskTags: (row.risk_tags ?? []).join(", "),
|
riskTags: (row.risk_tags ?? []).join(", "),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,8 @@ type AgentsPlayersPanelProps = {
|
|||||||
allowCreatePlayer?: boolean;
|
allowCreatePlayer?: boolean;
|
||||||
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** 外部触发创建直属玩家的计数器 */
|
||||||
|
createRequestKey?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AgentsPlayersPanel({
|
export function AgentsPlayersPanel({
|
||||||
@@ -156,6 +158,7 @@ export function AgentsPlayersPanel({
|
|||||||
agentNodeId,
|
agentNodeId,
|
||||||
allowCreatePlayer,
|
allowCreatePlayer,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
createRequestKey = 0,
|
||||||
}: AgentsPlayersPanelProps): React.ReactElement {
|
}: AgentsPlayersPanelProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "players", "common"]);
|
const { t } = useTranslation(["agents", "players", "common"]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
@@ -226,6 +229,7 @@ export function AgentsPlayersPanel({
|
|||||||
const [payMethod, setPayMethod] = useState("");
|
const [payMethod, setPayMethod] = useState("");
|
||||||
const [payProof, setPayProof] = useState("");
|
const [payProof, setPayProof] = useState("");
|
||||||
const [badDebtReason, setBadDebtReason] = useState("");
|
const [badDebtReason, setBadDebtReason] = useState("");
|
||||||
|
const lastCreateRequestKeyRef = useRef(createRequestKey);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (siteCode.trim() === "") {
|
if (siteCode.trim() === "") {
|
||||||
@@ -269,6 +273,46 @@ export function AgentsPlayersPanel({
|
|||||||
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (password.trim().length < 8) {
|
||||||
|
toast.error(
|
||||||
|
t("playersPanel.passwordMinLength", { defaultValue: "初始密码至少 8 位" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedCreditLimit =
|
||||||
|
creditLimit.trim() === "" ? 0 : Number.parseInt(creditLimit, 10);
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsedCreditLimit) ||
|
||||||
|
parsedCreditLimit < 0 ||
|
||||||
|
!Number.isInteger(parsedCreditLimit)
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
t("playersPanel.creditLimitInvalid", { defaultValue: "授信额度必须为不小于 0 的整数" }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parentAvailableCredit !== null &&
|
||||||
|
parsedCreditLimit > parentAvailableCredit
|
||||||
|
) {
|
||||||
|
toast.error(
|
||||||
|
t("playersPanel.creditLimitExceeded", {
|
||||||
|
defaultValue: "授信额度不能超过当前代理可下发额度",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedRebateRate = rebateRate.trim() === "" ? null : parsePercentUi(rebateRate);
|
||||||
|
if (rebateRate.trim() !== "" && (parsedRebateRate === null || parsedRebateRate < 0 || parsedRebateRate > 100)) {
|
||||||
|
toast.error(
|
||||||
|
t("playersPanel.rebateRateInvalid", {
|
||||||
|
defaultValue: "回水比例须在 0–100% 之间",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -278,10 +322,9 @@ export function AgentsPlayersPanel({
|
|||||||
password: password,
|
password: password,
|
||||||
nickname: nickname.trim() || null,
|
nickname: nickname.trim() || null,
|
||||||
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
|
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
|
||||||
credit_limit:
|
credit_limit: parsedCreditLimit,
|
||||||
creditLimit.trim() === "" ? 0 : Math.max(0, Number.parseInt(creditLimit, 10) || 0),
|
...(parsedRebateRate !== null
|
||||||
...(rebateRate.trim() !== ""
|
? { rebate_rate: parsedRebateRate }
|
||||||
? { rebate_rate: percentUiToRatio(rebateRate) }
|
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
@@ -306,6 +349,11 @@ export function AgentsPlayersPanel({
|
|||||||
|
|
||||||
function openCreateDialog(): void {
|
function openCreateDialog(): void {
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setNickname("");
|
||||||
|
setCreditLimit("");
|
||||||
|
setRebateRate("");
|
||||||
if (effectiveAgentId !== null) {
|
if (effectiveAgentId !== null) {
|
||||||
void getAgentNodeProfile(effectiveAgentId)
|
void getAgentNodeProfile(effectiveAgentId)
|
||||||
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
|
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
|
||||||
@@ -315,6 +363,16 @@ export function AgentsPlayersPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (createRequestKey === 0 || createRequestKey === lastCreateRequestKeyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastCreateRequestKeyRef.current = createRequestKey;
|
||||||
|
if (canCreatePlayer) {
|
||||||
|
openCreateDialog();
|
||||||
|
}
|
||||||
|
}, [canCreatePlayer, createRequestKey]);
|
||||||
|
|
||||||
const applyEditForm = (row: AdminPlayerRow): void => {
|
const applyEditForm = (row: AdminPlayerRow): void => {
|
||||||
const form = fillEditFormFromPlayer(row);
|
const form = fillEditFormFromPlayer(row);
|
||||||
setEditUsername(form.username);
|
setEditUsername(form.username);
|
||||||
@@ -382,7 +440,7 @@ export function AgentsPlayersPanel({
|
|||||||
}
|
}
|
||||||
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
const prevRebate = resolvePlayerRebateRate(editingPlayer);
|
||||||
const nextPercent = parsePercentUi(editRebateRate);
|
const nextPercent = parsePercentUi(editRebateRate);
|
||||||
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
|
const nextRebate = nextPercent === null ? null : nextPercent;
|
||||||
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
|
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
|
||||||
body.rebate_rate = nextRebate;
|
body.rebate_rate = nextRebate;
|
||||||
}
|
}
|
||||||
@@ -648,13 +706,9 @@ export function AgentsPlayersPanel({
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<div />
|
||||||
{t("playersPanel.creditListHint", {
|
|
||||||
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
{canCreatePlayer ? (
|
{canCreatePlayer && !embedded ? (
|
||||||
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
|
||||||
<Plus className="mr-1.5 size-3.5" />
|
<Plus className="mr-1.5 size-3.5" />
|
||||||
{createPlayerLabel}
|
{createPlayerLabel}
|
||||||
@@ -693,7 +747,7 @@ export function AgentsPlayersPanel({
|
|||||||
{!embedded ? (
|
{!embedded ? (
|
||||||
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
|
||||||
) : null}
|
) : null}
|
||||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
|
<TableHead className="sticky right-0 z-20 w-14 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
{t("common:table.actions", { defaultValue: "操作" })}
|
{t("common:table.actions", { defaultValue: "操作" })}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -750,7 +804,7 @@ export function AgentsPlayersPanel({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
|
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||||
@@ -763,7 +817,7 @@ export function AgentsPlayersPanel({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
) : null}
|
) : null}
|
||||||
<TableCell
|
<TableCell
|
||||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
|
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<AdminRowActionsMenu
|
<AdminRowActionsMenu
|
||||||
@@ -844,83 +898,105 @@ export function AgentsPlayersPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[460px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{createPlayerLabel}</DialogTitle>
|
<DialogTitle>{createPlayerLabel}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
|
<div className="grid gap-5 py-2">
|
||||||
<Input value={siteCode} readOnly disabled />
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted-foreground">{t("playersPanel.siteCode", { defaultValue: "所属线路" })}</Label>
|
||||||
|
<Input value={siteCode} readOnly disabled className="bg-muted/40 text-muted-foreground opacity-100 font-mono" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-player-username" className="text-muted-foreground">
|
||||||
|
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-player-username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-player-password" className="text-muted-foreground">
|
||||||
|
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-player-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
|
{t("playersPanel.passwordHint", { defaultValue: "至少 8 位" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-player-nickname" className="text-muted-foreground">
|
||||||
|
{t("players:nickname", { defaultValue: "昵称" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-player-nickname"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-x-4 gap-y-5 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-player-credit" className="text-muted-foreground">
|
||||||
|
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-player-credit"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={creditLimit}
|
||||||
|
onChange={(e) => setCreditLimit(e.target.value)}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
{parentAvailableCredit !== null ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
|
{t("playersPanel.availableToGrant", {
|
||||||
|
defaultValue: "代理剩余可下发:{{amount}}",
|
||||||
|
amount: formatCredit(parentAvailableCredit),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
|
||||||
|
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="agent-player-rebate"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step="0.01"
|
||||||
|
value={rebateRate}
|
||||||
|
placeholder="0"
|
||||||
|
onChange={(e) => setRebateRate(e.target.value)}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground/80">
|
||||||
|
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-player-username">
|
<DialogFooter className="mt-2">
|
||||||
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-username"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-player-password">
|
|
||||||
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-player-credit">
|
|
||||||
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-credit"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={creditLimit}
|
|
||||||
onChange={(e) => setCreditLimit(e.target.value)}
|
|
||||||
/>
|
|
||||||
{parentAvailableCredit !== null ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("playersPanel.availableToGrant", {
|
|
||||||
defaultValue: "代理剩余可下发:{{amount}}",
|
|
||||||
amount: formatCredit(parentAvailableCredit),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="agent-player-rebate">
|
|
||||||
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="agent-player-rebate"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step="0.01"
|
|
||||||
value={rebateRate}
|
|
||||||
placeholder="0.5"
|
|
||||||
onChange={(e) => setRebateRate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
{t("common:actions.cancel", { defaultValue: "取消" })}
|
{t("common:actions.cancel", { defaultValue: "取消" })}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1174,10 +1250,10 @@ export function AgentsPlayersPanel({
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={editRebateRate}
|
value={editRebateRate}
|
||||||
onChange={(e) => setEditRebateRate(e.target.value)}
|
onChange={(e) => setEditRebateRate(e.target.value)}
|
||||||
placeholder="0.5"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
|
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,52 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
|
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AdminSubnav,
|
|
||||||
AdminSubnavBar,
|
AdminSubnavBar,
|
||||||
AdminSubnavLink,
|
|
||||||
} from "@/components/admin/admin-subnav";
|
} from "@/components/admin/admin-subnav";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||||
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
|
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
const primaryTabs: {
|
|
||||||
href: string;
|
|
||||||
labelKey: string;
|
|
||||||
matchPrefix: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
href: "/admin/agents",
|
|
||||||
labelKey: "subnav.operations",
|
|
||||||
matchPrefix: "/admin/agents",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const provisionTab = {
|
|
||||||
href: "/admin/agents/provision",
|
|
||||||
labelKey: "subnav.provision",
|
|
||||||
matchPrefix: "/admin/agents/provision",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
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"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname === href || pathname.startsWith(`${matchPrefix}/`) || pathname === matchPrefix;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentsSubnav(): React.ReactElement {
|
export function AgentsSubnav(): React.ReactElement {
|
||||||
const { t } = useTranslation("agents");
|
const { t } = useTranslation("agents");
|
||||||
@@ -55,18 +26,14 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
const { sites: siteOptions } = useAdminSiteCodeOptions();
|
||||||
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
|
||||||
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
|
||||||
|
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
||||||
|
const [siteKeyword, setSiteKeyword] = useState("");
|
||||||
|
const deferredKeyword = useDeferredValue(siteKeyword);
|
||||||
|
|
||||||
const canSwitchSite =
|
const canSwitchSite =
|
||||||
profile?.is_super_admin === true ||
|
profile?.is_super_admin === true ||
|
||||||
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
|
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
|
||||||
|
|
||||||
const showProvision = isAgentLineSubnavTabVisible(provisionTab.href, profile);
|
|
||||||
|
|
||||||
const visiblePrimaryTabs = useMemo(
|
|
||||||
() => primaryTabs.filter((tab) => isAgentLineSubnavTabVisible(tab.href, profile)),
|
|
||||||
[profile],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminSiteId !== null || siteOptions.length === 0) {
|
if (adminSiteId !== null || siteOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -80,58 +47,94 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
|
||||||
|
|
||||||
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||||
const selectedSiteLabel = useMemo(() => {
|
const selectedSite = useMemo(() => {
|
||||||
const site = siteOptions.find((item) => item.id === selectSiteId);
|
const site = siteOptions.find((item) => item.id === selectSiteId);
|
||||||
return site ? `${site.name} (${site.code})` : null;
|
return site ?? null;
|
||||||
}, [selectSiteId, siteOptions]);
|
}, [selectSiteId, siteOptions]);
|
||||||
|
|
||||||
if (visiblePrimaryTabs.length === 0 && !showProvision) {
|
const filteredSites = useMemo(() => {
|
||||||
return <></>;
|
const normalized = deferredKeyword.trim().toLowerCase();
|
||||||
}
|
if (normalized === "") {
|
||||||
|
return siteOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized));
|
||||||
|
}, [deferredKeyword, siteOptions]);
|
||||||
|
|
||||||
const siteSelector =
|
const siteSelector =
|
||||||
canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||||
<Select
|
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
||||||
value={String(selectSiteId)}
|
<PopoverTrigger
|
||||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
className={cn(
|
||||||
>
|
buttonVariants({ variant: "outline", size: "lg" }),
|
||||||
<SelectTrigger className="h-9 w-[200px] bg-background">
|
"h-10 w-[240px] justify-between gap-2 bg-background px-3 text-left font-normal",
|
||||||
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
|
)}
|
||||||
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
|
>
|
||||||
</SelectValue>
|
<span className="min-w-0 flex-1 truncate">
|
||||||
</SelectTrigger>
|
{selectedSite?.name ?? t("lineFilter", { defaultValue: "一级代理" })}
|
||||||
<SelectContent>
|
</span>
|
||||||
{siteOptions.map((site) => (
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
<SelectItem key={site.id} value={String(site.id)}>
|
{selectedSite?.code ?? ""}
|
||||||
{site.name} ({site.code})
|
</span>
|
||||||
</SelectItem>
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||||
))}
|
</PopoverTrigger>
|
||||||
</SelectContent>
|
<PopoverContent align="end" className="w-[320px] p-0">
|
||||||
</Select>
|
<div className="border-b border-border/60 p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={siteKeyword}
|
||||||
|
onChange={(event) => setSiteKeyword(event.target.value)}
|
||||||
|
placeholder={t("siteSearch", { defaultValue: "搜索站点名称" })}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-72">
|
||||||
|
<div className="p-2">
|
||||||
|
{filteredSites.map((site) => {
|
||||||
|
const active = site.id === selectSiteId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={site.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
|
||||||
|
active ? "bg-primary/10 text-primary" : "hover:bg-muted/70",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setAdminSiteId(site.id);
|
||||||
|
setSitePickerOpen(false);
|
||||||
|
setSiteKeyword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">{site.name}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{site.code}</div>
|
||||||
|
</div>
|
||||||
|
{active ? <Check className="size-4 shrink-0" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredSites.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("common:states.empty", { defaultValue: "暂无数据" })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminSubnavBar trailing={siteSelector}>
|
<AdminSubnavBar trailing={siteSelector}>
|
||||||
<AdminSubnav aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}>
|
<div className="pb-1">
|
||||||
{visiblePrimaryTabs.map((tab) => (
|
<p className="text-sm font-medium text-foreground">
|
||||||
<AdminSubnavLink
|
{t("title", { defaultValue: "代理管理" })}
|
||||||
key={tab.href}
|
</p>
|
||||||
href={tab.href}
|
</div>
|
||||||
active={isTabActive(pathname, tab.href, tab.matchPrefix)}
|
|
||||||
>
|
|
||||||
{t(tab.labelKey)}
|
|
||||||
</AdminSubnavLink>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{showProvision ? (
|
|
||||||
<AdminSubnavLink
|
|
||||||
href={provisionTab.href}
|
|
||||||
active={isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)}
|
|
||||||
>
|
|
||||||
{t(provisionTab.labelKey)}
|
|
||||||
</AdminSubnavLink>
|
|
||||||
) : null}
|
|
||||||
</AdminSubnav>
|
|
||||||
</AdminSubnavBar>
|
</AdminSubnavBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,14 +659,14 @@ export function OddsConfigDocScreen({
|
|||||||
id="odds-rebate-rate"
|
id="odds-rebate-rate"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-9 font-mono tabular-nums"
|
className="h-9 text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={rebatePercentUi}
|
value={rebatePercentUi}
|
||||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="h-9 w-full max-w-xs justify-center">
|
<ConfigReadonlyValue className="h-9 w-full max-w-xs justify-center text-base font-semibold">
|
||||||
{rebatePercentUi}
|
{rebatePercentUi}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
@@ -703,7 +703,7 @@ export function OddsConfigDocScreen({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="ml-auto h-9 w-full max-w-[9rem] font-mono tabular-nums"
|
className="ml-auto h-9 w-full max-w-[9rem] text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={oddsMultiplierLabel(row.odds_value)}
|
value={oddsMultiplierLabel(row.odds_value)}
|
||||||
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
|
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
|
||||||
@@ -714,7 +714,7 @@ export function OddsConfigDocScreen({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="ml-auto h-9 w-full max-w-[9rem] justify-center">
|
<ConfigReadonlyValue className="ml-auto h-9 w-full max-w-[9rem] justify-center text-base font-semibold">
|
||||||
{oddsMultiplierLabel(row.odds_value)}
|
{oddsMultiplierLabel(row.odds_value)}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)
|
)
|
||||||
@@ -743,7 +743,7 @@ export function OddsConfigDocScreen({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-9 w-full font-mono tabular-nums"
|
className="h-9 w-full text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={oddsMultiplierLabel(row.odds_value)}
|
value={oddsMultiplierLabel(row.odds_value)}
|
||||||
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
|
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
|
||||||
@@ -754,7 +754,7 @@ export function OddsConfigDocScreen({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
<ConfigReadonlyValue className="h-9 w-full justify-center text-base font-semibold">
|
||||||
{oddsMultiplierLabel(row.odds_value)}
|
{oddsMultiplierLabel(row.odds_value)}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)
|
)
|
||||||
@@ -771,14 +771,14 @@ export function OddsConfigDocScreen({
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-9 w-full font-mono tabular-nums"
|
className="h-9 w-full text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={rebatePercentUi}
|
value={rebatePercentUi}
|
||||||
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
|
||||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
<ConfigReadonlyValue className="h-9 w-full justify-center text-base font-semibold">
|
||||||
{rebatePercentUi}
|
{rebatePercentUi}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
@@ -857,10 +857,10 @@ export function OddsConfigDocScreen({
|
|||||||
{publishDiffRows.map((row) => (
|
{publishDiffRows.map((row) => (
|
||||||
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
|
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
|
||||||
<span>{row.label}</span>
|
<span>{row.label}</span>
|
||||||
<span className="text-right font-mono tabular-nums">
|
<span className="text-right text-base font-semibold">
|
||||||
{row.oldValue === null ? "—" : oddsMultiplierLabel(row.oldValue)}
|
{row.oldValue === null ? "—" : oddsMultiplierLabel(row.oldValue)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right font-mono tabular-nums">
|
<span className="text-right text-base font-semibold">
|
||||||
{row.newValue === null ? "—" : oddsMultiplierLabel(row.newValue)}
|
{row.newValue === null ? "—" : oddsMultiplierLabel(row.newValue)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function OddsConfigSummaryPanel({
|
|||||||
return (
|
return (
|
||||||
<div key={scope} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
|
<div key={scope} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
|
||||||
<dt className="text-muted-foreground">{prizeScopeLabel(scope, t)}</dt>
|
<dt className="text-muted-foreground">{prizeScopeLabel(scope, t)}</dt>
|
||||||
<dd className="font-mono text-right tabular-nums text-foreground">
|
<dd className="text-right text-lg font-semibold text-foreground">
|
||||||
{row ? oddsMultiplierLabel(row.odds_value) : "—"}
|
{row ? oddsMultiplierLabel(row.odds_value) : "—"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@ export function OddsConfigSummaryPanel({
|
|||||||
{playRebatePercent ? (
|
{playRebatePercent ? (
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
|
||||||
<dt className="text-muted-foreground">{t("odds.rebateRate")}</dt>
|
<dt className="text-muted-foreground">{t("odds.rebateRate")}</dt>
|
||||||
<dd className="font-mono text-right tabular-nums text-foreground">{playRebatePercent}</dd>
|
<dd className="text-right text-lg font-semibold text-foreground">{playRebatePercent}</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -818,7 +818,7 @@ export function PlayConfigDocScreen() {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
className="mx-auto h-8 w-24 text-center text-sm font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
||||||
placeholder={t("play.placeholders.minBetAmount", { ns: "config" })}
|
placeholder={t("play.placeholders.minBetAmount", { ns: "config" })}
|
||||||
@@ -830,7 +830,7 @@ export function PlayConfigDocScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="justify-center">
|
<ConfigReadonlyValue className="justify-center text-sm font-semibold">
|
||||||
{formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
{formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
@@ -840,7 +840,7 @@ export function PlayConfigDocScreen() {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
className="mx-auto h-8 w-24 text-center text-sm font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
||||||
placeholder={t("play.placeholders.maxBetAmount", { ns: "config" })}
|
placeholder={t("play.placeholders.maxBetAmount", { ns: "config" })}
|
||||||
@@ -852,7 +852,7 @@ export function PlayConfigDocScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="justify-center">
|
<ConfigReadonlyValue className="justify-center text-sm font-semibold">
|
||||||
{formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
{formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -553,14 +553,14 @@ export function RebateConfigDocScreen({
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min={0}
|
min={0}
|
||||||
className="font-mono tabular-nums"
|
className="h-9 text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={p2}
|
value={p2}
|
||||||
placeholder={t("rebate.placeholders.d2", { ns: "config" })}
|
placeholder={t("rebate.placeholders.d2", { ns: "config" })}
|
||||||
onChange={(e) => setP2(e.target.value)}
|
onChange={(e) => setP2(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono>{p2}</ConfigReadonlyValue>
|
<ConfigReadonlyValue className="text-base font-semibold">{p2}</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -570,14 +570,14 @@ export function RebateConfigDocScreen({
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min={0}
|
min={0}
|
||||||
className="font-mono tabular-nums"
|
className="h-9 text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={p3}
|
value={p3}
|
||||||
placeholder={t("rebate.placeholders.d3", { ns: "config" })}
|
placeholder={t("rebate.placeholders.d3", { ns: "config" })}
|
||||||
onChange={(e) => setP3(e.target.value)}
|
onChange={(e) => setP3(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono>{p3}</ConfigReadonlyValue>
|
<ConfigReadonlyValue className="text-base font-semibold">{p3}</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -587,14 +587,14 @@ export function RebateConfigDocScreen({
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min={0}
|
min={0}
|
||||||
className="font-mono tabular-nums"
|
className="h-9 text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={p4}
|
value={p4}
|
||||||
placeholder={t("rebate.placeholders.d4", { ns: "config" })}
|
placeholder={t("rebate.placeholders.d4", { ns: "config" })}
|
||||||
onChange={(e) => setP4(e.target.value)}
|
onChange={(e) => setP4(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono>{p4}</ConfigReadonlyValue>
|
<ConfigReadonlyValue className="text-base font-semibold">{p4}</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMinorToEditableMajor(minor: number, currencyCode: string): string {
|
||||||
|
return formatAdminMinorDecimal(minor, currencyCode).replace(/,/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
export function RiskCapDocScreen() {
|
export function RiskCapDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
const tRef = useTranslationRef(["config", "common"]);
|
const tRef = useTranslationRef(["config", "common"]);
|
||||||
@@ -161,7 +165,7 @@ export function RiskCapDocScreen() {
|
|||||||
setDefaultCapStr("");
|
setDefaultCapStr("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDefaultCapStr(formatAdminMinorDecimal(defaultRow.cap_amount, amountCurrencyCode));
|
setDefaultCapStr(formatMinorToEditableMajor(defaultRow.cap_amount, amountCurrencyCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDetail = useCallback(async (id: number) => {
|
const loadDetail = useCallback(async (id: number) => {
|
||||||
@@ -381,7 +385,12 @@ export function RiskCapDocScreen() {
|
|||||||
() => specialRows.filter(({ row }) => row.draw_id != null),
|
() => specialRows.filter(({ row }) => row.draw_id != null),
|
||||||
[specialRows],
|
[specialRows],
|
||||||
);
|
);
|
||||||
const defaultCapDisplay = defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode);
|
const defaultCapDisplay = detail
|
||||||
|
? formatAdminMinorDecimal(
|
||||||
|
draftRows.find(isDefaultRiskRow)?.cap_amount ?? 0,
|
||||||
|
amountCurrencyCode,
|
||||||
|
)
|
||||||
|
: formatAdminMinorDecimal(0, amountCurrencyCode);
|
||||||
|
|
||||||
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
async function handleDeleteVersion(row: ConfigVersionSummary) {
|
||||||
try {
|
try {
|
||||||
@@ -524,7 +533,7 @@ export function RiskCapDocScreen() {
|
|||||||
].map((card) => (
|
].map((card) => (
|
||||||
<div key={card.key} className="rounded-xl border border-border/60 bg-background p-4 shadow-sm">
|
<div key={card.key} className="rounded-xl border border-border/60 bg-background p-4 shadow-sm">
|
||||||
<p className="text-xs text-muted-foreground">{card.label}</p>
|
<p className="text-xs text-muted-foreground">{card.label}</p>
|
||||||
<p className="mt-1 font-mono text-lg font-semibold tabular-nums">{card.value}</p>
|
<p className="mt-1 text-2xl font-semibold tabular-nums text-foreground">{card.value}</p>
|
||||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">{card.hint}</p>
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">{card.hint}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -539,15 +548,15 @@ export function RiskCapDocScreen() {
|
|||||||
id="default-cap"
|
id="default-cap"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="w-[220px] font-mono tabular-nums"
|
className="h-9 w-[220px] text-base font-semibold"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={defaultCapStr}
|
value={defaultCapStr}
|
||||||
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
|
||||||
onChange={(e) => setDefaultCapStr(e.target.value)}
|
onChange={(e) => setDefaultCapStr(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="w-[220px]">
|
<ConfigReadonlyValue className="h-9 w-[220px] text-base font-semibold">
|
||||||
{defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode)}
|
{defaultCapDisplay}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -679,9 +688,9 @@ export function RiskCapDocScreen() {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-8 font-mono tabular-nums"
|
className="h-8 tabular-nums"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
value={formatMinorToEditableMajor(r.cap_amount, amountCurrencyCode)}
|
||||||
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateRow(idx, {
|
updateRow(idx, {
|
||||||
@@ -691,7 +700,7 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono>
|
<ConfigReadonlyValue>
|
||||||
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -258,13 +258,13 @@ export function RiskCapRuntimePanel() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
||||||
<TableCell className="text-center text-xs tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.locked_amount, currencyCode ?? undefined)}
|
{formatAdminMinorUnits(row.locked_amount, currencyCode ?? undefined)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-xs tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)}
|
{formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-xs tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{row.usage_ratio != null ? `${Math.round(row.usage_ratio * 100)}%` : "—"}
|
{row.usage_ratio != null ? `${Math.round(row.usage_ratio * 100)}%` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-xs">
|
<TableCell className="text-center text-xs">
|
||||||
|
|||||||
@@ -251,7 +251,6 @@ export function AgentDashboardConsole(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-300">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-300">
|
||||||
<span>{t("agent.shareRate", { rate: overview.total_share_rate })}</span>
|
<span>{t("agent.shareRate", { rate: overview.total_share_rate })}</span>
|
||||||
<span>{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}</span>
|
|
||||||
<span>
|
<span>
|
||||||
{overview.latest_bet_at
|
{overview.latest_bet_at
|
||||||
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ function MaskedValueWithCopy({
|
|||||||
type FormState = {
|
type FormState = {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
admin_username: string;
|
||||||
|
admin_nickname: string;
|
||||||
|
admin_password: string;
|
||||||
|
admin_email: string;
|
||||||
currency_code: string;
|
currency_code: string;
|
||||||
status: number;
|
status: number;
|
||||||
wallet_api_url: string;
|
wallet_api_url: string;
|
||||||
@@ -128,6 +132,10 @@ type FormState = {
|
|||||||
const EMPTY_FORM: FormState = {
|
const EMPTY_FORM: FormState = {
|
||||||
code: "",
|
code: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
admin_username: "",
|
||||||
|
admin_nickname: "",
|
||||||
|
admin_password: "",
|
||||||
|
admin_email: "",
|
||||||
currency_code: "NPR",
|
currency_code: "NPR",
|
||||||
status: 1,
|
status: 1,
|
||||||
wallet_api_url: "",
|
wallet_api_url: "",
|
||||||
@@ -155,6 +163,10 @@ function rowToForm(row: AdminIntegrationSiteDetail): FormState {
|
|||||||
return {
|
return {
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
|
admin_username: "",
|
||||||
|
admin_nickname: "",
|
||||||
|
admin_password: "",
|
||||||
|
admin_email: "",
|
||||||
currency_code: row.currency_code,
|
currency_code: row.currency_code,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
wallet_api_url: row.wallet_api_url ?? "",
|
wallet_api_url: row.wallet_api_url ?? "",
|
||||||
@@ -189,7 +201,16 @@ function formToPayload(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (includeCode) {
|
if (includeCode) {
|
||||||
return { code: form.code.trim(), ...base };
|
return {
|
||||||
|
code: form.code.trim(),
|
||||||
|
admin_account: {
|
||||||
|
username: form.admin_username.trim(),
|
||||||
|
nickname: form.admin_nickname.trim(),
|
||||||
|
password: form.admin_password,
|
||||||
|
email: form.admin_email.trim() || null,
|
||||||
|
},
|
||||||
|
...base,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
@@ -303,11 +324,34 @@ export function IntegrationSitesConsole({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "create" && form.admin_username.trim() === "") {
|
||||||
|
toast.error(t("integrationSites.form.adminUsernameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "create" && form.admin_nickname.trim() === "") {
|
||||||
|
toast.error(t("integrationSites.form.adminNicknameRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "create" && form.admin_password.trim().length < 8) {
|
||||||
|
toast.error(t("integrationSites.form.adminPasswordRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
const created = await postAdminIntegrationSite(formToPayload(form, true));
|
const created = await postAdminIntegrationSite(formToPayload(form, true));
|
||||||
toast.success(t("integrationSites.createSuccess", { code: created.code }));
|
toast.success(t("integrationSites.createSuccess", { code: created.code }));
|
||||||
|
if (created.admin_user?.username) {
|
||||||
|
toast.success(
|
||||||
|
t("integrationSites.adminAccountCreated", {
|
||||||
|
username: created.admin_user.username,
|
||||||
|
defaultValue: "已同时创建站点后台账号 {{username}}",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
showSecretsOnce(created);
|
showSecretsOnce(created);
|
||||||
} else if (editingId !== null) {
|
} else if (editingId !== null) {
|
||||||
await putAdminIntegrationSite(editingId, formToPayload(form, false));
|
await putAdminIntegrationSite(editingId, formToPayload(form, false));
|
||||||
@@ -487,7 +531,6 @@ export function IntegrationSitesConsole({
|
|||||||
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
||||||
<TableHead>{t("integrationSites.columns.lineRoot")}</TableHead>
|
<TableHead>{t("integrationSites.columns.lineRoot")}</TableHead>
|
||||||
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
||||||
<TableHead>{t("integrationSites.columns.h5Url")}</TableHead>
|
|
||||||
<TableHead>{t("integrationSites.columns.ssoSecret")}</TableHead>
|
<TableHead>{t("integrationSites.columns.ssoSecret")}</TableHead>
|
||||||
<TableHead>{t("integrationSites.columns.walletApiKey")}</TableHead>
|
<TableHead>{t("integrationSites.columns.walletApiKey")}</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("integrationSites.columns.actions")}</TableHead>
|
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("integrationSites.columns.actions")}</TableHead>
|
||||||
@@ -541,9 +584,6 @@ export function IntegrationSitesConsole({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
|
|
||||||
{row.lottery_h5_base_url ?? "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MaskedValueWithCopy
|
<MaskedValueWithCopy
|
||||||
configured={row.has_sso_secret}
|
configured={row.has_sso_secret}
|
||||||
@@ -641,6 +681,83 @@ export function IntegrationSitesConsole({
|
|||||||
onChange={(e) => updateForm("name", e.target.value)}
|
onChange={(e) => updateForm("name", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{mode === "create" ? (
|
||||||
|
<>
|
||||||
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{t("integrationSites.adminAccountSectionTitle", {
|
||||||
|
defaultValue: "站点后台管理账号",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("integrationSites.adminAccountSectionDescription", {
|
||||||
|
defaultValue: "创建站点时将同步创建一个绑定该站点的后台管理账号。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="is-admin-username">
|
||||||
|
{t("integrationSites.fields.adminUsername", { defaultValue: "后台登录名" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="is-admin-username"
|
||||||
|
value={form.admin_username}
|
||||||
|
placeholder={t("integrationSites.placeholders.adminUsername", {
|
||||||
|
defaultValue: "请输入后台登录名",
|
||||||
|
})}
|
||||||
|
onChange={(e) => updateForm("admin_username", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="is-admin-nickname">
|
||||||
|
{t("integrationSites.fields.adminNickname", { defaultValue: "账号昵称" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="is-admin-nickname"
|
||||||
|
value={form.admin_nickname}
|
||||||
|
placeholder={t("integrationSites.placeholders.adminNickname", {
|
||||||
|
defaultValue: "请输入账号昵称",
|
||||||
|
})}
|
||||||
|
onChange={(e) => updateForm("admin_nickname", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="is-admin-password">
|
||||||
|
{t("integrationSites.fields.adminPassword", { defaultValue: "初始密码" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="is-admin-password"
|
||||||
|
type="password"
|
||||||
|
value={form.admin_password}
|
||||||
|
placeholder={t("integrationSites.placeholders.adminPassword", {
|
||||||
|
defaultValue: "至少 8 位",
|
||||||
|
})}
|
||||||
|
onChange={(e) => updateForm("admin_password", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="is-admin-email">
|
||||||
|
{t("integrationSites.fields.adminEmail", { defaultValue: "邮箱(可选)" })}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="is-admin-email"
|
||||||
|
value={form.admin_email}
|
||||||
|
placeholder={t("integrationSites.placeholders.adminEmail", {
|
||||||
|
defaultValue: "请输入邮箱",
|
||||||
|
})}
|
||||||
|
onChange={(e) => updateForm("admin_email", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
|
<Label htmlFor="is-currency">{t("integrationSites.fields.currency")}</Label>
|
||||||
@@ -673,15 +790,6 @@ export function IntegrationSitesConsole({
|
|||||||
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
|
onChange={(e) => updateForm("wallet_api_url", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="is-h5">{t("integrationSites.fields.lotteryH5BaseUrl")}</Label>
|
|
||||||
<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>
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
|
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -254,34 +254,38 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm"
|
className="space-y-3 rounded-xl border border-border/60 bg-background p-3 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
<h3 className="text-base font-semibold">{p.currency_code}</h3>
|
||||||
<p className="text-muted-foreground text-xs">{t("configTitle")}</p>
|
<p className="text-muted-foreground text-xs">{t("configTitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground font-mono text-xs">{t("displayBalance", { amount: currentAmount })}</p>
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
|
{t("displayBalance", { amount: currentAmount })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||||
<p className="text-muted-foreground text-xs">{t("currentAmount")}</p>
|
<p className="text-muted-foreground text-xs">{t("currentAmount")}</p>
|
||||||
<p className="mt-0.5 font-mono text-base font-semibold">{currentAmount}</p>
|
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
|
||||||
|
{currentAmount}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||||
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
<p className="text-muted-foreground text-xs">{t("status")}</p>
|
||||||
<p className="mt-0.5 text-base font-semibold">
|
<p className="mt-1 text-base font-semibold">
|
||||||
{statusOn ? t("enabled") : t("disabled")}
|
{statusOn ? t("enabled") : t("disabled")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||||
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
<p className="text-muted-foreground text-xs">{t("payoutRate")}</p>
|
||||||
<p className="mt-0.5 font-mono text-base font-semibold">
|
<p className="mt-1 text-lg font-semibold">
|
||||||
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
|
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||||
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
|
<p className="text-muted-foreground text-xs">{t("forceTriggerGap")}</p>
|
||||||
<p className="mt-0.5 font-mono text-base font-semibold">{d.force_trigger_draw_gap}</p>
|
<p className="mt-1 text-lg font-semibold">{d.force_trigger_draw_gap}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -480,7 +484,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
<li key={row.id} className="rounded-md border border-border/60 bg-muted/20 p-2">
|
<li key={row.id} className="rounded-md border border-border/60 bg-muted/20 p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-mono text-xs">{row.adjustment_no}</span>
|
<span className="font-mono text-xs">{row.adjustment_no}</span>
|
||||||
<span className="font-mono text-xs">
|
<span className="text-sm font-semibold">
|
||||||
{row.amount_delta > 0 ? "+" : ""}
|
{row.amount_delta > 0 ? "+" : ""}
|
||||||
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
|
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
|
||||||
</span>
|
</span>
|
||||||
@@ -496,9 +500,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
|||||||
|
|
||||||
<div className="rounded-lg border border-border/60 bg-background p-3">
|
<div className="rounded-lg border border-border/60 bg-background p-3">
|
||||||
<p className="text-muted-foreground text-xs">{t("triggerThreshold")}</p>
|
<p className="text-muted-foreground text-xs">{t("triggerThreshold")}</p>
|
||||||
<p className="mt-0.5 font-mono text-sm">{triggerThreshold}</p>
|
<p className="mt-1 text-lg font-semibold">{triggerThreshold}</p>
|
||||||
<p className="text-muted-foreground mt-2 text-xs">{t("minBetAmount")}</p>
|
<p className="text-muted-foreground mt-2 text-xs">{t("minBetAmount")}</p>
|
||||||
<p className="mt-0.5 font-mono text-sm">{minBetAmount}</p>
|
<p className="mt-1 text-lg font-semibold">{minBetAmount}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
|
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-right text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
|
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
|
||||||
@@ -309,7 +309,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
||||||
<AdminPlayerIdentityCells row={r} />
|
<AdminPlayerIdentityCells row={r} />
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-right text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{riskActionTypeLabel(row.action_type, t)}
|
{riskActionTypeLabel(row.action_type, t)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-sm tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -120,19 +120,19 @@ export function RiskPoolDetailConsole({
|
|||||||
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="rounded-lg border bg-muted/40 p-3">
|
<div className="rounded-lg border bg-muted/40 p-3">
|
||||||
<p className="text-xs text-muted-foreground">{t("totalCap")}</p>
|
<p className="text-xs text-muted-foreground">{t("totalCap")}</p>
|
||||||
<p className="mt-1 font-mono text-sm font-medium tabular-nums">
|
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
|
||||||
{formatAdminMinorUnits(pool.total_cap_amount, currencyCode)}
|
{formatAdminMinorUnits(pool.total_cap_amount, currencyCode)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-muted/40 p-3">
|
<div className="rounded-lg border bg-muted/40 p-3">
|
||||||
<p className="text-xs text-muted-foreground">{t("lockedWorstCase")}</p>
|
<p className="text-xs text-muted-foreground">{t("lockedWorstCase")}</p>
|
||||||
<p className="mt-1 font-mono text-sm font-medium tabular-nums">
|
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
|
||||||
{formatAdminMinorUnits(pool.locked_amount, currencyCode)}
|
{formatAdminMinorUnits(pool.locked_amount, currencyCode)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-muted/40 p-3">
|
<div className="rounded-lg border bg-muted/40 p-3">
|
||||||
<p className="text-xs text-muted-foreground">{t("remainingSellable")}</p>
|
<p className="text-xs text-muted-foreground">{t("remainingSellable")}</p>
|
||||||
<p className="mt-1 font-mono text-sm font-medium tabular-nums">
|
<p className="mt-1 text-2xl font-semibold leading-none tracking-tight">
|
||||||
{formatAdminMinorUnits(pool.remaining_amount, currencyCode)}
|
{formatAdminMinorUnits(pool.remaining_amount, currencyCode)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +178,7 @@ export function RiskPoolDetailConsole({
|
|||||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell>
|
<TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell>
|
||||||
<TableCell className="text-center text-sm tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.amount, currencyCode)}
|
{formatAdminMinorUnits(row.amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -298,13 +298,13 @@ export function RiskPoolsConsole({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
|
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
|
||||||
<TableCell className="text-center text-sm tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
|
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-sm tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
|
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-sm tabular-nums">
|
<TableCell className="text-center text-sm font-semibold">
|
||||||
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
|
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
|
|||||||
@@ -113,22 +113,22 @@ export function DrawSettingsPanel() {
|
|||||||
title={t("system.sections.draw", { ns: "config" })}
|
title={t("system.sections.draw", { ns: "config" })}
|
||||||
description={t("system.sections.drawDescription", { ns: "config" })}
|
description={t("system.sections.drawDescription", { ns: "config" })}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||||
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
|
||||||
<Switch
|
<Label className="font-medium cursor-pointer" onClick={() => updateField("requireManualReview", !draft.requireManualReview)}>{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||||
checked={draft.requireManualReview}
|
<Switch
|
||||||
disabled={!canManage || loading || saving}
|
checked={draft.requireManualReview}
|
||||||
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
disabled={!canManage || loading || saving}
|
||||||
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
aria-label={t("system.fields.manualReview", { ns: "config" })}
|
||||||
/>
|
onCheckedChange={(value) => updateField("requireManualReview", value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-border/60" />
|
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<Label htmlFor="default-currency" className="text-muted-foreground">
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="default-currency" className="text-sm font-medium">
|
|
||||||
{t("system.fields.defaultCurrency", { ns: "config" })}
|
{t("system.fields.defaultCurrency", { ns: "config" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -138,10 +138,11 @@ export function DrawSettingsPanel() {
|
|||||||
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
|
<Label htmlFor="draw-interval-minutes" className="text-muted-foreground">
|
||||||
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -154,10 +155,11 @@ export function DrawSettingsPanel() {
|
|||||||
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
|
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
|
||||||
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
|
<Label htmlFor="draw-betting-window-seconds" className="text-muted-foreground">
|
||||||
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -169,10 +171,11 @@ export function DrawSettingsPanel() {
|
|||||||
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
|
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
|
||||||
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
|
<Label htmlFor="draw-close-before-seconds" className="text-muted-foreground">
|
||||||
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -184,10 +187,11 @@ export function DrawSettingsPanel() {
|
|||||||
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
|
||||||
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
|
<Label htmlFor="draw-buffer-ahead" className="text-muted-foreground">
|
||||||
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -199,10 +203,11 @@ export function DrawSettingsPanel() {
|
|||||||
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
|
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
|
||||||
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
|
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
|
||||||
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
{t("system.fields.cooldownMinutes", { ns: "config" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -214,6 +219,7 @@ export function DrawSettingsPanel() {
|
|||||||
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
|
||||||
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
|
className="h-10 bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,15 +71,17 @@ export function FrontendSettingsPanel() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-4">
|
||||||
<Label className="text-sm font-medium">
|
<div className="space-y-1">
|
||||||
{t("system.fields.playRulesHtml", { ns: "config" })}
|
<Label className="font-medium text-muted-foreground">
|
||||||
</Label>
|
{t("system.fields.playRulesHtml", { ns: "config" })}
|
||||||
<p className="text-xs text-muted-foreground">
|
</Label>
|
||||||
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
<p className="text-xs text-muted-foreground/80">
|
||||||
</p>
|
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Tabs defaultValue="zh" className="w-full">
|
<Tabs defaultValue="zh" className="w-full">
|
||||||
<TabsList className="w-full max-w-md">
|
<TabsList className="w-full max-w-md bg-muted/40">
|
||||||
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
<TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
|
||||||
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
<TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
|
||||||
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
|
||||||
@@ -90,7 +92,7 @@ export function FrontendSettingsPanel() {
|
|||||||
value={draft.playRulesHtmlZh}
|
value={draft.playRulesHtmlZh}
|
||||||
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
|
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
className="min-h-[200px] font-mono text-xs"
|
className="min-h-[200px] font-mono text-xs bg-background/50 transition-colors focus:bg-background"
|
||||||
placeholder="<div>...</div>"
|
placeholder="<div>...</div>"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -100,7 +102,7 @@ export function FrontendSettingsPanel() {
|
|||||||
value={draft.playRulesHtmlEn}
|
value={draft.playRulesHtmlEn}
|
||||||
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
|
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
className="min-h-[200px] font-mono text-xs"
|
className="min-h-[200px] font-mono text-xs bg-background/50 transition-colors focus:bg-background"
|
||||||
placeholder="<div>...</div>"
|
placeholder="<div>...</div>"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -110,7 +112,7 @@ export function FrontendSettingsPanel() {
|
|||||||
value={draft.playRulesHtmlNe}
|
value={draft.playRulesHtmlNe}
|
||||||
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
|
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
|
||||||
disabled={!canManage || loading || saving}
|
disabled={!canManage || loading || saving}
|
||||||
className="min-h-[200px] font-mono text-xs"
|
className="min-h-[200px] font-mono text-xs bg-background/50 transition-colors focus:bg-background"
|
||||||
placeholder="<div>...</div>"
|
placeholder="<div>...</div>"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -82,53 +82,49 @@ export function SettlementSettingsPanel() {
|
|||||||
description={t("system.sections.settlementDescription", { ns: "config" })}
|
description={t("system.sections.settlementDescription", { ns: "config" })}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
|
||||||
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
||||||
<Switch
|
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoSettlement", !draft.autoSettlement)}>{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||||
checked={draft.autoSettlement}
|
<Switch
|
||||||
disabled={loading || saving}
|
checked={draft.autoSettlement}
|
||||||
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
disabled={loading || saving}
|
||||||
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
aria-label={t("system.fields.autoSettlement", { ns: "config" })}
|
||||||
/>
|
onCheckedChange={(value) => updateField("autoSettlement", value)}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
<div className="h-px bg-border/60" />
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoApprove", !draft.autoApprove)}>{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
||||||
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { ns: "config" })}</Label>
|
<Switch
|
||||||
<Switch
|
checked={draft.autoApprove}
|
||||||
checked={draft.autoApprove}
|
disabled={loading || saving}
|
||||||
disabled={loading || saving}
|
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
||||||
aria-label={t("system.fields.autoApprove", { ns: "config" })}
|
onCheckedChange={(value) => updateField("autoApprove", value)}
|
||||||
onCheckedChange={(value) => updateField("autoApprove", value)}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30 border-b border-border/50">
|
||||||
<div className="h-px bg-border/60" />
|
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoPayout", !draft.autoPayout)}>{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
||||||
|
<Switch
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
checked={draft.autoPayout}
|
||||||
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { ns: "config" })}</Label>
|
disabled={loading || saving}
|
||||||
<Switch
|
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
||||||
checked={draft.autoPayout}
|
onCheckedChange={(value) => updateField("autoPayout", value)}
|
||||||
disabled={loading || saving}
|
/>
|
||||||
aria-label={t("system.fields.autoPayout", { ns: "config" })}
|
</div>
|
||||||
onCheckedChange={(value) => updateField("autoPayout", value)}
|
|
||||||
/>
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 bg-background/50 transition-colors hover:bg-muted/30">
|
||||||
</div>
|
<div className="min-w-0 space-y-1 pr-4 cursor-pointer" onClick={() => updateField("applyRebateToPayout", !draft.applyRebateToPayout)}>
|
||||||
|
<Label className="font-medium cursor-pointer">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
||||||
<div className="h-px bg-border/60" />
|
<p className="text-[11px] text-muted-foreground/80">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Switch
|
||||||
<div className="min-w-0 space-y-1 pr-4">
|
checked={draft.applyRebateToPayout}
|
||||||
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
|
disabled={loading || saving}
|
||||||
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
|
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
||||||
|
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
|
||||||
checked={draft.applyRebateToPayout}
|
|
||||||
disabled={loading || saving}
|
|
||||||
aria-label={t("system.fields.applyRebateToPayout", { ns: "config" })}
|
|
||||||
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSectionActions
|
<SettingsSectionActions
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function AgentBillDetail({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
|
<div className="grid gap-6 md:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
|
||||||
<div className="space-y-5 text-sm">
|
<div className="space-y-5 text-sm">
|
||||||
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
|
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
|
||||||
<SettlementBillPartiesRow bill={bill} />
|
<SettlementBillPartiesRow bill={bill} />
|
||||||
@@ -294,22 +294,22 @@ export function AgentBillDetail({
|
|||||||
|
|
||||||
<div className="space-y-5 text-sm">
|
<div className="space-y-5 text-sm">
|
||||||
{rebateAllocations.length > 0 ? (
|
{rebateAllocations.length > 0 ? (
|
||||||
<div className="space-y-2 rounded-xl border border-border/70 p-4">
|
<div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||||
<p className="font-medium">
|
<p className="font-semibold tracking-tight">
|
||||||
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
|
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
|
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
|
||||||
defaultValue: "各层级代理对回水的承担明细。",
|
defaultValue: "各层级代理对回水的承担明细。",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<ul className="space-y-1.5 text-muted-foreground">
|
<ul className="space-y-1.5 text-muted-foreground">
|
||||||
{rebateAllocationSummary.map((row) => (
|
{rebateAllocationSummary.map((row) => (
|
||||||
<li key={row.key} className="flex justify-between gap-2">
|
<li key={row.key} className="flex justify-between gap-2">
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
{row.label}
|
{row.label}
|
||||||
<span className="ml-2 text-xs text-muted-foreground/75">
|
<span className="ml-2 text-xs text-muted-foreground/60">
|
||||||
{t("common:count", { defaultValue: "{{count}} 条", count: row.rows })}
|
{t("common:count", { defaultValue: "{{count}} 条", count: row.rows })}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -354,12 +354,12 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManage && bill.status === "pending_confirm" ? (
|
{canManage && bill.status === "pending_confirm" ? (
|
||||||
<div className="space-y-3 rounded-xl border border-border/70 bg-muted/15 p-4">
|
<div className="space-y-4 rounded-xl border border-border/70 bg-primary/5 p-5 shadow-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">
|
<p className="font-semibold tracking-tight text-primary">
|
||||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("settlementCenter:billDisplay.confirmHint", {
|
{t("settlementCenter:billDisplay.confirmHint", {
|
||||||
defaultValue: "确认后才可以登记收款或付款。",
|
defaultValue: "确认后才可以登记收款或付款。",
|
||||||
})}
|
})}
|
||||||
@@ -377,54 +377,57 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<p className="font-medium">{paymentTitle}</p>
|
<p className="font-semibold tracking-tight">{paymentTitle}</p>
|
||||||
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
|
<span className="rounded-full bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-500/20 dark:text-amber-400">
|
||||||
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}{" "}
|
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}{" "}
|
||||||
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-1 text-[13px] text-muted-foreground">
|
||||||
<span>{direction.payer}</span>
|
<span className="font-medium text-foreground/80">{direction.payer}</span>
|
||||||
<ArrowRight className="size-3.5 shrink-0" aria-hidden />
|
<ArrowRight className="size-3.5 shrink-0" aria-hidden />
|
||||||
<span>{direction.payee}</span>
|
<span className="font-medium text-foreground/80">{direction.payee}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-4 mt-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
<Label className="text-muted-foreground">{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={payAmount}
|
value={payAmount}
|
||||||
onChange={(e) => setPayAmount(e.target.value)}
|
onChange={(e) => setPayAmount(e.target.value)}
|
||||||
placeholder={String(bill.unpaid_amount)}
|
placeholder={String(bill.unpaid_amount)}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<Label>{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
<Label className="text-muted-foreground">{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={payMethod}
|
value={payMethod}
|
||||||
onChange={(e) => setPayMethod(e.target.value)}
|
onChange={(e) => setPayMethod(e.target.value)}
|
||||||
placeholder={t("settlementBills.paymentMethodPlaceholder", {
|
placeholder={t("settlementBills.paymentMethodPlaceholder", {
|
||||||
defaultValue: "例如:现金 / 银行转账",
|
defaultValue: "例如:现金 / 银行转账",
|
||||||
})}
|
})}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
<Label className="text-muted-foreground">{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={payProof}
|
value={payProof}
|
||||||
onChange={(e) => setPayProof(e.target.value)}
|
onChange={(e) => setPayProof(e.target.value)}
|
||||||
placeholder={t("settlementBills.paymentProofPlaceholder", {
|
placeholder={t("settlementBills.paymentProofPlaceholder", {
|
||||||
defaultValue: "可填写流水号、截图说明或备注",
|
defaultValue: "可填写流水号、截图说明或备注",
|
||||||
})}
|
})}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full"
|
className="w-full mt-2"
|
||||||
disabled={confirmBusy}
|
disabled={confirmBusy}
|
||||||
onClick={requestPayment}
|
onClick={requestPayment}
|
||||||
>
|
>
|
||||||
@@ -434,31 +437,32 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canWriteOff ? (
|
{canWriteOff ? (
|
||||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">
|
<p className="font-semibold tracking-tight text-destructive">
|
||||||
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-destructive/80">
|
||||||
{t("settlementBills.badDebtHint", {
|
{t("settlementBills.badDebtHint", {
|
||||||
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
|
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5 mt-2">
|
||||||
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
<Label className="text-destructive/90">{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={badDebtReason}
|
value={badDebtReason}
|
||||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||||
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
|
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
|
||||||
defaultValue: "例如:客户失联、确认坏账",
|
defaultValue: "例如:客户失联、确认坏账",
|
||||||
})}
|
})}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background border-destructive/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="w-full"
|
className="w-full mt-2"
|
||||||
disabled={confirmBusy}
|
disabled={confirmBusy}
|
||||||
onClick={requestBadDebtWriteOff}
|
onClick={requestBadDebtWriteOff}
|
||||||
>
|
>
|
||||||
@@ -468,19 +472,19 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManage && locked ? (
|
{canManage && locked ? (
|
||||||
<div className="space-y-3 rounded-xl border border-dashed border-border/70 p-4">
|
<div className="space-y-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium">
|
<p className="font-semibold tracking-tight">
|
||||||
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
|
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{t("settlementBills.adjustmentHint", {
|
{t("settlementBills.adjustmentHint", {
|
||||||
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
|
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5 mt-2">
|
||||||
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
<Label className="text-muted-foreground">{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={adjustAmount}
|
value={adjustAmount}
|
||||||
onChange={(e) => setAdjustAmount(e.target.value)}
|
onChange={(e) => setAdjustAmount(e.target.value)}
|
||||||
@@ -488,22 +492,24 @@ export function AgentBillDetail({
|
|||||||
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
|
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
|
||||||
defaultValue: "输入正数或负数",
|
defaultValue: "输入正数或负数",
|
||||||
})}
|
})}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<Label>{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
|
<Label className="text-muted-foreground">{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={adjustReason}
|
value={adjustReason}
|
||||||
onChange={(e) => setAdjustReason(e.target.value)}
|
onChange={(e) => setAdjustReason(e.target.value)}
|
||||||
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
|
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
|
||||||
defaultValue: "例如:人工复核补差、冲正错账",
|
defaultValue: "例如:人工复核补差、冲正错账",
|
||||||
})}
|
})}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full mt-2"
|
||||||
disabled={confirmBusy}
|
disabled={confirmBusy}
|
||||||
onClick={requestAdjustment}
|
onClick={requestAdjustment}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function SettlementBillSummaryHeader({
|
|||||||
const unpaid = bill.unpaid_amount > 0;
|
const unpaid = bill.unpaid_amount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-xl border border-border/70 bg-muted/15 p-4">
|
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<AdminStatusBadge status={bill.status}>
|
<AdminStatusBadge status={bill.status}>
|
||||||
{settlementBillStatusLabel(bill.status, t)}
|
{settlementBillStatusLabel(bill.status, t)}
|
||||||
@@ -60,7 +60,7 @@ export function SettlementBillSummaryHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<div className="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
|
<div className="rounded-xl border border-border/50 bg-background/50 px-4 py-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
|
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
|
||||||
</p>
|
</p>
|
||||||
@@ -70,10 +70,10 @@ export function SettlementBillSummaryHeader({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border px-3 py-2",
|
"rounded-xl border px-4 py-3",
|
||||||
unpaid
|
unpaid
|
||||||
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
|
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||||
: "border-border/50 bg-background/80",
|
: "border-border/50 bg-background/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -125,8 +125,8 @@ export function SettlementBillAmountBreakdown({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||||
<p className="font-medium text-foreground">
|
<p className="font-semibold tracking-tight text-foreground">
|
||||||
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
|
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -178,18 +178,18 @@ export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps
|
|||||||
const counterparty = resolveBillPartyName(bill, "counterparty", t);
|
const counterparty = resolveBillPartyName(bill, "counterparty", t);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
<div className="grid gap-4 text-sm sm:grid-cols-2">
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
|
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
|
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-medium text-foreground">{owner}</p>
|
<p className="mt-1 font-semibold text-foreground">{owner}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
|
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
|
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 font-medium text-foreground">{counterparty}</p>
|
<p className="mt-1 font-semibold text-foreground">{counterparty}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -107,6 +107,17 @@ function unpaidMoneyClass(row: SettlementBillRow): string {
|
|||||||
return "font-medium text-amber-800 dark:text-amber-300";
|
return "font-medium text-amber-800 dark:text-amber-300";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paidMoneyClass(row: SettlementBillRow): string {
|
||||||
|
if ((row.paid_amount ?? 0) <= 0) {
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
if (row.unpaid_amount > 0) {
|
||||||
|
return "font-medium text-amber-800 dark:text-amber-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "font-medium text-emerald-700";
|
||||||
|
}
|
||||||
|
|
||||||
function ownerPartyLabel(row: SettlementBillRow): string | null {
|
function ownerPartyLabel(row: SettlementBillRow): string | null {
|
||||||
if (row.bill_type === "player") {
|
if (row.bill_type === "player") {
|
||||||
return row.player_username ?? row.owner_label ?? null;
|
return row.player_username ?? row.owner_label ?? null;
|
||||||
@@ -118,18 +129,6 @@ function ownerPartyLabel(row: SettlementBillRow): string | null {
|
|||||||
return row.owner_label ?? null;
|
return row.owner_label ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fundingModeHint(row: SettlementBillRow, t: (key: string, options?: Record<string, unknown>) => string) {
|
|
||||||
if (row.owner_funding_mode !== "credit") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="rounded-full border border-border/70 bg-muted/30 px-1.5 py-0.5 text-[11px] font-normal leading-none text-muted-foreground">
|
|
||||||
{t("columns.creditMode", { defaultValue: "信用盘" })}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettlementBillsTable({
|
export function SettlementBillsTable({
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
@@ -211,10 +210,7 @@ export function SettlementBillsTable({
|
|||||||
{playerView ? (
|
{playerView ? (
|
||||||
<>
|
<>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<SettlementDashCell value={row.player_username ?? row.owner_label} />
|
||||||
<SettlementDashCell value={row.player_username ?? row.owner_label} />
|
|
||||||
{fundingModeHint(row, t)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
<SettlementDashCell
|
<SettlementDashCell
|
||||||
@@ -234,14 +230,7 @@ export function SettlementBillsTable({
|
|||||||
) : null}
|
) : null}
|
||||||
{mixedView ? (
|
{mixedView ? (
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{isPlayerBill ? (
|
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
|
||||||
{fundingModeHint(row, t)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
) : null}
|
) : null}
|
||||||
<TableCell className="min-w-[10rem] text-sm">
|
<TableCell className="min-w-[10rem] text-sm">
|
||||||
@@ -275,7 +264,7 @@ export function SettlementBillsTable({
|
|||||||
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
|
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
<TableCell className={cn("text-right tabular-nums", paidMoneyClass(row))}>
|
||||||
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
|
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Check, ChevronDown, Search } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,16 +25,14 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { Button } from "@/components/ui/button";
|
||||||
Select,
|
import { Input } from "@/components/ui/input";
|
||||||
SelectContent,
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
SelectItem,
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
SelectTrigger,
|
import { cn } from "@/lib/utils";
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
type SiteOption = { id: number; label: string; currency_code: string };
|
type SiteOption = { id: number; label: string; code: string; currency_code: string };
|
||||||
|
|
||||||
export function SettlementCenterShell(): React.ReactElement {
|
export function SettlementCenterShell(): React.ReactElement {
|
||||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||||
@@ -54,6 +53,8 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
|
|
||||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||||
|
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
||||||
|
const [siteKeyword, setSiteKeyword] = useState("");
|
||||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||||
const [periodsReady, setPeriodsReady] = useState(false);
|
const [periodsReady, setPeriodsReady] = useState(false);
|
||||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||||
@@ -62,7 +63,12 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boundAgent?.admin_site_id) {
|
if (boundAgent?.admin_site_id) {
|
||||||
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
|
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
|
||||||
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
|
setSiteOptions([{
|
||||||
|
id: boundAgent.admin_site_id,
|
||||||
|
label,
|
||||||
|
code: boundAgent.site_code ?? boundAgent.code ?? "",
|
||||||
|
currency_code: "NPR",
|
||||||
|
}]);
|
||||||
setAdminSiteId(boundAgent.admin_site_id);
|
setAdminSiteId(boundAgent.admin_site_id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,6 +77,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
const options = (sites.items ?? []).map((site) => ({
|
const options = (sites.items ?? []).map((site) => ({
|
||||||
id: site.id,
|
id: site.id,
|
||||||
label: formatAdminSiteLabel(site.name, site.code),
|
label: formatAdminSiteLabel(site.name, site.code),
|
||||||
|
code: site.code,
|
||||||
currency_code: site.currency_code ?? "NPR",
|
currency_code: site.currency_code ?? "NPR",
|
||||||
}));
|
}));
|
||||||
setSiteOptions(options);
|
setSiteOptions(options);
|
||||||
@@ -81,8 +88,81 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
}, [adminSiteId, boundAgent]);
|
}, [adminSiteId, boundAgent]);
|
||||||
|
|
||||||
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||||
const siteLabel = siteOptions.find((s) => s.id === siteId)?.label ?? null;
|
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
|
||||||
|
const siteLabel = selectedSite?.label ?? null;
|
||||||
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
||||||
|
const filteredSites = siteKeyword.trim().toLowerCase()
|
||||||
|
? siteOptions.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase()))
|
||||||
|
: siteOptions;
|
||||||
|
|
||||||
|
const siteSelector =
|
||||||
|
siteOptions.length > 0 && siteId !== null ? (
|
||||||
|
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-[240px] justify-between gap-2 bg-background px-3 font-normal"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">{siteLabel ?? "—"}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{selectedSite?.code ?? ""}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-[320px] p-0">
|
||||||
|
<div className="border-b border-border/60 p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={siteKeyword}
|
||||||
|
onChange={(event) => setSiteKeyword(event.target.value)}
|
||||||
|
placeholder={t("siteSearch", { defaultValue: "搜索站点名称" })}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="max-h-72">
|
||||||
|
<div className="p-2">
|
||||||
|
{filteredSites.map((site) => {
|
||||||
|
const active = site.id === siteId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={site.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
|
||||||
|
active ? "bg-primary/10 text-primary" : "hover:bg-muted/70",
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setAdminSiteId(site.id);
|
||||||
|
setSitePickerOpen(false);
|
||||||
|
setSiteKeyword("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">{site.label}</div>
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{site.code}</div>
|
||||||
|
</div>
|
||||||
|
{active ? <Check className="size-4 shrink-0" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredSites.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t("common:states.empty", { defaultValue: "暂无数据" })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
|
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
|
||||||
if (siteId === null) {
|
if (siteId === null) {
|
||||||
@@ -118,41 +198,6 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
|
||||||
{t("title", { defaultValue: "结算中心" })}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{isListMode
|
|
||||||
? t("subtitleList", { defaultValue: "账期列表:开账、关账,从行操作进入账单与报表。" })
|
|
||||||
: t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{siteOptions.length >= 1 && siteId !== null ? (
|
|
||||||
<Select
|
|
||||||
value={String(siteId)}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setAdminSiteId(Number(v));
|
|
||||||
setPeriodsReady(false);
|
|
||||||
router.push("/admin/settlement-center");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 w-[220px]">
|
|
||||||
<SelectValue>{siteLabel}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{siteOptions.map((site) => (
|
|
||||||
<SelectItem key={site.id} value={String(site.id)}>
|
|
||||||
{site.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{siteId === null || !periodsReady ? (
|
{siteId === null || !periodsReady ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
||||||
) : isListMode ? (
|
) : isListMode ? (
|
||||||
@@ -161,6 +206,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
currencyCode={currency}
|
currencyCode={currency}
|
||||||
canManage={canManagePeriods}
|
canManage={canManagePeriods}
|
||||||
periods={periods}
|
periods={periods}
|
||||||
|
headerActions={siteSelector}
|
||||||
onViewDetail={(id) => openPeriodView(id, "bills")}
|
onViewDetail={(id) => openPeriodView(id, "bills")}
|
||||||
onReloadPeriods={loadPeriods}
|
onReloadPeriods={loadPeriods}
|
||||||
onPeriodOpened={() => {
|
onPeriodOpened={() => {
|
||||||
|
|||||||
@@ -91,6 +91,31 @@ function reasonLabel(
|
|||||||
return creditLedgerReasonLabel(value, t);
|
return creditLedgerReasonLabel(value, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CreditLedgerReasonBadge({ reason }: { reason: string }): React.ReactElement {
|
||||||
|
const { t } = useTranslation(["settlementCenter"]);
|
||||||
|
const label = creditLedgerReasonLabel(reason, t);
|
||||||
|
|
||||||
|
let colorClass = "border-border bg-muted/30 text-foreground/80";
|
||||||
|
|
||||||
|
if (reason.includes("payment") || reason.includes("settlement_payout")) {
|
||||||
|
colorClass = "border-emerald-200/60 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-400";
|
||||||
|
} else if (reason.includes("game_settlement") || reason === "share_ledger") {
|
||||||
|
colorClass = "border-blue-200/60 bg-blue-50 text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/30 dark:text-blue-400";
|
||||||
|
} else if (reason === "bet_hold" || reason === "bet_hold_release") {
|
||||||
|
colorClass = "border-orange-200/60 bg-orange-50 text-orange-700 dark:border-orange-800/60 dark:bg-orange-950/30 dark:text-orange-400";
|
||||||
|
} else if (reason === "adjustment" || reason === "reversal") {
|
||||||
|
colorClass = "border-amber-200/60 bg-amber-50 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-400";
|
||||||
|
} else if (reason === "bad_debt") {
|
||||||
|
colorClass = "border-rose-200/60 bg-rose-50 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/30 dark:text-rose-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium", colorClass)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SettlementCreditLedgerPanelProps = {
|
type SettlementCreditLedgerPanelProps = {
|
||||||
adminSiteId: number;
|
adminSiteId: number;
|
||||||
settlementPeriodId: number;
|
settlementPeriodId: number;
|
||||||
@@ -209,17 +234,7 @@ export function SettlementCreditLedgerPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-xl border border-border/70 bg-muted/20 p-4 text-sm text-muted-foreground">
|
|
||||||
<p className="font-medium text-foreground">
|
|
||||||
{t("panels.ledger.title", { defaultValue: "账务流水" })}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1">
|
|
||||||
{t("ledger.groupIntro", {
|
|
||||||
defaultValue:
|
|
||||||
"账期内资金变动明细:信用占用、账单收付、调账与坏账。关账后生成的占成账单在「账单管理」。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
<div className="admin-list-field">
|
<div className="admin-list-field">
|
||||||
@@ -346,7 +361,9 @@ export function SettlementCreditLedgerPanel({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{creditLedgerReasonLabel(row.biz_type, t)}</TableCell>
|
<TableCell>
|
||||||
|
<CreditLedgerReasonBadge reason={row.biz_type} />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="tabular-nums text-xs">
|
<TableCell className="tabular-nums text-xs">
|
||||||
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
|
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -209,105 +209,109 @@ export function SettlementMainPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Label htmlFor="sb-bill-id">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
<div className="grid gap-2">
|
||||||
<Input
|
<Label htmlFor="sb-bill-id" className="text-muted-foreground">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
||||||
id="sb-bill-id"
|
<Input
|
||||||
inputMode="numeric"
|
id="sb-bill-id"
|
||||||
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
inputMode="numeric"
|
||||||
value={draft.billId}
|
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
value={draft.billId}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
||||||
if (e.key === "Enter") {
|
onKeyDown={(e) => {
|
||||||
runSearch();
|
if (e.key === "Enter") {
|
||||||
|
runSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="sb-owner" className="text-muted-foreground">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
||||||
|
<Input
|
||||||
|
id="sb-owner"
|
||||||
|
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||||
|
value={draft.ownerKeyword}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
runSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-background/50 transition-colors focus:bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="sb-status" className="text-muted-foreground">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
||||||
|
<Select
|
||||||
|
modal={false}
|
||||||
|
value={draft.statusScope}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
statusScope: (v ?? "all") as BillStatusFilter,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
/>
|
<SelectTrigger id="sb-status" className="w-full bg-background/50 transition-colors focus:bg-background">
|
||||||
</div>
|
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
||||||
<div className="grid gap-1.5">
|
</SelectTrigger>
|
||||||
<Label htmlFor="sb-owner">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
<SelectContent>
|
||||||
<Input
|
{(
|
||||||
id="sb-owner"
|
[
|
||||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
"all",
|
||||||
value={draft.ownerKeyword}
|
"pending_confirm",
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
"awaiting_payment",
|
||||||
onKeyDown={(e) => {
|
"settled",
|
||||||
if (e.key === "Enter") {
|
"adjustment",
|
||||||
runSearch();
|
] as BillStatusFilter[]
|
||||||
|
).map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{statusOptionLabel(value)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="sb-type" className="text-muted-foreground">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
||||||
|
<Select
|
||||||
|
modal={false}
|
||||||
|
value={draft.billType}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
billType: (v ?? "all") as BillTypeFilter,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
/>
|
<SelectTrigger id="sb-type" className="w-full bg-background/50 transition-colors focus:bg-background">
|
||||||
|
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{billTypeLabel(value)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sb-status">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
|
||||||
<Select
|
|
||||||
modal={false}
|
|
||||||
value={draft.statusScope}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
statusScope: (v ?? "all") as BillStatusFilter,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="sb-status" className="h-9 w-full">
|
|
||||||
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
"all",
|
|
||||||
"pending_confirm",
|
|
||||||
"awaiting_payment",
|
|
||||||
"settled",
|
|
||||||
"adjustment",
|
|
||||||
] as BillStatusFilter[]
|
|
||||||
).map((value) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{statusOptionLabel(value)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Label htmlFor="sb-type">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
|
||||||
<Select
|
|
||||||
modal={false}
|
|
||||||
value={draft.billType}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
billType: (v ?? "all") as BillTypeFilter,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="sb-type" className="h-9 w-full">
|
|
||||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
|
|
||||||
<SelectItem key={value} value={value}>
|
|
||||||
{billTypeLabel(value)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
<Button type="button" onClick={() => runSearch()}>
|
||||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
<Button type="button" variant="outline" onClick={() => resetFilters()}>
|
||||||
{t("billsPanel.reset", { defaultValue: "重置" })}
|
{t("billsPanel.reset", { defaultValue: "重置" })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||||
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && rows.length === 0 ? (
|
{loading && rows.length === 0 ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ type SettlementPeriodWorkbenchProps = {
|
|||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
periods: SettlementPeriodRow[];
|
periods: SettlementPeriodRow[];
|
||||||
|
headerActions?: ReactNode;
|
||||||
onViewDetail: (periodId: number) => void;
|
onViewDetail: (periodId: number) => void;
|
||||||
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
|
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
|
||||||
onPeriodOpened?: (periodId: number) => void;
|
onPeriodOpened?: (periodId: number) => void;
|
||||||
@@ -62,6 +63,7 @@ export function SettlementPeriodWorkbench({
|
|||||||
currencyCode,
|
currencyCode,
|
||||||
canManage,
|
canManage,
|
||||||
periods,
|
periods,
|
||||||
|
headerActions,
|
||||||
onViewDetail,
|
onViewDetail,
|
||||||
onReloadPeriods,
|
onReloadPeriods,
|
||||||
onPeriodOpened,
|
onPeriodOpened,
|
||||||
@@ -257,6 +259,7 @@ export function SettlementPeriodWorkbench({
|
|||||||
<AdminPageCard
|
<AdminPageCard
|
||||||
title={t("periodTable.title", { defaultValue: "账期管理" })}
|
title={t("periodTable.title", { defaultValue: "账期管理" })}
|
||||||
description={cardDescription}
|
description={cardDescription}
|
||||||
|
actions={headerActions}
|
||||||
>
|
>
|
||||||
<div className="admin-list-toolbar">
|
<div className="admin-list-toolbar">
|
||||||
<div className="admin-list-field">
|
<div className="admin-list-field">
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export type AdminAgentLineProvisionPayload = {
|
|||||||
credit_limit?: number;
|
credit_limit?: number;
|
||||||
rebate_limit?: number;
|
rebate_limit?: number;
|
||||||
default_player_rebate?: number;
|
default_player_rebate?: number;
|
||||||
settlement_cycle?: "daily" | "weekly" | "monthly";
|
|
||||||
can_grant_extra_rebate?: boolean;
|
can_grant_extra_rebate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export type AgentNodeProfileSummary = {
|
|||||||
available_credit: number;
|
available_credit: number;
|
||||||
rebate_limit: number;
|
rebate_limit: number;
|
||||||
default_player_rebate: number;
|
default_player_rebate: number;
|
||||||
settlement_cycle: "daily" | "weekly" | "monthly";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentNodeRow = {
|
export type AgentNodeRow = {
|
||||||
@@ -46,12 +45,16 @@ export type AgentTreeData = {
|
|||||||
tree: AgentNodeRow[];
|
tree: AgentNodeRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentNodeListData = {
|
||||||
|
admin_site_id: number | null;
|
||||||
|
items: AgentNodeRow[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentProfilePayload = {
|
export type AgentProfilePayload = {
|
||||||
total_share_rate?: number;
|
total_share_rate?: number;
|
||||||
credit_limit?: number;
|
credit_limit?: number;
|
||||||
rebate_limit?: number;
|
rebate_limit?: number;
|
||||||
default_player_rebate?: number;
|
default_player_rebate?: number;
|
||||||
settlement_cycle?: "daily" | "weekly" | "monthly";
|
|
||||||
can_grant_extra_rebate?: boolean;
|
can_grant_extra_rebate?: boolean;
|
||||||
can_create_child_agent?: boolean;
|
can_create_child_agent?: boolean;
|
||||||
can_create_player?: boolean;
|
can_create_player?: boolean;
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export type AdminDashboardAgentOverview = {
|
|||||||
used_credit: number;
|
used_credit: number;
|
||||||
available_credit: number;
|
available_credit: number;
|
||||||
total_share_rate: number;
|
total_share_rate: number;
|
||||||
settlement_cycle: string;
|
|
||||||
can_create_child_agent: boolean;
|
can_create_child_agent: boolean;
|
||||||
can_create_player: boolean;
|
can_create_player: boolean;
|
||||||
direct_child_count: number;
|
direct_child_count: number;
|
||||||
|
|||||||
@@ -35,9 +35,17 @@ export type AdminIntegrationSiteListData = {
|
|||||||
items: AdminIntegrationSiteRow[];
|
items: AdminIntegrationSiteRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminIntegrationSiteAdminAccountPayload = {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
password: string;
|
||||||
|
email?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminIntegrationSiteCreatePayload = {
|
export type AdminIntegrationSiteCreatePayload = {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
admin_account: AdminIntegrationSiteAdminAccountPayload;
|
||||||
currency_code?: string;
|
currency_code?: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
wallet_api_url?: string | null;
|
wallet_api_url?: string | null;
|
||||||
@@ -50,11 +58,20 @@ export type AdminIntegrationSiteCreatePayload = {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminIntegrationSiteUpdatePayload = Omit<AdminIntegrationSiteCreatePayload, "code">;
|
export type AdminIntegrationSiteUpdatePayload = Omit<
|
||||||
|
AdminIntegrationSiteCreatePayload,
|
||||||
|
"code" | "admin_account"
|
||||||
|
>;
|
||||||
|
|
||||||
export type AdminIntegrationSiteWithSecrets = AdminIntegrationSiteDetail & {
|
export type AdminIntegrationSiteWithSecrets = AdminIntegrationSiteDetail & {
|
||||||
secrets?: AdminIntegrationSiteSecrets;
|
secrets?: AdminIntegrationSiteSecrets;
|
||||||
secrets_display_once?: boolean;
|
secrets_display_once?: boolean;
|
||||||
|
admin_user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminIntegrationSiteConnectivityProbe = {
|
export type AdminIntegrationSiteConnectivityProbe = {
|
||||||
|
|||||||
Reference in New Issue
Block a user