refactor: update agent API schemas, standardize UI text styling, and enhance settlement credit ledger components

This commit is contained in:
2026-06-11 18:02:02 +08:00
parent 44ad51698f
commit 1eb6702c51
54 changed files with 1888 additions and 1103 deletions

View File

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

View File

@@ -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 />;
} }

View File

@@ -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>

View File

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

View File

@@ -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];

View File

@@ -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>

View File

@@ -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}/`;

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"
}, },

View File

@@ -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",

View File

@@ -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": "回水比例须在 0100% 之间",
"riskTags": "风控标签", "riskTags": "风控标签",
"riskTagsPlaceholder": "逗号分隔", "riskTagsPlaceholder": "逗号分隔",
"fundingMode": "资金模式", "fundingMode": "资金模式",

View File

@@ -163,6 +163,7 @@
"account": "账号设置", "account": "账号设置",
"integration": "接入配置", "integration": "接入配置",
"agents": "代理线路", "agents": "代理线路",
"agent_list": "代理列表",
"settlement_center": "结算中心", "settlement_center": "结算中心",
"config": "运营配置" "config": "运营配置"
}, },

View File

@@ -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": "生效中",

View File

@@ -93,7 +93,7 @@
"reason": "业务类型", "reason": "业务类型",
"ref": "关联", "ref": "关联",
"amount": "金额", "amount": "金额",
"channel": "渠道", "channel": "来源",
"status": "状态", "status": "状态",
"time": "时间" "time": "时间"
}, },

View File

@@ -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",
}; };

View File

@@ -1,4 +1,4 @@
/** API / 存储用小数比例01后台表单统一用百分比0100展示与录入。 */ /** API 统一用百分比0100后台表单统一用百分比0100展示与录入。 */
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);
} }
/** 存库小数 01 → 表单百分比,如 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)) {

View File

@@ -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,

View File

@@ -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[];
}; };

View File

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

View File

@@ -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: "占成比例须在 0100 之间",
}),
);
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: "回水上限须在 0100% 之间",
}),
);
return;
}
if (
Number.isNaN(defaultPlayerRebate) ||
defaultPlayerRebate < 0 ||
defaultPlayerRebate > 100
) {
toast.error(
t("agents:profile.validation.defaultRebateRange", {
defaultValue: "默认玩家回水须在 0100% 之间",
}),
);
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>
); );
} }

View File

@@ -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(() => {

View File

@@ -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>
); );

View File

@@ -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">

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

View File

@@ -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: "回水比例须在 0100% 之间",
}),
);
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">

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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>

View File

@@ -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>
)} )}

View File

@@ -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">

View File

@@ -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) })

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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}
> >

View File

@@ -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>
); );

View File

@@ -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))}>

View File

@@ -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={() => {

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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">

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = {