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,
AgentAdminUserRoleSyncPayload,
AgentNodeCreatePayload,
AgentNodeListData,
AgentNodeRow,
AgentNodeUpdatePayload,
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> {
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() {
redirect("/admin/agents");
return <AgentsDirectoryConsole />;
}

View File

@@ -1,7 +1,10 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
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 type { Metadata } from "next";
@@ -10,7 +13,9 @@ export const metadata: Metadata = buildPageMetadata("agents", "title");
export default function AgentsPage() {
return (
<ModuleScaffold embedded>
<AdminPermissionGate requiredAny={PRD_AGENTS_ACCESS_ANY}>
<AdminPermissionGate
requiredAny={[...PRD_AGENTS_ACCESS_ANY, ...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}
>
<AgentsConsole />
</AdminPermissionGate>
</ModuleScaffold>

View File

@@ -1,21 +1,5 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-wizard";
import { PRD_AGENT_LINE_PROVISION_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = buildPageMetadata("agents", "lineProvision.title");
export default function AgentLineProvisionPage(): React.ReactElement {
return (
<ModuleScaffold embedded>
<AdminPermissionGate
requiredAny={PRD_AGENT_LINE_PROVISION_ACCESS_ANY}
denyWhenBoundLineAgent
>
<AgentLineProvisionWizard />
</AdminPermissionGate>
</ModuleScaffold>
);
export default function AgentProvisionRedirectPage(): never {
redirect("/admin/agents");
}

View File

@@ -93,11 +93,13 @@ export function AdminBreadcrumb() {
const navItem = navItems
.filter(
(item) =>
pathname === item.href ||
(item.activeExact === true
? pathname === item.href
: pathname === item.href ||
pathname.startsWith(`${item.href}/`) ||
(item.activeMatchPrefix != null &&
(pathname === item.activeMatchPrefix ||
pathname.startsWith(`${item.activeMatchPrefix}/`))),
pathname.startsWith(`${item.activeMatchPrefix}/`)))),
)
.sort((a, b) => b.href.length - a.href.length)[0];

View File

@@ -75,7 +75,7 @@ export function AdminTableNoResourceRow({
compact?: boolean;
}): ReactElement {
return (
<TableRow className={className}>
<TableRow className={cn("hover:bg-transparent", className)}>
<TableCell colSpan={colSpan} className={cn("text-muted-foreground", cellClassName)}>
<AdminNoResourceState message={message} compact={compact} />
</TableCell>

View File

@@ -34,9 +34,12 @@ const SUB_NAV =
function isActive(
pathname: string,
item: { href: string; activeMatchPrefix?: string; segment?: string },
item: { href: string; activeMatchPrefix?: string; activeExact?: boolean; segment?: string },
): boolean {
const { href, activeMatchPrefix, segment } = item;
const { href, activeMatchPrefix, activeExact, segment } = item;
if (activeExact) {
return pathname === href;
}
const prefix = activeMatchPrefix ?? href;
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;

View File

@@ -54,16 +54,7 @@ export function PlayerLedgerSourceBadge({
return null;
}
const sourceClass =
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 sourceClass = "border-border bg-muted/30 text-muted-foreground";
const label =
ledgerSource === "wallet_txn"

View File

@@ -11,14 +11,17 @@
"selectAgentHint": "Settlement boundaries follow the agent tree; share, credit and rebate are configured per node.",
"allocatedCredit": "Allocated",
"availableCredit": "Available",
"profileFootnote": "Rebate cap {{rebate}}% · Default {{defaultRebate}}% · {{cycle}}",
"profileFootnote": "Rebate cap {{rebate}}% · Default {{defaultRebate}}%",
"tabOverview": "Overview",
"tabProfile": "Share & credit",
"tabProfileReadOnly": "Share & credit (read-only)",
"currentSite": "Site",
"viewAll": "View all",
"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",
"editAccount": "Account & status",
"saveProfile": "Save share & credit",
@@ -28,6 +31,7 @@
},
"listTitle": "Agents",
"listSearch": "Search name / code / login",
"siteSearch": "Search site name",
"parentAgent": "Parent",
"childrenCount": "Direct downline",
"subnav": {
@@ -189,7 +193,7 @@
"externalIdHint": "Leave blank to auto-generate",
"creditLimit": "Credit limit",
"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}}",
"riskTags": "Risk tags",
"riskTagsPlaceholder": "Comma-separated",

View File

@@ -163,6 +163,7 @@
"account": "Account settings",
"integration": "Integration",
"agents": "Agent lines",
"agent_list": "Agent list",
"settlement_center": "Settlement center",
"config": "Operations config"
},

View File

@@ -53,6 +53,7 @@
"loadFailed": "Failed to load integration sites",
"saveFailed": "Save failed",
"createSuccess": "Created site {{code}}",
"adminAccountCreated": "Created site admin account {{username}}",
"updateSuccess": "Updated site {{code}}",
"connectivityTest": "Test connectivity",
"connectivityTitle": "Partner wallet connectivity",
@@ -85,7 +86,10 @@
"dialogDescription": "Default wallet paths are fine unless the partner uses custom URLs.",
"form": {
"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": {
"code": "site_code",
@@ -97,6 +101,10 @@
"fields": {
"code": "site_code",
"name": "Site name",
"adminUsername": "Admin username",
"adminNickname": "Admin nickname",
"adminPassword": "Initial password",
"adminEmail": "Email (optional)",
"currency": "Default currency",
"status": "Status",
"walletApiUrl": "Partner wallet base URL",
@@ -109,13 +117,19 @@
"placeholders": {
"code": "Enter site identifier, for example partner-a",
"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",
"walletApiUrl": "Enter wallet API URL",
"lotteryH5BaseUrl": "Enter H5 URL",
"iframeOrigins": "Enter allowed origins, for example https://www.example.com",
"notes": "Enter notes",
"connectivityPlayerId": "Enter player ID, for example 10001"
}
},
"adminAccountSectionTitle": "Site admin account",
"adminAccountSectionDescription": "Creating a site will also create one admin account bound to that site."
},
"versionStatus": {
"active": "Active",

View File

@@ -11,21 +11,25 @@
"selectAgentHint": "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
"allocatedCredit": "已下发",
"availableCredit": "可下发",
"profileFootnote": "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
"profileFootnote": "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}%",
"tabOverview": "概览",
"currentSite": "当前站点",
"viewAll": "查看全部",
"shareRebateCap": "回水上限 {{rate}}%",
"overviewDownlineCard": "{{count}} 个,可在对应 Tab 管理下级代理",
"overviewDownlineCard": "查看并管理直属下级代理",
"overviewDownlineCount": "{{count}} 个",
"downlineEmptyTitle": "暂无直属下级",
"tabProfile": "占成与授信",
"tabProfileReadOnly": "占成与授信(只读)",
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
"overviewDownlineHint": "直属下级 {{count}} 个可在「直属下级」Tab 管理。",
"overviewPlayersHint": "直属玩家请在「直属玩家」Tab 维护。",
"overviewPlayersHint": "查看直属玩家与授信情况",
"overviewPlayersSummary": "玩家管理",
"tabDownline": "直属下级",
"tabPlayers": "直属玩家",
"playersUnavailableHint": "当前代理未开启“允许创建玩家”,如需新增请先调整该代理配置。",
"playersNoPermissionHint": "当前账号没有该节点的玩家管理权限。",
"downlineColumns": {
"email": "邮箱",
"downlineCount": "下级数"
@@ -41,11 +45,14 @@
"downlineEmpty": "暂无直属下级。创建下级代理后将在此展示。",
"downlineEmptyShort": "暂无直属下级。",
"noDelegatedTabs": "该代理未开放创建下级或玩家。请使用「编辑本代理」维护占成、授信与风控标签。",
"sidebarShareRate": "占成 {{rate}}%",
"sidebarAvailableCredit": "可下发 {{amount}}",
"expand": "展开",
"collapse": "收起"
},
"listTitle": "代理列表",
"listSearch": "搜索代理名称 / 编码 / 登录名",
"siteSearch": "搜索站点名称",
"parentAgent": "上级代理",
"childrenCount": "直属下级",
"subnav": {
@@ -130,6 +137,8 @@
"profile": {
"section": "占成与授信",
"totalShareRate": "占成比例 (%)",
"relativeShareRate": "占成比例(占上级 %",
"relativeShareRateValue": "占上级 {{rate}}%",
"creditLimit": "授信额度",
"rebateLimit": "回水上限 (%)",
"defaultPlayerRebate": "默认玩家回水 (%)",
@@ -292,12 +301,15 @@
"siteCode": "接入站点",
"siteCodePlaceholder": "选择站点",
"siteRequired": "请选择接入站点",
"codeRequired": "请填写代理编码",
"codePatternInvalid": "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
"noUnboundSite": "暂无未绑定一级代理的站点",
"openIntegrationSites": "前往接入站点",
"code": "代理编码",
"name": "一级代理名称",
"username": "后台登录账号",
"password": "初始密码",
"passwordHint": "至少 8 位",
"submit": "创建一级代理",
"success": "一级代理已创建",
"link": "创建一级代理"
@@ -305,18 +317,24 @@
"noAccess": "您没有代理经营相关权限,请联系管理员开通。",
"playersPanel": {
"create": "创建玩家",
"siteCode": "所属线路",
"scopedTo": "直属玩家:{{agent}}",
"allUnderSite": "当前一级代理线路下可见玩家",
"filterHint": "可按上级代理查看其直属玩家。",
"loginRequired": "请填写登录账号与初始密码",
"loginUsername": "登录账号",
"initialPassword": "初始密码",
"passwordHint": "至少 8 位",
"passwordMinLength": "初始密码至少 8 位",
"externalIdOptional": "外部 ID可选",
"externalIdHint": "留空则系统自动生成",
"creditLimit": "授信额度",
"rebateRate": "回水比例 (%)",
"rebateRateHint": "填写百分比,如 0.5 表示 0.5%",
"rebateRateHint": "填写百分比,如 5 表示 5%",
"availableToGrant": "代理剩余可下发:{{amount}}",
"creditLimitInvalid": "授信额度必须为不小于 0 的整数",
"creditLimitExceeded": "授信额度不能超过当前代理可下发额度",
"rebateRateInvalid": "回水比例须在 0100% 之间",
"riskTags": "风控标签",
"riskTagsPlaceholder": "逗号分隔",
"fundingMode": "资金模式",

View File

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

View File

@@ -53,6 +53,7 @@
"loadFailed": "加载接入站点失败",
"saveFailed": "保存失败",
"createSuccess": "已创建站点 {{code}}",
"adminAccountCreated": "已同时创建站点后台账号 {{username}}",
"updateSuccess": "已更新站点 {{code}}",
"connectivityTest": "联通检测",
"connectivityTitle": "主站钱包联通检测",
@@ -85,7 +86,10 @@
"dialogDescription": "钱包路径使用默认值即可,除非主站 URL 规范不同。",
"form": {
"required": "请填写站点名称",
"codeRequired": "请填写 site_code"
"codeRequired": "请填写 site_code",
"adminUsernameRequired": "请填写站点后台登录名",
"adminNicknameRequired": "请填写站点后台账号昵称",
"adminPasswordRequired": "请填写至少 8 位的站点后台初始密码"
},
"columns": {
"code": "site_code",
@@ -106,6 +110,10 @@
"fields": {
"code": "site_code",
"name": "站点名称",
"adminUsername": "后台登录名",
"adminNickname": "账号昵称",
"adminPassword": "初始密码",
"adminEmail": "邮箱(可选)",
"currency": "默认币种",
"status": "状态",
"walletApiUrl": "主站钱包根 URL",
@@ -118,13 +126,19 @@
"placeholders": {
"code": "请输入站点标识,如 partner-a",
"name": "请输入站点名称",
"adminUsername": "请输入后台登录名",
"adminNickname": "请输入账号昵称",
"adminPassword": "至少 8 位",
"adminEmail": "请输入邮箱",
"currency": "请输入币种代码,如 NPR",
"walletApiUrl": "请输入钱包接口地址",
"lotteryH5BaseUrl": "请输入 H5 地址",
"iframeOrigins": "请输入允许的来源地址,如 https://www.example.com",
"notes": "请输入备注说明",
"connectivityPlayerId": "请输入玩家 ID如 10001"
}
},
"adminAccountSectionTitle": "站点后台管理账号",
"adminAccountSectionDescription": "创建站点时将同步创建一个绑定该站点的后台管理账号。"
},
"versionStatus": {
"active": "生效中",

View File

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

View File

@@ -23,6 +23,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
settings: "settings",
integration: "integration",
agents: "agents",
agent_list: "agent_list",
config: "config",
};

View File

@@ -1,4 +1,4 @@
/** API / 存储用小数比例01后台表单统一用百分比0100展示与录入。 */
/** API 统一用百分比0100后台表单统一用百分比0100展示与录入。 */
const RATIO_TO_PERCENT = 100;
@@ -20,7 +20,7 @@ export function percentValueToUi(
return formatPercentNumber(n, decimals);
}
/** 存库小数 01 → 表单百分比,如 0.2 → "20"0.005 → "0.5" */
/** @deprecated API 已统一为百分比,不再需要此转换 */
export function ratioToPercentUi(
ratio: number | string | null | undefined,
decimals = 2,
@@ -32,7 +32,7 @@ export function ratioToPercentUi(
return formatPercentNumber(n * RATIO_TO_PERCENT, decimals);
}
/** "0.5" → 0.005 */
/** @deprecated API 已统一为百分比,不再需要此转换 */
export function percentUiToRatio(percent: number | string | null | undefined): number {
const n = typeof percent === "string" ? Number.parseFloat(percent.trim()) : percent;
if (n == null || !Number.isFinite(n)) {

View File

@@ -32,6 +32,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
{
dashboard: LayoutDashboard,
agents: Network,
agent_list: Users,
players: Users,
draws: CalendarClock,
rules_plays: ClipboardList,

View File

@@ -11,6 +11,7 @@ export type AdminNavGroup =
export type AdminNavSegment =
| "dashboard"
| "agents"
| "agent_list"
| "players"
| "draws"
| "rules_plays"
@@ -39,5 +40,6 @@ export type AdminNavItem = {
platform_only?: boolean;
agent_hidden?: boolean;
activeMatchPrefix?: string;
activeExact?: boolean;
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 { 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 { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import {
@@ -26,17 +26,16 @@ import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { cn } from "@/lib/utils";
import type { AgentNodeProfileSummary, AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
function settlementCycleLabel(
cycle: AgentNodeProfileSummary["settlement_cycle"] | undefined,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (cycle === "daily") {
return t("profile.cycleDaily", { defaultValue: "日结" });
function relativeShareRate(totalShareRate: number | undefined, parentShareRate: number | undefined): string | null {
if (
totalShareRate == null ||
parentShareRate == null ||
parentShareRate <= 0
) {
return null;
}
if (cycle === "monthly") {
return t("profile.cycleMonthly", { defaultValue: "月结" });
}
return t("profile.cycleWeekly", { defaultValue: "周结" });
return percentValueToUi((totalShareRate / parentShareRate) * 100);
}
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
@@ -57,18 +56,22 @@ export type AgentLineDetailPanelProps = {
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
playersTabHint?: string | null;
canManageNode: boolean;
canCreateChild: boolean;
canCreateChildAgent: boolean;
canCreatePlayerAction: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onAddChild: () => void;
onAddPlayer: () => void;
onEditCurrent: () => void;
onSelectChild: (node: AgentNodeRow) => void;
profileFields: AgentProfileFieldsProps | null;
profileSaving: boolean;
onSaveProfile: () => void;
playerCreateRequestKey?: number;
};
export function AgentLineDetailPanel({
@@ -87,18 +90,22 @@ export function AgentLineDetailPanel({
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
playersTabHint,
canManageNode,
canCreateChild,
canCreateChildAgent,
canCreatePlayerAction,
canDeleteChild,
onEditChild,
onDeleteChild,
onAddChild,
onAddPlayer,
onEditCurrent,
onSelectChild,
profileFields,
profileSaving,
onSaveProfile,
playerCreateRequestKey = 0,
}: AgentLineDetailPanelProps): React.ReactElement {
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 }[] = [
{
key: "overview",
@@ -157,8 +157,6 @@ export function AgentLineDetailPanel({
siteLabel && siteCode.trim() !== ""
? `${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
? null
: canCreateChildAgent
@@ -168,11 +166,22 @@ export function AgentLineDetailPanel({
: t("lineUi.addChildNoPermissionHint", {
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 (
<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">
<div className="flex flex-wrap items-start justify-between gap-4">
<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-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2.5">
<h2 className="truncate text-xl font-semibold tracking-tight text-foreground">
@@ -184,60 +193,38 @@ export function AgentLineDetailPanel({
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</div>
{(codeText !== "" || usernameText !== "" || parentName) ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{codeText !== "" ? (
<span className="rounded-md bg-muted/50 px-2 py-1 font-mono text-xs text-foreground/80">
{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>
{siteDisplay ? (
<p className="mt-1 truncate text-sm text-muted-foreground" title={siteDisplay}>
{siteDisplay}
</p>
) : null}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
{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}
<div className="flex shrink-0 flex-col items-end gap-2">
{canManageNode ? (
<div className="flex max-w-[28rem] flex-col items-end gap-2">
<>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" size="sm" variant="outline" onClick={onEditCurrent}>
<Pencil className="mr-1.5 size-3.5" />
{t("lineUi.editAgent", { defaultValue: "编辑代理" })}
</Button>
{canCreateChild ? (
<Button type="button" size="sm" onClick={onAddChild}>
{showPrimaryAction && primaryActionEnabled ? (
<Button
type="button"
size="sm"
onClick={detailTab === "players" ? onAddPlayer : onAddChild}
>
<Plus className="mr-1.5 size-3.5" />
{t("createChild", { defaultValue: "添加下级代理" })}
{primaryActionLabel}
</Button>
) : null}
</div>
{childActionHint ? (
<p className="text-right text-xs leading-5 text-muted-foreground">
{childActionHint}
{primaryActionHint ? (
<p className="max-w-[26rem] text-right text-xs leading-5 text-muted-foreground">
{primaryActionHint}
</p>
) : null}
</div>
</>
) : null}
</div>
</div>
@@ -266,10 +253,10 @@ export function AgentLineDetailPanel({
<OverviewTab
profile={profile}
profileLoading={profileLoading}
cycleLabel={cycleLabel}
profileReadOnly={profileReadOnly}
canViewDownlineTab={canViewDownlineTab}
canViewPlayersTab={canViewPlayersTab}
playersTabHint={playersTabHint}
childCount={childAgents.length}
onGoToDownline={() => onDetailTabChange("downline")}
onGoToPlayers={() => onDetailTabChange("players")}
@@ -319,6 +306,7 @@ export function AgentLineDetailPanel({
<DownlineTable
childAgents={childAgents}
childCountById={childCountById}
parentTotalShareRate={profile?.total_share_rate}
canManageNode={canManageNode}
canCreateChild={canCreateChild}
canDeleteChild={canDeleteChild}
@@ -335,6 +323,7 @@ export function AgentLineDetailPanel({
agentNodeId={node.id}
allowCreatePlayer={profile?.can_create_player === true}
embedded
createRequestKey={playerCreateRequestKey}
/>
) : null}
</div>
@@ -345,20 +334,20 @@ export function AgentLineDetailPanel({
function OverviewTab({
profile,
profileLoading,
cycleLabel,
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
playersTabHint,
childCount,
onGoToDownline,
onGoToPlayers,
}: {
profile: AgentProfileRow | null;
profileLoading: boolean;
cycleLabel: string;
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
playersTabHint?: string | null;
childCount: number;
onGoToDownline: () => void;
onGoToPlayers: () => void;
@@ -367,6 +356,10 @@ function OverviewTab({
const rebateCap =
profile && !profileLoading ? percentValueToUi(profile.rebate_limit ?? 0) : null;
const parentRelativeShare = relativeShareRate(
profile?.total_share_rate,
profile?.parent_caps?.total_share_rate,
);
return (
<div className="mx-auto max-w-5xl space-y-6">
@@ -392,7 +385,12 @@ function OverviewTab({
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
subtitle={
rebateCap !== null
parentRelativeShare
? t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: parentRelativeShare,
})
: rebateCap !== null
? t("lineUi.shareRebateCap", {
defaultValue: "回水上限 {{rate}}%",
rate: rebateCap,
@@ -418,17 +416,24 @@ function OverviewTab({
)}
{!profileReadOnly && !profileLoading && profile ? (
<p className="text-xs text-muted-foreground">
{t("lineUi.profileFootnote", {
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
rebate: percentValueToUi(profile.rebate_limit ?? 0),
defaultRebate: percentValueToUi(profile.default_player_rebate ?? 0),
cycle: cycleLabel,
})}
{(profile.risk_tags?.length ?? 0) > 0
? ` · ${t("profile.riskTags", { defaultValue: "风控" })}: ${profile.risk_tags?.join(", ")}`
: ""}
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MetricCard
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
value={`${percentValueToUi(profile.rebate_limit ?? 0)}%`}
/>
<MetricCard
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
value={`${percentValueToUi(profile.default_player_rebate ?? 0)}%`}
/>
<MetricCard
label={t("profile.riskTags", { defaultValue: "风控标签" })}
value={
(profile.risk_tags?.length ?? 0) > 0
? profile.risk_tags!.join(", ")
: t("common:states.none", { defaultValue: "无" })
}
/>
</div>
) : null}
{profileReadOnly ? (
@@ -440,14 +445,18 @@ function OverviewTab({
</p>
) : null}
{canViewDownlineTab || canViewPlayersTab ? (
{canViewDownlineTab || canViewPlayersTab || playersTabHint ? (
<div className="grid gap-4 md:grid-cols-2">
{canViewDownlineTab ? (
<OverviewLinkCard
icon={Network}
title={t("lineUi.tabDownline", { defaultValue: "直属下级" })}
description={t("lineUi.overviewDownlineCard", {
defaultValue: "{{count}} 个,可在对应 Tab 管理下级代理。",
summary={t("lineUi.overviewDownlineCount", {
defaultValue: "{{count}} 个",
count: childCount,
})}
description={t("lineUi.overviewDownlineHint", {
defaultValue: "直属下级 {{count}} 个,可在对应 Tab 管理下级代理。",
count: childCount,
})}
actionLabel={t("lineUi.viewDownline", { defaultValue: "查看直属下级" })}
@@ -458,6 +467,9 @@ function OverviewTab({
<OverviewLinkCard
icon={Users}
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
summary={t("lineUi.overviewPlayersSummary", {
defaultValue: "玩家管理",
})}
description={t("lineUi.overviewPlayersHint", {
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
})}
@@ -465,6 +477,21 @@ function OverviewTab({
onAction={onGoToPlayers}
/>
) : 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>
) : null}
</div>
@@ -474,38 +501,55 @@ function OverviewTab({
function OverviewLinkCard({
icon: Icon,
title,
summary,
description,
actionLabel,
onAction,
}: {
icon: ComponentType<{ className?: string }>;
title: string;
summary: string;
description: string;
actionLabel: string;
onAction: () => void;
}): React.ReactElement {
return (
<Card className="border-border/70 shadow-sm">
<CardContent className="flex items-start justify-between gap-4 pt-5">
<div className="flex min-w-0 gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/8 text-primary">
<Icon className="size-5" aria-hidden />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">{title}</p>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<Card
className="group relative cursor-pointer overflow-hidden border-border/70 shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
onClick={onAction}
>
<CardContent className="flex flex-col p-5">
<div className="flex items-start justify-between">
<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">
<Icon className="size-5.5" aria-hidden />
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-primary hover:text-primary"
onClick={onAction}
className="shrink-0 text-primary -mr-2"
onClick={(e) => {
e.stopPropagation();
onAction();
}}
>
{actionLabel}
<ChevronRight className="ml-0.5 size-3.5" aria-hidden />
<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>
</CardContent>
</Card>
);
@@ -514,6 +558,7 @@ function OverviewLinkCard({
function DownlineTable({
childAgents,
childCountById,
parentTotalShareRate,
canManageNode,
canCreateChild,
canDeleteChild,
@@ -524,6 +569,7 @@ function DownlineTable({
}: {
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
parentTotalShareRate?: number;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
@@ -537,21 +583,6 @@ function DownlineTable({
const editChildLabel = t("lineUi.editDownline", { 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 (
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div className="overflow-x-auto">
@@ -571,22 +602,22 @@ function DownlineTable({
<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)]">
<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: "操作" })}
</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{childAgents.map((child) => {
{childAgents.length === 0 ? (
<AdminTableNoResourceRow colSpan={canManageNode ? 10 : 9} cellClassName="py-12 text-center" />
) : (
childAgents.map((child) => {
const summary = child.profile_summary;
return (
<TableRow
@@ -601,7 +632,22 @@ function DownlineTable({
{child.email ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
{summary ? (
<div className="space-y-0.5">
<div>{`${summary.total_share_rate ?? 0}%`}</div>
{parentTotalShareRate && parentTotalShareRate > 0 ? (
<div className="text-[11px] text-muted-foreground">
{t("profile.relativeShareRateValue", {
defaultValue: "占上级 {{rate}}%",
rate: relativeShareRate(
summary.total_share_rate,
parentTotalShareRate,
) ?? "0",
})}
</div>
) : null}
</div>
) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.credit_limit) : "—"}
@@ -609,9 +655,6 @@ function DownlineTable({
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.allocated_credit) : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{summary ? settlementCycleLabel(summary.settlement_cycle, t) : "—"}
</TableCell>
<TableCell className="text-center tabular-nums text-xs">
{childCountById.get(child.id) ?? 0}
</TableCell>
@@ -624,7 +667,7 @@ function DownlineTable({
</TableCell>
{canManageNode ? (
<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()}
>
<AdminRowActionsMenu
@@ -649,7 +692,8 @@ function DownlineTable({
) : null}
</TableRow>
);
})}
})
)}
</TableBody>
</Table>
</div>

View File

@@ -20,12 +20,20 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { percentValueToUi } from "@/lib/admin-rate-percent";
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
import { LotteryApiBizError } from "@/types/api/errors";
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 [submitting, setSubmitting] = useState(false);
const [sitesLoading, setSitesLoading] = useState(true);
@@ -40,7 +48,6 @@ export function AgentLineProvisionWizard(): React.ReactElement {
credit_limit: "0",
rebate_limit: "0",
default_player_rebate: "0",
settlement_cycle: "weekly" as "daily" | "weekly" | "monthly",
can_grant_extra_rebate: false,
});
@@ -63,32 +70,114 @@ export function AgentLineProvisionWizard(): React.ReactElement {
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
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);
try {
await postAdminAgentLine({
const result = await postAdminAgentLine({
site_code: form.site_code.trim().toLowerCase(),
code: form.code.trim().toLowerCase(),
name: form.name.trim(),
username: form.username.trim(),
password: form.password,
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
settlement_cycle: form.settlement_cycle,
total_share_rate: shareRate,
credit_limit: creditLimit,
rebate_limit: rebateLimit,
default_player_rebate: defaultPlayerRebate,
can_grant_extra_rebate: form.can_grant_extra_rebate,
});
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({
...f,
site_code: "",
code: "",
name: "",
username: "",
password: "",
total_share_rate: "0",
credit_limit: "0",
rebate_limit: "0",
default_player_rebate: "0",
can_grant_extra_rebate: false,
}));
const data = await getAdminIntegrationSites();
setSites(data.items);
await onSuccess?.(result);
} catch (err) {
const msg =
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
@@ -98,18 +187,20 @@ export function AgentLineProvisionWizard(): React.ReactElement {
}
}
return (
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
const content = (
<>
{!embedded ? (
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
{t("agents:subnav.provisionHint", {
defaultValue:
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
})}
</p>
) : null}
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
{t("agents:lineProvision.description", {
defaultValue:
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。",
})}{" "}
<Link
href="/admin/config/integration-sites"
@@ -190,6 +281,9 @@ export function AgentLineProvisionWizard(): React.ReactElement {
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
{t("agents:lineProvision.passwordHint", { defaultValue: "至少 8 位" })}
</p>
</div>
<p className="text-sm font-medium">
@@ -224,7 +318,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
max={100}
step="0.01"
value={form.rebate_limit}
placeholder="0.5"
placeholder="50"
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
/>
</div>
@@ -236,46 +330,11 @@ export function AgentLineProvisionWizard(): React.ReactElement {
max={100}
step="0.01"
value={form.default_player_rebate}
placeholder="0.5"
placeholder="50"
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
/>
</div>
</div>
<div className="grid gap-2">
<Label>{t("agents:profile.settlementCycle", { defaultValue: "结算周期" })}</Label>
<Select
value={form.settlement_cycle}
onValueChange={(value) =>
setForm((f) => ({
...f,
settlement_cycle: (value as "daily" | "weekly" | "monthly") ?? "weekly",
}))
}
>
<SelectTrigger>
<SelectValue>
{(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">
<Switch
checked={form.can_grant_extra_rebate}
@@ -294,6 +353,16 @@ export function AgentLineProvisionWizard(): React.ReactElement {
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
</Button>
</form>
</>
);
if (embedded) {
return <div className="space-y-0">{content}</div>;
}
return (
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
{content}
</AdminPageCard>
);
}

View File

@@ -4,10 +4,8 @@ import { ChevronRight, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Input } from "@/components/ui/input";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { cn } from "@/lib/utils";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
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 = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
@@ -110,8 +104,8 @@ function TreeRow({
<li>
<div
className={cn(
"flex w-full items-start gap-0.5 rounded-lg py-1.5 pr-2 transition-colors",
active ? "bg-primary/12 ring-1 ring-primary/30 shadow-sm" : "hover:bg-background/80",
"flex w-full items-start gap-0.5 rounded-md py-1 pr-2 transition-colors",
active ? "bg-primary/10 ring-1 ring-primary/25" : "hover:bg-background/80",
)}
style={{ paddingLeft: `${6 + indent}px` }}
>
@@ -120,7 +114,7 @@ function TreeRow({
type="button"
aria-expanded={expanded}
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) => {
e.stopPropagation();
onToggleExpand(node.id);
@@ -132,7 +126,7 @@ function TreeRow({
/>
</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
type="button"
@@ -141,18 +135,8 @@ function TreeRow({
className="min-w-0 flex-1 px-1 py-0.5 text-left"
onClick={() => onSelect(node)}
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{node.name}</span>
<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">
<div className="truncate text-sm font-medium leading-5">{node.name}</div>
<p className="truncate text-xs leading-5 text-muted-foreground">
{node.username ?? node.code}
</p>
</button>
@@ -192,9 +176,7 @@ export function AgentLineSidebar({
const normalizedKeyword = keyword.trim().toLowerCase();
const displayForest = useMemo(() => {
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
return unwrapSiteRoots(pruned);
return pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
}, [normalizedKeyword, parentNameMap, tree]);
useEffect(() => {

View File

@@ -16,6 +16,8 @@ import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { cn } from "@/lib/utils";
import type { AgentParentCaps } from "@/types/api/admin-agent";
import { Info } from "lucide-react";
export type AgentProfileFieldsProps = {
disabled?: boolean;
loading?: boolean;
@@ -31,8 +33,6 @@ export type AgentProfileFieldsProps = {
onRebateLimitChange: (value: string) => void;
defaultRebate: string;
onDefaultRebateChange: (value: string) => void;
settlementCycle: "daily" | "weekly" | "monthly";
onSettlementCycleChange: (value: "daily" | "weekly" | "monthly") => void;
extraRebate: boolean;
onExtraRebateChange: (value: boolean) => void;
canCreatePlayer: boolean;
@@ -62,8 +62,6 @@ export function AgentProfileFields({
onRebateLimitChange,
defaultRebate,
onDefaultRebateChange,
settlementCycle,
onSettlementCycleChange,
extraRebate,
onExtraRebateChange,
canCreatePlayer,
@@ -81,47 +79,48 @@ export function AgentProfileFields({
const isCard = variant === "card";
return (
<div className="space-y-5">
<div className="space-y-6">
{(parentCaps || availableCredit !== null) && !loading ? (
<div
className={cn(
"rounded-lg text-xs text-muted-foreground",
isCard ? "border border-border/60 bg-muted/25 px-3 py-2.5 space-y-1" : "space-y-1",
)}
>
<div className="flex items-start gap-3 rounded-xl border border-primary/20 bg-primary/5 p-4 text-primary shadow-sm">
<Info className="mt-0.5 size-5 shrink-0 opacity-80" aria-hidden />
<div className="flex flex-col gap-1.5 min-w-0">
{parentCaps ? (
<p>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium leading-snug">
{t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发额度 {{credit}}",
defaultValue: "上级占成 {{share}}%,可下发 {{credit}}",
share: parentCaps.total_share_rate,
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
</div>
) : null}
{availableCredit !== null ? (
<p>
<p className={cn("text-sm", parentCaps ? "text-primary/80" : "font-medium")}>
{t("profile.availableCredit", {
defaultValue: "可下发额度{{amount}}",
defaultValue: "可下发额度 {{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
</div>
</div>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground animate-pulse">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
<div
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" : "",
)}
>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-share-rate`}>
<Label htmlFor={`${idPrefix}-share-rate`} className="text-muted-foreground">
{parentCaps
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %" })
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
@@ -132,11 +131,12 @@ export function AgentProfileFields({
min={0}
max={100}
step="0.01"
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={shareRate}
onChange={(e) => onShareRateChange(e.target.value)}
/>
{parentCaps && shareRate ? (
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
@@ -145,19 +145,20 @@ export function AgentProfileFields({
) : null}
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-credit-limit`}>
<Label htmlFor={`${idPrefix}-credit-limit`} className="text-muted-foreground">
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id={`${idPrefix}-credit-limit`}
type="number"
min={0}
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={creditLimit}
onChange={(e) => onCreditLimitChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-rebate-limit`}>
<Label htmlFor={`${idPrefix}-rebate-limit`} className="text-muted-foreground">
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
</Label>
<Input
@@ -166,13 +167,14 @@ export function AgentProfileFields({
min={0}
max={100}
step="0.01"
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={rebateLimit}
onChange={(e) => onRebateLimitChange(e.target.value)}
placeholder="0.5"
placeholder="50"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-default-rebate`}>
<Label htmlFor={`${idPrefix}-default-rebate`} className="text-muted-foreground">
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
</Label>
<Input
@@ -181,17 +183,19 @@ export function AgentProfileFields({
min={0}
max={100}
step="0.01"
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={defaultRebate}
onChange={(e) => onDefaultRebateChange(e.target.value)}
placeholder="0.5"
placeholder="50"
/>
</div>
<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: "风控标签" })}
</Label>
<Input
id={`${idPrefix}-risk-tags`}
className="h-10 bg-background/50 transition-colors focus:bg-background"
value={riskTags}
onChange={(e) => onRiskTagsChange(e.target.value)}
placeholder={t("profile.riskTagsPlaceholder", {
@@ -199,49 +203,15 @@ export function AgentProfileFields({
})}
/>
</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
className={cn(
"space-y-4 border-t border-border/60 pt-4",
"pt-2",
fieldDisabled ? "pointer-events-none opacity-50" : "",
)}
>
{!isCard ? (
<p className="text-xs text-muted-foreground">
{t("profile.capabilityHint", {
defaultValue:
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
})}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-1">
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
<SwitchRow
checked={extraRebate}
onCheckedChange={onExtraRebateChange}
@@ -257,8 +227,17 @@ export function AgentProfileFields({
onCheckedChange={onCanCreateChildChange}
disabled={!canCreateChildAgent && !isSuperAdmin}
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
isLast
/>
</div>
{!isCard ? (
<p className="mt-3 px-1 text-xs text-muted-foreground/80">
{t("profile.capabilityHint", {
defaultValue:
"保存后约束该代理主账号能否开玩家/下级;与平台「代理」角色叠加,以本开关为准。",
})}
</p>
) : null}
</div>
</div>
);
@@ -269,15 +248,20 @@ function SwitchRow({
onCheckedChange,
label,
disabled = false,
isLast = false,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: boolean;
isLast?: boolean;
}): React.ReactElement {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
<Label className="font-normal">{label}</Label>
<div className={cn(
"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} />
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -40,11 +41,11 @@ import {
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_MANAGE,
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
PRD_AGENT_PROFILE_MANAGE,
PRD_AGENTS_ACCESS_ANY,
PRD_USERS_MANAGE,
} from "@/lib/admin-prd";
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
@@ -80,8 +81,10 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
export function AgentsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const tRef = useTranslationRef(["agents", "common"]);
const searchParams = useSearchParams();
const profile = useAdminProfile();
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const boundAgent = profile?.agent ?? null;
const isSuperAdmin = profile?.is_super_admin === true;
const canManageNode =
@@ -95,6 +98,10 @@ export function AgentsConsole(): React.ReactElement {
PRD_AGENT_PROFILE_MANAGE,
PRD_AGENT_MANAGE,
]);
const canProvisionLine =
boundAgent === null &&
(isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]));
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
@@ -107,7 +114,7 @@ export function AgentsConsole(): React.ReactElement {
const [profileSaving, setProfileSaving] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0);
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
const [targetParentId, setTargetParentId] = useState<number | null>(null);
@@ -121,9 +128,6 @@ export function AgentsConsole(): React.ReactElement {
const [profileCreditLimit, setProfileCreditLimit] = useState("0");
const [profileRebateLimit, setProfileRebateLimit] = useState("0");
const [profileDefaultRebate, setProfileDefaultRebate] = useState("0");
const [profileSettlementCycle, setProfileSettlementCycle] = useState<
"daily" | "weekly" | "monthly"
>("weekly");
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
@@ -134,7 +138,6 @@ export function AgentsConsole(): React.ReactElement {
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
const boundAgent = profile?.agent ?? null;
/** 登录账号是否可向子代理下放「允许创建下级」 */
const canCreateChildAgent =
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
@@ -142,13 +145,13 @@ export function AgentsConsole(): React.ReactElement {
isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
const selectedNodeIdFromUrl = Number.parseInt(searchParams.get("agent_node_id") ?? "", 10);
const resetProfileForm = (mode: "create" | "edit" = "create") => {
setProfileShareRate("0");
setProfileCreditLimit("0");
setProfileRebateLimit("0");
setProfileDefaultRebate("0");
setProfileSettlementCycle("weekly");
setProfileExtraRebate(false);
setProfileCanCreateChild(mode === "create" ? false : false);
setProfileCanCreatePlayer(true);
@@ -168,7 +171,6 @@ export function AgentsConsole(): React.ReactElement {
setProfileCreditLimit(String(row.credit_limit ?? 0));
setProfileRebateLimit(percentValueToUi(row.rebate_limit ?? 0));
setProfileDefaultRebate(percentValueToUi(row.default_player_rebate ?? 0));
setProfileSettlementCycle(normalizeAgentSettlementCycle(row.settlement_cycle));
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
setProfileCanCreatePlayer(row.can_create_player !== false);
@@ -183,7 +185,6 @@ export function AgentsConsole(): React.ReactElement {
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
can_grant_extra_rebate: profileExtraRebate,
can_create_child_agent: profileCanCreateChild,
can_create_player: profileCanCreatePlayer,
@@ -239,7 +240,7 @@ export function AgentsConsole(): React.ReactElement {
() => new Map<number, string>(flatNodes.map((node) => [node.id, node.name])),
[flatNodes],
);
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
const visibleAgentRows = flatNodes;
const selectedSiteLabel = useMemo(
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
[adminSiteId, siteOptions],
@@ -324,6 +325,16 @@ export function AgentsConsole(): React.ReactElement {
}
}, [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(() => {
if (selectedNode === null) {
setSelectedProfile(null);
@@ -374,6 +385,26 @@ export function AgentsConsole(): React.ReactElement {
[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(
() => canManageNode && selectedProfile?.can_create_child_agent === true,
[canManageNode, selectedProfile?.can_create_child_agent],
@@ -401,15 +432,15 @@ export function AgentsConsole(): React.ReactElement {
]);
useAsyncEffect(() => {
if (businessRows.length === 0) {
if (visibleAgentRows.length === 0) {
setSelectedNodeId(null);
return;
}
if (selectedNodeId === null || !businessRows.some((row) => row.id === selectedNodeId)) {
setSelectedNodeId(businessRows[0]?.id ?? null);
if (selectedNodeId === null || !visibleAgentRows.some((row) => row.id === selectedNodeId)) {
setSelectedNodeId(visibleAgentRows[0]?.id ?? null);
}
}, [businessRows, selectedNodeId]);
}, [visibleAgentRows, selectedNodeId]);
useEffect(() => {
setDetailTab("overview");
@@ -422,10 +453,14 @@ export function AgentsConsole(): React.ReactElement {
}, [detailTab, isOwnAgentNode]);
useAsyncEffect(() => {
if (!canViewAgents) {
setLoading(false);
return;
}
if (adminSiteId !== null) {
void loadTree(adminSiteId);
}
}, [adminSiteId, loadTree]);
}, [adminSiteId, canViewAgents, loadTree]);
const openCreateChildForNode = (node: AgentNodeRow) => {
setNodeDialogMode("create");
@@ -435,9 +470,10 @@ export function AgentsConsole(): React.ReactElement {
setNodeStatus(1);
setNodeUsername("");
setNodePassword("");
setProfileLoading(false);
setProfileLoaded(true);
setProfileLoading(canManageProfile);
setProfileLoaded(!canManageProfile);
setEditingNodeNeedsPrimaryAccount(false);
resetProfileForm("create");
setNodeDialogOpen(true);
if (canManageProfile) {
void getAgentNodeProfile(node.id)
@@ -450,15 +486,18 @@ export function AgentsConsole(): React.ReactElement {
available_credit: p.available_credit ?? 0,
});
setProfileAvailableCredit(p.available_credit ?? null);
resetProfileForm("create");
setProfileLoaded(true);
})
.catch(() => {
setProfileParentCaps(null);
setProfileAvailableCredit(null);
resetProfileForm("create");
setProfileLoaded(false);
})
.finally(() => {
setProfileLoading(false);
});
} else {
resetProfileForm("create");
setProfileLoading(false);
}
};
@@ -573,8 +612,6 @@ export function AgentsConsole(): React.ReactElement {
onRebateLimitChange: setProfileRebateLimit,
defaultRebate: profileDefaultRebate,
onDefaultRebateChange: setProfileDefaultRebate,
settlementCycle: profileSettlementCycle,
onSettlementCycleChange: setProfileSettlementCycle,
extraRebate: profileExtraRebate,
onExtraRebateChange: setProfileExtraRebate,
canCreatePlayer: profileCanCreatePlayer,
@@ -598,12 +635,11 @@ export function AgentsConsole(): React.ReactElement {
profileParentCaps,
profileRebateLimit,
profileRiskTags,
profileSettlementCycle,
profileShareRate,
selectedProfileLoading,
]);
const showAgentSidebar = businessRows.length > 1;
const showAgentSidebar = visibleAgentRows.length > 0;
const openAddAgent = (): void => {
const parent = selectedNode ?? rootNode;
@@ -742,7 +778,7 @@ export function AgentsConsole(): React.ReactElement {
return null;
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
if (!canViewAgents) {
if (!canViewAgents && !canProvisionLine) {
return (
<p className="text-sm text-muted-foreground">
{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: "代理列表" })} />;
}
@@ -758,8 +794,9 @@ export function AgentsConsole(): React.ReactElement {
<div className="flex min-h-[32rem] flex-col gap-0">
<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}
{canViewAgents ? (
<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
@@ -768,7 +805,7 @@ export function AgentsConsole(): React.ReactElement {
parentNameMap={parentNameMap}
selectedId={selectedNodeId}
keyword={keyword}
agentCount={businessRows.length}
agentCount={visibleAgentRows.length}
onKeywordChange={(value) => {
setKeyword(value);
}}
@@ -798,12 +835,19 @@ export function AgentsConsole(): React.ReactElement {
profileReadOnly={isOwnAgentNode}
canViewDownlineTab={canShowDownlineTab}
canViewPlayersTab={canShowPlayersTab}
playersTabHint={playersTabHint}
canManageNode={canManageNode}
canCreateChild={canCreateChildOnSelected}
canCreateChildAgent={canCreateChildAgent}
canCreatePlayerAction={
isSuperAdmin ||
(selectedProfile?.can_create_player === true &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]))
}
canDeleteChild={canDeleteNode}
onEditChild={(node) => openEditForNode(node)}
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
onAddPlayer={() => setPlayerCreateRequestKey((value) => value + 1)}
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
onDeleteChild={(node) => handleDeleteNode(node)}
onSelectChild={(child) => {
@@ -812,8 +856,16 @@ export function AgentsConsole(): React.ReactElement {
profileFields={inlineProfileFields}
profileSaving={profileSaving}
onSaveProfile={() => void saveInlineProfile()}
playerCreateRequestKey={playerCreateRequestKey}
/>
</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}>
<DialogContent
@@ -828,42 +880,39 @@ export function AgentsConsole(): React.ReactElement {
</DialogTitle>
</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="grid gap-5">
<div className="space-y-2">
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
<Label htmlFor="agent-name" className="text-muted-foreground">{t("name", { defaultValue: "名称" })}</Label>
<Input
id="agent-name"
value={nodeName}
placeholder={t("namePlaceholder")}
onChange={(e) => setNodeName(e.target.value)}
autoComplete="off"
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-username">{t("users.username", { defaultValue: "登录名" })}</Label>
<Label htmlFor="agent-username" className="text-muted-foreground">{t("users.username", { defaultValue: "登录名" })}</Label>
<Input
id="agent-username"
value={nodeUsername}
placeholder={t("usernamePlaceholder")}
onChange={(e) => setNodeUsername(e.target.value)}
autoComplete="off"
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-password">
<Label htmlFor="agent-password" className="text-muted-foreground">
{nodeDialogMode === "create" || editingNodeNeedsPrimaryAccount
? t("users.password", { defaultValue: "密码" })
: t("resetPassword", { defaultValue: "重置密码" })}
</Label>
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
<p className="text-xs text-muted-foreground">
{t("bindAccountHint", {
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
})}
</p>
) : null}
<Input
id="agent-password"
type="password"
@@ -875,21 +924,32 @@ export function AgentsConsole(): React.ReactElement {
: t("passwordPlaceholder")
}
autoComplete="new-password"
className="bg-background/50 transition-colors focus:bg-background"
/>
{nodeDialogMode === "edit" && editingNodeNeedsPrimaryAccount ? (
<p className="text-[11px] text-muted-foreground/80">
{t("bindAccountHint", {
defaultValue: "该代理尚无登录账号,保存时将自动创建并绑定。",
})}
</p>
) : 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">
<Label className="font-medium cursor-pointer" onClick={() => setNodeStatus(nodeStatus === 1 ? 0 : 1)}>
{t("status", { defaultValue: "状态" })}
</Label>
<Switch checked={nodeStatus === 1} onCheckedChange={(value) => setNodeStatus(value ? 1 : 0)} />
<Label>{t("status", { defaultValue: "状态" })}</Label>
</div>
{canManageProfile &&
(nodeDialogMode === "create" ||
(editingNodeId !== null && boundAgent?.id !== editingNodeId)) ? (
<div className="space-y-3 border-t pt-3">
<p className="text-sm font-medium">
{t("profile.section", { defaultValue: "占成与授信" })}
</p>
<div className="space-y-4 border-t border-border/60 pt-5 mt-1">
<h3 className="text-sm font-semibold tracking-tight">
{t("profile.section", { defaultValue: "占成与授信配置" })}
</h3>
<AgentProfileFields
loading={profileLoading}
parentCaps={profileParentCaps}
@@ -904,8 +964,6 @@ export function AgentsConsole(): React.ReactElement {
onRebateLimitChange={setProfileRebateLimit}
defaultRebate={profileDefaultRebate}
onDefaultRebateChange={setProfileDefaultRebate}
settlementCycle={profileSettlementCycle}
onSettlementCycleChange={setProfileSettlementCycle}
extraRebate={profileExtraRebate}
onExtraRebateChange={setProfileExtraRebate}
canCreatePlayer={profileCanCreatePlayer}
@@ -919,6 +977,7 @@ export function AgentsConsole(): React.ReactElement {
</div>
) : null}
</div>
</div>
<DialogFooter className="!m-0 shrink-0 rounded-b-xl border-t bg-background px-4 py-4">
<Button type="button" variant="outline" onClick={() => setNodeDialogOpen(false)}>

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";
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 { toast } from "sonner";
@@ -57,7 +57,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display";
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_USERS_MANAGE } from "@/lib/admin-prd";
@@ -136,7 +136,7 @@ function fillEditFormFromPlayer(row: AdminPlayerRow): {
currency: row.default_currency ?? "",
status: row.status,
creditLimit: row.credit_limit ?? 0,
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
rebateRate: rebate != null ? percentValueToUi(rebate) : "",
riskTags: (row.risk_tags ?? []).join(", "),
};
}
@@ -149,6 +149,8 @@ type AgentsPlayersPanelProps = {
allowCreatePlayer?: boolean;
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
embedded?: boolean;
/** 外部触发创建直属玩家的计数器 */
createRequestKey?: number;
};
export function AgentsPlayersPanel({
@@ -156,6 +158,7 @@ export function AgentsPlayersPanel({
agentNodeId,
allowCreatePlayer,
embedded = false,
createRequestKey = 0,
}: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]);
const formatDt = useAdminDateTimeFormatter();
@@ -226,6 +229,7 @@ export function AgentsPlayersPanel({
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const lastCreateRequestKeyRef = useRef(createRequestKey);
const load = useCallback(async () => {
if (siteCode.trim() === "") {
@@ -269,6 +273,46 @@ export function AgentsPlayersPanel({
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
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);
try {
@@ -278,10 +322,9 @@ export function AgentsPlayersPanel({
password: password,
nickname: nickname.trim() || null,
...(isSuperAdmin && effectiveAgentId ? { agent_node_id: effectiveAgentId } : {}),
credit_limit:
creditLimit.trim() === "" ? 0 : Math.max(0, Number.parseInt(creditLimit, 10) || 0),
...(rebateRate.trim() !== ""
? { rebate_rate: percentUiToRatio(rebateRate) }
credit_limit: parsedCreditLimit,
...(parsedRebateRate !== null
? { rebate_rate: parsedRebateRate }
: {}),
});
toast.success(
@@ -306,6 +349,11 @@ export function AgentsPlayersPanel({
function openCreateDialog(): void {
setDialogOpen(true);
setUsername("");
setPassword("");
setNickname("");
setCreditLimit("");
setRebateRate("");
if (effectiveAgentId !== null) {
void getAgentNodeProfile(effectiveAgentId)
.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 form = fillEditFormFromPlayer(row);
setEditUsername(form.username);
@@ -382,7 +440,7 @@ export function AgentsPlayersPanel({
}
const prevRebate = resolvePlayerRebateRate(editingPlayer);
const nextPercent = parsePercentUi(editRebateRate);
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
const nextRebate = nextPercent === null ? null : nextPercent;
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
body.rebate_rate = nextRebate;
}
@@ -648,13 +706,9 @@ export function AgentsPlayersPanel({
})}
</p>
) : (
<p className="text-sm text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
<div />
)}
{canCreatePlayer ? (
{canCreatePlayer && !embedded ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{createPlayerLabel}
@@ -693,7 +747,7 @@ export function AgentsPlayersPanel({
{!embedded ? (
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
) : 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: "操作" })}
</TableHead>
</TableRow>
@@ -750,7 +804,7 @@ export function AgentsPlayersPanel({
: undefined
}
>
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
{rebate != null ? `${percentValueToUi(rebate)}%` : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
@@ -763,7 +817,7 @@ export function AgentsPlayersPanel({
</TableCell>
) : null}
<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()}
>
<AdminRowActionsMenu
@@ -844,16 +898,20 @@ export function AgentsPlayersPanel({
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{createPlayerLabel}</DialogTitle>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="space-y-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input value={siteCode} readOnly disabled />
<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">
<Label htmlFor="agent-player-username" className="text-muted-foreground">
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
</Label>
<Input
@@ -861,10 +919,11 @@ export function AgentsPlayersPanel({
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">
<Label htmlFor="agent-player-password" className="text-muted-foreground">
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
</Label>
<Input
@@ -873,20 +932,29 @@ export function AgentsPlayersPanel({
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">
<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">
<Label htmlFor="agent-player-credit" className="text-muted-foreground">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
@@ -895,9 +963,10 @@ export function AgentsPlayersPanel({
min={0}
value={creditLimit}
onChange={(e) => setCreditLimit(e.target.value)}
className="bg-background/50 transition-colors focus:bg-background"
/>
{parentAvailableCredit !== null ? (
<p className="text-xs text-muted-foreground">
<p className="text-[11px] text-muted-foreground/80">
{t("playersPanel.availableToGrant", {
defaultValue: "代理剩余可下发:{{amount}}",
amount: formatCredit(parentAvailableCredit),
@@ -906,7 +975,7 @@ export function AgentsPlayersPanel({
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-rebate">
<Label htmlFor="agent-player-rebate" className="text-muted-foreground">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
@@ -916,11 +985,18 @@ export function AgentsPlayersPanel({
max={100}
step="0.01"
value={rebateRate}
placeholder="0.5"
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>
<DialogFooter>
</div>
</div>
<DialogFooter className="mt-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
@@ -1174,10 +1250,10 @@ export function AgentsPlayersPanel({
step="0.01"
value={editRebateRate}
onChange={(e) => setEditRebateRate(e.target.value)}
placeholder="0.5"
placeholder="0"
/>
<p className="text-xs text-muted-foreground">
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 5 表示 5%" })}
</p>
</div>
<div className="space-y-2">

View File

@@ -1,52 +1,23 @@
"use client";
import { Check, ChevronDown, Search } from "lucide-react";
import { useDeferredValue, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
AdminSubnav,
AdminSubnavBar,
AdminSubnavLink,
} from "@/components/admin/admin-subnav";
import { buttonVariants } from "@/components/ui/button";
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 { 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 { useAgentManagementSiteStore } from "@/stores/agent-management-site";
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;
}
import { cn } from "@/lib/utils";
export function AgentsSubnav(): React.ReactElement {
const { t } = useTranslation("agents");
@@ -55,18 +26,14 @@ export function AgentsSubnav(): React.ReactElement {
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const [sitePickerOpen, setSitePickerOpen] = useState(false);
const [siteKeyword, setSiteKeyword] = useState("");
const deferredKeyword = useDeferredValue(siteKeyword);
const canSwitchSite =
profile?.is_super_admin === true ||
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(() => {
if (adminSiteId !== null || siteOptions.length === 0) {
return;
@@ -80,58 +47,94 @@ export function AgentsSubnav(): React.ReactElement {
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSiteLabel = useMemo(() => {
const selectedSite = useMemo(() => {
const site = siteOptions.find((item) => item.id === selectSiteId);
return site ? `${site.name} (${site.code})` : null;
return site ?? null;
}, [selectSiteId, siteOptions]);
if (visiblePrimaryTabs.length === 0 && !showProvision) {
return <></>;
const filteredSites = useMemo(() => {
const normalized = deferredKeyword.trim().toLowerCase();
if (normalized === "") {
return siteOptions;
}
return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized));
}, [deferredKeyword, siteOptions]);
const siteSelector =
canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => setAdminSiteId(Number(value))}
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
<PopoverTrigger
className={cn(
buttonVariants({ variant: "outline", size: "lg" }),
"h-10 w-[240px] justify-between gap-2 bg-background px-3 text-left font-normal",
)}
>
<SelectTrigger className="h-9 w-[200px] bg-background">
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
<span className="min-w-0 flex-1 truncate">
{selectedSite?.name ?? t("lineFilter", { defaultValue: "一级代理" })}
</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 === 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;
return (
<AdminSubnavBar trailing={siteSelector}>
<AdminSubnav aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}>
{visiblePrimaryTabs.map((tab) => (
<AdminSubnavLink
key={tab.href}
href={tab.href}
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>
<div className="pb-1">
<p className="text-sm font-medium text-foreground">
{t("title", { defaultValue: "代理管理" })}
</p>
</div>
</AdminSubnavBar>
);
}

View File

@@ -659,14 +659,14 @@ export function OddsConfigDocScreen({
id="odds-rebate-rate"
type="text"
inputMode="decimal"
className="h-9 font-mono tabular-nums"
className="h-9 text-base font-semibold"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
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}
</ConfigReadonlyValue>
)}
@@ -703,7 +703,7 @@ export function OddsConfigDocScreen({
<Input
type="text"
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}
value={oddsMultiplierLabel(row.odds_value)}
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)}
</ConfigReadonlyValue>
)
@@ -743,7 +743,7 @@ export function OddsConfigDocScreen({
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
className="h-9 w-full text-base font-semibold"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
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)}
</ConfigReadonlyValue>
)
@@ -771,14 +771,14 @@ export function OddsConfigDocScreen({
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
className="h-9 w-full text-base font-semibold"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
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}
</ConfigReadonlyValue>
)}
@@ -857,10 +857,10 @@ export function OddsConfigDocScreen({
{publishDiffRows.map((row) => (
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
<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)}
</span>
<span className="text-right font-mono tabular-nums">
<span className="text-right text-base font-semibold">
{row.newValue === null ? "—" : oddsMultiplierLabel(row.newValue)}
</span>
</div>

View File

@@ -92,7 +92,7 @@ export function OddsConfigSummaryPanel({
return (
<div key={scope} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<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) : "—"}
</dd>
</div>
@@ -101,7 +101,7 @@ export function OddsConfigSummaryPanel({
{playRebatePercent ? (
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<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>
) : null}
</dl>

View File

@@ -818,7 +818,7 @@ export function PlayConfigDocScreen() {
<Input
type="text"
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}
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
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)}
</ConfigReadonlyValue>
)}
@@ -840,7 +840,7 @@ export function PlayConfigDocScreen() {
<Input
type="text"
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}
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
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)}
</ConfigReadonlyValue>
)}

View File

@@ -553,14 +553,14 @@ export function RebateConfigDocScreen({
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
className="h-9 text-base font-semibold"
disabled={saving}
value={p2}
placeholder={t("rebate.placeholders.d2", { ns: "config" })}
onChange={(e) => setP2(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p2}</ConfigReadonlyValue>
<ConfigReadonlyValue className="text-base font-semibold">{p2}</ConfigReadonlyValue>
)}
</div>
<div className="grid gap-2">
@@ -570,14 +570,14 @@ export function RebateConfigDocScreen({
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
className="h-9 text-base font-semibold"
disabled={saving}
value={p3}
placeholder={t("rebate.placeholders.d3", { ns: "config" })}
onChange={(e) => setP3(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p3}</ConfigReadonlyValue>
<ConfigReadonlyValue className="text-base font-semibold">{p3}</ConfigReadonlyValue>
)}
</div>
<div className="grid gap-2">
@@ -587,14 +587,14 @@ export function RebateConfigDocScreen({
type="number"
step="0.01"
min={0}
className="font-mono tabular-nums"
className="h-9 text-base font-semibold"
disabled={saving}
value={p4}
placeholder={t("rebate.placeholders.d4", { ns: "config" })}
onChange={(e) => setP4(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono>{p4}</ConfigReadonlyValue>
<ConfigReadonlyValue className="text-base font-semibold">{p4}</ConfigReadonlyValue>
)}
</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() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
@@ -161,7 +165,7 @@ export function RiskCapDocScreen() {
setDefaultCapStr("");
return;
}
setDefaultCapStr(formatAdminMinorDecimal(defaultRow.cap_amount, amountCurrencyCode));
setDefaultCapStr(formatMinorToEditableMajor(defaultRow.cap_amount, amountCurrencyCode));
}
const loadDetail = useCallback(async (id: number) => {
@@ -381,7 +385,12 @@ export function RiskCapDocScreen() {
() => specialRows.filter(({ row }) => row.draw_id != null),
[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) {
try {
@@ -524,7 +533,7 @@ export function RiskCapDocScreen() {
].map((card) => (
<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="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>
</div>
))}
@@ -539,15 +548,15 @@ export function RiskCapDocScreen() {
id="default-cap"
type="text"
inputMode="decimal"
className="w-[220px] font-mono tabular-nums"
className="h-9 w-[220px] text-base font-semibold"
disabled={saving}
value={defaultCapStr}
placeholder={t("riskCap.placeholders.defaultCap", { ns: "config" })}
onChange={(e) => setDefaultCapStr(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="w-[220px]">
{defaultCapStr || formatAdminMinorDecimal(0, amountCurrencyCode)}
<ConfigReadonlyValue className="h-9 w-[220px] text-base font-semibold">
{defaultCapDisplay}
</ConfigReadonlyValue>
)}
</div>
@@ -679,9 +688,9 @@ export function RiskCapDocScreen() {
<Input
type="text"
inputMode="decimal"
className="h-8 font-mono tabular-nums"
className="h-8 tabular-nums"
disabled={saving}
value={formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
value={formatMinorToEditableMajor(r.cap_amount, amountCurrencyCode)}
placeholder={t("riskCap.placeholders.capAmount", { ns: "config" })}
onChange={(e) =>
updateRow(idx, {
@@ -691,7 +700,7 @@ export function RiskCapDocScreen() {
}
/>
) : (
<ConfigReadonlyValue mono>
<ConfigReadonlyValue>
{formatAdminMinorDecimal(r.cap_amount, amountCurrencyCode)}
</ConfigReadonlyValue>
)}

View File

@@ -258,13 +258,13 @@ export function RiskCapRuntimePanel() {
)}
>
<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)}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.remaining_amount, currencyCode ?? undefined)}
</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)}%` : "—"}
</TableCell>
<TableCell className="text-center text-xs">

View File

@@ -251,7 +251,6 @@ export function AgentDashboardConsole(): ReactElement {
</div>
<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.settlementCycle", { cycle: overview.settlement_cycle })}</span>
<span>
{overview.latest_bet_at
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })

View File

@@ -113,6 +113,10 @@ function MaskedValueWithCopy({
type FormState = {
code: string;
name: string;
admin_username: string;
admin_nickname: string;
admin_password: string;
admin_email: string;
currency_code: string;
status: number;
wallet_api_url: string;
@@ -128,6 +132,10 @@ type FormState = {
const EMPTY_FORM: FormState = {
code: "",
name: "",
admin_username: "",
admin_nickname: "",
admin_password: "",
admin_email: "",
currency_code: "NPR",
status: 1,
wallet_api_url: "",
@@ -155,6 +163,10 @@ function rowToForm(row: AdminIntegrationSiteDetail): FormState {
return {
code: row.code,
name: row.name,
admin_username: "",
admin_nickname: "",
admin_password: "",
admin_email: "",
currency_code: row.currency_code,
status: row.status,
wallet_api_url: row.wallet_api_url ?? "",
@@ -189,7 +201,16 @@ function formToPayload(
};
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;
@@ -303,11 +324,34 @@ export function IntegrationSitesConsole({
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);
try {
if (mode === "create") {
const created = await postAdminIntegrationSite(formToPayload(form, true));
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);
} else if (editingId !== null) {
await putAdminIntegrationSite(editingId, formToPayload(form, false));
@@ -487,7 +531,6 @@ export function IntegrationSitesConsole({
<TableHead>{t("integrationSites.columns.status")}</TableHead>
<TableHead>{t("integrationSites.columns.lineRoot")}</TableHead>
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
<TableHead>{t("integrationSites.columns.h5Url")}</TableHead>
<TableHead>{t("integrationSites.columns.ssoSecret")}</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>
@@ -541,9 +584,6 @@ export function IntegrationSitesConsole({
) : null}
</div>
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
{row.lottery_h5_base_url ?? "—"}
</TableCell>
<TableCell>
<MaskedValueWithCopy
configured={row.has_sso_secret}
@@ -641,6 +681,83 @@ export function IntegrationSitesConsole({
onChange={(e) => updateForm("name", e.target.value)}
/>
</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 gap-2">
<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)}
/>
</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">
<Label htmlFor="is-origins">{t("integrationSites.fields.iframeOrigins")}</Label>
<Textarea

View File

@@ -254,34 +254,38 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
key={p.id}
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>
<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>
</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 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">
<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 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="mt-0.5 text-base font-semibold">
<p className="mt-1 text-base font-semibold">
{statusOn ? t("enabled") : t("disabled")}
</p>
</div>
<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="mt-0.5 font-mono text-base font-semibold">
<p className="mt-1 text-lg font-semibold">
{formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
</p>
</div>
<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="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>
@@ -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">
<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">
<span className="text-sm font-semibold">
{row.amount_delta > 0 ? "+" : ""}
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)}
</span>
@@ -496,9 +500,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<div className="rounded-lg border border-border/60 bg-background p-3">
<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="mt-0.5 font-mono text-sm">{minBetAmount}</p>
<p className="mt-1 text-lg font-semibold">{minBetAmount}</p>
</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.draw_no ?? "—"}</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")}
</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.ticket_no ?? "—"}</TableCell>
<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")}
</TableCell>
<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">
{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, data?.currency_code ?? "NPR")}
</TableCell>
<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">
<div className="rounded-lg border bg-muted/40 p-3">
<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)}
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<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)}
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-3">
<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)}
</p>
</div>
@@ -178,7 +178,7 @@ export function RiskPoolDetailConsole({
{row.created_at ? formatDt(row.created_at) : "—"}
</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)}
</TableCell>
<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="text-center text-sm tabular-nums">
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
</TableCell>
<TableCell className="text-center text-sm tabular-nums">
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
</TableCell>
<TableCell className="text-center text-sm tabular-nums">
<TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
</TableCell>
<TableCell className="text-center text-sm tabular-nums">

View File

@@ -113,9 +113,10 @@ export function DrawSettingsPanel() {
title={t("system.sections.draw", { ns: "config" })}
description={t("system.sections.drawDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
<div className="space-y-6">
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
<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">
<Label className="font-medium cursor-pointer" onClick={() => updateField("requireManualReview", !draft.requireManualReview)}>{t("system.fields.manualReview", { ns: "config" })}</Label>
<Switch
checked={draft.requireManualReview}
disabled={!canManage || loading || saving}
@@ -123,12 +124,11 @@ export function DrawSettingsPanel() {
onCheckedChange={(value) => updateField("requireManualReview", value)}
/>
</div>
</div>
<div className="h-px bg-border/60" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="default-currency" className="text-sm font-medium">
<div className="grid gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="default-currency" className="text-muted-foreground">
{t("system.fields.defaultCurrency", { ns: "config" })}
</Label>
<Input
@@ -138,10 +138,11 @@ export function DrawSettingsPanel() {
onChange={(e) => updateField("defaultCurrency", e.target.value.toUpperCase())}
disabled={!canManage || loading || saving}
maxLength={16}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-interval-minutes" className="text-sm font-medium">
<div className="space-y-2">
<Label htmlFor="draw-interval-minutes" className="text-muted-foreground">
{t("system.fields.drawIntervalMinutes", { ns: "config" })}
</Label>
<Input
@@ -154,10 +155,11 @@ export function DrawSettingsPanel() {
placeholder={t("system.placeholders.drawIntervalMinutes", { ns: "config" })}
onChange={(e) => updateField("drawIntervalMinutes", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-betting-window-seconds" className="text-sm font-medium">
<div className="space-y-2">
<Label htmlFor="draw-betting-window-seconds" className="text-muted-foreground">
{t("system.fields.drawBettingWindowSeconds", { ns: "config" })}
</Label>
<Input
@@ -169,10 +171,11 @@ export function DrawSettingsPanel() {
placeholder={t("system.placeholders.drawBettingWindowSeconds", { ns: "config" })}
onChange={(e) => updateField("drawBettingWindowSeconds", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-close-before-seconds" className="text-sm font-medium">
<div className="space-y-2">
<Label htmlFor="draw-close-before-seconds" className="text-muted-foreground">
{t("system.fields.drawCloseBeforeDrawSeconds", { ns: "config" })}
</Label>
<Input
@@ -184,10 +187,11 @@ export function DrawSettingsPanel() {
placeholder={t("system.placeholders.drawCloseBeforeDrawSeconds", { ns: "config" })}
onChange={(e) => updateField("drawCloseBeforeDrawSeconds", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="draw-buffer-ahead" className="text-sm font-medium">
<div className="space-y-2">
<Label htmlFor="draw-buffer-ahead" className="text-muted-foreground">
{t("system.fields.drawBufferDrawsAhead", { ns: "config" })}
</Label>
<Input
@@ -199,10 +203,11 @@ export function DrawSettingsPanel() {
placeholder={t("system.placeholders.drawBufferDrawsAhead", { ns: "config" })}
onChange={(e) => updateField("drawBufferDrawsAhead", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium">
<div className="space-y-2">
<Label htmlFor="cooldown-minutes" className="text-muted-foreground">
{t("system.fields.cooldownMinutes", { ns: "config" })}
</Label>
<Input
@@ -214,6 +219,7 @@ export function DrawSettingsPanel() {
placeholder={t("system.placeholders.cooldownMinutes", { ns: "config" })}
onChange={(e) => updateField("cooldownMinutes", e.target.value)}
disabled={!canManage || loading || saving}
className="h-10 bg-background/50 transition-colors focus:bg-background"
/>
</div>
</div>

View File

@@ -71,15 +71,17 @@ export function FrontendSettingsPanel() {
return (
<>
<AdminPageCard title={t("system.frontendConfig", { ns: "config" })}>
<div className="grid gap-2">
<Label className="text-sm font-medium">
<div className="grid gap-4">
<div className="space-y-1">
<Label className="font-medium text-muted-foreground">
{t("system.fields.playRulesHtml", { ns: "config" })}
</Label>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t("system.fields.playRulesHtmlDesc", { ns: "config" })}
</p>
</div>
<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="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
<TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
@@ -90,7 +92,7 @@ export function FrontendSettingsPanel() {
value={draft.playRulesHtmlZh}
onChange={(e) => updateField("playRulesHtmlZh", e.target.value)}
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>"
/>
</TabsContent>
@@ -100,7 +102,7 @@ export function FrontendSettingsPanel() {
value={draft.playRulesHtmlEn}
onChange={(e) => updateField("playRulesHtmlEn", e.target.value)}
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>"
/>
</TabsContent>
@@ -110,7 +112,7 @@ export function FrontendSettingsPanel() {
value={draft.playRulesHtmlNe}
onChange={(e) => updateField("playRulesHtmlNe", e.target.value)}
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>"
/>
</TabsContent>

View File

@@ -82,8 +82,9 @@ export function SettlementSettingsPanel() {
description={t("system.sections.settlementDescription", { ns: "config" })}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<div className="rounded-xl border border-border/70 bg-card overflow-hidden shadow-sm">
<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">
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoSettlement", !draft.autoSettlement)}>{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<Switch
checked={draft.autoSettlement}
disabled={loading || saving}
@@ -92,10 +93,8 @@ export function SettlementSettingsPanel() {
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoApprove", { 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">
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoApprove", !draft.autoApprove)}>{t("system.fields.autoApprove", { ns: "config" })}</Label>
<Switch
checked={draft.autoApprove}
disabled={loading || saving}
@@ -104,10 +103,8 @@ export function SettlementSettingsPanel() {
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<Label className="text-sm font-medium">{t("system.fields.autoPayout", { 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">
<Label className="font-medium cursor-pointer" onClick={() => updateField("autoPayout", !draft.autoPayout)}>{t("system.fields.autoPayout", { ns: "config" })}</Label>
<Switch
checked={draft.autoPayout}
disabled={loading || saving}
@@ -116,12 +113,10 @@ export function SettlementSettingsPanel() {
/>
</div>
<div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 space-y-1 pr-4">
<Label className="text-sm font-medium">{t("system.fields.applyRebateToPayout", { ns: "config" })}</Label>
<p className="text-xs text-muted-foreground">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
<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 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>
<p className="text-[11px] text-muted-foreground/80">{t("system.hints.applyRebateToPayout", { ns: "config" })}</p>
</div>
<Switch
checked={draft.applyRebateToPayout}
@@ -130,6 +125,7 @@ export function SettlementSettingsPanel() {
onCheckedChange={(value) => updateField("applyRebateToPayout", value)}
/>
</div>
</div>
<SettingsSectionActions
dirty={dirty}

View File

@@ -262,7 +262,7 @@ export function AgentBillDetail({
return (
<>
<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">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
<SettlementBillPartiesRow bill={bill} />
@@ -294,22 +294,22 @@ export function AgentBillDetail({
<div className="space-y-5 text-sm">
{rebateAllocations.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 p-4">
<p className="font-medium">
<div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight">
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
defaultValue: "各层级代理对回水的承担明细。",
})}
</p>
<div className="space-y-3">
<div className="mt-3 space-y-3">
<ul className="space-y-1.5 text-muted-foreground">
{rebateAllocationSummary.map((row) => (
<li key={row.key} className="flex justify-between gap-2">
<span className="min-w-0">
{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 })}
</span>
</span>
@@ -354,12 +354,12 @@ export function AgentBillDetail({
) : null}
{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">
<p className="font-medium">
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
@@ -377,54 +377,57 @@ export function AgentBillDetail({
) : null}
{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-1">
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">{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">
<p className="font-semibold tracking-tight">{paymentTitle}</p>
<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: "未结" })}{" "}
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</span>
</div>
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
<span>{direction.payer}</span>
<div className="flex flex-wrap items-center gap-1 text-[13px] text-muted-foreground">
<span className="font-medium text-foreground/80">{direction.payer}</span>
<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 className="grid gap-2">
<div className="space-y-1">
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<div className="grid gap-4 mt-2">
<div className="space-y-1.5">
<Label className="text-muted-foreground">{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
placeholder={String(bill.unpaid_amount)}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
<div className="space-y-1.5">
<Label className="text-muted-foreground">{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
<Input
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
placeholder={t("settlementBills.paymentMethodPlaceholder", {
defaultValue: "例如:现金 / 银行转账",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
<div className="space-y-1.5">
<Label className="text-muted-foreground">{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
<Input
value={payProof}
onChange={(e) => setPayProof(e.target.value)}
placeholder={t("settlementBills.paymentProofPlaceholder", {
defaultValue: "可填写流水号、截图说明或备注",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
</div>
<Button
type="button"
className="w-full"
className="w-full mt-2"
disabled={confirmBusy}
onClick={requestPayment}
>
@@ -434,31 +437,32 @@ export function AgentBillDetail({
) : null}
{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">
<p className="font-medium">
<p className="font-semibold tracking-tight text-destructive">
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-destructive/80">
{t("settlementBills.badDebtHint", {
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
})}
</p>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<div className="space-y-1.5 mt-2">
<Label className="text-destructive/90">{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input
value={badDebtReason}
onChange={(e) => setBadDebtReason(e.target.value)}
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
defaultValue: "例如:客户失联、确认坏账",
})}
className="bg-background/50 transition-colors focus:bg-background border-destructive/30"
/>
</div>
<Button
type="button"
variant="destructive"
className="w-full"
className="w-full mt-2"
disabled={confirmBusy}
onClick={requestBadDebtWriteOff}
>
@@ -468,19 +472,19 @@ export function AgentBillDetail({
) : null}
{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">
<p className="font-medium">
<p className="font-semibold tracking-tight">
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground/80">
{t("settlementBills.adjustmentHint", {
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
})}
</p>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
<div className="space-y-1.5 mt-2">
<Label className="text-muted-foreground">{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
<Input
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
@@ -488,22 +492,24 @@ export function AgentBillDetail({
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "输入正数或负数",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
<div className="space-y-1.5">
<Label className="text-muted-foreground">{t("settlementBills.adjustmentReason", { defaultValue: "调整原因" })}</Label>
<Input
value={adjustReason}
onChange={(e) => setAdjustReason(e.target.value)}
placeholder={t("settlementBills.adjustmentReasonPlaceholder", {
defaultValue: "例如:人工复核补差、冲正错账",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<Button
type="button"
variant="outline"
className="w-full"
className="w-full mt-2"
disabled={confirmBusy}
onClick={requestAdjustment}
>

View File

@@ -31,7 +31,7 @@ export function SettlementBillSummaryHeader({
const unpaid = bill.unpaid_amount > 0;
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">
<AdminStatusBadge status={bill.status}>
{settlementBillStatusLabel(bill.status, t)}
@@ -60,7 +60,7 @@ export function SettlementBillSummaryHeader({
</div>
<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">
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
</p>
@@ -70,10 +70,10 @@ export function SettlementBillSummaryHeader({
</div>
<div
className={cn(
"rounded-lg border px-3 py-2",
"rounded-xl border px-4 py-3",
unpaid
? "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">
@@ -125,8 +125,8 @@ export function SettlementBillAmountBreakdown({
}
return (
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<p className="font-medium text-foreground">
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight text-foreground">
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
</p>
@@ -178,18 +178,18 @@ export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps
const counterparty = resolveBillPartyName(bill, "counterparty", t);
return (
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
<div className="grid gap-4 text-sm sm:grid-cols-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">
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
</p>
<p className="mt-1 font-medium text-foreground">{owner}</p>
<p className="mt-1 font-semibold text-foreground">{owner}</p>
</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">
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
</p>
<p className="mt-1 font-medium text-foreground">{counterparty}</p>
<p className="mt-1 font-semibold text-foreground">{counterparty}</p>
</div>
</div>
);

View File

@@ -107,6 +107,17 @@ function unpaidMoneyClass(row: SettlementBillRow): string {
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 {
if (row.bill_type === "player") {
return row.player_username ?? row.owner_label ?? null;
@@ -118,18 +129,6 @@ function ownerPartyLabel(row: SettlementBillRow): string | 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({
rows,
loading,
@@ -211,10 +210,7 @@ export function SettlementBillsTable({
{playerView ? (
<>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<SettlementDashCell value={row.player_username ?? row.owner_label} />
{fundingModeHint(row, t)}
</div>
</TableCell>
<TableCell className="font-mono text-xs">
<SettlementDashCell
@@ -234,14 +230,7 @@ export function SettlementBillsTable({
) : null}
{mixedView ? (
<TableCell className="text-sm">
{isPlayerBill ? (
<div className="flex flex-wrap items-center gap-1.5">
<SettlementDashCell value={ownerPartyLabel(row)} />
{fundingModeHint(row, t)}
</div>
) : (
<SettlementDashCell value={ownerPartyLabel(row)} />
)}
</TableCell>
) : null}
<TableCell className="min-w-[10rem] text-sm">
@@ -275,7 +264,7 @@ export function SettlementBillsTable({
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</div>
</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)}
</TableCell>
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>

View File

@@ -1,5 +1,6 @@
"use client";
import { Check, ChevronDown, Search } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
@@ -24,16 +25,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
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 {
const { t } = useTranslation(["settlementCenter", "common"]);
@@ -54,6 +53,8 @@ export function SettlementCenterShell(): React.ReactElement {
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const [sitePickerOpen, setSitePickerOpen] = useState(false);
const [siteKeyword, setSiteKeyword] = useState("");
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
@@ -62,7 +63,12 @@ export function SettlementCenterShell(): React.ReactElement {
useEffect(() => {
if (boundAgent?.admin_site_id) {
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);
return;
}
@@ -71,6 +77,7 @@ export function SettlementCenterShell(): React.ReactElement {
const options = (sites.items ?? []).map((site) => ({
id: site.id,
label: formatAdminSiteLabel(site.name, site.code),
code: site.code,
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
@@ -81,8 +88,81 @@ export function SettlementCenterShell(): React.ReactElement {
}, [adminSiteId, boundAgent]);
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 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[]> => {
if (siteId === null) {
@@ -118,41 +198,6 @@ export function SettlementCenterShell(): React.ReactElement {
return (
<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 ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : isListMode ? (
@@ -161,6 +206,7 @@ export function SettlementCenterShell(): React.ReactElement {
currencyCode={currency}
canManage={canManagePeriods}
periods={periods}
headerActions={siteSelector}
onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods}
onPeriodOpened={() => {

View File

@@ -91,6 +91,31 @@ function reasonLabel(
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 = {
adminSiteId: number;
settlementPeriodId: number;
@@ -209,17 +234,7 @@ export function SettlementCreditLedgerPanel({
return (
<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-field">
@@ -346,7 +361,9 @@ export function SettlementCreditLedgerPanel({
<TableCell>
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell>
<TableCell className="text-xs">{creditLedgerReasonLabel(row.biz_type, t)}</TableCell>
<TableCell>
<CreditLedgerReasonBadge reason={row.biz_type} />
</TableCell>
<TableCell className="tabular-nums text-xs">
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
</TableCell>

View File

@@ -209,10 +209,11 @@ export function SettlementMainPanel({
};
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="sb-bill-id">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
<div className="space-y-5">
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-2">
<Label htmlFor="sb-bill-id" className="text-muted-foreground">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
<Input
id="sb-bill-id"
inputMode="numeric"
@@ -224,10 +225,11 @@ export function SettlementMainPanel({
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sb-owner">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
<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: "玩家账号、代理名称" })}
@@ -238,10 +240,11 @@ export function SettlementMainPanel({
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sb-status">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
<div className="grid gap-2">
<Label htmlFor="sb-status" className="text-muted-foreground">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
<Select
modal={false}
value={draft.statusScope}
@@ -252,7 +255,7 @@ export function SettlementMainPanel({
}))
}
>
<SelectTrigger id="sb-status" className="h-9 w-full">
<SelectTrigger id="sb-status" className="w-full bg-background/50 transition-colors focus:bg-background">
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -272,8 +275,8 @@ export function SettlementMainPanel({
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sb-type">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
<div className="grid gap-2">
<Label htmlFor="sb-type" className="text-muted-foreground">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
<Select
modal={false}
value={draft.billType}
@@ -284,7 +287,7 @@ export function SettlementMainPanel({
}))
}
>
<SelectTrigger id="sb-type" className="h-9 w-full">
<SelectTrigger id="sb-type" className="w-full bg-background/50 transition-colors focus:bg-background">
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -298,17 +301,18 @@ export function SettlementMainPanel({
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button type="button" onClick={() => runSearch()}>
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
<Button type="button" variant="outline" onClick={() => resetFilters()}>
{t("billsPanel.reset", { defaultValue: "重置" })}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("billsPanel.refresh", { defaultValue: "刷新" })}
</Button>
</div>
</div>
{loading && rows.length === 0 ? (
<AdminLoadingState />

View File

@@ -1,7 +1,7 @@
"use client";
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 { toast } from "sonner";
@@ -51,6 +51,7 @@ type SettlementPeriodWorkbenchProps = {
currencyCode: string;
canManage: boolean;
periods: SettlementPeriodRow[];
headerActions?: ReactNode;
onViewDetail: (periodId: number) => void;
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
onPeriodOpened?: (periodId: number) => void;
@@ -62,6 +63,7 @@ export function SettlementPeriodWorkbench({
currencyCode,
canManage,
periods,
headerActions,
onViewDetail,
onReloadPeriods,
onPeriodOpened,
@@ -257,6 +259,7 @@ export function SettlementPeriodWorkbench({
<AdminPageCard
title={t("periodTable.title", { defaultValue: "账期管理" })}
description={cardDescription}
actions={headerActions}
>
<div className="admin-list-toolbar">
<div className="admin-list-field">

View File

@@ -10,7 +10,6 @@ export type AdminAgentLineProvisionPayload = {
credit_limit?: number;
rebate_limit?: number;
default_player_rebate?: number;
settlement_cycle?: "daily" | "weekly" | "monthly";
can_grant_extra_rebate?: boolean;
};

View File

@@ -21,7 +21,6 @@ export type AgentNodeProfileSummary = {
available_credit: number;
rebate_limit: number;
default_player_rebate: number;
settlement_cycle: "daily" | "weekly" | "monthly";
};
export type AgentNodeRow = {
@@ -46,12 +45,16 @@ export type AgentTreeData = {
tree: AgentNodeRow[];
};
export type AgentNodeListData = {
admin_site_id: number | null;
items: AgentNodeRow[];
};
export type AgentProfilePayload = {
total_share_rate?: number;
credit_limit?: number;
rebate_limit?: number;
default_player_rebate?: number;
settlement_cycle?: "daily" | "weekly" | "monthly";
can_grant_extra_rebate?: boolean;
can_create_child_agent?: boolean;
can_create_player?: boolean;

View File

@@ -60,7 +60,6 @@ export type AdminDashboardAgentOverview = {
used_credit: number;
available_credit: number;
total_share_rate: number;
settlement_cycle: string;
can_create_child_agent: boolean;
can_create_player: boolean;
direct_child_count: number;

View File

@@ -35,9 +35,17 @@ export type AdminIntegrationSiteListData = {
items: AdminIntegrationSiteRow[];
};
export type AdminIntegrationSiteAdminAccountPayload = {
username: string;
nickname: string;
password: string;
email?: string | null;
};
export type AdminIntegrationSiteCreatePayload = {
code: string;
name: string;
admin_account: AdminIntegrationSiteAdminAccountPayload;
currency_code?: string;
status?: number;
wallet_api_url?: string | null;
@@ -50,11 +58,20 @@ export type AdminIntegrationSiteCreatePayload = {
notes?: string | null;
};
export type AdminIntegrationSiteUpdatePayload = Omit<AdminIntegrationSiteCreatePayload, "code">;
export type AdminIntegrationSiteUpdatePayload = Omit<
AdminIntegrationSiteCreatePayload,
"code" | "admin_account"
>;
export type AdminIntegrationSiteWithSecrets = AdminIntegrationSiteDetail & {
secrets?: AdminIntegrationSiteSecrets;
secrets_display_once?: boolean;
admin_user?: {
id: number;
username: string;
nickname: string;
email: string | null;
};
};
export type AdminIntegrationSiteConnectivityProbe = {