feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates

Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
2026-06-04 18:01:05 +08:00
parent c2eac2fafc
commit 65eaeecf8c
139 changed files with 8852 additions and 1435 deletions

View File

@@ -0,0 +1,287 @@
import { adminRequest } from "@/lib/admin-http";
const A = `/admin`;
export type SettlementPeriodSummary = {
player_bills: number;
agent_bills: number;
adjustment_bills: number;
pending_confirm: number;
awaiting_payment: number;
settled: number;
total_unpaid: number;
};
export type SettlementPeriodPipeline = {
credit_ledger_count: number;
share_ledger_count: number;
};
export type SettlementPeriodRow = {
id: number;
admin_site_id: number;
period_start: string;
period_end: string;
status: string;
summary?: SettlementPeriodSummary;
pipeline?: SettlementPeriodPipeline;
};
export type AgentSettlementReportType =
| "summary"
| "player_win_loss"
| "agent_share"
| "rebate"
| "credit"
| "unpaid_bills"
| "overdue"
| "platform_pnl"
| "draw_period";
export type SettlementBillRow = {
id: number;
settlement_period_id: number;
bill_type: string;
owner_type: string;
owner_id: number;
counterparty_type: string;
counterparty_id: number;
gross_win_loss?: number;
rebate_amount?: number;
platform_rounding_adjustment?: number;
net_amount: number;
unpaid_amount: number;
paid_amount: number;
status: string;
owner_label?: string;
counterparty_label?: string;
owner_funding_mode?: string | null;
owner_auth_source?: string | null;
period_start?: string;
period_end?: string;
admin_site_id?: number;
meta_json?: string | Record<string, unknown> | null;
};
export async function getSettlementPeriods(params?: {
admin_site_id?: number;
}): Promise<{ items: SettlementPeriodRow[] }> {
return adminRequest.get(`${A}/settlement-periods`, { params });
}
export async function postSettlementPeriod(body: {
admin_site_id: number;
period_start: string;
period_end: string;
}): Promise<SettlementPeriodRow> {
return adminRequest.post(`${A}/settlement-periods`, body);
}
export type SettlementPeriodCloseResult = {
period_id: number;
unsettled_ticket_count?: number;
player_count?: number;
};
export async function postSettlementPeriodClose(
periodId: number,
): Promise<SettlementPeriodCloseResult> {
return adminRequest.post(`${A}/settlement-periods/${periodId}/close`);
}
export async function postSettlementBillBadDebtWriteOff(
billId: number,
body?: { reason?: string },
): Promise<{ original_bill_id: number; bad_debt_bill_id: number; bill: SettlementBillRow }> {
return adminRequest.post(`${A}/settlement-bills/${billId}/bad-debt-write-off`, body ?? {});
}
export type SettlementBillListScope =
| "pending_confirm"
| "awaiting_payment"
| "settled"
| "adjustment";
export async function getSettlementBills(params?: {
settlement_period_id?: number;
admin_site_id?: number;
bill_type?: string;
scope?: SettlementBillListScope;
}): Promise<{ items: SettlementBillRow[] }> {
return adminRequest.get(`${A}/settlement-bills`, { params });
}
export type SettlementPaymentRow = {
id: number;
settlement_bill_id: number;
payer_type: string;
payer_id: number;
payee_type: string;
payee_id: number;
amount: number;
method: string | null;
proof?: string | null;
remark?: string | null;
status: string;
bill_type: string;
owner_type: string;
owner_id: number;
period_start?: string;
period_end?: string;
confirmed_at?: string | null;
created_at?: string;
};
export async function getSettlementPayments(params?: {
settlement_period_id?: number;
admin_site_id?: number;
}): Promise<{ items: SettlementPaymentRow[] }> {
return adminRequest.get(`${A}/settlement-payments`, { params });
}
export type SettlementAdjustmentRow = {
id: number;
settlement_period_id: number | null;
original_bill_id: number | null;
adjustment_type: string;
amount: number;
reason: string | null;
created_by: number | null;
period_start?: string;
period_end?: string;
original_bill_type?: string | null;
original_owner_type?: string | null;
original_owner_id?: number | null;
created_at?: string;
};
export async function getSettlementAdjustments(params?: {
settlement_period_id?: number;
admin_site_id?: number;
adjustment_type?: string;
}): Promise<{ items: SettlementAdjustmentRow[] }> {
return adminRequest.get(`${A}/settlement-adjustments`, { params });
}
export type SettlementLedgerRow = {
entry_kind: "credit" | "payment" | "adjustment";
id: number;
row_key?: string;
txn_no: string;
player_id: number;
site_code?: string;
site_player_id?: string | null;
username?: string | null;
nickname?: string | null;
biz_type: string;
type?: string;
biz_no?: string | null;
direction: number;
amount: number;
amount_formatted?: string;
signed_amount?: number;
currency_code?: string;
status: string;
created_at?: string | null;
ledger_source: string;
funding_mode?: string;
auth_source?: string | null;
settlement_bill_id?: number | null;
bill_status?: string | null;
bill_type?: string | null;
bill_unpaid_amount?: number | null;
available_actions?: string[];
};
/** @deprecated Use {@link SettlementLedgerRow} */
export type CreditLedgerRow = SettlementLedgerRow;
export async function getCreditLedger(params?: {
admin_site_id?: number;
settlement_period_id?: number;
player_id?: number;
player_account?: string;
txn_no?: string;
reason?: string;
biz_type?: string;
entry_kind?: string;
bill_status?: string;
actionable_only?: boolean;
bad_debt_only?: boolean;
created_from?: string;
created_to?: string;
page?: number;
per_page?: number;
}): Promise<{
items: SettlementLedgerRow[];
total: number;
page: number;
per_page: number;
ledger_source: string;
}> {
return adminRequest.get(`${A}/credit-ledger`, { params });
}
export type RebateAllocationRow = {
id: number;
rebate_record_id: number;
participant_type: string;
participant_id: number;
actual_share_rate: number;
allocated_amount: number;
allocation_rule: string;
};
export type SettlementPaymentRow = {
id: number;
amount: number;
status: string;
method?: string | null;
proof?: string | null;
remark?: string | null;
};
export async function getSettlementBill(billId: number): Promise<{
bill: SettlementBillRow;
payments: SettlementPaymentRow[];
rebate_allocations: RebateAllocationRow[];
adjustments: Array<{ id: number; amount: number; adjustment_type: string; reason: string | null }>;
tier_edge?: string | null;
}> {
return adminRequest.get(`${A}/settlement-bills/${billId}`);
}
export async function postSettlementBillConfirm(billId: number): Promise<{ bill_id: number; status: string }> {
return adminRequest.post(`${A}/settlement-bills/${billId}/confirm`);
}
export async function postSettlementBillPayment(
billId: number,
body: { amount: number; method?: string; proof?: string; remark?: string },
): Promise<{ bill: SettlementBillRow }> {
return adminRequest.post(`${A}/settlement-bills/${billId}/payments`, body);
}
export type AgentSettlementReportResponse = {
type: string;
settlement_period_id: number | null;
period_start: string;
period_end: string;
data: unknown;
footnote: string | null;
};
export async function getAgentSettlementReport(params: {
type: AgentSettlementReportType;
settlement_period_id?: number;
admin_site_id?: number;
}): Promise<AgentSettlementReportResponse> {
return adminRequest.get<AgentSettlementReportResponse>(`${A}/settlement-reports`, { params });
}
export async function postSettlementBillAdjustment(
billId: number,
body: { amount: number; adjustment_type?: "adjustment" | "reversal"; reason?: string },
): Promise<{ adjustment_bill_id: number; bill: SettlementBillRow }> {
return adminRequest.post(`${A}/settlement-bills/${billId}/adjustments`, body);
}

View File

@@ -8,6 +8,7 @@ import type {
AdminIntegrationSiteListData,
AdminIntegrationSiteParameterSheet,
AdminIntegrationSiteUpdatePayload,
AdminIntegrationSiteSecrets,
AdminIntegrationSiteWithSecrets,
} from "@/types/api/admin-integration-site";
@@ -63,3 +64,9 @@ export async function getAdminIntegrationSiteExport(
);
}
export async function getAdminIntegrationSiteSecrets(
id: number,
): Promise<AdminIntegrationSiteSecrets> {
return adminRequest.get<AdminIntegrationSiteSecrets>(`${A}/integration-sites/${id}/secrets`);
}

View File

@@ -13,6 +13,7 @@ import type {
AdminUserPermissionListData,
AdminUserPermissionRow,
AdminUserRoleSyncData,
AdminUserRoleSyncPayload,
AdminUserUpdatePayload,
} from "@/types/api/admin-user";
@@ -81,9 +82,7 @@ export async function putAdminRolePermissions(
export async function putAdminUserRoles(
adminUserId: number,
roleSlugs: string[],
body: AdminUserRoleSyncPayload,
): Promise<AdminUserRoleSyncData> {
return adminRequest.put<AdminUserRoleSyncData>(`${A}/admin-users/${adminUserId}/roles`, {
role_slugs: roleSlugs,
});
return adminRequest.put<AdminUserRoleSyncData>(`${A}/admin-users/${adminUserId}/roles`, body);
}

View File

@@ -4,10 +4,8 @@ import { AgentsSubnav } from "@/modules/agents/agents-subnav";
export default function AdminAgentsLayout({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<AgentsSubnav />
</div>
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-4 px-4 py-4 sm:px-6 lg:px-8 lg:py-5">
<AgentsSubnav />
{children}
</div>
);

View File

@@ -10,7 +10,10 @@ export const metadata: Metadata = buildPageMetadata("agents", "lineProvision.tit
export default function AgentLineProvisionPage(): React.ReactElement {
return (
<ModuleScaffold embedded>
<AdminPermissionGate requiredAny={PRD_AGENT_LINE_PROVISION_ACCESS_ANY}>
<AdminPermissionGate
requiredAny={PRD_AGENT_LINE_PROVISION_ACCESS_ANY}
denyWhenBoundLineAgent
>
<AgentLineProvisionWizard />
</AdminPermissionGate>
</ModuleScaffold>

View File

@@ -1,18 +1,5 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { AgentBillsConsole } from "@/modules/settlement/agent-bills-console";
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = buildPageMetadata("agents", "subnav.settlementBills");
export default function AgentSettlementBillsPage(): React.ReactElement {
return (
<ModuleScaffold embedded>
<AdminPermissionGate requiredAny={PRD_SETTLEMENT_AGENT_ACCESS_ANY}>
<AgentBillsConsole />
</AdminPermissionGate>
</ModuleScaffold>
);
export default function AgentSettlementBillsPage(): never {
redirect("/admin/settlement-center");
}

View File

@@ -1,15 +1,6 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
import { PRD_AGENT_SITES_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = buildPageMetadata("agents", "sitesTitle");
export default function AgentLineSitesPage() {
return (
<AdminPermissionGate requiredAny={PRD_AGENT_SITES_ACCESS_ANY}>
<IntegrationSitesConsole restrictCreateToSuperAdmin />
</AdminPermissionGate>
);
/** @deprecated 接入站点配置已移至「运营配置」 */
export default function LegacyAgentLineSitesPage() {
redirect("/admin/config/integration-sites");
}

View File

@@ -1,6 +1,18 @@
import { redirect } from "next/navigation";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { IntegrationSitesConsole } from "@/modules/integration/integration-sites-console";
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
/** @deprecated 接入配置已并入「代理线路」目录 */
export default function LegacyIntegrationSitesPage() {
redirect("/admin/agents/sites");
export const metadata: Metadata = buildPageMetadata("config", "integrationSites.title");
export default function ConfigIntegrationSitesPage() {
return (
<ModuleScaffold>
<AdminPermissionGate requiredAny={PRD_INTEGRATION_ACCESS_ANY}>
<IntegrationSitesConsole />
</AdminPermissionGate>
</ModuleScaffold>
);
}

View File

@@ -1,6 +1,6 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { DrawFinanceConsole } from "@/modules/draws/draw-finance-console";
import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_DRAW_FINANCE_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
@@ -11,7 +11,7 @@ export default async function AdminDrawFinancePage(props: {
}) {
const { drawId } = await props.params;
return (
<AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}>
<AdminPermissionGate requiredAny={PRD_DRAW_FINANCE_ACCESS_ANY}>
<DrawFinanceConsole drawId={drawId} />
</AdminPermissionGate>
);

View File

@@ -1,4 +1,6 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { DrawPublishConsole } from "@/modules/draws/draw-publish-console";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
@@ -8,5 +10,9 @@ export default async function AdminDrawPublishBatchPage(props: {
params: Promise<{ drawId: string; batchId: string }>;
}) {
const { drawId, batchId } = await props.params;
return <DrawPublishConsole drawId={drawId} batchId={batchId} />;
return (
<AdminPermissionGate requiredAny={[PRD_DRAW_RESULT_MANAGE]}>
<DrawPublishConsole drawId={drawId} batchId={batchId} />
</AdminPermissionGate>
);
}

View File

@@ -1,13 +1,13 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { DrawReviewConsole } from "@/modules/draws/draw-review-console";
import { PRD_DRAW_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_DRAW_RESULT_MANAGE } from "@/lib/admin-prd";
export default async function AdminDrawReviewPage(props: {
params: Promise<{ drawId: string }>;
}) {
const { drawId } = await props.params;
return (
<AdminPermissionGate requiredAny={PRD_DRAW_ACCESS_ANY}>
<AdminPermissionGate requiredAny={[PRD_DRAW_RESULT_MANAGE]}>
<DrawReviewConsole drawId={drawId} />
</AdminPermissionGate>
);

View File

@@ -1,7 +1,7 @@
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { PRD_DASHBOARD_ACCESS_ANY } from "@/lib/admin-prd";
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
import { DashboardPageClient } from "@/modules/dashboard/dashboard-page-client";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
@@ -11,7 +11,7 @@ export default function AdminDashboardPage() {
return (
<ModuleScaffold>
<AdminPermissionGate requiredAny={PRD_DASHBOARD_ACCESS_ANY}>
<DashboardConsole />
<DashboardPageClient />
</AdminPermissionGate>
</ModuleScaffold>
);

View File

@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import { ReportsConsole } from "@/modules/reports/reports-console";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("reports", "legacyTitle");
export default function AdminReportsLegacyPage(): React.ReactElement {
return (
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
<ReportsConsole initialCategory="legacy" />
</AdminPermissionGate>
);
}

View File

@@ -0,0 +1,21 @@
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { SettlementCenterConsole } from "@/modules/settlement/settlement-center-console";
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
import { buildPageMetadata } from "@/lib/page-metadata";
import type { Metadata } from "next";
export const metadata: Metadata = buildPageMetadata("settlementCenter", "title");
export default function SettlementCenterPage(): React.ReactElement {
return (
<ModuleScaffold embedded>
<AdminPermissionGate
requiredAny={PRD_SETTLEMENT_AGENT_ACCESS_ANY}
allowWhenBoundLineAgent
>
<SettlementCenterConsole />
</AdminPermissionGate>
</ModuleScaffold>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function LegacyAgentBillsPage() {
redirect("/admin/agents/settlement-bills");
redirect("/admin/settlement-center");
}

View File

@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { WalletScopeHint } from "@/modules/wallet/wallet-scope-hint";
import { WalletSubnav } from "@/modules/wallet/wallet-subnav";
export default function AdminWalletLayout({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1680px] min-w-0 flex-col gap-6 px-4 py-5 sm:px-6 lg:px-8 lg:py-6">
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="sticky top-14 z-20 space-y-2 bg-background/95 pb-2 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<WalletSubnav />
<WalletScopeHint />
</div>
{children}
</div>

View File

@@ -43,8 +43,7 @@ const TOP_ROUTE_LABELS: Record<string, string> = {
const AGENT_ROUTE_LABELS: Record<string, string> = {
list: "agents.directoryTitle",
provision: "agents.subnav.provision",
sites: "agents.sitesTitle",
"settlement-bills": "agents.subnav.settlementBills",
"settlement-bills": "settlementCenter.title",
};
const CONFIG_ROUTE_LABELS: Record<string, string> = {

View File

@@ -0,0 +1,83 @@
"use client";
import Image from "next/image";
import type { ReactElement, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
const NOTDATA_IMAGE = "/notdata.png";
export function AdminNoResourceState({
className,
message,
compact = false,
imageClassName,
children,
}: {
className?: string;
/** 默认「暂无资源」 */
message?: string;
compact?: boolean;
imageClassName?: string;
children?: ReactNode;
}): ReactElement {
const { t } = useTranslation("common");
const label = message ?? t("states.noResource", { defaultValue: "暂无资源" });
return (
<div
className={cn(
"flex w-full flex-col items-center justify-center text-center",
compact ? "gap-2 py-4" : "gap-3 py-8",
className,
)}
role="status"
>
<Image
src={NOTDATA_IMAGE}
alt=""
width={compact ? 120 : 160}
height={compact ? 120 : 160}
className={cn(
"h-auto w-auto object-contain",
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",
imageClassName,
)}
/>
<p
className={cn(
"text-muted-foreground",
compact ? "text-[11px] leading-snug" : "text-sm",
)}
>
{label}
</p>
{children}
</div>
);
}
/** 表格无数据行(图片 + 暂无资源,竖排居中) */
export function AdminTableNoResourceRow({
colSpan,
className,
cellClassName,
message,
compact,
}: {
colSpan: number;
className?: string;
cellClassName?: string;
message?: string;
compact?: boolean;
}): ReactElement {
return (
<TableRow className={className}>
<TableCell colSpan={colSpan} className={cn("text-muted-foreground", cellClassName)}>
<AdminNoResourceState message={message} compact={compact} />
</TableCell>
</TableRow>
);
}

View File

@@ -11,6 +11,10 @@ type AdminPermissionGateProps = {
requiredAny: readonly string[];
children: ReactNode;
className?: string;
/** 与 `isAgentLineSubnavTabVisible` 一致:绑定线路代理时额外放行 */
allowWhenBoundLineAgent?: boolean;
/** 绑定线路代理时一律拒绝(如开通线路) */
denyWhenBoundLineAgent?: boolean;
};
/** 深链进入无权限页面时展示拒绝说明,避免空白或反复 403。 */
@@ -18,10 +22,29 @@ export function AdminPermissionGate({
requiredAny,
children,
className,
allowWhenBoundLineAgent = false,
denyWhenBoundLineAgent = false,
}: AdminPermissionGateProps): React.ReactElement {
const { t } = useTranslation("common");
const profile = useAdminProfile();
const allowed = adminHasAnyPermission(profile?.permissions, [...requiredAny]);
const boundAgent = profile?.agent ?? null;
if (denyWhenBoundLineAgent && boundAgent !== null) {
return (
<Card className={className ?? "admin-list-card"}>
<CardHeader>
<CardTitle className="text-base">{t("permission.deniedTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t("permission.deniedDescription")}</p>
</CardContent>
</Card>
);
}
const allowed =
(allowWhenBoundLineAgent && boundAgent !== null) ||
adminHasAnyPermission(profile?.permissions, [...requiredAny]);
if (allowed) {
return <>{children}</>;

View File

@@ -3,6 +3,7 @@
import { useMemo } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Table,
@@ -119,8 +120,8 @@ export function AdminPermissionPackageSelector({
if (groups.length === 0 || bundleCount === 0) {
return (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
{emptyText}
<div className="rounded-xl border border-dashed p-4">
<AdminNoResourceState compact className="py-4" />
</div>
);
}
@@ -128,26 +129,51 @@ export function AdminPermissionPackageSelector({
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => {
const next = new Set(selectedSet);
const currentLevel = PACKAGE_LEVEL_ORDER[bundleKey] ?? 10;
const relatedSlugs = group.packages
.filter((item) => {
const level = PACKAGE_LEVEL_ORDER[item.key] ?? 10;
return checked ? level <= currentLevel : level >= currentLevel;
})
.flatMap((item) => item.slugs);
for (const slug of relatedSlugs.length > 0 ? relatedSlugs : slugs) {
if (checked) next.add(slug);
else next.delete(slug);
if (checked) {
const implied = group.packages
.filter((item) => (PACKAGE_LEVEL_ORDER[item.key] ?? 10) <= currentLevel)
.flatMap((item) => item.slugs);
for (const slug of implied.length > 0 ? implied : slugs) {
next.add(slug);
}
for (const item of group.packages) {
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
for (const slug of item.slugs) {
next.delete(slug);
}
}
}
} else {
for (const slug of slugs) {
next.delete(slug);
}
if (bundleKey === "manage" || bundleKey === "reopen") {
for (const item of group.packages) {
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
for (const slug of item.slugs) {
next.delete(slug);
}
}
}
}
}
onChange(Array.from(next).sort());
};
const toggleGroup = (group: RenderGroup, checked: boolean) => {
const next = new Set(selectedSet);
const relatedSlugs = group.packages.flatMap((item) => item.slugs);
for (const slug of relatedSlugs) {
if (checked) next.add(slug);
else next.delete(slug);
if (checked) {
const viewBundle =
group.packages.find((item) => item.key === "view") ?? group.packages[0];
for (const slug of viewBundle?.slugs ?? []) {
next.add(slug);
}
} else {
for (const slug of group.packages.flatMap((item) => item.slugs)) {
next.delete(slug);
}
}
onChange(Array.from(next).sort());
};

View File

@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
import { useMemo, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
@@ -108,8 +109,8 @@ export function AdminPermissionSelector({
if (groups.length === 0 || totalCount === 0) {
return (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
{emptyText}
<div className="rounded-xl border border-dashed p-4">
<AdminNoResourceState compact className="py-4" />
</div>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useTranslation } from "react-i18next";
import { playerFundingModeLabel } from "@/lib/admin-player-display";
import { cn } from "@/lib/utils";
import type { AdminPlayerRow } from "@/types/api/admin-player";
type FundingRow = Pick<AdminPlayerRow, "funding_mode" | "uses_credit" | "auth_source">;
export function PlayerFundingModeBadge({
row,
className,
}: {
row: FundingRow;
className?: string;
}): React.ReactElement {
const { t } = useTranslation("players");
const isCredit = row.funding_mode === "credit" || row.uses_credit === true;
return (
<span
className={cn(
"inline-flex rounded-md border px-2 py-0.5 text-xs font-medium",
isCredit
? "border-violet-200 bg-violet-50 text-violet-900 dark:border-violet-800 dark:bg-violet-950/50 dark:text-violet-200"
: "border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800 dark:bg-sky-950/50 dark:text-sky-200",
className,
)}
>
{playerFundingModeLabel(row, t)}
</span>
);
}
export function PlayerLedgerSourceBadge({
ledgerSource,
className,
}: {
ledgerSource?: string | null;
className?: string;
}): React.ReactElement | null {
const { t } = useTranslation("wallet");
if (ledgerSource !== "credit_ledger" && ledgerSource !== "wallet_txn") {
return null;
}
const isCredit = ledgerSource === "credit_ledger";
return (
<span
className={cn(
"inline-flex rounded-md border px-2 py-0.5 text-xs font-medium",
isCredit
? "border-violet-200 bg-violet-50 text-violet-900"
: "border-sky-200 bg-sky-50 text-sky-900",
className,
)}
>
{isCredit
? t("ledgerCredit", { defaultValue: "信用流水" })
: t("ledgerWallet", { defaultValue: "钱包流水" })}
</span>
);
}

View File

@@ -32,11 +32,13 @@ async function fetchSiteCodeOptions(): Promise<AdminSiteCodeOption[]> {
inflightSites = getAdminIntegrationSites()
.then((data) => {
cachedSites = data.items.map((row) => ({
id: row.id,
code: row.code,
name: row.name,
}));
const byId = new Map<number, AdminSiteCodeOption>();
for (const row of data.items) {
if (!byId.has(row.id)) {
byId.set(row.id, { id: row.id, code: row.code, name: row.name });
}
}
cachedSites = [...byId.values()];
return cachedSites;
})
.catch(() => {

View File

@@ -25,6 +25,7 @@ import enReconcile from "@/i18n/locales/en/reconcile.json";
import enReports from "@/i18n/locales/en/reports.json";
import enWallet from "@/i18n/locales/en/wallet.json";
import enAgents from "@/i18n/locales/en/agents.json";
import enSettlementCenter from "@/i18n/locales/en/settlementCenter.json";
import neAudit from "@/i18n/locales/ne/audit.json";
import neAdminUsers from "@/i18n/locales/ne/adminUsers.json";
import neAuth from "@/i18n/locales/ne/auth.json";
@@ -57,12 +58,13 @@ import zhReconcile from "@/i18n/locales/zh/reconcile.json";
import zhReports from "@/i18n/locales/zh/reports.json";
import zhWallet from "@/i18n/locales/zh/wallet.json";
import zhAgents from "@/i18n/locales/zh/agents.json";
import zhSettlementCenter from "@/i18n/locales/zh/settlementCenter.json";
export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const;
export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number];
export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh";
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "agents", "config"] as const;
const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "settlementCenter", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "agents", "config"] as const;
const resources = {
en: {
@@ -82,6 +84,7 @@ const resources = {
settlement: enSettlement,
wallet: enWallet,
agents: enAgents,
settlementCenter: enSettlementCenter,
},
ne: {
common: neCommon,
@@ -100,6 +103,7 @@ const resources = {
settlement: neSettlement,
wallet: neWallet,
agents: neAgents,
settlementCenter: enSettlementCenter,
},
zh: {
common: zhCommon,
@@ -118,6 +122,7 @@ const resources = {
settlement: zhSettlement,
wallet: zhWallet,
agents: zhAgents,
settlementCenter: zhSettlementCenter,
},
} satisfies Record<AdminLanguage, Record<(typeof namespaces)[number], Record<string, unknown>>>;

View File

@@ -6,6 +6,7 @@
"loadFailed": "Failed to load admin list",
"nicknameRequired": "Enter a nickname",
"newPasswordMin": "New password must be at least 8 characters",
"siteRequired": "Select a site",
"roleRequired": "Select at least one role",
"usernameRequired": "Enter a login username",
"passwordMin": "Password must be at least 8 characters",
@@ -40,6 +41,7 @@
"account": "Account",
"nickname": "Nickname",
"status": "Status",
"sites": "Bound sites",
"roles": "Roles",
"effective": "Effective",
"actions": "Actions"
@@ -90,7 +92,8 @@
"permissionDialog": {
"title": "Assign roles",
"rolesTitle": "Roles",
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management.",
"site": "Site",
"rolesDescription": "Admins only bind roles here. Maintain detailed permissions in Role Management. Save roles per site.",
"rolePermissionCount": "Contains {{count}} functional permissions",
"selectedRoles": "Selected roles:",
"saveRoles": "Save roles"
@@ -125,8 +128,10 @@
"passwordOptional": "Password (optional)",
"passwordPlaceholderCreate": "At least 8 characters",
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
"rolesRequired": "Roles (default site, at least one)",
"rolesDescription": "After creation, you can continue adjusting role bindings in Assign Roles.",
"site": "Bound site",
"sitePlaceholder": "Select which site this account can access",
"rolesRequired": "Roles (at least one)",
"rolesDescription": "After creation, adjust per-site role bindings in Assign Roles.",
"noRoles": "No roles available yet. Wait for the list to finish loading and try again."
},
"delete": {

View File

@@ -1,15 +1,42 @@
{
"title": "Agent lines",
"sitesTitle": "Sites",
"sitesListHint": "For the full site table (keys, callbacks, etc.), go to",
"sitesListLink": "Sites",
"title": "Agent management",
"sitesListHint": "For integration keys and callbacks, go to",
"sitesListLink": "Config · Connected sites",
"lineUi": {
"kicker": "Credit share · Agent tree",
"agentCount": "{{count}} agents in this group",
"searchPlaceholder": "Search name or login",
"directChildren": "{{count}} direct downline",
"selectAgent": "Select an agent to view share & credit",
"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}}",
"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.",
"downlineEmptyTitle": "No direct downline yet",
"editAccount": "Account & status",
"saveProfile": "Save share & credit",
"tabDownline": "Downline",
"tabPlayers": "Players",
"noDelegatedTabs": "This agent cannot create downline agents or players; only share and credit settings apply."
},
"listTitle": "Agents",
"listSearch": "Search name / code / login",
"parentAgent": "Parent",
"childrenCount": "Direct downline",
"subnav": {
"label": "Agent line navigation",
"label": "Agent management navigation",
"noPermission": "No permission",
"operations": "Operations",
"provision": "Provision line",
"sites": "Sites",
"settlementBills": "Agent bills"
"operations": "Line & agent tree",
"provision": "Provision level-1 agent",
"settlementBills": "Periods & bills",
"provisionHint": "One-time onboarding; use Line & agent tree for daily work"
},
"includeRoots": "Include root nodes",
"includeRootsHint": "Root nodes represent site boundaries and are excluded from operating agent counts by default.",
@@ -35,7 +62,7 @@
"detailTitle": "Node details",
"selectNode": "Select an agent node from the tree",
"loadFailed": "Failed to load agent tree",
"siteLabel": "Site",
"lineFilter": "Level-1 agent",
"createChild": "Add child agent",
"viewDownline": "View sub-agents and players",
"downlineDialogTitle": "{{name}} — sub-agents and players",
@@ -86,8 +113,8 @@
"section": "Share & credit",
"totalShareRate": "Share rate (%)",
"creditLimit": "Credit limit",
"rebateLimit": "Rebate ceiling",
"defaultPlayerRebate": "Default player rebate",
"rebateLimit": "Rebate ceiling (%)",
"defaultPlayerRebate": "Default player rebate (%)",
"settlementCycle": "Settlement cycle",
"canGrantExtraRebate": "Allow extra rebate",
"canCreatePlayer": "Allow creating players",
@@ -101,41 +128,72 @@
"validation": {
"shareRange": "Share rate must be between 0 and 100",
"creditInvalid": "Credit limit cannot be negative",
"rebateLimitRange": "Rebate ceiling must be between 0 and 1 (e.g. 0.005 = 0.5%)",
"defaultRebateRange": "Default player rebate must be between 0 and 1",
"rebateLimitRange": "Rebate ceiling must be between 0 and 100%",
"defaultRebateRange": "Default player rebate must be between 0 and 100%",
"defaultExceedsLimit": "Default player rebate cannot exceed the rebate ceiling"
}
},
"settlementBills": {
"title": "Agent bills",
"description": "Player/agent bills generated after a period is closed",
"description": "Bills appear after a period is closed; latest period is selected by default.",
"periodLabel": "Period",
"periodPlaceholder": "Select period",
"allPeriods": "All periods",
"filteredByPeriodRange": "Bills for {{range}}",
"emptyNoPeriodsManage": "No periods or bills yet. Use quick presets under Period management, then close the period.",
"emptyNoPeriodsAgent": "No bills yet. Your upline or platform will close periods; you do not need to enter dates.",
"emptyNoClosed": "No closed period yet. Bills are generated after close.",
"typePlayer": "Player bill",
"typeAgent": "Agent bill",
"columns": {
"id": "ID",
"period": "Period",
"type": "Type",
"net": "Net",
"unpaid": "Unpaid",
"status": "Status"
}
},
"settlementPeriods": {
"manageTitle": "Period management",
"manageHint": "Open and close periods here; bills above update automatically. Quick presets are usually enough.",
"presetThisWeek": "This week",
"presetLastWeek": "Last week",
"presetThisMonth": "This month",
"statusOpen": "Open",
"statusClosed": "Closed"
},
"lineProvision": {
"title": "Provision agent line",
"description": "Creates site, root agent, and admin account in one step (site_code matches agent code).",
"code": "Site code",
"name": "Line name",
"username": "Agent login",
"title": "Create level-1 agent",
"description": "Creates the level-1 agent, admin login, and line settings (share, credit, rebate, settlement cycle) in one step. Code cannot be changed later.",
"code": "Agent code",
"name": "Level-1 agent name",
"username": "Admin login",
"password": "Initial password",
"walletUrl": "Wallet API URL",
"submit": "Provision",
"success": "Line provisioned",
"secretsOnce": "Secrets are shown once — save them now",
"link": "Provision line"
"walletUrl": "Wallet API URL (optional technical field)",
"submit": "Create level-1 agent",
"success": "Level-1 agent created",
"secretsOnce": "Integration secrets are shown once — save them now",
"link": "Create level-1 agent"
},
"noAccess": "You do not have permission to manage agents. Contact an administrator.",
"playersPanel": {
"create": "Create player",
"scopedTo": "Direct players: {{agent}}",
"allUnderSite": "Players visible on this site",
"filterHint": "Filter direct players by parent agent."
"filterHint": "Filter direct players by parent agent.",
"loginRequired": "Enter login username and initial password",
"loginUsername": "Login username",
"initialPassword": "Initial password",
"externalIdOptional": "External ID (optional)",
"externalIdHint": "Leave blank to auto-generate",
"creditLimit": "Credit limit",
"rebateRate": "Rebate rate (%)",
"rebateRateHint": "Enter percent, e.g. 0.5 = 0.5%",
"availableToGrant": "Agent available to grant: {{amount}}",
"riskTags": "Risk tags",
"riskTagsPlaceholder": "Comma-separated",
"createSuccessNative": "Player {{name}} created — use lottery /login"
},
"delegation": {
"title": "Delegation ceiling",

View File

@@ -106,7 +106,8 @@
"next": "Next"
},
"states": {
"noData": "No data",
"noResource": "No resources",
"noData": "No resources",
"loading": "Loading…",
"comingSoon": "Feature under development"
},
@@ -162,6 +163,7 @@
"account": "Account settings",
"integration": "Integration",
"agents": "Agent lines",
"settlement_center": "Settlement center",
"config": "Operations config"
},
"sidebar": {

View File

@@ -154,6 +154,31 @@
"riskMonitor": "Risk monitor",
"systemSettings": "System settings"
},
"agent": {
"title": "Operations overview",
"subtitle": "Your line scope · {{name}}",
"creditTitle": "Credit limit",
"creditAvailable": "Available {{amount}}",
"creditAllocated": "Allocated {{amount}}",
"creditUsed": "Used {{amount}}",
"shareRate": "Total share {{rate}}%",
"settlementCycle": "Cycle {{cycle}}",
"teamTitle": "Team size",
"directChildren": "Direct child agents",
"directPlayers": "Direct players",
"subtreeAgents": "Agents in line",
"pendingBills": "Open agent bills",
"pendingUnpaid": "Unpaid total {{amount}}",
"viewBills": "View bills",
"viewLine": "Agent line",
"quickLinks": {
"tickets": "Tickets",
"players": "Players",
"reports": "Reports",
"agents": "Downline agents",
"bills": "Agent bills"
}
},
"warnings": {
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
"walletPermission": "This account has no wallet reconciliation permission. Abnormal transfer count was not returned.",

View File

@@ -31,12 +31,12 @@
"confirmAdjustmentTitle": "Confirm pool balance adjustment?",
"confirmAdjustmentDescription": "This writes a ledger entry and updates the pool balance. Verify amount and reason.",
"recentAdjustments": "Recent adjustments",
"contributionRate": "Contribution rate 0-1",
"contributionRatePlaceholder": "Enter contribution rate, for example 0.02",
"contributionRate": "Contribution rate (%)",
"contributionRatePlaceholder": "e.g. 2 = 2%",
"triggerThreshold": "Burst threshold (minor unit)",
"triggerThresholdPlaceholder": "Enter burst threshold",
"payoutRate": "Burst payout rate 0-1",
"payoutRatePlaceholder": "Enter payout rate, for example 0.05",
"payoutRate": "Burst payout rate (%)",
"payoutRatePlaceholder": "e.g. 5 = 5%",
"forceTriggerGap": "Force burst gap (settled draws)",
"forceTriggerGapPlaceholder": "Enter forced burst gap in draws",
"minBetAmount": "Minimum bet amount (minor unit)",

View File

@@ -8,6 +8,7 @@
"tabOverview": "Overview",
"tabTickets": "Tickets",
"tabWalletTxns": "Wallet transactions",
"tabCreditLedger": "Credit ledger",
"tabTransferOrders": "Transfer orders",
"profileSection": "Profile",
"walletsSection": "Wallets",
@@ -21,6 +22,11 @@
"searchPlaceholder": "Search by player ID / username / nickname",
"filterSite": "Site",
"filterAllSites": "All sites",
"scopeAllSites": "Scope: all players on all sites (super admin)",
"scopeFilteredSite": "Scope: site {{site}}",
"scopeAgentLine": "Scope: {{site}} · agent line “{{name}}” and downstream players",
"scopeSingleSite": "Scope: site {{site}}",
"scopeMultiSite": "Scope: {{count}} bound site(s); use filter to narrow",
"search": "Search",
"refresh": "Refresh",
"loadFailed": "Failed to load player list",
@@ -49,6 +55,16 @@
"available": "Available",
"status": "Status",
"lastLogin": "Last login",
"fundingMode": "Funding mode",
"authSource": "Auth source",
"creditSection": "Credit line",
"usedCredit": "Used credit",
"fundingCredit": "Credit line",
"fundingWallet": "Main-site wallet",
"authMainSite": "Main-site SSO",
"authNative": "Lottery native",
"creditLimit": "Credit limit",
"availableCredit": "Available credit",
"actions": "Actions",
"edit": "Edit",
"freeze": "Freeze",

View File

@@ -225,10 +225,12 @@
"status": "Status",
"createdAt": "Created at"
},
"legacyTitle": "Legacy wallet reports",
"categories": {
"all": "All",
"profit": "Profit",
"wallet": "Funds",
"legacy": "Legacy",
"risk": "Risk",
"audit": "Audit"
},
@@ -297,7 +299,8 @@
},
"rebate_commission": {
"title": "Commission / rebate report",
"summary": "Summarize commission, rebate, and matched rules by play and period."
"summary": "Summarize commission, rebate, and matched rules by play and period.",
"disclaimer": "Wallet-mode instant rebate/commission — not agent credit-line period settlement. Use Agent → Settlement bills for credit-line reports."
},
"admin_audit": {
"title": "Admin operation audit report",

View File

@@ -0,0 +1,203 @@
{
"title": "Settlement center",
"header": {
"subtitle": "Credit-line settlement",
"statusRunning": "Period open",
"statusIdle": "No open period",
"statusCompleted": "Period completed"
},
"subnav": {
"label": "Settlement center navigation"
},
"nav": {
"aria": "Settlement center navigation",
"group": {
"hub": "Workbench",
"finance": "Finance",
"ledger": "Ledger",
"bills": "Bills"
},
"overview": "Overview",
"periods": "Periods",
"ledger": "Account ledger",
"bills": "Bills",
"creditLedger": "Credit ledger",
"playerBills": "Player bills",
"agentBills": "Agent bills",
"pendingConfirm": "Pending confirm",
"awaitingPayment": "Awaiting payment",
"payments": "Payment log",
"adjustments": "Adjust / reverse",
"badDebt": "Bad debt",
"reports": "Period reports"
},
"filters": {
"period": "Period",
"allPeriods": "All periods",
"statusOpen": "Open",
"statusClosed": "Closed",
"statusCompleted": "Completed"
},
"overview": {
"pendingConfirm": "Pending confirm",
"awaitingPayment": "Awaiting payment",
"totalUnpaid": "Total unpaid",
"openPeriod": "Open period",
"creditLedger": "Credit ledger (in period)",
"shareLedger": "Share ledger (in period)",
"pipelineHint": "Bills are created after period close; counts below are in-period activity."
},
"ledger": {
"groupIntro": "In-period money movements: credit holds, bill payments, adjustments, and bad debt. Share bills after close are under Bills.",
"paymentsIntro": "Confirmed bill payments (payment_records). Register from bill detail; this page is the period-wide log.",
"adjustmentsIntro": "Bill adjustments and reversals (settlement_adjustments).",
"badDebtIntro": "Bad debt write-off entries linked to original bills."
},
"creditLedger": {
"intro": "In-period credit holds, bill payments, adjustments, and bad debt. Use ⋯ on a row to confirm, record payment, adjust, reverse, or write off (when a bill is linked).",
"columns": {
"txn": "Txn ID",
"player": "Player",
"reason": "Type",
"ref": "Reference",
"amount": "Amount",
"channel": "Channel",
"status": "Status",
"time": "Time"
},
"channelCredit": "Credit line",
"viewPlayer": "Player detail",
"entryKind": {
"adjustment": "Adjustment"
},
"actions": {
"viewPlayer": "Player detail",
"viewBill": "Bill detail",
"confirm": "Confirm bill",
"confirmDesc": "After confirm, the bill moves to awaiting payment.",
"payment": "Record payment",
"adjustment": "Adjust",
"reversal": "Reverse",
"badDebt": "Bad debt"
},
"reason": {
"payment_record": "Bill payment",
"bet_hold": "Bet hold",
"bet_hold_release": "Hold release",
"game_settlement_loss": "Draw settlement",
"settlement_confirm": "Period confirm"
}
},
"columns": {
"period": "Period",
"type": "Type",
"owner": "Owner",
"counterparty": "Counterparty",
"gross": "Win/loss",
"net": "Net",
"paid": "Paid",
"unpaid": "Unpaid",
"status": "Status",
"billId": "Bill ID",
"payer": "Payer",
"payee": "Payee",
"amount": "Amount",
"method": "Method",
"time": "Time",
"adjustmentType": "Type",
"originalBill": "Original bill",
"reason": "Reason",
"badDebtAmount": "Write-off"
},
"billStatus": {
"pending_confirm": "Pending confirm",
"confirmed": "Confirmed",
"partial_paid": "Partial paid",
"settled": "Settled",
"overdue": "Overdue",
"reversed": "Reversed"
},
"billType": {
"adjustment": "Adjustment",
"reversal": "Reversal",
"badDebt": "Bad debt write-off"
},
"adjustmentType": {
"adjustment": "Adjustment",
"reversal": "Reversal",
"bad_debt": "Bad debt"
},
"actions": {
"detail": "Detail",
"viewBill": "View bill",
"billDetail": "Bill detail"
},
"ledgerPanel": {
"search": "Search",
"searchBtn": "Search",
"reset": "Reset filters",
"refresh": "Refresh page",
"filterAll": "Any",
"playerAccount": "Player account",
"playerAccountPh": "Username or site player ID",
"playerId": "Player ID",
"optional": "Optional",
"billStatus": "Bill status",
"dateRange": "Date range",
"rowPosted": "Posted",
"category": {
"all": "All",
"credit": "Credit holds",
"payment": "Payments",
"adjustment": "Adjust / reverse",
"badDebt": "Bad debt",
"actionable": "Needs action"
}
},
"billsPanel": {
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
"category": {
"all": "All",
"player": "Player bills",
"agent": "Agent bills",
"pendingConfirm": "Pending confirm",
"awaitingPayment": "Awaiting payment"
}
},
"panels": {
"overview": { "title": "Overview" },
"ledger": { "title": "Account ledger" },
"bills": { "title": "Bills" },
"creditLedger": { "title": "Credit ledger" },
"playerBills": { "title": "Player bills" },
"agentBills": { "title": "Agent bills" },
"pendingConfirm": { "title": "Pending confirm" },
"awaiting": { "title": "Awaiting payment" },
"payments": { "title": "Payment log" },
"adjustments": { "title": "Adjust / reverse" },
"reports": { "title": "Period reports" },
"badDebt": { "title": "Bad debt" }
},
"empty": {
"noSite": "Select an integration site.",
"noPeriods": "Open and close a period under Periods first.",
"noClosed": "Close a period to generate bills.",
"noBadDebt": "No bad debt write-offs yet.",
"noCreditLedger": "No credit ledger rows in this period. Check credit players placed bets and the period date range.",
"billsNeedClose": "Share bills appear after period close. If credit ledger has rows but bills are empty, settle draws then close the period."
},
"periods": {
"loadFailed": "Failed to load periods"
},
"toast": {
"periodClosed": "Period closed",
"periodClosedUnsettled": "Period closed; {{count}} ticket(s) still unsettled."
},
"errors": {
"loadBills": "Failed to load bills",
"loadPayments": "Failed to load payments",
"loadAdjustments": "Failed to load adjustments",
"loadBadDebt": "Failed to load bad debt records",
"loadCreditLedger": "Failed to load credit ledger"
}
}

View File

@@ -1,8 +1,14 @@
{
"title": "Wallet",
"subnavLabel": "Wallet sub pages",
"subnavTransactions": "Wallet transactions",
"subnavTransferOrders": "Transfer orders",
"subnavTransactions": "Main-site wallet txns",
"subnavTransferOrders": "Main-site transfers",
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line bet holds and settlement entries, see",
"scopeHintSettlementLink": "Settlement center → Credit ledger",
"scopeHintSettlement": "Settlement center → Credit ledger",
"ledgerChannel": "Ledger",
"ledgerCredit": "Credit ledger",
"ledgerWallet": "Wallet txn",
"subnavPlayerWallet": "Player wallet",
"noPermission": "Current account has no access to this page",
"copySuccess": "{{label}} copied to clipboard",

View File

@@ -1,14 +1,12 @@
{
"title": "एजेन्ट लाइन",
"sitesTitle": "साइट सूची",
"sitesListHint": "पूर्ण साइट तालिका (कुञ्जी, कलब्याक) को लागि",
"sitesListLink": "साइट सूची",
"sitesListHint": "कुञ्जी र कलब्याकको लागि",
"sitesListLink": "कन्फिग · साइट",
"subnav": {
"label": "एजेन्ट लाइन नेभ",
"noPermission": "अनुमति छैन",
"operations": "सञ्चालन",
"provision": "लाइन खोल्नुहोस्",
"sites": "साइट सूची",
"provision": "प्रथम-स्तर एजेन्ट सिर्जना",
"settlementBills": "एजेन्ट बिल"
},
"tabs": {
@@ -35,7 +33,7 @@
"detailTitle": "Node details",
"selectNode": "Select an agent node from the tree",
"loadFailed": "Failed to load agent tree",
"siteLabel": "Site",
"lineFilter": "प्रथम-स्तर एजेन्ट",
"createChild": "Add child agent",
"viewDownline": "चाइल्ड एजेन्ट र खेलाडी हेर्नुहोस्",
"downlineDialogTitle": "{{name}} — चाइल्ड एजेन्ट र खेलाडी",
@@ -118,17 +116,17 @@
}
},
"lineProvision": {
"title": "एजेन्ट लाइन खोल्नुहोस्",
"description": "एकै चरणमा साइट, रुट एजेन्ट खाता सिर्जना (site_code = agent code)।",
"code": "साइट code",
"name": "लाइन नाम",
"username": "एजेन्ट लगइन",
"title": "प्रथम-स्तर एजेन्ट सिर्जना",
"description": "एकै चरणमा प्रथम-स्तर एजेन्ट, खाता र लाइन सेटिङ। कोड पछि बदल्न मिल्दैन।",
"code": "एजेन्ट कोड",
"name": "एजेन्ट नाम",
"username": "एडमिन लगइन",
"password": "प्रारम्भिक पासवर्ड",
"walletUrl": "वालेट API URL",
"submit": "खोल्नुहोस्",
"success": "लाइन खोलियो",
"walletUrl": "वालेट API URL (वैकल्पिक)",
"submit": "सिर्जना गर्नुहोस्",
"success": "प्रथम-स्तर एजेन्ट सिर्जना भयो",
"secretsOnce": "कुञ्जी एक पटक मात्र देखाइन्छ",
"link": "लाइन खोल्नुहोस्"
"link": "प्रथम-स्तर एजेन्ट सिर्जना"
},
"noAccess": "एजेन्ट सञ्चालन अनुमति छैन। प्रशासकलाई सम्पर्क गर्नुहोस्।",
"playersPanel": {

View File

@@ -106,7 +106,8 @@
"next": "अर्को"
},
"states": {
"noData": "डाटा छैन",
"noResource": "स्रोत छैन",
"noData": "स्रोत छैन",
"loading": "लोड हुँदैछ…",
"comingSoon": "सुविधा विकासमा छ"
},

View File

@@ -16,6 +16,7 @@
"deleteSuccess": "已删除 {{name}}",
"deleteFailed": "删除失败",
"roleListTitle": "平台角色管理",
"roleListHint": "平台仅保留「超级管理员」与「代理」两个内置角色;超级管理员自动拥有全部权限。",
"createRole": "新增平台角色",
"roleCreateSuccess": "已创建角色 {{name}}",
"roleUpdateSuccess": "已更新角色 {{name}}",
@@ -40,10 +41,12 @@
"account": "账号",
"nickname": "昵称",
"status": "状态",
"sites": "绑定站点",
"roles": "角色",
"effective": "生效权限",
"actions": "操作"
},
"siteRequired": "请选择绑定站点",
"status": {
"enabled": "启用",
"disabled": "禁用"
@@ -90,13 +93,15 @@
"permissionDialog": {
"title": "分配角色",
"rolesTitle": "角色",
"rolesDescription": "管理员只绑定角色;具体权限请到「角色管理」里维护。",
"site": "站点",
"rolesDescription": "管理员只绑定角色;具体权限请到「角色管理」里维护。按站点分别保存角色。",
"rolePermissionCount": "含 {{count}} 项功能权限",
"selectedRoles": "当前勾选的角色:",
"saveRoles": "保存角色"
},
"rolePermissionDialog": {
"title": "角色权限"
"title": "角色权限",
"packageHint": "勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。"
},
"roleDialog": {
"createTitle": "新增角色",
@@ -125,8 +130,10 @@
"passwordOptional": "密码(可选)",
"passwordPlaceholderCreate": "至少 8 位",
"passwordPlaceholderEdit": "不修改请留空",
"rolesRequired": "角色(默认站点,至少一项)",
"rolesDescription": "创建后可在「分配角色」中继续调整角色绑定。",
"site": "绑定站点",
"sitePlaceholder": "选择该账号可访问的数据站点",
"rolesRequired": "角色(至少一项)",
"rolesDescription": "创建后可在「分配角色」中按站点继续调整角色绑定。",
"noRoles": "暂无角色数据,请等待列表加载完成后重试。"
},
"delete": {

View File

@@ -1,18 +1,63 @@
{
"title": "代理线路",
"sitesTitle": "站点列表",
"sitesListHint": "完整站点表格(密钥、回调等)请前往",
"sitesListLink": "站点列表",
"title": "代理管理",
"sitesListHint": "线路对接参数(密钥、回调等)请前往",
"sitesListLink": "运营配置 · 接入站点",
"lineUi": {
"kicker": "信用占成 · 代理树",
"agentCount": "本组 {{count}} 个代理",
"searchPlaceholder": "搜索名称或登录名",
"directChildren": "直属下级 {{count}}",
"selectAgent": "选择左侧代理查看占成与授信",
"selectAgentHint": "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
"allocatedCredit": "已下发",
"availableCredit": "可下发",
"profileFootnote": "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
"tabOverview": "概览",
"currentSite": "当前站点",
"viewAll": "查看全部",
"shareRebateCap": "回水上限 {{rate}}%",
"overviewDownlineCard": "{{count}} 个,可在对应 Tab 管理下级代理。",
"downlineEmptyTitle": "暂无直属下级",
"tabProfile": "占成与授信",
"tabProfileReadOnly": "占成与授信(只读)",
"profileReadOnlyHint": "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
"selfAgentOverviewHint": "以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
"overviewDownlineHint": "直属下级 {{count}} 个可在「直属下级」Tab 管理。",
"overviewPlayersHint": "直属玩家请在「直属玩家」Tab 维护。",
"tabDownline": "直属下级",
"tabPlayers": "直属玩家",
"downlineColumns": {
"email": "邮箱",
"downlineCount": "下级数"
},
"editAccount": "账号与状态",
"editCurrent": "编辑本代理",
"saveProfile": "保存占成与授信",
"profileTabHint": "占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
"nextSteps": "建议操作",
"stepProfile": "配置占成、授信与回水",
"stepDownline": "管理直属下级(当前 {{count}} 个)",
"stepPlayers": "创建或维护直属玩家",
"downlineEmpty": "暂无直属下级。创建下级代理后将在此展示。",
"downlineEmptyShort": "暂无直属下级。",
"noDelegatedTabs": "该代理未开放创建下级或玩家。请使用「编辑本代理」维护占成、授信与风控标签。",
"expand": "展开",
"collapse": "收起"
},
"listTitle": "代理列表",
"listSearch": "搜索代理名称 / 编码 / 登录名",
"parentAgent": "上级代理",
"childrenCount": "直属下级",
"subnav": {
"label": "代理线路导航",
"label": "代理管理导航",
"noPermission": "无权限",
"operations": "代理经营",
"provision": "开通线路",
"sites": "站点列表",
"settlementBills": "代理账单"
"operations": "线路与代理树",
"provision": "开通一级代理",
"settlementBills": "账期与账单",
"provisionHint": "请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。"
},
"includeRoots": "包含根节点",
"includeRootsHint": "根节点用于表示站点边界,默认不计入经营代理列表。",
"includeRootsHint": "根节点表示一级代理线路边界,默认不计入经营代理列表。",
"directoryStatus": {
"all": "全部状态",
"enabled": "仅启用",
@@ -35,7 +80,7 @@
"detailTitle": "代理详情",
"selectNode": "请选择代理",
"loadFailed": "加载代理列表失败",
"siteLabel": "站点",
"lineFilter": "一级代理",
"createChild": "添加下级代理",
"viewDownline": "查看下级代理和玩家",
"downlineDialogTitle": "{{name}} — 下级代理与玩家",
@@ -73,12 +118,12 @@
"modelGuide": "代理层负责数据范围Scope与授权上限Ceiling账号权限请通过角色分配。",
"pageGuide": "这里统一管理代理树、代理角色、代理账号与下放上限。平台账号和平台角色请到各自的平台治理页面维护。",
"summary": {
"currentSiteNodes": "当前站点节点总数",
"currentSiteAgents": "当前站点经营代理数",
"currentSiteNodes": "当前线路节点总数",
"currentSiteAgents": "当前线路经营代理数",
"visibleList": "当前平铺列表条数",
"visibleAgents": "当前可见经营代理数",
"globalNodes": "全部站点节点总数",
"globalAgents": "全部站点经营代理数",
"globalNodes": "全部线路节点总数",
"globalAgents": "全部线路经营代理数",
"enabledAgents": "启用中的经营代理数",
"rootNodes": "根节点数量"
},
@@ -86,12 +131,15 @@
"section": "占成与授信",
"totalShareRate": "占成比例 (%)",
"creditLimit": "授信额度",
"rebateLimit": "回水上限",
"defaultPlayerRebate": "默认玩家回水",
"rebateLimit": "回水上限 (%)",
"defaultPlayerRebate": "默认玩家回水 (%)",
"settlementCycle": "结算周期",
"canGrantExtraRebate": "允许额外回水",
"canCreatePlayer": "允许创建玩家",
"canCreateChildAgent": "允许创建下级代理",
"capabilityHint": "保存后对该代理主账号生效;若平台「代理」角色含玩家/节点管理权限,此前仍可能可操作——现已按本开关自动收紧登录权限。",
"parentCaps": "上级占成 {{share}}%,可下发 {{credit}}",
"availableCredit": "可下发额度 {{amount}}",
"cycleDaily": "日结",
"cycleWeekly": "周结",
"cycleMonthly": "月结",
@@ -101,41 +149,184 @@
"validation": {
"shareRange": "占成比例须在 0100 之间",
"creditInvalid": "授信额度不能为负数",
"rebateLimitRange": "回水上限须在 01 之间(如 0.005 表示 0.5%",
"defaultRebateRange": "默认玩家回水须在 01 之间",
"rebateLimitRange": "回水上限须在 0100% 之间",
"defaultRebateRange": "默认玩家回水须在 0100% 之间",
"defaultExceedsLimit": "默认玩家回水不能超过回水上限"
}
},
"riskTags": "风控标签",
"riskTagsPlaceholder": "逗号分隔,如 overdue, high_turnover",
"saveSuccess": "占成与授信已保存"
},
"settlementBills": {
"title": "代理账单",
"description": "账期关闭后生成的玩家/代理账单",
"description": "账期关闭后生成的玩家/代理账单;默认展示最近一期。",
"periodLabel": "查看账期",
"periodPlaceholder": "选择账期",
"allPeriods": "全部账期",
"filteredByPeriodRange": "账期 {{range}} 的账单",
"emptyNoPeriodsManage": "尚无账期与账单。请在下方「账期管理」点「本周」开期,到期后关账,账单会自动出现在这里。",
"emptyNoPeriodsAgent": "尚无账单。账期由上级或平台关账后自动生成,无需您手动筛选时间。",
"emptyNoClosed": "当前没有已关账的账期,账单尚未生成。请等待负责人关账后再查看。",
"typePlayer": "玩家账单",
"typeAgent": "代理层级账单",
"platform": "平台",
"filteredByPeriod": "当前仅显示账期 #{{id}} 的账单",
"clearPeriodFilter": "显示全部账期",
"columns": {
"id": "ID",
"period": "账期",
"type": "类型",
"party": "本方",
"counterparty": "对方",
"net": "净额",
"unpaid": "未结",
"status": "状态"
"status": "状态",
"grossWinLoss": "输赢"
},
"detail": "详情",
"confirm": "确认账单",
"confirmed": "已确认",
"paymentAmount": "收付金额",
"recordPayment": "登记收付",
"paid": "已登记收付",
"subtreeSummary": "子树汇总",
"grossWinLoss": "输赢 (gross_win_loss)",
"rebateAmount": "回水",
"shareProfit": "占成利润",
"platformRounding": "平台尾差"
},
"settlementReports": {
"title": "账期报表(信用占成盘)",
"description": "§21.12 报表集:玩家输赢、代理占成、回水、授信、未结/逾期与平台盈亏。",
"type": "报表类型",
"footnote": "本组报表为信用占成盘账期口径,与「佣金/回水」旧钱包报表不同。",
"noPeriodHint": "未选具体账期时使用近 7 日区间;平台盈亏需选择账期。",
"types": {
"summary": "摘要",
"player_win_loss": "玩家输赢",
"agent_share": "代理占成",
"rebate": "回水",
"credit": "授信",
"unpaid_bills": "未结账单",
"overdue": "逾期",
"platform_pnl": "平台盈亏",
"draw_period": "期号维度"
},
"summary": {
"billCount": "账单数",
"totalNet": "净额合计",
"totalUnpaid": "未结合计",
"overdueCount": "逾期账单",
"platformRounding": "平台尾差合计"
},
"rebate": {
"accrued": "应计",
"inBill": "已入账单",
"settled": "已结算",
"allocated": "已分摊"
},
"credit": {
"agents": "代理授信",
"players": "玩家授信"
},
"platformPnl": {
"periodRequired": "请选择具体账期后查看平台盈亏。",
"billNet": "平台账单净额",
"rounding": "尾差调整",
"shareProfit": "占成利润(元数据)"
},
"columns": {
"player": "玩家",
"gameType": "玩法",
"grossWinLoss": "输赢",
"rebate": "回水",
"agentId": "代理 ID",
"count": "笔数",
"billId": "账单",
"billType": "类型",
"unpaid": "未结",
"status": "状态",
"overdueDays": "逾期天数",
"drawNo": "期号",
"code": "编码",
"name": "名称",
"creditLimit": "授信",
"allocated": "已下发",
"available": "可用",
"used": "已用",
"frozen": "冻结",
"rebateType": "回水类型",
"amount": "金额"
}
},
"settlementPeriods": {
"title": "代理账期",
"manageTitle": "账期管理",
"manageHint": "平台或负责人开期并关账后,上方账单会自动生成。一般用快捷账期即可,无需手填时间。",
"presetHint": "快捷账期(推荐)",
"presetThisWeek": "本周",
"presetLastWeek": "上周",
"presetThisMonth": "本月",
"openWithPreset": "按上方时间开期",
"showAdvanced": "自定义起止时间",
"hideAdvanced": "收起自定义时间",
"range": "账期",
"statusOpen": "进行中",
"statusClosed": "已关账",
"empty": "尚无账期。点「本周」等快捷账期后开期,到期后在此关账。",
"start": "开始",
"end": "结束",
"status": "状态",
"open": "开期",
"close": "关账并生成账单",
"viewBills": "账单",
"opened": "账期已开启",
"closed": "账期已关账,账单已生成",
"openFailed": "开期失败",
"closeFailed": "关账失败",
"datesRequired": "请填写账期起止"
},
"lineProvision": {
"title": "开通代理线路",
"description": "一次创建站点、根代理与后台账号site_code 与代理 code 一致)。",
"code": "站点 code",
"name": "线路名称",
"username": "代理账号",
"title": "创建一级代理",
"description": "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
"siteCode": "接入站点",
"siteCodePlaceholder": "选择站点",
"siteRequired": "请选择接入站点",
"noUnboundSite": "暂无未绑定一级代理的站点",
"openIntegrationSites": "前往接入站点",
"code": "代理编码",
"name": "一级代理名称",
"username": "后台登录账号",
"password": "初始密码",
"walletUrl": "钱包 API URL",
"submit": "开通线路",
"success": "线路已开通",
"secretsOnce": "密钥仅显示一次,请妥善保存",
"link": "开通线路"
"submit": "创建一级代理",
"success": "一级代理已创建",
"link": "创建一级代理"
},
"noAccess": "您没有代理经营相关权限,请联系管理员开通。",
"playersPanel": {
"create": "创建玩家",
"scopedTo": "直属玩家:{{agent}}",
"allUnderSite": "当前站点下可见玩家",
"filterHint": "可按上级代理查看其直属玩家。"
"allUnderSite": "当前一级代理线路下可见玩家",
"filterHint": "可按上级代理查看其直属玩家。",
"loginRequired": "请填写登录账号与初始密码",
"loginUsername": "登录账号",
"initialPassword": "初始密码",
"externalIdOptional": "外部 ID可选",
"externalIdHint": "留空则系统自动生成",
"creditLimit": "授信额度",
"rebateRate": "回水比例 (%)",
"rebateRateHint": "填写百分比,如 0.5 表示 0.5%",
"availableToGrant": "代理剩余可下发:{{amount}}",
"riskTags": "风控标签",
"riskTagsPlaceholder": "逗号分隔",
"fundingMode": "资金模式",
"authSource": "登录来源",
"rebateInherited": "继承代理默认回水",
"creditListHint": "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
"playerRef": "玩家标识",
"usernameNickname": "用户名 / 昵称",
"creditLimitAvailable": "授信 / 可用",
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端 /login 登录"
},
"delegation": {
"title": "下放权限上限",

View File

@@ -106,7 +106,8 @@
"next": "下一页"
},
"states": {
"noData": "暂无数据",
"noResource": "暂无资源",
"noData": "暂无资源",
"loading": "加载中…",
"comingSoon": "功能开发中"
},
@@ -162,6 +163,7 @@
"account": "账号设置",
"integration": "接入配置",
"agents": "代理线路",
"settlement_center": "结算中心",
"config": "运营配置"
},
"sidebar": {

View File

@@ -21,7 +21,7 @@
},
"hub": {
"title": "运营配置总览",
"description": "按业务域进入玩法、赔率回水、奖池与限额配置。侧栏已提供直达入口,本页为汇总导航。",
"description": "按业务域进入玩法、赔率回水、奖池与限额配置;接入站点在侧栏「平台管理 → 接入配置」。",
"playsTitle": "投注规则",
"playsDesc": "玩法开关、限额与规则说明",
"oddsTitle": "赔率与回水",
@@ -84,10 +84,19 @@
"columns": {
"code": "site_code",
"name": "名称",
"currency": "币种",
"status": "状态",
"lineRoot": "一级代理",
"walletUrl": "钱包 API",
"h5Url": "彩票 H5",
"ssoSecret": "SSO 密钥",
"walletApiKey": "钱包密钥",
"actions": "操作"
},
"lineRootBound": "已绑定",
"lineRootUnbound": "未绑定",
"secretNotConfigured": "尚未配置密钥",
"secretCopyRequiresManage": "需要接入站点管理权限才能复制密钥",
"fields": {
"code": "site_code",
"name": "站点名称",

View File

@@ -154,6 +154,31 @@
"riskMonitor": "风控监控",
"systemSettings": "系统设置"
},
"agent": {
"title": "经营概览",
"subtitle": "本线路数据范围 · {{name}}",
"creditTitle": "授信额度",
"creditAvailable": "可下发 {{amount}}",
"creditAllocated": "已下发 {{amount}}",
"creditUsed": "已占用 {{amount}}",
"shareRate": "总占成 {{rate}}%",
"settlementCycle": "账期 {{cycle}}",
"teamTitle": "团队规模",
"directChildren": "直属下级代理",
"directPlayers": "直属玩家",
"subtreeAgents": "线路代理数",
"pendingBills": "待结代理账单",
"pendingUnpaid": "未结合计 {{amount}}",
"viewBills": "查看账单",
"viewLine": "代理线路",
"quickLinks": {
"tickets": "注单查询",
"players": "玩家管理",
"reports": "报表统计",
"agents": "下级代理",
"bills": "代理账单"
}
},
"warnings": {
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
"walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",

View File

@@ -31,12 +31,12 @@
"confirmAdjustmentTitle": "确认提交奖池余额调整?",
"confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。",
"recentAdjustments": "最近调整记录",
"contributionRate": "蓄水比例 01",
"contributionRatePlaceholder": "请输入贡献比例,如 0.02",
"contributionRate": "蓄水比例 (%)",
"contributionRatePlaceholder": "如 2 表示 2%",
"triggerThreshold": "爆池阈值(最小单位)",
"triggerThresholdPlaceholder": "请输入触发阈值",
"payoutRate": "爆池派彩比例 01",
"payoutRatePlaceholder": "请输入派彩比例,如 0.05",
"payoutRate": "爆池派彩比例 (%)",
"payoutRatePlaceholder": "如 5 表示 5%",
"forceTriggerGap": "强制爆池间隔(已结算期数)",
"forceTriggerGapPlaceholder": "请输入强制触发间隔期数",
"minBetAmount": "最低下注额(最小单位)",

View File

@@ -8,6 +8,7 @@
"tabOverview": "概览",
"tabTickets": "注单",
"tabWalletTxns": "钱包流水",
"tabCreditLedger": "信用流水",
"tabTransferOrders": "转账单",
"profileSection": "基本资料",
"walletsSection": "钱包余额",
@@ -21,6 +22,11 @@
"searchPlaceholder": "按玩家 ID / 用户名 / 昵称搜索",
"filterSite": "主站站点",
"filterAllSites": "全部站点",
"scopeAllSites": "数据范围:全部站点玩家(超管)",
"scopeFilteredSite": "数据范围:主站 {{site}}",
"scopeAgentLine": "数据范围:{{site}} · 代理线「{{name}}」及下级玩家",
"scopeSingleSite": "数据范围:主站 {{site}}",
"scopeMultiSite": "数据范围:已绑定 {{count}} 个主站(可用筛选收窄)",
"search": "搜索",
"refresh": "刷新",
"loadFailed": "加载玩家列表失败",
@@ -49,6 +55,18 @@
"available": "可用",
"status": "状态",
"lastLogin": "最后登录",
"fundingMode": "资金模式",
"authSource": "登录来源",
"creditSection": "信用额度",
"usedCredit": "已用信用",
"fundingCredit": "信用盘",
"fundingWallet": "主站钱包",
"authMainSite": "主站 SSO",
"authNative": "彩票端",
"creditLimit": "授信额度",
"availableCredit": "可用信用",
"rebateRate": "回水",
"riskTags": "风控标签",
"actions": "操作",
"edit": "编辑",
"freeze": "冻结",

View File

@@ -225,10 +225,12 @@
"status": "状态",
"createdAt": "创建时间"
},
"legacyTitle": "旧版钱包报表",
"categories": {
"all": "全部",
"profit": "盈亏",
"wallet": "资金",
"legacy": "旧版口径",
"risk": "风控",
"audit": "审计"
},
@@ -297,7 +299,8 @@
},
"rebate_commission": {
"title": "佣金/回水报表",
"summary": "按玩法与时间段汇总佣金、回水与配置命中情况。"
"summary": "按玩法与时间段汇总佣金、回水与配置命中情况。",
"disclaimer": "本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。"
},
"admin_audit": {
"title": "后台操作审计报表",

View File

@@ -0,0 +1,203 @@
{
"title": "结算中心",
"header": {
"subtitle": "信用占成账务",
"statusRunning": "账期进行中",
"statusIdle": "等待开期",
"statusCompleted": "账期已结清"
},
"subnav": {
"label": "结算中心导航"
},
"nav": {
"aria": "结算中心导航",
"group": {
"hub": "工作台",
"finance": "账务",
"ledger": "账务流水",
"bills": "账单管理"
},
"overview": "概览",
"periods": "账期管理",
"ledger": "账务流水",
"bills": "账单",
"creditLedger": "信用流水",
"playerBills": "玩家账单",
"agentBills": "代理账单",
"pendingConfirm": "待确认",
"awaitingPayment": "待收付",
"payments": "收付记录",
"adjustments": "调账 / 冲正",
"badDebt": "坏账核销",
"reports": "账期报表"
},
"filters": {
"period": "账期范围",
"allPeriods": "全部账期",
"statusOpen": "进行中",
"statusClosed": "已关账",
"statusCompleted": "已结清"
},
"overview": {
"pendingConfirm": "待确认",
"awaitingPayment": "待收付",
"totalUnpaid": "未结合计",
"openPeriod": "进行中账期",
"creditLedger": "信用流水(账期内)",
"shareLedger": "占成流水(账期内)",
"pipelineHint": "账单须关账后生成;下方为账期内实时流水笔数。"
},
"ledger": {
"groupIntro": "账期内资金变动明细:信用占用、账单收付、调账与坏账。关账后生成的占成账单在「账单管理」。",
"paymentsIntro": "针对已确认账单的实收实付登记payment_records可在账单详情中操作此处为账期汇总查询。",
"adjustmentsIntro": "账单补差、冲正等调账流水settlement_adjustments。",
"badDebtIntro": "坏账核销产生的调账流水,关联原账单。"
},
"creditLedger": {
"intro": "账期内信用占用、账单收付、调账与坏账等流水;行内「⋯」可确认账单、登记收付、调账、冲正或坏账(需关联账单)。",
"columns": {
"txn": "流水号",
"player": "玩家",
"reason": "业务类型",
"ref": "关联",
"amount": "金额",
"channel": "渠道",
"status": "状态",
"time": "时间"
},
"channelCredit": "信用盘",
"viewPlayer": "玩家详情",
"entryKind": {
"adjustment": "调账流水"
},
"actions": {
"viewPlayer": "玩家详情",
"viewBill": "账单详情",
"confirm": "确认账单",
"confirmDesc": "确认后账单进入待收付状态。",
"payment": "登记收付",
"adjustment": "调账",
"reversal": "冲正",
"badDebt": "坏账核销"
},
"reason": {
"payment_record": "账单收付",
"bet_hold": "下注占用",
"bet_hold_release": "占用释放",
"game_settlement_loss": "开奖结算扣款",
"settlement_confirm": "账期结算确认"
}
},
"columns": {
"period": "账期",
"type": "类型",
"owner": "本方",
"counterparty": "对方",
"gross": "输赢",
"net": "净额",
"paid": "已收付",
"unpaid": "未结",
"status": "状态",
"billId": "账单 ID",
"payer": "付款方",
"payee": "收款方",
"amount": "金额",
"method": "方式",
"time": "时间",
"adjustmentType": "调账类型",
"originalBill": "原账单",
"reason": "原因",
"badDebtAmount": "核销金额"
},
"billStatus": {
"pending_confirm": "待确认",
"confirmed": "已确认",
"partial_paid": "部分结清",
"settled": "已结清",
"overdue": "逾期",
"reversed": "已冲正"
},
"billType": {
"adjustment": "补差单",
"reversal": "冲正单",
"badDebt": "坏账核销"
},
"adjustmentType": {
"adjustment": "补差",
"reversal": "冲正",
"bad_debt": "坏账核销"
},
"actions": {
"detail": "详情",
"viewBill": "查看账单",
"billDetail": "账单详情"
},
"ledgerPanel": {
"search": "搜索",
"searchBtn": "搜索",
"reset": "重置筛选",
"refresh": "刷新当前页",
"filterAll": "不限",
"playerAccount": "玩家账号",
"playerAccountPh": "用户名 / 站点玩家 ID",
"playerId": "玩家 ID",
"optional": "可选",
"billStatus": "账单状态",
"dateRange": "时间范围",
"rowPosted": "已记账",
"category": {
"all": "全部",
"credit": "信用占用",
"payment": "收付",
"adjustment": "调账 / 冲正",
"badDebt": "坏账",
"actionable": "待操作"
}
},
"billsPanel": {
"intro": "关账后生成的占成账单;可按类型与状态筛选,打开详情进行确认与收付。",
"category": {
"all": "全部",
"player": "玩家账单",
"agent": "代理账单",
"pendingConfirm": "待确认",
"awaitingPayment": "待收付"
}
},
"panels": {
"overview": { "title": "结算概览" },
"ledger": { "title": "账务流水" },
"bills": { "title": "账单" },
"creditLedger": { "title": "信用流水" },
"playerBills": { "title": "玩家账单" },
"agentBills": { "title": "代理账单" },
"pendingConfirm": { "title": "待确认账单" },
"awaiting": { "title": "待收付账单" },
"payments": { "title": "收付记录" },
"adjustments": { "title": "调账 / 冲正" },
"reports": { "title": "账期报表" },
"badDebt": { "title": "坏账核销" }
},
"empty": {
"noSite": "请选择接入站点。",
"noPeriods": "请先在「账期管理」开期并关账。",
"noClosed": "请先关账生成账单。",
"noBadDebt": "暂无坏账核销记录。",
"noCreditLedger": "所选账期内暂无信用流水。请确认信用盘玩家已下注且账期时间范围正确。",
"billsNeedClose": "占成账单须先关账才会出现;若上方「信用流水」有数据而账单为空,请完成开奖结算后执行关账。"
},
"periods": {
"loadFailed": "账期列表加载失败"
},
"toast": {
"periodClosed": "账期已关账",
"periodClosedUnsettled": "账期已关账;仍有 {{count}} 笔注单未结算,请尽快处理。"
},
"errors": {
"loadBills": "账单加载失败",
"loadPayments": "收付记录加载失败",
"loadAdjustments": "调账记录加载失败",
"loadBadDebt": "坏账记录加载失败",
"loadCreditLedger": "信用流水加载失败"
}
}

View File

@@ -1,8 +1,14 @@
{
"title": "钱包",
"subnavLabel": "钱包子页",
"subnavTransactions": "钱包流水",
"subnavTransferOrders": "转账单",
"subnavTransactions": "主站钱包流水",
"subnavTransferOrders": "主站转账单",
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看",
"scopeHintSettlementLink": "结算中心 → 信用流水",
"scopeHintSettlement": "结算中心 → 信用流水",
"ledgerChannel": "账本",
"ledgerCredit": "信用流水",
"ledgerWallet": "钱包流水",
"subnavPlayerWallet": "玩家钱包",
"noPermission": "当前账号无访问该页的权限",
"copySuccess": "{{label}}已复制到剪贴板",

View File

@@ -40,7 +40,8 @@ function requestLocale(): AdminApiLocale {
return primary;
}
}
return "en";
// 与 i18n `ADMIN_DEFAULT_LANGUAGE`zh一致避免界面中文而校验仍为英文
return "zh";
}
/** 当前生效的语言(与即将发出的 API 头一致) */
@@ -112,6 +113,8 @@ export function hydrateAdminUiLocale(): AdminApiLocale | null {
return stored;
}
setAdminRequestLocale("zh");
return null;
}

View File

@@ -15,6 +15,7 @@ const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
risk_cap: "risk_cap",
risk: "risk",
settlement: "settlement",
settlement_center: "settlement_center",
reconcile: "reconcile",
reports: "reports",
tickets: "tickets",

View File

@@ -18,9 +18,10 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
"/admin/agents": { ns: "agents", key: "title" },
"/admin/agents/list": { ns: "agents", key: "directoryTitle" },
"/admin/agents/provision": { ns: "agents", key: "subnav.provision" },
"/admin/agents/sites": { ns: "agents", key: "sitesTitle" },
"/admin/agents/settlement-bills": { ns: "agents", key: "subnav.settlementBills" },
"/admin/config/integration-sites": { ns: "agents", key: "sitesTitle" },
"/admin/agents/sites": { ns: "config", key: "integrationSites.title" },
"/admin/settlement-center": { ns: "settlementCenter", key: "title" },
"/admin/agents/settlement-bills": { ns: "settlementCenter", key: "title" },
"/admin/config/integration-sites": { ns: "config", key: "integrationSites.title" },
"/admin/wallet": { ns: "wallet", key: "title" },
"/admin/wallet/transactions": { ns: "wallet", key: "walletTransactions" },
"/admin/wallet/transfer-orders": { ns: "wallet", key: "transferOrders" },

View File

@@ -98,6 +98,10 @@ export const ADMIN_PERMISSION_PACKAGES: Record<string, AdminPermissionPackage[]>
{ key: "view", label: "查看", slugs: ["prd.integration.view"] },
{ key: "manage", label: "管理", slugs: ["prd.integration.manage"] },
],
settlement_agent: [
{ key: "view", label: "查看", slugs: ["prd.settlement.agent.view"] },
{ key: "manage", label: "管理", slugs: ["prd.settlement.agent.manage"] },
],
jackpot: [
{ key: "view", label: "查看", slugs: ["prd.jackpot.view"] },
{ key: "manage", label: "管理", slugs: ["prd.jackpot.manage"] },

View File

@@ -0,0 +1,90 @@
import { formatAdminCreditMajor } from "@/lib/money";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
/** 信用额度与代理 profile 一致,按主货币整数展示(非 wallet minor。 */
export function formatPlayerCreditAmount(amount: number, currencyCode: string): string {
return formatAdminCreditMajor(amount, currencyCode.trim() || "NPR");
}
export function preferredDisplayWallet(row: AdminPlayerRow): AdminPlayerWalletRow | null {
const { wallets, default_currency } = row;
if (wallets.length === 0) {
return null;
}
const code = default_currency.trim().toUpperCase();
return wallets.find((w) => w.currency_code.toUpperCase() === code) ?? wallets[0];
}
/** 与后端 {@see PlayerFundingMode::usesCredit} 一致。 */
export function isCreditFundingPlayer(
row: Pick<AdminPlayerRow, "funding_mode" | "uses_credit" | "available_credit" | "credit_limit">,
): boolean {
if (row.uses_credit === true) {
return true;
}
if (row.funding_mode === "credit") {
return true;
}
return row.funding_mode !== "wallet" && (row.available_credit != null || row.credit_limit != null);
}
/** 主站 ↔ 彩票钱包划转单,仅钱包模式玩家适用。 */
export function playerShowsTransferOrders(
row: Pick<AdminPlayerRow, "funding_mode" | "uses_credit" | "available_credit" | "credit_limit">,
): boolean {
return !isCreditFundingPlayer(row);
}
export function playerBalanceCells(
row: AdminPlayerRow,
formatWalletMinor: (minor: number, currency: string) => string,
): { balance: string; available: string; balanceLabel: "credit" | "wallet" } {
if (isCreditFundingPlayer(row)) {
const ccy = row.default_currency;
return {
balance: formatPlayerCreditAmount(row.credit_limit ?? 0, ccy),
available: formatPlayerCreditAmount(row.available_credit ?? 0, ccy),
balanceLabel: "credit",
};
}
const displayWallet = preferredDisplayWallet(row);
if (!displayWallet) {
const ccy = row.default_currency;
return {
balance: formatWalletMinor(0, ccy),
available: formatWalletMinor(0, ccy),
balanceLabel: "wallet",
};
}
return {
balance: formatWalletMinor(displayWallet.balance, displayWallet.currency_code),
available: formatWalletMinor(displayWallet.available_balance, displayWallet.currency_code),
balanceLabel: "wallet",
};
}
export function playerFundingModeLabel(
row: Pick<AdminPlayerRow, "funding_mode">,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (row.funding_mode === "credit") {
return t("players:fundingCredit", { defaultValue: "信用" });
}
if (row.funding_mode === "wallet") {
return t("players:fundingWallet", { defaultValue: "钱包" });
}
return row.funding_mode ?? "—";
}
export function playerAuthSourceLabel(
row: Pick<AdminPlayerRow, "auth_source">,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (row.auth_source === "main_site_sso") {
return t("players:authMainSite", { defaultValue: "主站 SSO" });
}
if (row.auth_source === "lottery_native") {
return t("players:authNative", { defaultValue: "彩票端" });
}
return row.auth_source ?? "—";
}

View File

@@ -129,6 +129,16 @@ export const PRD_PAYOUT_ACCESS_ANY = [
PRD_PAYOUT_MANAGE,
] as const;
/** 期号内「资金」Tab开奖管理或财务/报表视角) */
export const PRD_DRAW_FINANCE_ACCESS_ANY = [
PRD_DRAW_RESULT_MANAGE,
PRD_PAYOUT_VIEW,
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
PRD_REPORT_VIEW,
PRD_USERS_VIEW_FINANCE,
] as const;
/** 接入站点配置页 */
export const PRD_INTEGRATION_ACCESS_ANY = [PRD_INTEGRATION_VIEW, PRD_INTEGRATION_MANAGE] as const;
@@ -143,13 +153,11 @@ export const PRD_AGENT_USER_MANAGE = "prd.agent.user.manage" as const;
export const PRD_AGENT_LINE_PROVISION = "prd.agent-line.provision" as const;
export const PRD_AGENT_PROFILE_MANAGE = "prd.agent.profile.manage" as const;
/** 代理线路内「站点列表」入口(接入权限或线路经营权限) */
export const PRD_AGENT_SITES_ACCESS_ANY = [
...PRD_INTEGRATION_ACCESS_ANY,
PRD_AGENT_LINE_PROVISION,
PRD_AGENT_MANAGE,
PRD_AGENT_VIEW,
] as const;
/**
* 运营配置「接入站点」入口:仅平台侧技术配置。
* 不含 prd.agent.view|manage避免经营代理看到接入密钥。
*/
export const PRD_AGENT_SITES_ACCESS_ANY = [...PRD_INTEGRATION_ACCESS_ANY] as const;
export const PRD_AGENTS_ACCESS_ANY = [
PRD_AGENT_VIEW,
@@ -161,14 +169,15 @@ export const PRD_AGENTS_ACCESS_ANY = [
PRD_AGENT_PROFILE_MANAGE,
] as const;
export const PRD_AGENT_LINE_PROVISION_ACCESS_ANY = [
PRD_AGENT_LINE_PROVISION,
PRD_AGENT_MANAGE,
] as const;
/** 仅平台开通新线路;经营代理的 prd.agent.manage 不含开通线路页。 */
export const PRD_AGENT_LINE_PROVISION_ACCESS_ANY = [PRD_AGENT_LINE_PROVISION] as const;
export const PRD_SETTLEMENT_AGENT_VIEW = "prd.settlement.agent.view" as const;
export const PRD_SETTLEMENT_AGENT_MANAGE = "prd.settlement.agent.manage" as const;
export const PRD_SETTLEMENT_AGENT_ACCESS_ANY = [
"prd.settlement.agent.view",
"prd.settlement.agent.manage",
PRD_SETTLEMENT_AGENT_VIEW,
PRD_SETTLEMENT_AGENT_MANAGE,
] as const;
/** 侧栏「代理线路」分组:含经营、开通、接入配置、代理账单任一权限即可见入口 */
@@ -176,6 +185,6 @@ export const PRD_AGENT_HUB_ACCESS_ANY = [
...PRD_AGENTS_ACCESS_ANY,
PRD_AGENT_LINE_PROVISION,
...PRD_INTEGRATION_ACCESS_ANY,
"prd.settlement.agent.view",
"prd.settlement.agent.manage",
PRD_SETTLEMENT_AGENT_VIEW,
PRD_SETTLEMENT_AGENT_MANAGE,
] as const;

View File

@@ -0,0 +1,59 @@
/** API / 存储用小数比例01后台表单统一用百分比0100展示与录入。 */
const RATIO_TO_PERCENT = 100;
/** 表单展示:去掉无意义的尾随 020.00 → "20"0.50 → "0.5" */
function formatPercentNumber(value: number, decimals = 2): string {
const scaled = Math.round(value * 10 ** decimals) / 10 ** decimals;
return String(scaled);
}
/** API 已是 0100 的占成等,直接格式化 */
export function percentValueToUi(
percent: number | string | null | undefined,
decimals = 2,
): string {
const n = typeof percent === "string" ? Number.parseFloat(percent) : percent;
if (n == null || !Number.isFinite(n)) {
return "0";
}
return formatPercentNumber(n, decimals);
}
/** 存库小数 01 → 表单百分比,如 0.2 → "20"0.005 → "0.5" */
export function ratioToPercentUi(
ratio: number | string | null | undefined,
decimals = 2,
): string {
const n = typeof ratio === "string" ? Number.parseFloat(ratio) : ratio;
if (n == null || !Number.isFinite(n)) {
return "0";
}
return formatPercentNumber(n * RATIO_TO_PERCENT, decimals);
}
/** "0.5" → 0.005 */
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)) {
return 0;
}
return n / RATIO_TO_PERCENT;
}
export function parsePercentUi(value: string): number | null {
const trimmed = value.trim();
if (trimmed === "") {
return null;
}
const n = Number.parseFloat(trimmed);
return Number.isFinite(n) ? n : null;
}
/** 只读展示0.005 → "0.50%" */
export function formatRatioAsPercent(
ratio: number | null | undefined,
decimals = 2,
): string {
return `${ratioToPercentUi(ratio, decimals)}%`;
}

View File

@@ -0,0 +1,54 @@
/**
* 与 Laravel 平台角色模板对齐;经营主账号只用「平台角色管理 → 代理」(slug=agent)。
* 线路内「角色」页用于子账号自定义角色,不再使用 agent_owner_* 默认包。
*/
export const AGENT_OWNER_BASE_SLUGS = [
"prd.dashboard.view",
"prd.agent.view",
"prd.agent.role.view",
"prd.agent.user.view",
"prd.tickets.view",
"prd.report.view",
"prd.settlement.agent.view",
] as const;
export const AGENT_OWNER_LINE_ROOT_EXTRA_SLUGS = [
"prd.agent.manage",
"prd.agent.profile.manage",
"prd.agent.role.manage",
"prd.agent.user.manage",
"prd.users.manage",
"prd.users.view_finance",
"prd.users.view_cs",
"prd.settlement.agent.manage",
] as const;
/** 代理模块内可下放/勾选的产品权限分组(不含平台专属:对账、赔率、接入站点等)。 */
export const AGENT_PERMISSION_PACKAGES: Record<
string,
{ key: string; label: string; slugs: string[] }[]
> = {
dashboard: [{ key: "view", label: "查看", slugs: ["prd.dashboard.view"] }],
agents: [
{ key: "node_view", label: "线路·查看", slugs: ["prd.agent.view"] },
{ key: "node_manage", label: "线路·管理", slugs: ["prd.agent.manage", "prd.agent.profile.manage"] },
{ key: "role_view", label: "角色·查看", slugs: ["prd.agent.role.view"] },
{ key: "role_manage", label: "角色·管理", slugs: ["prd.agent.role.manage"] },
{ key: "user_view", label: "账号·查看", slugs: ["prd.agent.user.view"] },
{ key: "user_manage", label: "账号·管理", slugs: ["prd.agent.user.manage"] },
],
players: [
{
key: "view",
label: "查看",
slugs: ["prd.users.view_finance", "prd.users.view_cs"],
},
{ key: "manage", label: "管理", slugs: ["prd.users.manage"] },
],
tickets: [{ key: "view", label: "查看", slugs: ["prd.tickets.view"] }],
reports: [{ key: "view", label: "查看", slugs: ["prd.report.view"] }],
settlement_agent: [
{ key: "view", label: "账单·查看", slugs: ["prd.settlement.agent.view"] },
{ key: "manage", label: "账单·管理", slugs: ["prd.settlement.agent.manage"] },
],
};

View File

@@ -0,0 +1,113 @@
import type { AgentSettlementCycle } from "@/lib/agent-settlement-cycle";
export type SettlementPeriodPresetKey = "this_week" | "last_week" | "this_month";
/** `datetime-local` 控件取值格式 */
export function toDateTimeLocalValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function startOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
function endOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(23, 59, 0, 0);
return d;
}
/** 周一为一周起始(与产品文档「周结」一致) */
function startOfWeekMonday(date: Date): Date {
const d = startOfDay(date);
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
return d;
}
function addDays(date: Date, days: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function startOfMonth(date: Date): Date {
const d = startOfDay(date);
d.setDate(1);
return d;
}
function endOfMonth(date: Date): Date {
const d = startOfDay(date);
d.setMonth(d.getMonth() + 1);
d.setDate(0);
return endOfDay(d);
}
export function settlementPeriodPresetRange(
key: SettlementPeriodPresetKey,
now: Date = new Date(),
): { period_start: string; period_end: string } {
switch (key) {
case "this_week": {
const start = startOfWeekMonday(now);
const end = endOfDay(addDays(start, 6));
return {
period_start: toDateTimeLocalValue(start),
period_end: toDateTimeLocalValue(end),
};
}
case "last_week": {
const thisStart = startOfWeekMonday(now);
const start = addDays(thisStart, -7);
const end = endOfDay(addDays(start, 6));
return {
period_start: toDateTimeLocalValue(start),
period_end: toDateTimeLocalValue(end),
};
}
case "this_month": {
const start = startOfMonth(now);
const end = endOfMonth(now);
return {
period_start: toDateTimeLocalValue(start),
period_end: toDateTimeLocalValue(end),
};
}
}
}
/** 按代理结算周期推荐默认快捷开期(周结优先) */
export function defaultSettlementPeriodPreset(
cycle: AgentSettlementCycle,
): SettlementPeriodPresetKey {
if (cycle === "monthly") {
return "this_month";
}
return "this_week";
}
export function formatSettlementPeriodSpan(
periodStart: string | undefined,
periodEnd: string | undefined,
): string {
const start = periodStart?.slice(0, 10) ?? "—";
const end = periodEnd?.slice(0, 10) ?? "—";
return `${start} ~ ${end}`;
}

31
src/lib/draw-access.ts Normal file
View File

@@ -0,0 +1,31 @@
import {
PRD_DRAW_RESULT_MANAGE,
PRD_DRAW_RESULT_VIEW,
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
PRD_PAYOUT_VIEW,
PRD_REPORT_VIEW,
PRD_USERS_VIEW_FINANCE,
} from "@/lib/admin-prd";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
export function canManageDrawResults(permissions: readonly string[] | undefined): boolean {
return adminHasAnyPermission(permissions, [PRD_DRAW_RESULT_MANAGE]);
}
export function canViewDrawFinance(permissions: readonly string[] | undefined): boolean {
return (
canManageDrawResults(permissions) ||
adminHasAnyPermission(permissions, [
PRD_PAYOUT_VIEW,
PRD_PAYOUT_MANAGE,
PRD_PAYOUT_REVIEW,
PRD_REPORT_VIEW,
PRD_USERS_VIEW_FINANCE,
])
);
}
export function canViewDrawResults(permissions: readonly string[] | undefined): boolean {
return adminHasAnyPermission(permissions, [PRD_DRAW_RESULT_VIEW, PRD_DRAW_RESULT_MANAGE]);
}

View File

@@ -44,6 +44,44 @@ export function formatAdminMinorUnits(
})}`;
}
/**
* 信用占成盘授信/已下发/可用额度:库内为主货币整数(与后台录入一致),展示带小数位。
*/
export function formatAdminCreditMajor(
major: number,
currencyCode = "NPR",
decimalPlaces?: number,
): string {
const safeMajor = Number.isFinite(major) ? major : 0;
const resolvedDecimalPlaces =
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
? decimalPlaces
: getAdminCurrencyDecimalPlaces(currencyCode);
return `${currencyCode} ${safeMajor.toLocaleString(undefined, {
minimumFractionDigits: resolvedDecimalPlaces,
maximumFractionDigits: resolvedDecimalPlaces,
})}`;
}
/** 授信额度展示(无币种前缀,用于代理概要卡片等)。 */
export function formatAdminCreditMajorDecimal(
major: number,
currencyCode = "NPR",
decimalPlaces?: number,
): string {
const safeMajor = Number.isFinite(major) ? major : 0;
const resolvedDecimalPlaces =
typeof decimalPlaces === "number" && Number.isFinite(decimalPlaces) && decimalPlaces >= 0
? decimalPlaces
: getAdminCurrencyDecimalPlaces(currencyCode);
return safeMajor.toLocaleString(undefined, {
minimumFractionDigits: resolvedDecimalPlaces,
maximumFractionDigits: resolvedDecimalPlaces,
});
}
export function formatAdminMinorDecimal(
minor: number,
currencyCode = "NPR",

View File

@@ -16,6 +16,7 @@ import enCommon from "@/i18n/locales/en/common.json";
import enTickets from "@/i18n/locales/en/tickets.json";
import enWallet from "@/i18n/locales/en/wallet.json";
import enAgents from "@/i18n/locales/en/agents.json";
import enSettlementCenter from "@/i18n/locales/en/settlementCenter.json";
const EN_FLAT: Record<string, Record<string, unknown>> = {
dashboard: enDashboard,
@@ -35,6 +36,7 @@ const EN_FLAT: Record<string, Record<string, unknown>> = {
common: enCommon,
auth: enAuth,
agents: enAgents,
settlementCenter: enSettlementCenter,
};
function getByPath(obj: Record<string, unknown>, path: string): string | undefined {

View File

@@ -0,0 +1,12 @@
import type { AdminRoleRow } from "@/types/api/index";
export const PLATFORM_SUPER_ADMIN_SLUG = "super_admin";
export const PLATFORM_AGENT_SLUG = "agent";
export function isPlatformFixedRole(role: Pick<AdminRoleRow, "slug">): boolean {
return role.slug === PLATFORM_SUPER_ADMIN_SLUG || role.slug === PLATFORM_AGENT_SLUG;
}
export function isPlatformSuperAdminRole(role: Pick<AdminRoleRow, "slug">): boolean {
return role.slug === PLATFORM_SUPER_ADMIN_SLUG;
}

13
src/lib/player-funding.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* 玩家资金模式:统一从 {@see admin-player-display} 导出,避免各页重复判断。
*/
export {
isCreditFundingPlayer,
isCreditFundingPlayer as playerUsesCredit,
playerAuthSourceLabel,
playerBalanceCells,
playerFundingModeLabel,
playerShowsTransferOrders,
formatPlayerCreditAmount,
preferredDisplayWallet,
} from "@/lib/admin-player-display";

View File

@@ -10,6 +10,7 @@ import {
Network,
Scale,
ScrollText,
Receipt,
Settings,
ShieldAlert,
ShieldCheck,
@@ -37,6 +38,7 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
reports: FileSpreadsheet,
risk: ShieldAlert,
settlement: Landmark,
settlement_center: Receipt,
reconcile: Scale,
audit: ScrollText,
admin_users: ShieldCheck,

View File

@@ -23,6 +23,7 @@ export type AdminNavSegment =
| "risk"
| "settings"
| "settlement"
| "settlement_center"
| "reconcile"
| "audit"
| "admin_users"

View File

@@ -13,11 +13,12 @@ import {
deleteAdminRole,
getAdminRoles,
getAdminUserPermissionCatalog,
postAdminRole,
putAdminRole,
putAdminRolePermissions,
} from "@/api/admin-users";
import { isPlatformFixedRole, isPlatformSuperAdminRole } from "@/lib/platform-system-roles";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
import { Badge } from "@/components/ui/badge";
@@ -77,7 +78,6 @@ export function AdminRolesConsole(): React.ReactElement {
const [roleSaving, setRoleSaving] = useState(false);
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [roleMode, setRoleMode] = useState<"create" | "edit">("create");
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [roleSlug, setRoleSlug] = useState("");
const [roleName, setRoleName] = useState("");
@@ -116,18 +116,10 @@ export function AdminRolesConsole(): React.ReactElement {
void load();
}, []);
function openCreateRole(): void {
setRoleMode("create");
setEditingRoleId(null);
setRoleSlug("");
setRoleName("");
setRoleDescription("");
setRoleStatus(1);
setRoleDialogOpen(true);
}
function openEditRole(role: AdminRoleRow): void {
setRoleMode("edit");
if (isPlatformSuperAdminRole(role)) {
return;
}
setEditingRoleId(role.id);
setRoleSlug(role.slug);
setRoleName(role.name);
@@ -180,39 +172,24 @@ export function AdminRolesConsole(): React.ReactElement {
async function submitRole(): Promise<void> {
const name = roleName.trim();
const slug = roleSlug.trim().toLowerCase();
if (name === "" || slug === "") {
if (name === "" || slug === "" || editingRoleId === null) {
toast.error(t("roleFormRequired"));
return;
}
setRoleFormSaving(true);
try {
if (roleMode === "create") {
const created = await postAdminRole({
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => [...prev, created]);
setCatalog((prev) => (prev ? { ...prev, roles: [...prev.roles, created] } : prev));
toast.success(t("roleCreateSuccess", { name: created.name }));
} else {
if (editingRoleId === null) {
return;
}
const updated = await putAdminRole(editingRoleId, {
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
);
toast.success(t("roleUpdateSuccess", { name: updated.name }));
}
const updated = await putAdminRole(editingRoleId, {
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
);
toast.success(t("roleUpdateSuccess", { name: updated.name }));
handleRoleDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleSaveFailed");
@@ -249,11 +226,6 @@ export function AdminRolesConsole(): React.ReactElement {
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle>{t("roleListTitle", { defaultValue: "平台角色管理" })}</CardTitle>
{canManageRoles ? (
<Button type="button" size="sm" onClick={() => openCreateRole()}>
{t("createRole", { defaultValue: "新增平台角色" })}
</Button>
) : null}
</div>
<div className="admin-list-actions">
<AdminTableExportButton
@@ -267,6 +239,11 @@ export function AdminRolesConsole(): React.ReactElement {
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("roleListHint", {
defaultValue: "平台仅保留「超级管理员」与「代理」两个内置角色;超级管理员自动拥有全部权限。",
})}
</p>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
<div className="rounded-md border">
<Table id="admin-roles-table">
@@ -286,13 +263,13 @@ export function AdminRolesConsole(): React.ReactElement {
{loading && roles.length === 0 ? (
<AdminTableLoadingRow colSpan={8} />
) : roles.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
) : (
roles.map((role) => (
roles.map((role) => {
const fixedRole = isPlatformFixedRole(role);
const superAdminRole = isPlatformSuperAdminRole(role);
return (
<TableRow key={role.id}>
<TableCell>{role.id}</TableCell>
<TableCell>
@@ -324,12 +301,14 @@ export function AdminRolesConsole(): React.ReactElement {
key: "permissions",
label: t("roleActions.permissions"),
icon: KeyRound,
disabled: superAdminRole,
onClick: () => openRolePermissionEditor(role),
},
{
key: "edit",
label: t("actions.edit"),
icon: Pencil,
disabled: superAdminRole,
onClick: () => openEditRole(role),
},
{
@@ -337,7 +316,7 @@ export function AdminRolesConsole(): React.ReactElement {
label: t("actions.delete"),
icon: Trash2,
destructive: true,
disabled: role.is_system || role.user_count > 0,
disabled: fixedRole || role.user_count > 0,
onClick: () => setRoleDeleteTarget(role),
},
]}
@@ -347,7 +326,8 @@ export function AdminRolesConsole(): React.ReactElement {
)}
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>
@@ -375,6 +355,10 @@ export function AdminRolesConsole(): React.ReactElement {
onChange={setDraftRolePermissions}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
helperText={t("rolePermissionDialog.packageHint", {
defaultValue:
"勾选左侧模块行仅授予「查看」;录入、封盘、开奖等管理操作请单独勾选「管理」。",
})}
emptyText={t("states.noData", { ns: "common" })}
heightClassName="h-[min(56vh,520px)]"
/>
@@ -406,9 +390,7 @@ export function AdminRolesConsole(): React.ReactElement {
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
<DialogContent showCloseButton className="max-w-lg gap-4">
<DialogHeader>
<DialogTitle>
{roleMode === "create" ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
</DialogTitle>
<DialogTitle>{t("roleDialog.editTitle")}</DialogTitle>
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
@@ -418,7 +400,7 @@ export function AdminRolesConsole(): React.ReactElement {
value={roleSlug}
placeholder={t("roleDialog.slugPlaceholder")}
onChange={(e) => setRoleSlug(e.target.value)}
disabled={roleMode === "edit"}
disabled
/>
</div>
<div className="space-y-1.5">
@@ -462,10 +444,7 @@ export function AdminRolesConsole(): React.ReactElement {
onClick={() =>
requestConfirm({
title: t("confirmSaveRoleTitle"),
description:
roleMode === "create"
? t("confirmSaveRoleCreateDescription", { name: roleName || roleSlug || "—" })
: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
description: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => submitRole(),
})

View File

@@ -3,6 +3,7 @@
import { KeyRound, Pencil, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -18,6 +19,7 @@ import {
putAdminUserRoles,
} from "@/api/admin-users";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -36,6 +38,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -49,7 +58,11 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ADMIN_USER_MANAGE } from "@/lib/admin-prd";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
import type {
AdminPermissionCatalogData,
AdminUserPermissionRow,
AdminUserSiteBinding,
} from "@/types/api/index";
import { LotteryApiBizError } from "@/types/api/errors";
export function AdminUsersConsole(): React.ReactElement {
@@ -58,6 +71,17 @@ export function AdminUsersConsole(): React.ReactElement {
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const exportLabels = useExportLabels("adminUsers");
const profile = useAdminProfile();
const { sites: hookSiteOptions } = useAdminSiteCodeOptions();
const siteOptions = useMemo(() => {
if (hookSiteOptions.length > 0) {
return hookSiteOptions;
}
return (profile?.accessible_sites ?? []).map((site) => ({
id: site.id,
code: site.code,
name: site.name,
}));
}, [hookSiteOptions, profile?.accessible_sites]);
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_USER_MANAGE]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
@@ -86,6 +110,8 @@ export function AdminUsersConsole(): React.ReactElement {
const [formPassword, setFormPassword] = useState("");
const [formStatus, setFormStatus] = useState(0);
const [formCreateRoles, setFormCreateRoles] = useState<string[]>([]);
const [formAdminSiteId, setFormAdminSiteId] = useState<number | null>(null);
const [roleEditSiteId, setRoleEditSiteId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminUserPermissionRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
@@ -99,6 +125,39 @@ export function AdminUsersConsole(): React.ReactElement {
[catalog],
);
const defaultSiteId = useMemo(() => siteOptions[0]?.id ?? null, [siteOptions]);
const roleEditSiteLabel = useMemo(() => {
const site = siteOptions.find((item) => item.id === roleEditSiteId);
return site ? `${site.name} (${site.code})` : null;
}, [roleEditSiteId, siteOptions]);
const formAdminSiteLabel = useMemo(() => {
const site = siteOptions.find((item) => item.id === formAdminSiteId);
return site ? `${site.name} (${site.code})` : null;
}, [formAdminSiteId, siteOptions]);
useAsyncEffect(() => {
if (formAdminSiteId === null && defaultSiteId !== null && accountOpen && accountMode === "create") {
setFormAdminSiteId(defaultSiteId);
}
}, [accountOpen, accountMode, defaultSiteId, formAdminSiteId]);
function formatSiteBindings(bindings: AdminUserSiteBinding[] | undefined): string {
if (!bindings || bindings.length === 0) {
return "";
}
return bindings
.map((b) => `${b.site_code}${b.role_slugs.length > 0 ? ` (${b.role_slugs.length})` : ""}`)
.join(", ");
}
function rolesForSite(bindings: AdminUserSiteBinding[] | undefined, siteId: number | null): string[] {
if (siteId === null) {
return [];
}
const match = bindings?.find((b) => b.site_id === siteId);
return match ? [...match.role_slugs].sort() : [];
}
const load = useCallback(async () => {
setLoading(true);
setErr(null);
@@ -149,8 +208,16 @@ export function AdminUsersConsole(): React.ReactElement {
}
function openPermissionEditor(row: AdminUserPermissionRow): void {
const bindings = row.site_bindings ?? [];
const initialSiteId =
bindings[0]?.site_id ?? defaultSiteId ?? siteOptions[0]?.id ?? null;
setSelectedId(row.id);
setDraftRoles([...row.roles].sort());
setRoleEditSiteId(initialSiteId);
setDraftRoles(
rolesForSite(bindings, initialSiteId).length > 0
? rolesForSite(bindings, initialSiteId)
: [...row.roles].sort(),
);
setPermissionOpen(true);
}
@@ -158,6 +225,7 @@ export function AdminUsersConsole(): React.ReactElement {
setPermissionOpen(open);
if (!open) {
setSelectedId(null);
setRoleEditSiteId(null);
}
}
@@ -170,6 +238,7 @@ export function AdminUsersConsole(): React.ReactElement {
setFormPassword("");
setFormStatus(0);
setFormCreateRoles([]);
setFormAdminSiteId(defaultSiteId);
setAccountOpen(true);
}
@@ -205,6 +274,10 @@ export function AdminUsersConsole(): React.ReactElement {
toast.error(t("roleRequired"));
return;
}
if (accountMode === "create" && (formAdminSiteId === null || formAdminSiteId <= 0)) {
toast.error(t("siteRequired"));
return;
}
setAccountSaving(true);
try {
@@ -224,6 +297,7 @@ export function AdminUsersConsole(): React.ReactElement {
email: formEmail.trim() === "" ? null : formEmail.trim(),
password: formPassword,
status: formStatus,
admin_site_id: formAdminSiteId as number,
role_slugs: formCreateRoles,
});
setItems((prev) => [created, ...prev]);
@@ -265,9 +339,16 @@ export function AdminUsersConsole(): React.ReactElement {
if (!selectedUser) {
return;
}
if (roleEditSiteId === null || roleEditSiteId <= 0) {
toast.error(t("siteRequired"));
return;
}
setSavingRoles(true);
try {
const result = await putAdminUserRoles(selectedUser.id, draftRoles);
const result = await putAdminUserRoles(selectedUser.id, {
admin_site_id: roleEditSiteId,
role_slugs: draftRoles,
});
setDraftRoles([...result.roles].sort());
setItems((prev) =>
prev.map((row) =>
@@ -275,6 +356,7 @@ export function AdminUsersConsole(): React.ReactElement {
? {
...row,
roles: result.roles,
site_bindings: result.site_bindings ?? row.site_bindings,
effective_permissions: result.effective_permissions,
}
: row,
@@ -378,6 +460,7 @@ export function AdminUsersConsole(): React.ReactElement {
<TableHead>{t("table.account")}</TableHead>
<TableHead>{t("table.nickname")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
<TableHead>{t("table.sites")}</TableHead>
<TableHead>{t("table.roles")}</TableHead>
<TableHead>{t("table.effective")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions")}</TableHead>
@@ -385,13 +468,9 @@ export function AdminUsersConsole(): React.ReactElement {
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={7} />
<AdminTableLoadingRow colSpan={8} />
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={8} className="text-muted-foreground" />
) : (
items.map((row) => (
<TableRow key={row.id}>
@@ -408,6 +487,9 @@ export function AdminUsersConsole(): React.ReactElement {
{row.status === 0 ? t("status.enabled") : t("status.disabled")}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatSiteBindings(row.site_bindings) || "—"}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{row.roles.length === 0 ? (
@@ -494,6 +576,33 @@ export function AdminUsersConsole(): React.ReactElement {
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
<div className="space-y-3">
<p className="text-xs text-muted-foreground">{t("permissionDialog.rolesDescription")}</p>
{siteOptions.length > 0 ? (
<div className="space-y-1.5">
<Label htmlFor="role-edit-site">{t("permissionDialog.site")}</Label>
<Select
value={roleEditSiteId !== null ? String(roleEditSiteId) : undefined}
disabled={siteOptions.length <= 1}
onValueChange={(value) => {
const siteId = Number(value);
setRoleEditSiteId(siteId);
setDraftRoles(rolesForSite(selectedUser?.site_bindings, siteId));
}}
>
<SelectTrigger id="role-edit-site" className="h-9 w-full max-w-md">
<SelectValue placeholder={t("accountDialog.sitePlaceholder")}>
{roleEditSiteLabel ?? t("accountDialog.sitePlaceholder")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
{(catalog?.roles ?? []).map((role) => {
const checked = draftRoles.includes(role.slug);
@@ -606,6 +715,29 @@ export function AdminUsersConsole(): React.ReactElement {
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
{accountMode === "create" && siteOptions.length > 0 ? (
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("accountDialog.site")}</div>
<Select
value={formAdminSiteId !== null ? String(formAdminSiteId) : undefined}
disabled={siteOptions.length <= 1}
onValueChange={(value) => setFormAdminSiteId(Number(value))}
>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder={t("accountDialog.sitePlaceholder")}>
{formAdminSiteLabel ?? t("accountDialog.sitePlaceholder")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{accountMode === "create" ? (
<div className="space-y-2">
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>

View File

@@ -0,0 +1,699 @@
"use client";
import type { ComponentType } from "react";
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminNoResourceState } 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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
import { AgentProfileFields, type AgentProfileFieldsProps } from "@/modules/agents/agent-profile-fields";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { Button } from "@/components/ui/button";
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
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: "日结" });
}
if (cycle === "monthly") {
return t("profile.cycleMonthly", { defaultValue: "月结" });
}
return t("profile.cycleWeekly", { defaultValue: "周结" });
}
export type AgentDetailTab = "overview" | "profile" | "downline" | "players";
export type AgentLineDetailPanelProps = {
node: AgentNodeRow | null;
profile: AgentProfileRow | null;
profileLoading: boolean;
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
siteCode: string;
siteLabel: string | null;
parentName: string | null;
detailTab: AgentDetailTab;
onDetailTabChange: (tab: AgentDetailTab) => void;
canViewProfileTab: boolean;
canEditProfileTab: boolean;
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onAddChild: () => void;
onEditCurrent: () => void;
onSelectChild: (node: AgentNodeRow) => void;
profileFields: AgentProfileFieldsProps | null;
profileSaving: boolean;
onSaveProfile: () => void;
};
export function AgentLineDetailPanel({
node,
profile,
profileLoading,
childAgents,
childCountById,
siteCode,
siteLabel,
parentName,
detailTab,
onDetailTabChange,
canViewProfileTab,
canEditProfileTab,
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
canManageNode,
canCreateChild,
canDeleteChild,
onEditChild,
onDeleteChild,
onAddChild,
onEditCurrent,
onSelectChild,
profileFields,
profileSaving,
onSaveProfile,
}: AgentLineDetailPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
if (node === null) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-muted/20 px-6 py-20 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/80 bg-background">
<Network className="size-6 text-muted-foreground/70" aria-hidden />
</div>
<p className="mt-4 text-sm font-medium text-foreground">
{t("lineUi.selectAgent", { defaultValue: "选择左侧代理查看占成与授信" })}
</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{t("lineUi.selectAgentHint", {
defaultValue: "信用占成盘以代理树为结算边界,占成、授信与回水均在代理节点配置。",
})}
</p>
</div>
);
}
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",
label: t("lineUi.tabOverview", { defaultValue: "概览" }),
visible: true,
},
{
key: "profile",
label: profileReadOnly
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" }),
visible: canViewProfileTab,
},
{
key: "downline",
label: t("lineUi.tabDownline", { defaultValue: "直属下级" }),
count: childAgents.length,
visible: canViewDownlineTab,
},
{
key: "players",
label: t("lineUi.tabPlayers", { defaultValue: "直属玩家" }),
visible: canViewPlayersTab,
},
];
const siteDisplay =
siteLabel && siteCode.trim() !== ""
? `${siteLabel} (${siteCode})`
: siteLabel ?? siteCode;
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">
<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">
{node.name}
</h2>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)} className="shrink-0">
{node.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</div>
<p className="mt-1.5 text-sm text-muted-foreground">
<span className="font-mono text-xs text-foreground/80">{node.code}</span>
{node.username ? (
<>
<span className="mx-1.5 text-border">·</span>
{node.username}
</>
) : null}
{parentName ? (
<>
<span className="mx-1.5 text-border">·</span>
{t("parentAgent", { defaultValue: "上级代理" })} {parentName}
</>
) : null}
</p>
</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}
{canManageNode ? (
<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.editAccount", { defaultValue: "账号与状态" })}
</Button>
{canCreateChild ? (
<Button type="button" size="sm" onClick={onAddChild}>
<Plus className="mr-1.5 size-3.5" />
{t("createChild", { defaultValue: "添加下级代理" })}
</Button>
) : null}
</div>
) : null}
</div>
</div>
</header>
<div className="flex items-center gap-0 overflow-x-auto border-b border-border/60 bg-card px-5 sm:px-6">
{tabs
.filter((tab) => tab.visible)
.map((tab) => (
<TabButton
key={tab.key}
active={detailTab === tab.key}
onClick={() => onDetailTabChange(tab.key)}
label={tab.label}
count={tab.count}
/>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
{detailTab === "overview" ? (
<OverviewTab
profile={profile}
profileLoading={profileLoading}
cycleLabel={cycleLabel}
profileReadOnly={profileReadOnly}
canViewDownlineTab={canViewDownlineTab}
canViewPlayersTab={canViewPlayersTab}
childCount={childAgents.length}
onGoToDownline={() => onDetailTabChange("downline")}
onGoToPlayers={() => onDetailTabChange("players")}
/>
) : null}
{detailTab === "profile" && canViewProfileTab && profileFields ? (
<Card className="mx-auto max-w-3xl border-border/70 shadow-sm">
<CardHeader className="border-b border-border/60 pb-4">
<CardTitle className="text-base">
{profileReadOnly
? t("lineUi.tabProfileReadOnly", { defaultValue: "占成与授信(只读)" })
: t("lineUi.tabProfile", { defaultValue: "占成与授信" })}
</CardTitle>
<p className="text-sm font-normal text-muted-foreground">
{profileReadOnly
? t("lineUi.profileReadOnlyHint", {
defaultValue: "占成、授信与回水由上级配置,如需调整请联系上级代理或平台。",
})
: t("lineUi.profileTabHint", {
defaultValue:
"占成、授信、回水与风控标签在此维护;登录名与密码请用「账号与状态」。",
})}
</p>
</CardHeader>
<CardContent className="pt-5">
<AgentProfileFields {...profileFields} idPrefix="inline-agent-profile" variant="card" />
{canManageNode && canEditProfileTab ? (
<div className="mt-6 flex justify-end border-t border-border/60 pt-5">
<Button
type="button"
className="min-w-[10rem]"
disabled={profileSaving || profileFields.loading}
onClick={onSaveProfile}
>
{profileSaving
? t("common:actions.saving", { defaultValue: "保存中…" })
: t("lineUi.saveProfile", { defaultValue: "保存占成与授信" })}
</Button>
</div>
) : null}
</CardContent>
</Card>
) : null}
{detailTab === "downline" && canViewDownlineTab ? (
<DownlineTable
childAgents={childAgents}
childCountById={childCountById}
canManageNode={canManageNode}
canCreateChild={canCreateChild}
canDeleteChild={canDeleteChild}
onEditChild={onEditChild}
onDeleteChild={onDeleteChild}
onSelectChild={onSelectChild}
onAddChild={onAddChild}
/>
) : null}
{detailTab === "players" && canViewPlayersTab ? (
<AgentsPlayersPanel
siteCode={siteCode}
agentNodeId={node.id}
allowCreatePlayer={profile?.can_create_player === true}
embedded
/>
) : null}
</div>
</div>
);
}
function OverviewTab({
profile,
profileLoading,
cycleLabel,
profileReadOnly,
canViewDownlineTab,
canViewPlayersTab,
childCount,
onGoToDownline,
onGoToPlayers,
}: {
profile: AgentProfileRow | null;
profileLoading: boolean;
cycleLabel: string;
profileReadOnly: boolean;
canViewDownlineTab: boolean;
canViewPlayersTab: boolean;
childCount: number;
onGoToDownline: () => void;
onGoToPlayers: () => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const rebateCap =
profile && !profileLoading ? ratioToPercentUi(profile.rebate_limit ?? 0) : null;
return (
<div className="mx-auto max-w-5xl space-y-6">
{profileReadOnly ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
subtitle={
rebateCap !== null
? t("lineUi.shareRebateCap", {
defaultValue: "回水上限 {{rate}}%",
rate: rebateCap,
})
: undefined
}
accent
/>
<MetricCard
label={t("profile.creditLimit", { defaultValue: "授信额度" })}
value={profileLoading ? "…" : formatCredit(profile?.credit_limit ?? 0)}
/>
<MetricCard
label={t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
value={profileLoading ? "…" : formatCredit(profile?.allocated_credit ?? 0)}
/>
<MetricCard
label={t("lineUi.availableCredit", { defaultValue: "可下发" })}
value={profileLoading ? "…" : formatCredit(profile?.available_credit ?? 0)}
highlight
/>
</div>
)}
{!profileReadOnly && !profileLoading && profile ? (
<p className="text-xs text-muted-foreground">
{t("lineUi.profileFootnote", {
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
rebate: ratioToPercentUi(profile.rebate_limit ?? 0),
defaultRebate: ratioToPercentUi(profile.default_player_rebate ?? 0),
cycle: cycleLabel,
})}
{(profile.risk_tags?.length ?? 0) > 0
? ` · ${t("profile.riskTags", { defaultValue: "风控" })}: ${profile.risk_tags?.join(", ")}`
: ""}
</p>
) : null}
{profileReadOnly ? (
<p className="rounded-lg border border-border/60 bg-card px-4 py-3 text-sm text-muted-foreground">
{t("lineUi.selfAgentOverviewHint", {
defaultValue:
"以下为上级为您分配的授信额度,占成与回水由上级在后台维护,本账号不可查看或修改。",
})}
</p>
) : null}
{canViewDownlineTab || canViewPlayersTab ? (
<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 管理下级代理。",
count: childCount,
})}
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
onAction={onGoToDownline}
/>
) : null}
{canViewPlayersTab ? (
<OverviewLinkCard
icon={Users}
title={t("lineUi.tabPlayers", { defaultValue: "直属玩家" })}
description={t("lineUi.overviewPlayersHint", {
defaultValue: "直属玩家请在「直属玩家」Tab 维护。",
})}
actionLabel={t("lineUi.viewAll", { defaultValue: "查看全部" })}
onAction={onGoToPlayers}
/>
) : null}
</div>
) : null}
</div>
);
}
function OverviewLinkCard({
icon: Icon,
title,
description,
actionLabel,
onAction,
}: {
icon: ComponentType<{ className?: string }>;
title: 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>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 text-primary hover:text-primary"
onClick={onAction}
>
{actionLabel}
<ChevronRight className="ml-0.5 size-3.5" aria-hidden />
</Button>
</CardContent>
</Card>
);
}
function DownlineTable({
childAgents,
childCountById,
canManageNode,
canCreateChild,
canDeleteChild,
onEditChild,
onDeleteChild,
onSelectChild,
onAddChild,
}: {
childAgents: AgentNodeRow[];
childCountById: Map<number, number>;
canManageNode: boolean;
canCreateChild: boolean;
canDeleteChild: (node: AgentNodeRow) => boolean;
onEditChild: (node: AgentNodeRow) => void;
onDeleteChild: (node: AgentNodeRow) => void;
onSelectChild: (node: AgentNodeRow) => void;
onAddChild: () => void;
}): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
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" />
{t("createChild", { defaultValue: "添加下级代理" })}
</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">
<Table>
<TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableHead>{t("agentCode", { defaultValue: "代理编码" })}</TableHead>
<TableHead>{t("agentName", { defaultValue: "代理名称" })}</TableHead>
<TableHead>{t("loginUsername", { defaultValue: "登录名" })}</TableHead>
<TableHead>{t("lineUi.downlineColumns.email", { defaultValue: "邮箱" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.totalShareRate", { defaultValue: "占成 (%)" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("lineUi.allocatedCredit", { defaultValue: "已下发" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
</TableHead>
<TableHead className="text-center whitespace-nowrap">
{t("lineUi.downlineColumns.downlineCount", { defaultValue: "下级数" })}
</TableHead>
<TableHead className="w-24">{t("common:status.label", { defaultValue: "状态" })}</TableHead>
{canManageNode ? (
<TableHead className="sticky right-0 z-10 w-14 bg-muted/40 text-center shadow-[-1px_0_0_var(--border)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
) : null}
</TableRow>
</TableHeader>
<TableBody>
{childAgents.map((child) => {
const summary = child.profile_summary;
return (
<TableRow
key={child.id}
className="cursor-pointer"
onClick={() => onSelectChild(child)}
>
<TableCell className="font-mono text-xs">{child.code}</TableCell>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell className="text-xs">{child.username ?? "—"}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs text-muted-foreground">
{child.email ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.credit_limit) : "—"}
</TableCell>
<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>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)} className="shrink-0">
{child.status === 1
? t("common:status.enabled", { defaultValue: "启用" })
: t("common:status.disabled", { defaultValue: "停用" })}
</AdminStatusBadge>
</TableCell>
{canManageNode ? (
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: t("editNode", { defaultValue: "编辑代理" }),
icon: Pencil,
onClick: () => onEditChild(child),
},
{
key: "delete",
label: t("deleteNode", { defaultValue: "删除代理" }),
icon: Trash2,
destructive: true,
disabled: !canDeleteChild(child),
onClick: () => onDeleteChild(child),
},
]}
/>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
function MetricCard({
label,
value,
subtitle,
accent = false,
highlight = false,
}: {
label: string;
value: string;
subtitle?: string;
accent?: boolean;
highlight?: boolean;
}): React.ReactElement {
return (
<div
className={cn(
"rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
highlight && "border-primary/25 bg-primary/[0.04]",
accent && !highlight && "border-border/70",
!accent && !highlight && "border-border/70",
)}
>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
highlight ? "text-primary" : "text-foreground",
)}
>
{value}
</p>
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
</div>
);
}
function TabButton({
active,
onClick,
label,
count,
}: {
active: boolean;
onClick: () => void;
label: string;
count?: number;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative -mb-px shrink-0 border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
)}
>
{label}
{count !== undefined && count > 0 ? (
<span
className={cn(
"ml-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{count}
</span>
) : null}
</button>
);
}

View File

@@ -1,10 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postAdminAgentLine } from "@/api/admin-agent-lines";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -17,21 +19,22 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { percentUiToRatio } from "@/lib/admin-rate-percent";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
export function AgentLineProvisionWizard(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [submitting, setSubmitting] = useState(false);
const [secrets, setSecrets] = useState<{ sso: string; wallet: string } | null>(null);
const [sitesLoading, setSitesLoading] = useState(true);
const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]);
const [form, setForm] = useState({
site_code: "",
code: "",
name: "",
username: "",
password: "",
currency_code: "NPR",
wallet_api_url: "",
notes: "",
total_share_rate: "0",
credit_limit: "0",
rebate_limit: "0",
@@ -40,33 +43,51 @@ export function AgentLineProvisionWizard(): React.ReactElement {
can_grant_extra_rebate: false,
});
useAsyncEffect(() => {
setSitesLoading(true);
void getAdminIntegrationSites()
.then((data) => setSites(data.items))
.catch(() => setSites([]))
.finally(() => setSitesLoading(false));
}, []);
const unboundSites = useMemo(
() => sites.filter((row) => !row.has_line_root),
[sites],
);
async function onSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!form.site_code.trim()) {
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
return;
}
setSubmitting(true);
setSecrets(null);
try {
const result = await postAdminAgentLine({
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,
currency_code: form.currency_code,
wallet_api_url: form.wallet_api_url.trim() || null,
notes: form.notes.trim() || null,
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
rebate_limit: percentUiToRatio(form.rebate_limit),
default_player_rebate: percentUiToRatio(form.default_player_rebate),
settlement_cycle: form.settlement_cycle,
can_grant_extra_rebate: form.can_grant_extra_rebate,
});
if (result.secrets) {
setSecrets({
sso: result.secrets.sso_jwt_secret,
wallet: result.secrets.wallet_api_key,
});
}
toast.success(t("agents:lineProvision.success", { defaultValue: "线路已开通" }));
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({
...f,
site_code: "",
code: "",
name: "",
username: "",
password: "",
}));
const data = await getAdminIntegrationSites();
setSites(data.items);
} catch (err) {
const msg =
err instanceof LotteryApiBizError ? err.message : t("common:error.generic");
@@ -77,10 +98,61 @@ export function AgentLineProvisionWizard(): React.ReactElement {
}
return (
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "开通代理线路" })}>
<AdminPageCard title={t("agents:lineProvision.title", { defaultValue: "创建一级代理" })}>
<p className="mb-2 max-w-xl text-sm text-muted-foreground">
{t("agents:subnav.provisionHint", {
defaultValue:
"请先在「平台管理 → 接入配置」创建接入站点;对接密钥在站点创建时一次性展示。",
})}
</p>
<p className="mb-4 max-w-xl text-sm text-muted-foreground">
{t("agents:lineProvision.description", {
defaultValue:
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。",
})}{" "}
<Link
href="/admin/config/integration-sites"
className="font-medium text-primary underline-offset-4 hover:underline"
>
{t("agents:lineProvision.openIntegrationSites", {
defaultValue: "前往接入站点",
})}
</Link>
</p>
<form className="grid max-w-xl gap-4" onSubmit={onSubmit}>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "站点 code" })}</Label>
<Label>{t("agents:lineProvision.siteCode", { defaultValue: "接入站点" })}</Label>
<Select
value={form.site_code}
onValueChange={(value) => setForm((f) => ({ ...f, site_code: value ?? "" }))}
disabled={sitesLoading || unboundSites.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
sitesLoading
? t("common:loading", { defaultValue: "加载中…" })
: unboundSites.length === 0
? t("agents:lineProvision.noUnboundSite", {
defaultValue: "暂无未绑定一级代理的站点",
})
: t("agents:lineProvision.siteCodePlaceholder", {
defaultValue: "选择站点",
})
}
/>
</SelectTrigger>
<SelectContent>
{unboundSites.map((site) => (
<SelectItem key={site.id} value={site.code}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
@@ -89,7 +161,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.name", { defaultValue: "线路名称" })}</Label>
<Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
@@ -97,7 +169,7 @@ export function AgentLineProvisionWizard(): React.ReactElement {
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.username", { defaultValue: "代理账号" })}</Label>
<Label>{t("agents:lineProvision.username", { defaultValue: "后台登录账号" })}</Label>
<Input
value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
@@ -114,13 +186,6 @@ export function AgentLineProvisionWizard(): React.ReactElement {
minLength={8}
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.walletUrl", { defaultValue: "钱包 API URL" })}</Label>
<Input
value={form.wallet_api_url}
onChange={(e) => setForm((f) => ({ ...f, wallet_api_url: e.target.value }))}
/>
</div>
<p className="text-sm font-medium">
{t("agents:profile.section", { defaultValue: "占成与授信" })}
@@ -147,24 +212,26 @@ export function AgentLineProvisionWizard(): React.ReactElement {
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限" })}</Label>
<Label>{t("agents:profile.rebateLimit", { defaultValue: "回水上限 (%)" })}</Label>
<Input
type="number"
min={0}
max={1}
step="0.0001"
max={100}
step="0.01"
value={form.rebate_limit}
placeholder="0.5"
onChange={(e) => setForm((f) => ({ ...f, rebate_limit: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}</Label>
<Label>{t("agents:profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}</Label>
<Input
type="number"
min={0}
max={1}
step="0.0001"
max={100}
step="0.01"
value={form.default_player_rebate}
placeholder="0.5"
onChange={(e) => setForm((f) => ({ ...f, default_player_rebate: e.target.value }))}
/>
</div>
@@ -208,32 +275,12 @@ export function AgentLineProvisionWizard(): React.ReactElement {
</Label>
</div>
<div className="grid gap-2">
<Label>{t("common:notes", { defaultValue: "备注" })}</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
/>
</div>
<Button type="submit" disabled={submitting}>
<Button type="submit" disabled={submitting || unboundSites.length === 0}>
{submitting
? t("common:submitting", { defaultValue: "提交中…" })
: t("agents:lineProvision.submit", { defaultValue: "开通线路" })}
: t("agents:lineProvision.submit", { defaultValue: "创建一级代理" })}
</Button>
</form>
{secrets ? (
<div className="mt-6 rounded-md border border-amber-500/40 bg-amber-500/5 p-4 text-sm">
<p className="font-medium text-amber-700">
{t("agents:lineProvision.secretsOnce", { defaultValue: "密钥仅显示一次,请妥善保存" })}
</p>
<p className="mt-2 break-all">
SSO: <code>{secrets.sso}</code>
</p>
<p className="mt-1 break-all">
Wallet API Key: <code>{secrets.wallet}</code>
</p>
</div>
) : null}
</AdminPageCard>
);
}

View File

@@ -0,0 +1,301 @@
"use client";
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";
function formatCredit(amount: number, currencyCode = "NPR"): string {
return formatAdminCreditMajorDecimal(amount, currencyCode);
}
function nodeMatchesKeyword(
node: AgentNodeRow,
normalized: string,
parentNameMap: Map<number, string>,
): boolean {
if (normalized === "") {
return true;
}
const parentName =
node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "") : "";
return [node.name, node.code, node.username ?? "", parentName]
.join(" ")
.toLowerCase()
.includes(normalized);
}
function pruneTreeForSearch(
nodes: AgentNodeRow[],
normalized: string,
parentNameMap: Map<number, string>,
): AgentNodeRow[] {
if (normalized === "") {
return nodes;
}
const out: AgentNodeRow[] = [];
for (const node of nodes) {
const children = pruneTreeForSearch(node.children ?? [], normalized, parentNameMap);
const selfMatch = nodeMatchesKeyword(node, normalized, parentNameMap);
if (selfMatch || children.length > 0) {
out.push({ ...node, children });
}
}
return out;
}
function collectExpandableIds(nodes: AgentNodeRow[], into: Set<number>): void {
for (const node of nodes) {
if ((node.children?.length ?? 0) > 0) {
into.add(node.id);
collectExpandableIds(node.children ?? [], into);
}
}
}
function unwrapSiteRoots(nodes: AgentNodeRow[]): AgentNodeRow[] {
return nodes.flatMap((node) => (node.is_root ? (node.children ?? []) : [node]));
}
export type AgentLineSidebarProps = {
siteLabel: string | null;
/** API 返回的嵌套树(含 children */
tree: AgentNodeRow[];
parentNameMap: Map<number, string>;
selectedId: number | null;
keyword: string;
agentCount: number;
onKeywordChange: (value: string) => void;
onSelect: (node: AgentNodeRow) => void;
};
type TreeRowProps = {
node: AgentNodeRow;
depth: number;
selectedId: number | null;
expandedIds: Set<number>;
onToggleExpand: (id: number) => void;
onSelect: (node: AgentNodeRow) => void;
};
function TreeRow({
node,
depth,
selectedId,
expandedIds,
onToggleExpand,
onSelect,
}: TreeRowProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const children = node.children ?? [];
const hasChildren = children.length > 0;
const expanded = expandedIds.has(node.id);
const active = selectedId === node.id;
const indent = depth * 14;
return (
<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",
)}
style={{ paddingLeft: `${6 + indent}px` }}
>
{hasChildren ? (
<button
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"
onClick={(e) => {
e.stopPropagation();
onToggleExpand(node.id);
}}
>
<ChevronRight
className={cn("size-3.5 transition-transform", expanded && "rotate-90")}
aria-hidden
/>
</button>
) : (
<span className="mt-1 inline-block size-6 shrink-0" aria-hidden />
)}
<button
type="button"
role="option"
aria-selected={active}
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">
{node.username ?? node.code}
</p>
</button>
</div>
{hasChildren && expanded ? (
<ul className="space-y-0.5">
{children.map((child) => (
<TreeRow
key={child.id}
node={child}
depth={depth + 1}
selectedId={selectedId}
expandedIds={expandedIds}
onToggleExpand={onToggleExpand}
onSelect={onSelect}
/>
))}
</ul>
) : null}
</li>
);
}
export function AgentLineSidebar({
siteLabel,
tree,
parentNameMap,
selectedId,
keyword,
agentCount,
onKeywordChange,
onSelect,
}: AgentLineSidebarProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const normalizedKeyword = keyword.trim().toLowerCase();
const displayForest = useMemo(() => {
const pruned = pruneTreeForSearch(tree, normalizedKeyword, parentNameMap);
return unwrapSiteRoots(pruned);
}, [normalizedKeyword, parentNameMap, tree]);
useEffect(() => {
const next = new Set<number>();
collectExpandableIds(tree, next);
setExpandedIds(next);
}, [tree]);
useEffect(() => {
if (selectedId === null) {
return;
}
setExpandedIds((prev) => {
const next = new Set(prev);
const walk = (nodes: AgentNodeRow[], ancestors: number[]): boolean => {
for (const node of nodes) {
const chain = [...ancestors, node.id];
if (node.id === selectedId) {
for (const id of ancestors) {
next.add(id);
}
return true;
}
if (walk(node.children ?? [], chain)) {
return true;
}
}
return false;
};
walk(tree, []);
return next;
});
}, [selectedId, tree]);
const toggleExpand = useCallback((id: number) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const hasAnyAgent = displayForest.length > 0;
return (
<aside className="flex h-full min-h-[28rem] w-full flex-col bg-muted/10 lg:w-[18rem] lg:shrink-0 lg:border-r lg:border-border/70">
<div className="space-y-3 border-b border-border/60 bg-card px-4 py-4">
{siteLabel ? (
<p className="truncate text-xs font-medium text-foreground/80" title={siteLabel}>
{siteLabel}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{t("lineUi.agentCount", {
defaultValue: "本组 {{count}} 个代理",
count: agentCount,
})}
</p>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
className="h-9 pl-8 text-sm"
placeholder={t("lineUi.searchPlaceholder", {
defaultValue: "搜索名称或登录名",
})}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{!hasAnyAgent ? (
<AdminNoResourceState className="px-2 py-8 text-center text-sm text-muted-foreground" />
) : (
<ul className="space-y-0.5" role="listbox" aria-label={t("listTitle", { defaultValue: "代理列表" })}>
{displayForest.map((node) => (
<TreeRow
key={node.id}
node={node}
depth={0}
selectedId={selectedId}
expandedIds={expandedIds}
onToggleExpand={toggleExpand}
onSelect={onSelect}
/>
))}
</ul>
)}
</div>
</aside>
);
}
export { formatCredit };

View File

@@ -0,0 +1,76 @@
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_LINE_PROVISION,
PRD_AGENTS_ACCESS_ANY,
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
} from "@/lib/admin-prd";
import type { AdminAgentContext } from "@/types/api/admin-agent";
type AdminSessionLike = {
is_super_admin?: boolean;
permissions?: string[] | null;
agent?: AdminAgentContext | null;
};
/** 线路内绑定代理可查看账单;平台账号需 settlement.agent.* */
export function canAccessAgentSettlementBills(
session: AdminSessionLike | null | undefined,
): boolean {
if (session?.agent != null) {
return true;
}
return adminHasAnyPermission(session?.permissions ?? [], [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]);
}
/** 仅平台侧可开通新线路;绑定代理永不可见 */
export function canAccessAgentLineProvision(
session: AdminSessionLike | null | undefined,
): boolean {
if (session?.agent != null) {
return false;
}
return adminHasAnyPermission(session?.permissions ?? [], [PRD_AGENT_LINE_PROVISION]);
}
export function isAgentLineSubnavTabVisible(
href: string,
session: AdminSessionLike | null | undefined,
): boolean {
const perms = session?.permissions ?? [];
const isSuper = session?.is_super_admin === true;
const boundAgent = session?.agent ?? null;
if (isSuper) {
return adminHasAnyPermission(perms, tabRequiredSlugs(href));
}
if (href === "/admin/agents/provision") {
return canAccessAgentLineProvision(session);
}
if (href === "/admin/settlement-center" || href === "/admin/agents/settlement-bills") {
return canAccessAgentSettlementBills(session);
}
if (href === "/admin/agents") {
return adminHasAnyPermission(perms, [...PRD_AGENTS_ACCESS_ANY]);
}
return false;
}
function tabRequiredSlugs(href: string): readonly string[] {
switch (href) {
case "/admin/agents":
return PRD_AGENTS_ACCESS_ANY;
case "/admin/agents/provision":
return [PRD_AGENT_LINE_PROVISION];
case "/admin/settlement-center":
case "/admin/agents/settlement-bills":
return PRD_SETTLEMENT_AGENT_ACCESS_ANY;
default:
return [];
}
}

View File

@@ -0,0 +1,268 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { cn } from "@/lib/utils";
import type { AgentParentCaps } from "@/types/api/admin-agent";
export type AgentProfileFieldsProps = {
disabled?: boolean;
loading?: boolean;
parentCaps: AgentParentCaps | null;
availableCredit: number | null;
canCreateChildAgent: boolean;
isSuperAdmin: boolean;
shareRate: string;
onShareRateChange: (value: string) => void;
creditLimit: string;
onCreditLimitChange: (value: string) => void;
rebateLimit: string;
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;
onCanCreatePlayerChange: (value: boolean) => void;
canCreateChild: boolean;
onCanCreateChildChange: (value: boolean) => void;
riskTags: string;
onRiskTagsChange: (value: string) => void;
idPrefix?: string;
currencyCode?: string;
/** card用于代理线路详情 Tab 内的卡片表单 */
variant?: "default" | "card";
};
export function AgentProfileFields({
disabled = false,
loading = false,
parentCaps,
availableCredit,
canCreateChildAgent,
isSuperAdmin,
shareRate,
onShareRateChange,
creditLimit,
onCreditLimitChange,
rebateLimit,
onRebateLimitChange,
defaultRebate,
onDefaultRebateChange,
settlementCycle,
onSettlementCycleChange,
extraRebate,
onExtraRebateChange,
canCreatePlayer,
onCanCreatePlayerChange,
canCreateChild,
onCanCreateChildChange,
riskTags,
onRiskTagsChange,
idPrefix = "agent-profile",
currencyCode = "NPR",
variant = "default",
}: AgentProfileFieldsProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const fieldDisabled = disabled || loading;
const isCard = variant === "card";
return (
<div className="space-y-5">
{(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",
)}
>
{parentCaps ? (
<p>
{t("profile.parentCaps", {
defaultValue: "上级占成 {{share}}%,可下发额度 {{credit}}",
share: parentCaps.total_share_rate,
credit: formatAdminCreditMajorDecimal(parentCaps.available_credit, currencyCode),
})}
</p>
) : null}
{availableCredit !== null ? (
<p>
{t("profile.availableCredit", {
defaultValue: "可下发额度:{{amount}}",
amount: formatAdminCreditMajorDecimal(availableCredit, currencyCode),
})}
</p>
) : null}
</div>
) : null}
{loading ? (
<p className="text-sm text-muted-foreground">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
<div
className={cn(
"grid gap-4 sm:grid-cols-2",
fieldDisabled ? "pointer-events-none opacity-50" : "",
)}
>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-share-rate`}>
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
<Input
id={`${idPrefix}-share-rate`}
type="number"
min={0}
max={100}
step="0.01"
value={shareRate}
onChange={(e) => onShareRateChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-credit-limit`}>
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id={`${idPrefix}-credit-limit`}
type="number"
min={0}
value={creditLimit}
onChange={(e) => onCreditLimitChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-rebate-limit`}>
{t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
</Label>
<Input
id={`${idPrefix}-rebate-limit`}
type="number"
min={0}
max={100}
step="0.01"
value={rebateLimit}
onChange={(e) => onRebateLimitChange(e.target.value)}
placeholder="0.5"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-default-rebate`}>
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
</Label>
<Input
id={`${idPrefix}-default-rebate`}
type="number"
min={0}
max={100}
step="0.01"
value={defaultRebate}
onChange={(e) => onDefaultRebateChange(e.target.value)}
placeholder="0.5"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor={`${idPrefix}-risk-tags`}>
{t("profile.riskTags", { defaultValue: "风控标签" })}
</Label>
<Input
id={`${idPrefix}-risk-tags`}
value={riskTags}
onChange={(e) => onRiskTagsChange(e.target.value)}
placeholder={t("profile.riskTagsPlaceholder", {
defaultValue: "逗号分隔,如 overdue, high_turnover",
})}
/>
</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 />
</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",
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">
<SwitchRow
checked={extraRebate}
onCheckedChange={onExtraRebateChange}
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
/>
<SwitchRow
checked={canCreatePlayer}
onCheckedChange={onCanCreatePlayerChange}
label={t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
/>
<SwitchRow
checked={canCreateChild}
onCheckedChange={onCanCreateChildChange}
disabled={!canCreateChildAgent && !isSuperAdmin}
label={t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
/>
</div>
</div>
</div>
);
}
function SwitchRow({
checked,
onCheckedChange,
label,
disabled = false,
}: {
checked: boolean;
onCheckedChange: (value: boolean) => void;
label: string;
disabled?: 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>
<Switch checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
);
}

View File

@@ -1,8 +1,6 @@
"use client";
import Link from "next/link";
import { Eye, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -14,13 +12,14 @@ import {
putAgentNode,
putAgentNodeProfile,
} from "@/api/admin-agents";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import {
AgentLineDetailPanel,
type AgentDetailTab,
} from "@/modules/agents/agent-line-detail-panel";
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -30,43 +29,41 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { AgentsPlayersPanel } from "@/modules/agents/agents-players-panel";
import {
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
percentUiToRatio,
percentValueToUi,
parsePercentUi,
ratioToPercentUi,
} from "@/lib/admin-rate-percent";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_MANAGE,
PRD_AGENT_PROFILE_MANAGE,
PRD_AGENTS_ACCESS_ANY,
PRD_AGENT_SITES_ACCESS_ANY,
PRD_INTEGRATION_ACCESS_ANY,
PRD_USERS_MANAGE,
} from "@/lib/admin-prd";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
import { cn } from "@/lib/utils";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { useAdminProfile } from "@/stores/admin-session";
import type { AgentNodeRow } from "@/types/api/admin-agent";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
import { LotteryApiBizError } from "@/types/api/errors";
function parseRiskTagsInput(text: string): string[] {
return Array.from(
new Set(
text
.split(/[,\s]+/)
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
);
}
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
const out: AgentNodeRow[] = [];
const walk = (list: AgentNodeRow[]) => {
@@ -82,10 +79,6 @@ function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
return out;
}
function countBusinessAgents(nodes: AgentNodeRow[]): number {
return nodes.filter((node) => !node.is_root).length;
}
export function AgentsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const tRef = useTranslationRef(["agents", "common"]);
@@ -104,23 +97,18 @@ export function AgentsConsole(): React.ReactElement {
PRD_AGENT_PROFILE_MANAGE,
PRD_AGENT_MANAGE,
]);
const canProvision =
isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]);
const canViewSiteList = adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_SITES_ACCESS_ANY]);
const canSwitchSite = isSuperAdmin || adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
const [siteOptions, setSiteOptions] = useState<{ id: number; label: string; code: string }[]>([]);
const [globalVisibleNodeCount, setGlobalVisibleNodeCount] = useState<number | null>(null);
const [globalBusinessAgentCount, setGlobalBusinessAgentCount] = useState<number | null>(null);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const [tree, setTree] = useState<AgentNodeRow[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [operationsTab, setOperationsTab] = useState<"subordinates" | "players">("subordinates");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [detailTab, setDetailTab] = useState<AgentDetailTab>("overview");
const [profileSaving, setProfileSaving] = useState(false);
const [selectedProfile, setSelectedProfile] = useState<AgentProfileRow | null>(null);
const [selectedProfileLoading, setSelectedProfileLoading] = useState(false);
const [nodeDialogOpen, setNodeDialogOpen] = useState(false);
const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create");
@@ -141,19 +129,21 @@ export function AgentsConsole(): React.ReactElement {
const [profileExtraRebate, setProfileExtraRebate] = useState(false);
const [profileCanCreateChild, setProfileCanCreateChild] = useState(false);
const [profileCanCreatePlayer, setProfileCanCreatePlayer] = useState(true);
const [profileRiskTags, setProfileRiskTags] = useState("");
const [profileLoading, setProfileLoading] = useState(false);
const [profileLoaded, setProfileLoaded] = useState(true);
const [profileParentCaps, setProfileParentCaps] = useState<AgentParentCaps | null>(null);
const [profileAvailableCredit, setProfileAvailableCredit] = useState<number | null>(null);
const [editingNodeNeedsPrimaryAccount, setEditingNodeNeedsPrimaryAccount] = useState(false);
const [downlineDialogNode, setDownlineDialogNode] = useState<AgentNodeRow | null>(null);
const boundAgent = profile?.agent ?? null;
/** 登录账号是否可向子代理下放「允许创建下级」 */
const canCreateChildAgent =
isSuperAdmin || boundAgent?.can_create_child_agent !== false;
const canCreateChildForNode = (_node: AgentNodeRow) => canManageNode && canCreateChildAgent;
const canViewPlayersTab =
const hasUsersManagePermission =
isSuperAdmin ||
(boundAgent?.can_create_player !== false &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
const resetProfileForm = (mode: "create" | "edit" = "create") => {
setProfileShareRate("0");
@@ -164,24 +154,42 @@ export function AgentsConsole(): React.ReactElement {
setProfileExtraRebate(false);
setProfileCanCreateChild(mode === "create" ? false : false);
setProfileCanCreatePlayer(true);
setProfileRiskTags("");
setProfileParentCaps(null);
setProfileAvailableCredit(null);
};
const applyProfileRowToForm = (row: AgentProfileRow) => {
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
setProfileCreditLimit(String(row.credit_limit ?? 0));
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
setProfileDefaultRebate(ratioToPercentUi(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);
setProfileParentCaps(row.parent_caps ?? null);
setProfileAvailableCredit(row.available_credit ?? null);
setProfileRiskTags((row.risk_tags ?? []).join(", "));
};
const profilePayload = () => ({
total_share_rate: Number.parseFloat(profileShareRate) || 0,
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
rebate_limit: percentUiToRatio(profileRebateLimit),
default_player_rebate: percentUiToRatio(profileDefaultRebate),
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
can_grant_extra_rebate: profileExtraRebate,
can_create_child_agent: profileCanCreateChild,
can_create_player: profileCanCreatePlayer,
risk_tags: parseRiskTagsInput(profileRiskTags),
});
const validateProfileFields = (): string | null => {
const shareRate = Number.parseFloat(profileShareRate);
const creditLimit = Number.parseInt(profileCreditLimit, 10);
const rebateLimit = Number.parseFloat(profileRebateLimit);
const defaultRebate = Number.parseFloat(profileDefaultRebate);
const rebateLimit = parsePercentUi(profileRebateLimit);
const defaultRebate = parsePercentUi(profileDefaultRebate);
if (Number.isNaN(shareRate) || shareRate < 0 || shareRate > 100) {
return t("profile.validation.shareRange", {
@@ -195,15 +203,15 @@ export function AgentsConsole(): React.ReactElement {
});
}
if (Number.isNaN(rebateLimit) || rebateLimit < 0 || rebateLimit > 1) {
if (rebateLimit === null || rebateLimit < 0 || rebateLimit > 100) {
return t("profile.validation.rebateLimitRange", {
defaultValue: "回水上限须在 01 之间(如 0.005 表示 0.5%",
defaultValue: "回水上限须在 0100% 之间",
});
}
if (Number.isNaN(defaultRebate) || defaultRebate < 0 || defaultRebate > 1) {
if (defaultRebate === null || defaultRebate < 0 || defaultRebate > 100) {
return t("profile.validation.defaultRebateRange", {
defaultValue: "默认玩家回水须在 01 之间",
defaultValue: "默认玩家回水须在 0100% 之间",
});
}
@@ -222,10 +230,8 @@ export function AgentsConsole(): React.ReactElement {
[flatNodes],
);
const businessRows = useMemo(() => flatNodes.filter((node) => !node.is_root), [flatNodes]);
const currentSiteNodeCount = flatNodes.length;
const currentSiteBusinessAgentCount = useMemo(() => countBusinessAgents(flatNodes), [flatNodes]);
const selectedSiteLabel = useMemo(
() => siteOptions.find((site) => site.id === adminSiteId)?.label ?? null,
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null,
[adminSiteId, siteOptions],
);
const activeSiteCode = useMemo(() => {
@@ -239,11 +245,44 @@ export function AgentsConsole(): React.ReactElement {
}
return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]);
const playersPanelAgentId = useMemo(
() => (isSuperAdmin ? null : (boundAgent?.id ?? null)),
[boundAgent?.id, isSuperAdmin],
const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes],
);
const selectedNode = useMemo(
() =>
selectedNodeId !== null
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
: null,
[flatNodes, selectedNodeId],
);
const isOwnAgentNode =
boundAgent !== null && selectedNodeId !== null && selectedNodeId === boundAgent.id;
const canEditSelectedProfile =
canManageProfile && selectedNode !== null && (isSuperAdmin || !isOwnAgentNode);
const selectedChildAgents = useMemo(() => {
if (selectedNode === null) {
return [];
}
return flatNodes.filter((node) => node.parent_id === selectedNode.id);
}, [flatNodes, selectedNode]);
const childCountById = useMemo(() => {
const counts = new Map<number, number>();
for (const node of flatNodes) {
if (node.parent_id === null) {
continue;
}
counts.set(node.parent_id, (counts.get(node.parent_id) ?? 0) + 1);
}
return counts;
}, [flatNodes]);
const filteredRows = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
@@ -262,21 +301,6 @@ export function AgentsConsole(): React.ReactElement {
});
}, [businessRows, keyword, parentNameMap]);
const total = filteredRows.length;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const currentPage = Math.min(page, lastPage);
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * perPage;
return filteredRows.slice(start, start + perPage);
}, [currentPage, filteredRows, perPage]);
const downlineChildAgents = useMemo(() => {
if (downlineDialogNode === null) {
return [];
}
return flatNodes.filter((node) => node.parent_id === downlineDialogNode.id);
}, [downlineDialogNode, flatNodes]);
const loadTree = useCallback(async (siteId?: number | null) => {
setLoading(true);
setErr(null);
@@ -292,59 +316,124 @@ export function AgentsConsole(): React.ReactElement {
}
}, [tRef]);
useAsyncEffect(() => {
useEffect(() => {
if (!canViewAgents) {
return;
}
if (canSwitchSite) {
void getAdminIntegrationSites()
.then((data) => {
const options = data.items.map((row) => ({
id: row.id,
code: row.code,
label: `${row.name} (${row.code})`,
}));
setSiteOptions(options);
if (options.length > 0 && adminSiteId === null) {
setAdminSiteId(options[0]?.id ?? null);
}
})
.catch(() => setSiteOptions([]));
} else if (profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
if (adminSiteId === null) {
if (profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
return;
}
if (siteOptions.length > 0 && isSuperAdmin) {
setAdminSiteId(siteOptions[0]?.id ?? null);
}
}
}, [adminSiteId, canSwitchSite, canViewAgents, profile?.agent?.admin_site_id]);
}, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
useAsyncEffect(() => {
if (!canSwitchSite || siteOptions.length === 0) {
setGlobalVisibleNodeCount(null);
setGlobalBusinessAgentCount(null);
if (selectedNode === null) {
setSelectedProfile(null);
setSelectedProfileLoading(false);
return;
}
void Promise.all(siteOptions.map(async (site) => getAgentTree(site.id)))
.then((results) => {
const allNodes = results.flatMap((result) => flattenTree(result.tree));
setGlobalVisibleNodeCount(allNodes.length);
setGlobalBusinessAgentCount(countBusinessAgents(allNodes));
setSelectedProfileLoading(true);
void getAgentNodeProfile(selectedNode.id)
.then((row) => {
setSelectedProfile(row);
if (!nodeDialogOpen) {
applyProfileRowToForm(row);
}
})
.catch(() => {
setGlobalVisibleNodeCount(null);
setGlobalBusinessAgentCount(null);
});
}, [canSwitchSite, siteOptions]);
.catch(() => setSelectedProfile(null))
.finally(() => setSelectedProfileLoading(false));
}, [selectedNode?.id, nodeDialogOpen]);
useAsyncEffect(() => {
if (adminSiteId === null && !canSwitchSite && profile?.agent?.admin_site_id) {
setAdminSiteId(profile.agent.admin_site_id);
if (rootNode === null) {
setRootProfile(null);
return;
}
if (adminSiteId !== null || !canSwitchSite) {
void getAgentNodeProfile(rootNode.id)
.then((p) => setRootProfile(p))
.catch(() => setRootProfile(null));
}, [rootNode?.id]);
/** 仅上级/平台维护下级占成授信;代理查看自己时不展示配置 Tab */
const canShowProfileTab = canEditSelectedProfile;
const canShowDownlineTab = useMemo(
() =>
selectedNode !== null &&
!selectedProfileLoading &&
selectedProfile?.can_create_child_agent === true,
[selectedNode, selectedProfile, selectedProfileLoading],
);
const canShowPlayersTab = useMemo(
() =>
selectedNode !== null &&
!selectedProfileLoading &&
selectedProfile?.can_create_player === true &&
hasUsersManagePermission,
[hasUsersManagePermission, selectedNode, selectedProfile, selectedProfileLoading],
);
const canCreateChildOnSelected = useMemo(
() => canManageNode && selectedProfile?.can_create_child_agent === true,
[canManageNode, selectedProfile?.can_create_child_agent],
);
useEffect(() => {
if (selectedProfileLoading || selectedNode === null) {
return;
}
if (detailTab === "profile" && !canShowProfileTab) {
setDetailTab("overview");
} else if (detailTab === "downline" && !canShowDownlineTab) {
setDetailTab(canShowPlayersTab ? "players" : "overview");
} else if (detailTab === "players" && !canShowPlayersTab) {
setDetailTab(canShowDownlineTab ? "downline" : "overview");
}
}, [
canShowDownlineTab,
canShowPlayersTab,
canShowProfileTab,
detailTab,
selectedNode,
selectedProfileLoading,
]);
useAsyncEffect(() => {
if (filteredRows.length === 0) {
setSelectedNodeId(null);
return;
}
if (selectedNodeId === null || !filteredRows.some((row) => row.id === selectedNodeId)) {
setSelectedNodeId(filteredRows[0]?.id ?? null);
}
}, [filteredRows, selectedNodeId]);
useEffect(() => {
setDetailTab("overview");
}, [selectedNodeId]);
useEffect(() => {
if (isOwnAgentNode && detailTab === "profile") {
setDetailTab("overview");
}
}, [detailTab, isOwnAgentNode]);
useAsyncEffect(() => {
if (adminSiteId !== null) {
void loadTree(adminSiteId);
}
}, [adminSiteId, canSwitchSite, loadTree, profile?.agent?.admin_site_id]);
}, [adminSiteId, loadTree]);
const openCreateChildForNode = (node: AgentNodeRow) => {
setNodeDialogMode("create");
@@ -359,6 +448,11 @@ export function AgentsConsole(): React.ReactElement {
setProfileLoaded(true);
setEditingNodeNeedsPrimaryAccount(false);
setNodeDialogOpen(true);
if (canManageProfile) {
void getAgentNodeProfile(node.id)
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
.catch(() => setProfileParentCaps(null));
}
};
const openEditForNode = (node: AgentNodeRow) => {
@@ -383,14 +477,7 @@ export function AgentsConsole(): React.ReactElement {
setProfileLoaded(false);
void getAgentNodeProfile(node.id)
.then((p) => {
setProfileShareRate(String(p.total_share_rate ?? 0));
setProfileCreditLimit(String(p.credit_limit ?? 0));
setProfileRebateLimit(String(p.rebate_limit ?? 0));
setProfileDefaultRebate(String(p.default_player_rebate ?? 0));
setProfileSettlementCycle(normalizeAgentSettlementCycle(p.settlement_cycle));
setProfileExtraRebate(Boolean(p.can_grant_extra_rebate));
setProfileCanCreateChild(Boolean(p.can_create_child_agent));
setProfileCanCreatePlayer(p.can_create_player !== false);
applyProfileRowToForm(p);
setProfileLoaded(true);
})
.catch(() => {
@@ -404,64 +491,118 @@ export function AgentsConsole(): React.ReactElement {
});
};
const renderRowActions = (node: AgentNodeRow) => {
const rowDeleteBlockedByChildren = (node.children?.length ?? 0) > 0;
const rowDeleteBlockedBySelf = profile?.agent?.id === node.id;
const rowCanDelete =
canManageNode && !rowDeleteBlockedByChildren && !rowDeleteBlockedBySelf;
const canDeleteNode = (node: AgentNodeRow): boolean => {
const blockedByChildren = (node.children?.length ?? 0) > 0;
const blockedBySelf = profile?.agent?.id === node.id;
return (
<AdminRowActionsMenu
actions={[
{
key: "edit",
label: t("editNode", { defaultValue: "编辑代理" }),
icon: Pencil,
hidden: !canManageNode,
onClick: () => openEditForNode(node),
},
{
key: "create-child",
label: t("createChild", { defaultValue: "添加下级代理" }),
icon: Plus,
hidden: !canCreateChildForNode(node),
onClick: () => openCreateChildForNode(node),
},
{
key: "view-downline",
label: t("viewDownline", { defaultValue: "查看下级代理和玩家" }),
icon: Eye,
onClick: () => setDownlineDialogNode(node),
},
{
key: "delete",
label: t("deleteNode", { defaultValue: "删除代理" }),
icon: Trash2,
destructive: true,
hidden: !canManageNode,
disabled: !rowCanDelete,
onClick: () => {
if (!rowCanDelete) {
return;
}
requestConfirm({
title: t("deleteNode", { defaultValue: "删除代理" }),
description: t("deleteNodeConfirm", {
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
}),
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
confirmVariant: "destructive",
onConfirm: async () => {
await deleteAgentNode(node.id);
toast.success(t("deleteSuccess", { name: node.name }));
await loadTree(adminSiteId);
},
});
},
},
]}
/>
);
return canManageNode && !blockedByChildren && !blockedBySelf;
};
const handleDeleteNode = (node: AgentNodeRow): void => {
if (!canDeleteNode(node)) {
return;
}
requestConfirm({
title: t("deleteNode", { defaultValue: "删除代理" }),
description: t("deleteNodeConfirm", {
defaultValue: "删除后将同时移除该代理的唯一登录账号,且不可恢复。",
}),
confirmLabel: t("deleteNode", { defaultValue: "删除代理" }),
confirmVariant: "destructive",
onConfirm: async () => {
await deleteAgentNode(node.id);
toast.success(t("deleteSuccess", { name: node.name }));
if (selectedNodeId === node.id) {
setSelectedNodeId(null);
}
await loadTree(adminSiteId);
},
});
};
const saveInlineProfile = async (): Promise<void> => {
if (selectedNode === null || !canEditSelectedProfile) {
return;
}
const validationError = validateProfileFields();
if (validationError) {
toast.error(validationError);
return;
}
setProfileSaving(true);
try {
const updated = await putAgentNodeProfile(selectedNode.id, profilePayload());
setSelectedProfile(updated);
applyProfileRowToForm(updated);
toast.success(t("profile.saveSuccess", { defaultValue: "占成与授信已保存" }));
await loadTree(adminSiteId);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("saveFailed"));
} finally {
setProfileSaving(false);
}
};
const inlineProfileFields = useMemo(() => {
if (!canShowProfileTab) {
return null;
}
return {
disabled: !canEditSelectedProfile,
loading: selectedProfileLoading,
parentCaps: profileParentCaps,
availableCredit: profileAvailableCredit,
canCreateChildAgent,
isSuperAdmin,
shareRate: profileShareRate,
onShareRateChange: setProfileShareRate,
creditLimit: profileCreditLimit,
onCreditLimitChange: setProfileCreditLimit,
rebateLimit: profileRebateLimit,
onRebateLimitChange: setProfileRebateLimit,
defaultRebate: profileDefaultRebate,
onDefaultRebateChange: setProfileDefaultRebate,
settlementCycle: profileSettlementCycle,
onSettlementCycleChange: setProfileSettlementCycle,
extraRebate: profileExtraRebate,
onExtraRebateChange: setProfileExtraRebate,
canCreatePlayer: profileCanCreatePlayer,
onCanCreatePlayerChange: setProfileCanCreatePlayer,
canCreateChild: profileCanCreateChild,
onCanCreateChildChange: setProfileCanCreateChild,
riskTags: profileRiskTags,
onRiskTagsChange: setProfileRiskTags,
};
}, [
canCreateChildAgent,
canEditSelectedProfile,
canShowProfileTab,
isSuperAdmin,
profileAvailableCredit,
profileCanCreateChild,
profileCanCreatePlayer,
profileCreditLimit,
profileDefaultRebate,
profileExtraRebate,
profileParentCaps,
profileRebateLimit,
profileRiskTags,
profileSettlementCycle,
profileShareRate,
selectedProfileLoading,
]);
const showAgentSidebar = businessRows.length > 1;
const openAddAgent = (): void => {
const parent = selectedNode ?? rootNode;
if (parent !== null) {
openCreateChildForNode(parent);
}
};
const saveNode = async () => {
@@ -499,7 +640,12 @@ export function AgentsConsole(): React.ReactElement {
return;
}
if (canManageProfile) {
const includeProfileInDialog =
canManageProfile &&
(nodeDialogMode === "create" ||
(editingNodeId !== null && boundAgent?.id !== editingNodeId));
if (includeProfileInDialog) {
if (nodeDialogMode === "edit" && !profileLoaded) {
toast.error(
t("profile.loadingBlocked", {
@@ -537,7 +683,7 @@ export function AgentsConsole(): React.ReactElement {
: nodePassword.trim() || undefined,
status: nodeStatus,
});
if (canManageProfile) {
if (includeProfileInDialog) {
await putAgentNodeProfile(editingNodeId, profilePayload());
}
toast.success(t("updateSuccess", { name: nodeName.trim() }));
@@ -545,12 +691,26 @@ export function AgentsConsole(): React.ReactElement {
setNodeDialogOpen(false);
await loadTree(adminSiteId);
if (nodeDialogMode === "create" && targetParentId !== null && downlineDialogNode?.id === targetParentId) {
const refreshedParent = flattenTree(
(await getAgentTree(adminSiteId ?? undefined)).tree,
).find((node) => node.id === targetParentId);
if (refreshedParent) {
setDownlineDialogNode(refreshedParent);
if (nodeDialogMode === "create" && targetParentId !== null) {
const refreshed = flattenTree((await getAgentTree(adminSiteId ?? undefined)).tree);
const created = refreshed.find(
(node) => node.parent_id === targetParentId && node.name === nodeName.trim(),
);
if (created) {
setSelectedNodeId(created.id);
}
} else if (nodeDialogMode === "edit" && editingNodeId !== null) {
setSelectedNodeId(editingNodeId);
if (canManageProfile) {
void getAgentNodeProfile(editingNodeId)
.then((p) => {
if (selectedNodeId === editingNodeId || selectedNodeId === null) {
setSelectedProfile(p);
}
})
.catch(() => {
/* 树已刷新,占成区可能短暂不可用 */
});
}
}
} catch (e) {
@@ -560,6 +720,21 @@ export function AgentsConsole(): React.ReactElement {
}
};
const addParent = selectedNode ?? rootNode;
const parentProfileForAdd = useMemo(() => {
if (addParent === null) {
return null;
}
if (addParent.id === selectedNodeId && selectedProfile !== null) {
return selectedProfile;
}
if (rootNode !== null && addParent.id === rootNode.id) {
return rootProfile;
}
return null;
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
if (!canViewAgents) {
return (
<p className="text-sm text-muted-foreground">
@@ -573,295 +748,71 @@ export function AgentsConsole(): React.ReactElement {
}
return (
<div className="space-y-4">
<div className="flex min-h-[32rem] flex-col gap-0">
<ConfirmDialog />
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-semibold">
{t("title", { defaultValue: "代理经营" })}
</h1>
{canProvision ? (
<Link
href="/admin/agents/provision"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("lineProvision.link", { defaultValue: "开通线路" })}
</Link>
) : null}
{canSwitchSite && siteOptions.length > 0 ? (
<Select
value={adminSiteId !== null ? String(adminSiteId) : undefined}
onValueChange={(value) => setAdminSiteId(Number(value))}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder={t("siteLabel", { defaultValue: "站点" })}>
{selectedSiteLabel}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
{canViewSiteList ? (
<Link
href="/admin/agents/sites"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-muted-foreground")}
>
{t("sitesListLink", { defaultValue: "站点列表" })}
</Link>
) : null}
</div>
{err ? <p className="px-1 text-sm text-destructive">{err}</p> : null}
{err ? <p className="text-sm text-destructive">{err}</p> : null}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{t("summary.currentSiteNodes", { defaultValue: "当前站点节点总数" })}
</p>
<p className="mt-1 text-2xl font-semibold">{currentSiteNodeCount}</p>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{t("summary.currentSiteAgents", { defaultValue: "当前站点经营代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold">{currentSiteBusinessAgentCount}</p>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{isSuperAdmin
? t("summary.globalNodes", { defaultValue: "全部站点节点总数" })
: t("summary.visibleList", { defaultValue: "当前最上级代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold">
{isSuperAdmin ? (globalVisibleNodeCount ?? "—") : filteredRows.length}
</p>
</div>
<div className="rounded-xl border bg-muted/20 p-3">
<p className="text-xs text-muted-foreground">
{isSuperAdmin
? t("summary.globalAgents", { defaultValue: "全部站点经营代理数" })
: t("summary.visibleAgents", { defaultValue: "当前可见经营代理数" })}
</p>
<p className="mt-1 text-2xl font-semibold">
{isSuperAdmin ? (globalBusinessAgentCount ?? "—") : currentSiteBusinessAgentCount}
</p>
</div>
</div>
<AdminPageCard title={t("listTitle", { defaultValue: "代理列表" })}>
{canViewPlayersTab ? (
<nav className="mb-4 flex flex-wrap gap-2 border-b border-border/60 pb-3">
<Button
type="button"
size="sm"
variant={operationsTab === "subordinates" ? "default" : "outline"}
onClick={() => setOperationsTab("subordinates")}
>
{t("tabs.subordinates", { defaultValue: "下级管理" })}
</Button>
<Button
type="button"
size="sm"
variant={operationsTab === "players" ? "default" : "outline"}
onClick={() => setOperationsTab("players")}
>
{t("tabs.players", { defaultValue: "玩家管理" })}
</Button>
</nav>
) : null}
{operationsTab === "subordinates" ? (
<div className="relative mb-3 min-w-[16rem] max-w-md">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
className="pl-8"
placeholder={t("listSearch", {
defaultValue: "搜索代理名称 / 编码 / 登录名",
})}
/>
</div>
) : null}
{operationsTab === "players" ? (
<AgentsPlayersPanel siteCode={activeSiteCode} agentNodeId={playersPanelAgentId} />
) : null}
<div className={cn("admin-table-shell mt-3", operationsTab === "players" ? "hidden" : "")}>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
<TableHead>{t("parentAgent", { defaultValue: "上级代理" })}</TableHead>
<TableHead className="w-16">{t("depth", { defaultValue: "层级" })}</TableHead>
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{pagedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("common:states.noData", { defaultValue: "暂无数据" })}
</TableCell>
</TableRow>
) : (
pagedRows.map((node) => (
<TableRow key={node.id}>
<TableCell className="font-medium">{node.name}</TableCell>
<TableCell className="font-mono text-xs">{node.code}</TableCell>
<TableCell className="font-mono text-xs">{node.username ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">
{node.parent_id !== null ? (parentNameMap.get(node.parent_id) ?? "—") : "—"}
</TableCell>
<TableCell>{node.depth}</TableCell>
<TableCell>{node.children?.length ?? 0}</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(node.status)}>
{node.status === 1
? t("common:status.enabled", { defaultValue: "Enabled" })
: t("common:status.disabled", { defaultValue: "Disabled" })}
</AdminStatusBadge>
</TableCell>
<TableCell>{renderRowActions(node)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{operationsTab === "subordinates" ? (
<AdminListPaginationFooter
selectId="agents-operations-per-page"
total={total}
page={currentPage}
lastPage={lastPage}
perPage={perPage}
loading={loading}
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
<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
siteLabel={selectedSiteLabel}
tree={tree}
parentNameMap={parentNameMap}
selectedId={selectedNodeId}
keyword={keyword}
agentCount={businessRows.length}
onKeywordChange={(value) => {
setKeyword(value);
}}
onSelect={(node) => {
setSelectedNodeId(node.id);
}}
onPageChange={setPage}
/>
) : null}
</AdminPageCard>
<Dialog
open={downlineDialogNode !== null}
onOpenChange={(open) => {
if (!open) {
setDownlineDialogNode(null);
<AgentLineDetailPanel
node={selectedNode}
profile={selectedProfile}
profileLoading={selectedProfileLoading}
childAgents={selectedChildAgents}
childCountById={childCountById}
siteCode={activeSiteCode}
siteLabel={selectedSiteLabel}
parentName={
selectedNode?.parent_id !== null && selectedNode?.parent_id !== undefined
? (parentNameMap.get(selectedNode.parent_id) ?? null)
: null
}
}}
>
<DialogContent className="flex max-h-[min(90vh,52rem)] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{downlineDialogNode
? t("downlineDialogTitle", {
name: downlineDialogNode.name,
defaultValue: "{{name}} — 下级代理与玩家",
})
: t("viewDownline", { defaultValue: "查看下级代理和玩家" })}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto pr-1">
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-medium">
{t("downlineAgentsSection", { defaultValue: "下级代理" })}
</h3>
{downlineDialogNode && canCreateChildForNode(downlineDialogNode) ? (
<Button
type="button"
size="sm"
onClick={() => openCreateChildForNode(downlineDialogNode)}
>
<Plus className="size-4" aria-hidden />
{t("createChild", { defaultValue: "添加下级代理" })}
</Button>
) : null}
</div>
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name", { defaultValue: "名称" })}</TableHead>
<TableHead>{t("code", { defaultValue: "编码" })}</TableHead>
<TableHead>{t("users.username", { defaultValue: "登录名" })}</TableHead>
<TableHead className="w-20">{t("childrenCount", { defaultValue: "直属下级" })}</TableHead>
<TableHead className="w-24">{t("status", { defaultValue: "状态" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{downlineChildAgents.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("downlineNoAgents", { defaultValue: "暂无下级代理" })}
</TableCell>
</TableRow>
) : (
downlineChildAgents.map((child) => (
<TableRow key={child.id}>
<TableCell className="font-medium">{child.name}</TableCell>
<TableCell className="font-mono text-xs">{child.code}</TableCell>
<TableCell className="font-mono text-xs">{child.username ?? "—"}</TableCell>
<TableCell>{child.children?.length ?? 0}</TableCell>
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(child.status)}>
{child.status === 1
? t("common:status.enabled", { defaultValue: "Enabled" })
: t("common:status.disabled", { defaultValue: "Disabled" })}
</AdminStatusBadge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</section>
{canViewPlayersTab && downlineDialogNode ? (
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("downlinePlayersSection", { defaultValue: "直属玩家" })}
</h3>
<AgentsPlayersPanel
siteCode={activeSiteCode}
agentNodeId={downlineDialogNode.id}
/>
</section>
) : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDownlineDialogNode(null)}>
{t("common:actions.close", { defaultValue: "关闭" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
detailTab={detailTab}
onDetailTabChange={setDetailTab}
canViewProfileTab={canShowProfileTab}
canEditProfileTab={canEditSelectedProfile}
profileReadOnly={isOwnAgentNode}
canViewDownlineTab={canShowDownlineTab}
canViewPlayersTab={canShowPlayersTab}
canManageNode={canManageNode}
canCreateChild={canCreateChildOnSelected}
canDeleteChild={canDeleteNode}
onEditChild={(node) => openEditForNode(node)}
onAddChild={() => selectedNode && openCreateChildForNode(selectedNode)}
onEditCurrent={() => selectedNode && openEditForNode(selectedNode)}
onDeleteChild={(node) => handleDeleteNode(node)}
onSelectChild={(child) => {
setSelectedNodeId(child.id);
}}
profileFields={inlineProfileFields}
profileSaving={profileSaving}
onSaveProfile={() => void saveInlineProfile()}
/>
</div>
<Dialog open={nodeDialogOpen} onOpenChange={setNodeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogContent
showCloseButton
className="flex h-[min(90vh,760px)] !max-w-[min(520px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:!max-w-[min(520px,calc(100vw-2rem))]"
>
<DialogHeader className="shrink-0 border-b px-4 py-4 pr-12">
<DialogTitle>
{nodeDialogMode === "create"
? t("createChild", { defaultValue: "添加下级代理" })
@@ -869,6 +820,7 @@ 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="space-y-2">
<Label htmlFor="agent-name">{t("name", { defaultValue: "名称" })}</Label>
<Input
@@ -923,130 +875,44 @@ export function AgentsConsole(): React.ReactElement {
<Label>{t("status", { defaultValue: "状态" })}</Label>
</div>
{canManageProfile ? (
{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>
{profileLoading ? (
<p className="text-sm text-muted-foreground">
{t("profile.loading", { defaultValue: "正在加载占成与授信…" })}
</p>
) : null}
<div className={cn("grid gap-3 sm:grid-cols-2", profileLoading ? "pointer-events-none opacity-50" : "")}>
<div className="space-y-2">
<Label htmlFor="agent-share-rate">
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
<Input
id="agent-share-rate"
type="number"
min={0}
max={100}
step="0.01"
value={profileShareRate}
onChange={(e) => setProfileShareRate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-credit-limit">
{t("profile.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-credit-limit"
type="number"
min={0}
value={profileCreditLimit}
onChange={(e) => setProfileCreditLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-rebate-limit">
{t("profile.rebateLimit", { defaultValue: "回水上限" })}
</Label>
<Input
id="agent-rebate-limit"
type="number"
min={0}
max={1}
step="0.0001"
value={profileRebateLimit}
onChange={(e) => setProfileRebateLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-default-rebate">
{t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水" })}
</Label>
<Input
id="agent-default-rebate"
type="number"
min={0}
max={1}
step="0.0001"
value={profileDefaultRebate}
onChange={(e) => setProfileDefaultRebate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="agent-settlement-cycle">
{t("profile.settlementCycle", { defaultValue: "结算周期" })}
</Label>
<Select
value={profileSettlementCycle}
onValueChange={(value) =>
setProfileSettlementCycle((value as "daily" | "weekly" | "monthly") ?? "weekly")
}
>
<SelectTrigger id="agent-settlement-cycle">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">
{t("profile.cycleDaily", { defaultValue: "日结" })}
</SelectItem>
<SelectItem value="weekly">
{t("profile.cycleWeekly", { defaultValue: "周结" })}
</SelectItem>
<SelectItem value="monthly">
{t("profile.cycleMonthly", { defaultValue: "月结" })}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
checked={profileExtraRebate}
onCheckedChange={setProfileExtraRebate}
/>
<Label>
{t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={profileCanCreatePlayer}
onCheckedChange={setProfileCanCreatePlayer}
/>
<Label>
{t("profile.canCreatePlayer", { defaultValue: "允许创建玩家" })}
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={profileCanCreateChild}
onCheckedChange={setProfileCanCreateChild}
disabled={!canCreateChildAgent && !isSuperAdmin}
/>
<Label>
{t("profile.canCreateChildAgent", { defaultValue: "允许创建下级代理" })}
</Label>
</div>
<AgentProfileFields
loading={profileLoading}
parentCaps={profileParentCaps}
availableCredit={profileAvailableCredit}
canCreateChildAgent={canCreateChildAgent}
isSuperAdmin={isSuperAdmin}
shareRate={profileShareRate}
onShareRateChange={setProfileShareRate}
creditLimit={profileCreditLimit}
onCreditLimitChange={setProfileCreditLimit}
rebateLimit={profileRebateLimit}
onRebateLimitChange={setProfileRebateLimit}
defaultRebate={profileDefaultRebate}
onDefaultRebateChange={setProfileDefaultRebate}
settlementCycle={profileSettlementCycle}
onSettlementCycleChange={setProfileSettlementCycle}
extraRebate={profileExtraRebate}
onExtraRebateChange={setProfileExtraRebate}
canCreatePlayer={profileCanCreatePlayer}
onCanCreatePlayerChange={setProfileCanCreatePlayer}
canCreateChild={profileCanCreateChild}
onCanCreateChildChange={setProfileCanCreateChild}
riskTags={profileRiskTags}
onRiskTagsChange={setProfileRiskTags}
idPrefix="dialog-agent-profile"
/>
</div>
) : null}
</div>
<DialogFooter>
<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)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>

View File

@@ -1,11 +1,21 @@
"use client";
import { Plus } from "lucide-react";
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminPlayers, postAdminPlayer } from "@/api/admin-player";
import { getAgentNodeProfile } from "@/api/admin-agents";
import {
deleteAdminPlayer,
getAdminPlayer,
getAdminPlayers,
postAdminPlayer,
putAdminPlayer,
} from "@/api/admin-player";
import { formatCredit } from "@/modules/agents/agent-line-sidebar";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -19,6 +29,13 @@ import {
} from "@/components/ui/dialog";
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,
@@ -28,31 +45,119 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { parsePercentUi, percentUiToRatio, ratioToPercentUi } 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";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow } from "@/types/api/admin-player";
const PLAYER_STATUS_OPTIONS = [
{ value: 0, labelKey: "players:statusNormal" as const },
{ value: 1, labelKey: "players:statusFrozen" as const },
{ value: 2, labelKey: "players:statusBanned" as const },
];
function playerStatusLabel(
status: number,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
const hit = PLAYER_STATUS_OPTIONS.find((opt) => opt.value === status);
if (hit) {
return t(hit.labelKey, {
defaultValue: status === 0 ? "正常" : status === 1 ? "冻结" : "封禁",
});
}
return String(status);
}
function resolvePlayerRebateRate(row: AdminPlayerRow): number | null {
if (row.rebate_rate != null) {
return row.rebate_rate;
}
const defaultProfile = row.rebate_profiles?.find((p) => p.game_type === "*");
if (defaultProfile && !defaultProfile.inherit_from_agent) {
return defaultProfile.rebate_rate;
}
return null;
}
function parseRiskTagsInput(text: string): string[] {
return Array.from(
new Set(
text
.split(/[,\s]+/)
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0),
),
);
}
function fillEditFormFromPlayer(row: AdminPlayerRow): {
username: string;
nickname: string;
currency: string;
status: number;
creditLimit: string;
rebateRate: string;
riskTags: string;
} {
const rebate = resolvePlayerRebateRate(row);
return {
username: row.username ?? "",
nickname: row.nickname ?? "",
currency: row.default_currency ?? "",
status: row.status,
creditLimit: row.credit_limit != null ? String(row.credit_limit) : "",
rebateRate: rebate != null ? ratioToPercentUi(rebate) : "",
riskTags: (row.risk_tags ?? []).join(", "),
};
}
type AgentsPlayersPanelProps = {
siteCode: string;
/** 筛选直属玩家时的代理节点null 表示当前登录代理或不过滤 */
agentNodeId: number | null;
/** 当前代理 profile 是否允许创建玩家;未传时沿用登录代理能力 */
allowCreatePlayer?: boolean;
/** 嵌入代理线路详情 Tab 时使用紧凑顶栏 */
embedded?: boolean;
};
export function AgentsPlayersPanel({
siteCode,
agentNodeId,
allowCreatePlayer,
embedded = false,
}: AgentsPlayersPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "players", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const isSuperAdmin = profile?.is_super_admin === true;
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profileAllowsCreate =
allowCreatePlayer === undefined
? boundAgent?.can_create_player !== false
: allowCreatePlayer === true;
const canCreatePlayer =
isSuperAdmin ||
(boundAgent?.can_create_player !== false &&
(profileAllowsCreate &&
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]));
const canManagePlayerRows = canCreatePlayer;
const effectiveAgentId = useMemo(() => {
if (agentNodeId !== null) {
@@ -72,7 +177,22 @@ export function AgentsPlayersPanel({
const [saving, setSaving] = useState(false);
const [sitePlayerId, setSitePlayerId] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [nickname, setNickname] = useState("");
const [creditLimit, setCreditLimit] = useState("");
const [rebateRate, setRebateRate] = useState("");
const [parentAvailableCredit, setParentAvailableCredit] = useState<number | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [editSaving, setEditSaving] = useState(false);
const [editingPlayer, setEditingPlayer] = useState<Awaited<ReturnType<typeof getAdminPlayers>>["items"][number] | null>(null);
const [editUsername, setEditUsername] = useState("");
const [editNickname, setEditNickname] = useState("");
const [editDefaultCurrency, setEditDefaultCurrency] = useState("");
const [editStatus, setEditStatus] = useState(0);
const [editCreditLimit, setEditCreditLimit] = useState("");
const [editRebateRate, setEditRebateRate] = useState("");
const [editRiskTags, setEditRiskTags] = useState("");
const [editDetailLoading, setEditDetailLoading] = useState(false);
const load = useCallback(async () => {
if (siteCode.trim() === "") {
@@ -108,8 +228,12 @@ export function AgentsPlayersPanel({
}, [load]);
async function savePlayer(): Promise<void> {
if (siteCode.trim() === "" || sitePlayerId.trim() === "") {
toast.error(t("players:sitePlayerIdRequired", { defaultValue: "请填写站点玩家 ID" }));
if (siteCode.trim() === "") {
toast.error(t("players:siteCodeRequired", { defaultValue: "请填写主站编号" }));
return;
}
if (username.trim() === "" || password.trim() === "") {
toast.error(t("playersPanel.loginRequired", { defaultValue: "请填写登录账号与初始密码" }));
return;
}
@@ -117,16 +241,30 @@ export function AgentsPlayersPanel({
try {
await postAdminPlayer({
site_code: siteCode.trim(),
site_player_id: sitePlayerId.trim(),
username: username.trim() || null,
...(sitePlayerId.trim() !== "" ? { site_player_id: sitePlayerId.trim() } : {}),
username: username.trim(),
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) }
: {}),
});
toast.success(t("players:createSuccess", { name: sitePlayerId.trim() }));
toast.success(
t("playersPanel.createSuccessNative", {
name: username.trim(),
defaultValue: "玩家 {{name}} 已创建,请使用彩票端登录",
}),
);
setDialogOpen(false);
setSitePlayerId("");
setUsername("");
setPassword("");
setNickname("");
setCreditLimit("");
setRebateRate("");
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("players:createFailed"));
@@ -135,56 +273,285 @@ export function AgentsPlayersPanel({
}
}
function openCreateDialog(): void {
setDialogOpen(true);
if (effectiveAgentId !== null) {
void getAgentNodeProfile(effectiveAgentId)
.then((p) => setParentAvailableCredit(p.available_credit ?? null))
.catch(() => setParentAvailableCredit(null));
} else {
setParentAvailableCredit(null);
}
}
const applyEditForm = (row: AdminPlayerRow): void => {
const form = fillEditFormFromPlayer(row);
setEditUsername(form.username);
setEditNickname(form.nickname);
setEditDefaultCurrency(form.currency);
setEditStatus(form.status);
setEditCreditLimit(form.creditLimit);
setEditRebateRate(form.rebateRate);
setEditRiskTags(form.riskTags);
};
const openEditPlayer = (row: AdminPlayerRow): void => {
setEditingPlayer(row);
applyEditForm(row);
setEditDialogOpen(true);
setEditDetailLoading(true);
void getAdminPlayer(row.id)
.then((full) => {
setEditingPlayer(full);
applyEditForm(full);
})
.catch(() => {
toast.error(t("players:loadFailed", { defaultValue: "加载玩家详情失败" }));
})
.finally(() => {
setEditDetailLoading(false);
});
};
function handleEditDialogOpenChange(open: boolean): void {
setEditDialogOpen(open);
if (!open) {
setEditingPlayer(null);
}
}
async function saveEditedPlayer(): Promise<void> {
if (!editingPlayer) {
return;
}
const body: Parameters<typeof putAdminPlayer>[1] = {};
if (editUsername.trim() !== "" && editUsername.trim() !== (editingPlayer.username ?? "")) {
body.username = editUsername.trim();
}
if (editNickname.trim() !== (editingPlayer.nickname ?? "")) {
body.nickname = editNickname.trim() || null;
}
const nextCurrency = editDefaultCurrency.trim().toUpperCase();
if (nextCurrency !== editingPlayer.default_currency) {
body.default_currency = nextCurrency;
}
if (editStatus !== editingPlayer.status) {
body.status = editStatus;
}
const nextCredit =
editCreditLimit.trim() === "" ? 0 : Number.parseInt(editCreditLimit, 10);
if (!Number.isNaN(nextCredit) && nextCredit !== (editingPlayer.credit_limit ?? 0)) {
body.credit_limit = Math.max(0, nextCredit);
}
const prevRebate = resolvePlayerRebateRate(editingPlayer);
const nextPercent = parsePercentUi(editRebateRate);
const nextRebate = nextPercent === null ? null : percentUiToRatio(nextPercent);
if (nextRebate !== null && nextRebate !== (prevRebate ?? 0)) {
body.rebate_rate = nextRebate;
}
const nextRiskTags = parseRiskTagsInput(editRiskTags);
const prevRiskTags = editingPlayer.risk_tags ?? [];
if (JSON.stringify(nextRiskTags) !== JSON.stringify(prevRiskTags)) {
body.risk_tags = nextRiskTags;
}
if (Object.keys(body).length === 0) {
toast.success(t("players:noChanges", { defaultValue: "没有变更" }));
handleEditDialogOpenChange(false);
return;
}
setEditSaving(true);
try {
const updated = await putAdminPlayer(editingPlayer.id, body);
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
toast.success(
t("players:updateSuccess", {
name: updated.username ?? updated.site_player_id,
defaultValue: "已更新 {{name}}",
}),
);
handleEditDialogOpenChange(false);
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("players:updateFailed", { defaultValue: "更新玩家失败" }),
);
} finally {
setEditSaving(false);
}
}
async function confirmDeletePlayer(row: Awaited<ReturnType<typeof getAdminPlayers>>["items"][number]): Promise<void> {
try {
await deleteAdminPlayer(row.id);
setItems((prev) => prev.filter((item) => item.id !== row.id));
setTotal((current) => Math.max(0, current - 1));
toast.success(t("deleteSuccess", { name: row.username ?? row.site_player_id }));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("deleteFailed"));
}
}
return (
<div className="space-y-3">
{canCreatePlayer ? (
<div className="flex justify-end">
<Button type="button" size="sm" onClick={() => setDialogOpen(true)}>
<Plus className="mr-1 size-3.5" />
<div className="space-y-4">
<ConfirmDialog />
<div className="flex flex-wrap items-center justify-between gap-3">
{!embedded ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
) : (
<p className="text-sm text-muted-foreground">
{t("playersPanel.creditListHint", {
defaultValue: "信用占成盘:下列为玩家授信额度与可用信用,非主站钱包余额。",
})}
</p>
)}
{canCreatePlayer ? (
<Button type="button" size="sm" className="shrink-0" onClick={openCreateDialog}>
<Plus className="mr-1.5 size-3.5" />
{t("playersPanel.create", { defaultValue: "创建玩家" })}
</Button>
</div>
) : null}
) : null}
</div>
{loading ? (
<AdminLoadingState minHeight="6rem" />
) : (
<>
<div className="admin-table-shell">
<div className="admin-table-shell overflow-hidden rounded-2xl border border-border/70 bg-card shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}</TableHead>
<TableHead>{t("players:username", { defaultValue: "用户名" })}</TableHead>
<TableHead>{t("players:nickname", { defaultValue: "昵称" })}</TableHead>
<TableHead className="w-24">{t("players:status", { defaultValue: "状态" })}</TableHead>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableHead className="w-14">{t("common:table.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("playersPanel.playerRef", { defaultValue: "玩家标识" })}</TableHead>
<TableHead className="whitespace-nowrap">
{t("playersPanel.usernameNickname", { defaultValue: "用户名 / 昵称" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("players:fundingMode", { defaultValue: "资金模式" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:currency", { defaultValue: "币种" })}</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("playersPanel.creditLimitAvailable", { defaultValue: "授信 / 可用" })}
</TableHead>
<TableHead className="text-right whitespace-nowrap">
{t("players:rebateRate", { defaultValue: "回水" })}
</TableHead>
<TableHead className="whitespace-nowrap">{t("players:lastLogin", { defaultValue: "最后登录" })}</TableHead>
{!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)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{t("common:states.noData", { defaultValue: "暂无数据" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={embedded ? 8 : 9} cellClassName="py-12 text-center" />
) : (
items.map((row) => (
items.map((row) => {
const balances = playerBalanceCells(row, formatAdminMinorUnits);
const rebate = resolvePlayerRebateRate(row);
return (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.site_player_id}</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell className="tabular-nums text-xs font-medium">#{row.id}</TableCell>
<TableCell className="max-w-[8rem] truncate font-mono text-xs" title={row.site_player_id}>
{row.site_player_id}
</TableCell>
<TableCell className="text-sm">
<span className="font-medium">{row.username ?? "—"}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{row.nickname ?? "—"}</span>
</TableCell>
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="text-xs font-medium">{row.default_currency}</TableCell>
<TableCell className="text-right text-xs tabular-nums">
<span>{balances.balance}</span>
<span className="text-muted-foreground"> / </span>
<span className="text-muted-foreground">{balances.available}</span>
</TableCell>
<TableCell
className="text-right text-xs tabular-nums font-medium"
title={
row.rebate_inherited
? t("playersPanel.rebateInherited", { defaultValue: "继承代理默认回水" })
: undefined
}
>
{rebate != null ? `${ratioToPercentUi(rebate)}%` : "—"}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
{!embedded ? (
<TableCell>
<AdminStatusBadge tone={resolveRoleStatusTone(row.status)}>
{row.status === 0
? t("players:statusNormal", { defaultValue: "正常" })
: String(row.status)}
{playerStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
) : null}
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_var(--border)]"
onClick={(e) => e.stopPropagation()}
>
<AdminRowActionsMenu
busy={confirmBusy}
actions={[
{
key: "detail",
label: t("players:viewDetail", { defaultValue: "查看详情" }),
icon: Eye,
href: adminPlayerDetailPath(row.id),
},
...(canManagePlayerRows
? [
{
key: "edit",
label: t("players:edit", { defaultValue: "编辑" }),
icon: Pencil,
onClick: () => openEditPlayer(row),
},
{
key: "delete",
label: t("players:delete", { defaultValue: "删除" }),
icon: Trash2,
destructive: true,
onClick: () =>
requestConfirm({
title: t("players:confirmDelete", {
defaultValue: "确认删除",
}),
description: t("players:confirmDeleteDesc", {
name: row.username ?? row.site_player_id,
defaultValue:
"确定要删除玩家 {{name}} 吗?此操作不可恢复。",
}),
confirmVariant: "destructive",
onConfirm: () => void confirmDeletePlayer(row),
}),
},
]
: []),
]}
/>
</TableCell>
</TableRow>
))
);
})
)}
</TableBody>
</Table>
</div>
</div>
<AdminListPaginationFooter
selectId="agents-players-per-page"
@@ -208,28 +575,42 @@ export function AgentsPlayersPanel({
<DialogTitle>{t("playersPanel.create", { defaultValue: "创建玩家" })}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>{t("players:siteCode", { defaultValue: "站点" })}</Label>
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input value={siteCode} readOnly disabled />
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-site-id">
{t("players:sitePlayerId", { defaultValue: "站点玩家 ID" })}
{t("playersPanel.externalIdOptional", { defaultValue: "外部 ID可选" })}
</Label>
<Input
id="agent-player-site-id"
value={sitePlayerId}
onChange={(e) => setSitePlayerId(e.target.value)}
autoComplete="off"
placeholder={t("playersPanel.externalIdHint", { defaultValue: "留空则系统自动生成" })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-username">
{t("players:username", { defaultValue: "用户名" })}
{t("playersPanel.loginUsername", { defaultValue: "登录账号" })}
</Label>
<Input
id="agent-player-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-password">
{t("playersPanel.initialPassword", { defaultValue: "初始密码" })}
</Label>
<Input
id="agent-player-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
@@ -242,6 +623,41 @@ export function AgentsPlayersPanel({
onChange={(e) => setNickname(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-player-credit"
type="number"
min={0}
value={creditLimit}
onChange={(e) => setCreditLimit(e.target.value)}
/>
{parentAvailableCredit !== null ? (
<p className="text-xs text-muted-foreground">
{t("playersPanel.availableToGrant", {
defaultValue: "代理剩余可下发:{{amount}}",
amount: formatCredit(parentAvailableCredit),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={rebateRate}
placeholder="0.5"
onChange={(e) => setRebateRate(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
@@ -252,6 +668,126 @@ export function AgentsPlayersPanel({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editDialogOpen} onOpenChange={handleEditDialogOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("players:editDialogTitle", { defaultValue: "编辑玩家" })}</DialogTitle>
</DialogHeader>
{editDetailLoading ? <AdminLoadingState minHeight="6rem" /> : null}
<div className={editDetailLoading ? "hidden" : "space-y-3"}>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-username">
{t("players:username", { defaultValue: "用户名" })}
</Label>
<Input
id="agent-player-edit-username"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-nickname">
{t("players:nickname", { defaultValue: "昵称" })}
</Label>
<Input
id="agent-player-edit-nickname"
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-currency">
{t("players:defaultCurrency", { defaultValue: "默认币种" })}
</Label>
<Input
id="agent-player-edit-currency"
value={editDefaultCurrency}
onChange={(e) => setEditDefaultCurrency(e.target.value)}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-credit">
{t("playersPanel.creditLimit", { defaultValue: "授信额度" })}
</Label>
<Input
id="agent-player-edit-credit"
type="number"
min={0}
value={editCreditLimit}
onChange={(e) => setEditCreditLimit(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-rebate">
{t("playersPanel.rebateRate", { defaultValue: "回水比例 (%)" })}
</Label>
<Input
id="agent-player-edit-rebate"
type="number"
min={0}
max={100}
step="0.01"
value={editRebateRate}
onChange={(e) => setEditRebateRate(e.target.value)}
placeholder="0.5"
/>
<p className="text-xs text-muted-foreground">
{t("playersPanel.rebateRateHint", { defaultValue: "填写百分比,如 0.5 表示 0.5%" })}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-risk-tags">
{t("playersPanel.riskTags", { defaultValue: "风控标签" })}
</Label>
<Input
id="agent-player-edit-risk-tags"
value={editRiskTags}
onChange={(e) => setEditRiskTags(e.target.value)}
placeholder={t("playersPanel.riskTagsPlaceholder", {
defaultValue: "逗号分隔",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-player-edit-status">
{t("players:status", { defaultValue: "状态" })}
</Label>
<Select value={String(editStatus)} onValueChange={(value) => setEditStatus(Number(value))}>
<SelectTrigger id="agent-player-edit-status">
<SelectValue placeholder={t("players:status", { defaultValue: "状态" })}>
{playerStatusLabel(editStatus, t)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{PLAYER_STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{t(opt.labelKey, {
defaultValue: opt.value === 0 ? "正常" : opt.value === 1 ? "冻结" : "封禁",
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "取消" })}
</Button>
<Button
type="button"
disabled={editSaving || editDetailLoading}
onClick={() => void saveEditedPlayer()}
>
{t("players:saveChanges", { defaultValue: "保存修改" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -2,60 +2,43 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
PRD_AGENT_SITES_ACCESS_ANY,
PRD_AGENTS_ACCESS_ANY,
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
} from "@/lib/admin-prd";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
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";
const tabs: {
const primaryTabs: {
href: string;
labelKey: string;
matchPrefix: string;
requiredAny: readonly string[];
}[] = [
{
href: "/admin/agents",
labelKey: "subnav.operations",
matchPrefix: "/admin/agents",
requiredAny: PRD_AGENTS_ACCESS_ANY,
},
{
href: "/admin/agents/provision",
labelKey: "subnav.provision",
matchPrefix: "/admin/agents/provision",
requiredAny: PRD_AGENT_LINE_PROVISION_ACCESS_ANY,
},
{
href: "/admin/agents/sites",
labelKey: "subnav.sites",
matchPrefix: "/admin/agents/sites",
requiredAny: PRD_AGENT_SITES_ACCESS_ANY,
},
{
href: "/admin/agents/settlement-bills",
labelKey: "subnav.settlementBills",
matchPrefix: "/admin/agents/settlement",
requiredAny: PRD_SETTLEMENT_AGENT_ACCESS_ANY,
},
];
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") &&
!pathname.startsWith("/admin/agents/sites") &&
!pathname.startsWith("/admin/agents/settlement"))
!pathname.startsWith("/admin/agents/provision"))
);
}
@@ -66,45 +49,105 @@ export function AgentsSubnav(): React.ReactElement {
const { t } = useTranslation("agents");
const pathname = usePathname();
const profile = useAdminProfile();
const perms = profile?.permissions;
const { sites: siteOptions } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const visibleTabs = useMemo(
() =>
tabs.filter(
(tab) =>
profile?.is_super_admin === true ||
adminHasAnyPermission(perms, [...tab.requiredAny]),
),
[perms, profile?.is_super_admin],
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],
);
if (visibleTabs.length === 0) {
useEffect(() => {
if (adminSiteId !== null || siteOptions.length === 0) {
return;
}
const boundSiteId = profile?.agent?.admin_site_id;
if (boundSiteId != null) {
setAdminSiteId(boundSiteId);
return;
}
setAdminSiteId(siteOptions[0]?.id ?? null);
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSiteLabel = useMemo(() => {
const site = siteOptions.find((item) => item.id === selectSiteId);
return site ? `${site.name} (${site.code})` : null;
}, [selectSiteId, siteOptions]);
if (visiblePrimaryTabs.length === 0 && !showProvision) {
return <></>;
}
return (
<nav
aria-label={t("subnav.label", { defaultValue: "代理线路导航" })}
className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1"
>
{visibleTabs.map((tab) => {
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
<nav
aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}
className="inline-flex max-w-full flex-wrap items-center gap-1"
>
{visiblePrimaryTabs.map((tab) => {
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
})}
</nav>
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
})}
{showProvision ? (
<>
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
<Link
href={provisionTab.href}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(provisionTab.labelKey)}
</Link>
</>
) : null}
</nav>
{canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => setAdminSiteId(Number(value))}
>
<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>
) : null}
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminAuditLogs } from "@/api/admin-audit";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button";
@@ -220,11 +221,7 @@ export function AuditLogsConsole(): React.ReactElement {
{loading && !data ? (
<AdminTableLoadingRow colSpan={6} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>

View File

@@ -43,7 +43,7 @@ const HUB_CARDS: HubCard[] = [
requiredAny: ["prd.risk_cap.manage", "prd.risk_cap.view"],
},
{
href: "/admin/agents/sites",
href: "/admin/config/integration-sites",
titleKey: "hub.integrationTitle",
descKey: "hub.integrationDesc",
requiredAny: PRD_INTEGRATION_ACCESS_ANY,

View File

@@ -5,6 +5,7 @@ import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import {
Dialog,
DialogContent,
@@ -192,9 +193,7 @@ export function ConfigVersionSwitcher({
<div className="flex-1 overflow-y-auto px-3 py-3">
{sortedVersions.length === 0 ? (
<p className="px-2 py-8 text-center text-sm text-muted-foreground">
{t("versionSwitcher.empty", { ns: "config" })}
</p>
<AdminNoResourceState compact className="px-2 py-8" />
) : (
<div className="space-y-5">
{visibleSections.map((section) => (

View File

@@ -33,6 +33,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
@@ -299,11 +300,7 @@ export function OddsConfigDocScreen({
if (!first) {
return "0";
}
const n = Number.parseFloat(String(first.rebate_rate));
if (!Number.isFinite(n)) {
return "0";
}
return String(Math.round(n * 10000) / 100);
return ratioToPercentUi(String(first.rebate_rate));
}, [scopeRows]);
function rowIndex(play_code: string, prize_scope: string): number {

View File

@@ -1,13 +1,9 @@
import { ratioToPercentUi } from "@/lib/admin-rate-percent";
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
import type { AdminPlayTypeRow, OddsItemRow } from "@/types/api/admin-config";
export function rateToPercentUi(rateStr: string): string {
const n = Number.parseFloat(rateStr);
if (!Number.isFinite(n)) {
return "0.00";
}
return (Math.round(n * 10000) / 100).toFixed(2);
}
/** 赔率/回水配置页展示用:小数比例 → 百分比 UI 字符串 */
export const rateToPercentUi = ratioToPercentUi;
export function inferRebatePercentFromDimension(
dim: 2 | 3 | 4,

View File

@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { getAdminDraws } from "@/api/admin-draws";
import { getAdminRiskPools } from "@/api/admin-risk";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
@@ -238,11 +239,7 @@ export function RiskCapRuntimePanel() {
{poolsLoading ? (
<AdminTableLoadingRow colSpan={5} />
) : pools.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
) : (
pools.map((row) => (
<TableRow

View File

@@ -0,0 +1,303 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart3,
Network,
RefreshCw,
Ticket,
Users,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
PRD_AGENT_HUB_ACCESS_ANY,
PRD_PLAYERS_ACCESS_ANY,
PRD_REPORTS_VIEW_ACCESS_ANY,
PRD_SETTLEMENT_AGENT_ACCESS_ANY,
PRD_TICKETS_ACCESS_ANY,
} from "@/lib/admin-prd";
import { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
export function AgentDashboardConsole(): ReactElement {
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const profile = useAdminProfile();
const agent = profile?.agent ?? null;
const permissions = profile?.permissions ?? [];
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
return formatAdminCalendarToday(locale, weekday);
}, [i18n.language, i18n.resolvedLanguage, t]);
useAdminCurrencyCatalog();
const playOptions = useCachedPlayTypeOptions();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
const [canFinance, setCanFinance] = useState(false);
const analyticsScope = useMemo(
() => ({
siteCode: agent?.site_code ?? "",
agentNodeId: agent?.id,
}),
[agent?.id, agent?.site_code],
);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.agent_overview);
setCanFinance(d.capabilities.draw_finance_risk);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load(false);
}, []);
const currency = "NPR";
const quickLinks = useMemo(() => {
const links: { href: string; label: string; icon: ReactElement }[] = [];
if (adminHasAnyPermission(permissions, [...PRD_TICKETS_ACCESS_ANY])) {
links.push({
href: "/admin/tickets",
label: t("agent.quickLinks.tickets"),
icon: <Ticket className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_PLAYERS_ACCESS_ANY])) {
links.push({
href: "/admin/players",
label: t("agent.quickLinks.players"),
icon: <Users className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY])) {
links.push({
href: "/admin/reports",
label: t("agent.quickLinks.reports"),
icon: <BarChart3 className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_AGENT_HUB_ACCESS_ANY])) {
links.push({
href: "/admin/agents",
label: t("agent.quickLinks.agents"),
icon: <Network className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY])) {
links.push({
href: "/admin/settlement-center",
label: t("agent.quickLinks.bills"),
icon: <Wallet className="size-4" />,
});
}
return links;
}, [permissions, t]);
return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="admin-list-title">{t("agent.title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
{agent
? t("agent.subtitle", { name: agent.name || agent.code })
: todayLabel}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={loading || refreshing}
onClick={() => void load(true)}
>
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
))}
</div>
) : overview ? (
<section className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-2xl font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.credit_limit, currency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, currency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.creditAllocated", {
amount: formatDashboardCreditMajor(overview.allocated_credit, currency),
})}
{" · "}
{t("agent.creditUsed", {
amount: formatDashboardCreditMajor(overview.used_credit, currency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.shareRate", { rate: overview.total_share_rate })}
{" · "}
{t("agent.settlementCycle", { cycle: overview.settlement_cycle })}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p>
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p>
</div>
<div className="col-span-2">
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p>
<p className="text-lg font-semibold tabular-nums">{overview.subtree_agent_count}</p>
</div>
</CardContent>
</Card>
<Card className="sm:col-span-2 xl:col-span-2">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.pendingBills")}</CardTitle>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0 text-xs")}
>
{t("agent.viewBills")}
</Link>
) : null}
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, currency),
})}
</p>
</CardContent>
</Card>
</section>
) : null}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canFinance ? (
<DashboardAnalyticsPanel
enabled={canFinance}
playOptions={playOptions}
scope={analyticsScope}
/>
) : (
<Alert className="border-muted">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert>
)}
{quickLinks.length > 0 ? (
<section className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 gap-1.5")}
>
{link.icon}
{link.label}
</Link>
))}
</section>
) : null}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -264,9 +265,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
currency={currency}
/>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
<AdminNoResourceState className="py-10 text-center text-sm text-muted-foreground" />
)}
</div>
</div>
@@ -348,9 +347,7 @@ export function DashboardPlayRankingCard({
compact
/>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("analytics.noPlayData")}
</p>
<AdminNoResourceState className="py-10" />
)}
</CardContent>
</Card>
@@ -456,7 +453,7 @@ export function DashboardAgentRankingCard({
})}
</div>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">{t("analytics.noAgentData")}</p>
<AdminNoResourceState className="py-10" />
)}
</CardContent>
</Card>

View File

@@ -2,23 +2,14 @@
import type { ReactElement } from "react";
import { cn } from "@/lib/utils";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
export function DashboardChartEmpty({
message,
compact = false,
}: {
message: string;
message?: string;
compact?: boolean;
}): ReactElement {
return (
<p
className={cn(
"text-center text-muted-foreground",
compact ? "py-1 text-[11px] leading-snug" : "py-10 text-sm",
)}
>
{message}
</p>
);
return <AdminNoResourceState message={message} compact={compact} />;
}

View File

@@ -31,6 +31,7 @@ import {
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -396,7 +397,7 @@ export function DashboardConsole(): ReactElement {
orders: lifetimeFinance.order_count,
tickets: lifetimeFinance.ticket_item_count,
})
: t("states.noData", { ns: "common" })
: t("states.noResource", { ns: "common" })
}
actionLabel={t("actions.viewAll", { ns: "common" })}
icon={<Wallet className="size-5" aria-hidden />}
@@ -446,9 +447,7 @@ export function DashboardConsole(): ReactElement {
) : finance ? (
<SettlementStatusChart finance={finance} />
) : (
<p className="py-10 text-center text-xs text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
@@ -505,9 +504,7 @@ export function DashboardConsole(): ReactElement {
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<p className="py-10 text-center text-xs text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
@@ -550,9 +547,7 @@ export function DashboardConsole(): ReactElement {
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<p className="py-10 text-center text-xs text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { ArrowRight, Clock, Ticket } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -45,9 +46,8 @@ export function DashboardCurrentDrawCard({
return (
<Card className="admin-list-card overflow-hidden py-0">
<CardContent className="flex min-h-[5.5rem] flex-col items-center justify-center gap-2 p-6 text-center">
<Ticket className="size-9 text-muted-foreground/40" aria-hidden />
<p className="text-sm font-medium text-muted-foreground">{t("sections.currentDraw")}</p>
<p className="text-xs text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
<AdminNoResourceState compact className="py-4" />
</CardContent>
</Card>
);

View File

@@ -0,0 +1,20 @@
"use client";
import type { ReactElement } from "react";
import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
import { useAdminProfile } from "@/stores/admin-session";
/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */
export function DashboardPageClient(): ReactElement {
const profile = useAdminProfile();
const isAgentOperator =
profile?.agent != null && profile.is_super_admin !== true;
if (isAgentOperator) {
return <AgentDashboardConsole />;
}
return <DashboardConsole />;
}

View File

@@ -80,7 +80,7 @@ export function DailyTrendChart({
);
if (series.length === 0) {
return <DashboardChartEmpty message={t("states.noData", { ns: "common" })} />;
return <DashboardChartEmpty />;
}
const plotHeight = series.length <= 7 ? 240 : series.length <= 14 ? 260 : 280;

View File

@@ -20,6 +20,7 @@ import {
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChartContainer,
@@ -516,9 +517,7 @@ export function AbnormalTransferPanelFooter({
if (total == null) {
return (
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
{t("states.noData", { ns: "common" })}
</p>
<AdminNoResourceState className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50" />
);
}

View File

@@ -11,6 +11,7 @@ import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import {
coerceAdminMinor,
formatAdminCreditMajor,
formatAdminMinorUnits,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
@@ -33,6 +34,23 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
/** 代理/玩家授信类字段:主货币整数 → 带小数展示(勿用 {@link formatDashboardMoneyMinor})。 */
export function formatDashboardCreditMajor(major: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const safeMajor = Number.isFinite(major) ? major : 0;
const decimals = getAdminCurrencyDecimalPlaces(code);
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
currency: code,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(safeMajor);
} catch {
return formatAdminCreditMajor(safeMajor, code, decimals);
}
}
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();

View File

@@ -17,12 +17,13 @@ import {
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { canManageDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
@@ -68,7 +69,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const tRef = useTranslationRef(["draws", "common"]);
const idNum = Number(drawId);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const canManageDraw = canManageDrawResults(profile?.permissions);
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
PRD_PAYOUT_MANAGE,
@@ -213,12 +214,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
const batch = data.result_batch_counts;
const hasResultActivity = batch.total > 0 || batch.pending_review > 0 || batch.published > 0;
const pendingReview = batch.pending_review ?? 0;
const totalBatches = batch.total ?? batch.published;
const hasResultActivity =
(canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
const showActions =
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
@@ -264,21 +271,25 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
<h3 className="text-sm font-medium">{t("resultBatchesTitle")}</h3>
{hasResultActivity ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="rounded-md bg-muted px-2.5 py-1">
{t("batchSummaryTotal", { count: batch.total })}
</span>
{batch.pending_review > 0 ? (
<Link
href={`/admin/draws/${drawId}/review`}
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
>
{t("batchSummaryPending", { count: batch.pending_review })}
</Link>
) : (
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
{t("batchSummaryPending", { count: 0 })}
{canManageDraw ? (
<span className="rounded-md bg-muted px-2.5 py-1">
{t("batchSummaryTotal", { count: totalBatches })}
</span>
)}
) : null}
{canManageDraw ? (
pendingReview > 0 ? (
<Link
href={`/admin/draws/${drawId}/review`}
className="rounded-md bg-amber-500/15 px-2.5 py-1 font-medium text-amber-800 dark:text-amber-200"
>
{t("batchSummaryPending", { count: pendingReview })}
</Link>
) : (
<span className="rounded-md bg-muted px-2.5 py-1 text-muted-foreground">
{t("batchSummaryPending", { count: 0 })}
</span>
)
) : null}
{batch.published > 0 ? (
<Link
href={`/admin/draws/${drawId}/results`}
@@ -294,13 +305,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</div>
) : (
<p className="text-sm text-muted-foreground">
{t("noResultBatchesYet")}{" "}
<Link
href={`/admin/draws/${drawId}/review`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{t("goToReviewTab")}
</Link>
{t("noResultBatchesYet")}
{canManageDraw ? (
<>
{" "}
<Link
href={`/admin/draws/${drawId}/review`}
className="font-medium text-primary underline-offset-4 hover:underline"
>
{t("goToReviewTab")}
</Link>
</>
) : null}
</p>
)}
</section>

View File

@@ -13,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -96,8 +97,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (err || !data) {
return <p className="text-destructive text-sm">{err ?? t("states.noData", { ns: "common" })}</p>;
if (err) {
return <p className="text-destructive text-sm">{err}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
const currencyCode = data.currency_code ?? "NPR";
@@ -180,7 +184,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</CardHeader>
<CardContent>
{data.settlement_batches.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noSettlementBatches")}</p>
<AdminNoResourceState className="py-4" />
) : (
<div className="overflow-x-auto rounded-md border">
<div className="admin-table-toolbar">

View File

@@ -16,6 +16,7 @@ import {
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -119,8 +120,11 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
if (!batch) {

View File

@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -20,22 +21,19 @@ import {
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { canManageDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawPrizeTypeLabel } from "./draw-display";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [
PRD_DRAW_RESULT_MANAGE,
]);
const canManageDraw = canManageDrawResults(profile?.permissions);
const idNum = Number(drawId);
const [data, setData] = useState<AdminDrawBatchesData | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -67,8 +65,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
const published = data.batches.filter((b) => b.status === "published");
@@ -82,41 +83,57 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
{t("drawNo")} {data.draw_no} · <DrawStatusBadge status={data.draw_status} />
</p>
</div>
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{canManageDraw ? t("reviewAndPublish") : t("viewReviewQueue")}
</Link>
{canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("reviewAndPublish")}
</Link>
) : null}
</div>
{published.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t("noPublishedBatch")}
<CardContent className="py-4">
<AdminNoResourceState />
</CardContent>
</Card>
) : (
published.map((batch) => <BatchTable key={batch.id} batch={batch} />)
published.map((batch) => (
<BatchTable key={batch.id} batch={batch} showOperationalMeta={canManageDraw} />
))
)}
</div>
);
}
function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
function BatchTable({
batch,
showOperationalMeta,
}: {
batch: AdminDrawBatchRow;
showOperationalMeta: boolean;
}) {
const { t } = useTranslation("draws");
const formatDt = useAdminDateTimeFormatter();
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("version", { version: batch.result_version })}</CardTitle>
<p className="font-mono text-xs text-muted-foreground">
{t("sourceType", {
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
})}{" "}
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
</p>
{showOperationalMeta ? (
<p className="font-mono text-xs text-muted-foreground">
{t("sourceType", {
source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
})}{" "}
· {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
</p>
)}
</CardHeader>
<CardContent className="overflow-x-auto pt-0">
<Table>

View File

@@ -16,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -152,8 +153,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
return <AdminLoadingState minHeight="6rem" className="py-6" />;
}
if (error || !data) {
return <p className="text-sm text-destructive">{error ?? t("states.noData", { ns: "common" })}</p>;
if (error) {
return <p className="text-sm text-destructive">{error}</p>;
}
if (!data) {
return <AdminNoResourceState />;
}
return (
@@ -233,9 +237,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
</CardHeader>
<CardContent>
{pending.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
{t("noPendingBatches")}
</p>
<AdminNoResourceState className="py-6" />
) : (
<Table>
<TableHeader>

View File

@@ -2,18 +2,35 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_FINANCE_ACCESS_ANY, PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "", key: "status", label: "subnav.status" },
{ suffix: "/results", key: "results", label: "subnav.results" },
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
{ suffix: "/review", key: "review", label: "subnav.review" },
{ suffix: "/risk/occupancy", key: "riskLockLogs", label: "subnav.riskLockLogs" },
{ suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
{ suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
{ suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
{ suffix: "/review", key: "review", label: "subnav.review", requiresManage: true },
{
suffix: "/risk/occupancy",
key: "riskLockLogs",
label: "subnav.riskLockLogs",
requiresManage: false,
requiresRisk: true,
},
{
suffix: "/risk/pools",
key: "riskPools",
label: "subnav.riskPools",
requiresManage: false,
requiresRisk: true,
},
] as const;
function isRiskPoolsTabActive(pathname: string, base: string): boolean {
@@ -23,6 +40,7 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
}
const rest = pathname.slice(riskPrefix.length);
return (
rest === "pools"
|| rest.startsWith("pools/")
@@ -34,21 +52,50 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
function isReviewTabActive(pathname: string, base: string): boolean {
const reviewPrefix = `${base}/review`;
const publishPrefix = `${base}/publish`;
return (
pathname === reviewPrefix ||
pathname.startsWith(`${reviewPrefix}/`) ||
pathname.startsWith(`${publishPrefix}/`)
pathname === reviewPrefix
|| pathname.startsWith(`${reviewPrefix}/`)
|| pathname.startsWith(`${publishPrefix}/`)
);
}
export function DrawSubnav({ drawId }: { drawId: string }) {
export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation("draws");
const pathname = usePathname();
const base = `/admin/draws/${drawId}`;
const profile = useAdminProfile();
const perms = profile?.permissions ?? [];
const canViewDraw = canViewDrawResults(perms);
const canManageDraw = canManageDrawResults(perms);
const canViewFinance = canViewDrawFinance(perms);
const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
const visibleSegments = useMemo(
() =>
segments.filter((segment) => {
if (!canViewDraw) {
return false;
}
if (segment.requiresManage && !canManageDraw) {
return false;
}
if (segment.key === "finance" && !canViewFinance) {
return false;
}
if ("requiresRisk" in segment && segment.requiresRisk && !canViewRisk) {
return false;
}
return true;
}),
[canManageDraw, canViewDraw, canViewRisk],
);
return (
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
{segments.map(({ suffix, key, label }) => {
{visibleSegments.map(({ suffix, key, label }) => {
const href = `${base}${suffix}`;
const active =
suffix === ""

View File

@@ -17,6 +17,7 @@ import {
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -58,6 +59,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { drawStatusLabel } from "./draw-display";
import { canManageDrawResults, canViewDrawFinance } from "@/lib/draw-access";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
@@ -94,7 +96,8 @@ export function DrawsIndexConsole() {
useAdminCurrencyCatalog();
const defaultCurrency = "NPR";
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const canManageDraw = canManageDrawResults(profile?.permissions);
const canViewFinance = canViewDrawFinance(profile?.permissions);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [data, setData] = useState<AdminDrawListData | null>(null);
const formatDt = useCallback(
@@ -395,21 +398,21 @@ export function DrawsIndexConsole() {
<TableHead>{t("closeTime")}</TableHead>
<TableHead>{t("drawTime")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-center">{t("betTotal")}</TableHead>
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-center">{t("profitLoss")}</TableHead>
{canViewFinance ? (
<>
<TableHead className="text-center">{t("betTotal")}</TableHead>
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-center">{t("profitLoss")}</TableHead>
</>
) : null}
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<AdminTableLoadingRow colSpan={10} />
<AdminTableLoadingRow colSpan={canViewFinance ? 10 : 7} />
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={canViewFinance ? 10 : 7} className="text-muted-foreground" />
) : (
data.items.map((row: AdminDrawListItem) => (
<TableRow key={row.id}>
@@ -431,26 +434,30 @@ export function DrawsIndexConsole() {
label={drawStatusLabel(row.status, t)}
/>
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{row.total_bet_minor != null
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{row.total_payout_minor != null
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell
className={cn(
"text-center text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
)}
>
{row.profit_loss_minor != null
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"}
</TableCell>
{canViewFinance ? (
<>
<TableCell className="text-center text-xs tabular-nums">
{row.total_bet_minor != null
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{row.total_payout_minor != null
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell
className={cn(
"text-center text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
)}
>
{row.profit_loss_minor != null
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"}
</TableCell>
</>
) : null}
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[

View File

@@ -1,7 +1,7 @@
"use client";
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
import { useCallback, useState } from "react";
import { Copy, Download, Link2, Pencil, ShieldAlert } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
@@ -11,12 +11,14 @@ import {
getAdminIntegrationSite,
getAdminIntegrationSiteExport,
getAdminIntegrationSites,
getAdminIntegrationSiteSecrets,
postAdminIntegrationSite,
postAdminIntegrationSiteConnectivityTest,
postAdminIntegrationSiteRotateSecrets,
putAdminIntegrationSite,
} from "@/api/admin-integration-sites";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
@@ -54,6 +56,60 @@ import type {
AdminIntegrationSiteWithSecrets,
} from "@/types/api/admin-integration-site";
function CopyIconButton({
label,
onClick,
disabled,
busy,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
busy?: boolean;
}): React.ReactElement {
return (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
disabled={disabled || busy}
aria-label={label}
title={label}
onClick={onClick}
>
<Copy className="h-3.5 w-3.5" />
</Button>
);
}
function MaskedValueWithCopy({
configured,
masked,
copyLabel,
canCopy,
copying,
onCopy,
}: {
configured: boolean;
masked: string | null;
copyLabel: string;
canCopy: boolean;
copying: boolean;
onCopy: () => void;
}): React.ReactElement {
return (
<div className="flex min-w-[7.5rem] max-w-[11rem] items-center gap-0.5">
<span className="truncate font-mono text-xs text-muted-foreground">
{configured ? (masked ?? "••••••••") : "—"}
</span>
{configured && canCopy ? (
<CopyIconButton label={copyLabel} onClick={onCopy} busy={copying} />
) : null}
</div>
);
}
type FormState = {
code: string;
name: string;
@@ -140,7 +196,7 @@ function formToPayload(
}
type IntegrationSitesConsoleProps = {
/** 代理线路内站点列表:仅超管可新建站点,普通账号走「开通线路」。 */
/** 为 true 时仅超管可新建站点(默认有 integration.site.manage 即可创建)。 */
restrictCreateToSuperAdmin?: boolean;
};
@@ -180,9 +236,12 @@ export function IntegrationSitesConsole({
const [connectivityResult, setConnectivityResult] =
useState<AdminIntegrationSiteConnectivityResult | null>(null);
const [exportBusyId, setExportBusyId] = useState<number | null>(null);
const [secretCopyBusyKey, setSecretCopyBusyKey] = useState<string | null>(null);
const secretsCacheRef = useRef(new Map<number, AdminIntegrationSiteSecrets>());
const load = useCallback(async () => {
setLoading(true);
secretsCacheRef.current.clear();
try {
const data = await getAdminIntegrationSites();
setItems(data.items);
@@ -273,6 +332,7 @@ export function IntegrationSitesConsole({
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
setRotateTarget(null);
secretsCacheRef.current.delete(rotateTarget.id);
showSecretsOnce(result);
await load();
} catch (error) {
@@ -350,6 +410,55 @@ export function IntegrationSitesConsole({
}
}
async function resolveSiteSecrets(siteId: number): Promise<AdminIntegrationSiteSecrets> {
const cached = secretsCacheRef.current.get(siteId);
if (cached) {
return cached;
}
const secrets = await getAdminIntegrationSiteSecrets(siteId);
secretsCacheRef.current.set(siteId, secrets);
return secrets;
}
async function copySiteSecret(
row: AdminIntegrationSiteRow,
field: "sso" | "wallet",
): Promise<void> {
if (!canManage) {
toast.error(t("integrationSites.secretCopyRequiresManage"));
return;
}
const configured = field === "sso" ? row.has_sso_secret : row.has_wallet_api_key;
if (!configured) {
toast.error(t("integrationSites.secretNotConfigured"));
return;
}
const busyKey = `${row.id}:${field}`;
setSecretCopyBusyKey(busyKey);
try {
const secrets = await resolveSiteSecrets(row.id);
const value = field === "sso" ? secrets.sso_jwt_secret : secrets.wallet_api_key;
if (!value) {
toast.error(t("integrationSites.secretNotConfigured"));
return;
}
await copyText(
field === "sso"
? t("integrationSites.fields.ssoSecret")
: t("integrationSites.fields.walletApiKey"),
value,
);
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("integrationSites.copyFailed"),
);
} finally {
setSecretCopyBusyKey(null);
}
}
return (
<>
<AdminPageCard
@@ -366,23 +475,38 @@ export function IntegrationSitesConsole({
{loading ? (
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
) : items.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("integrationSites.empty")}</p>
<AdminNoResourceState />
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("integrationSites.columns.code")}</TableHead>
<TableHead>{t("integrationSites.columns.name")}</TableHead>
<TableHead>{t("integrationSites.columns.currency")}</TableHead>
<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>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.code}</TableCell>
<TableCell>
<div className="flex max-w-[10rem] items-center gap-0.5">
<span className="truncate font-mono text-xs">{row.code}</span>
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() => void copyText(t("integrationSites.columns.code"), row.code)}
/>
</div>
</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell className="font-mono text-xs">{row.currency_code}</TableCell>
<TableCell>
<AdminStatusBadge
tone={row.status === 1 ? "success" : "neutral"}
@@ -392,8 +516,53 @@ export function IntegrationSitesConsole({
: t("integrationSites.statusDisabled")}
</AdminStatusBadge>
</TableCell>
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
{row.wallet_api_url ?? "—"}
<TableCell>
<AdminStatusBadge tone={row.has_line_root ? "success" : "neutral"}>
{row.has_line_root
? t("integrationSites.lineRootBound")
: t("integrationSites.lineRootUnbound")}
</AdminStatusBadge>
</TableCell>
<TableCell>
<div className="flex max-w-[14rem] items-center gap-0.5">
<span className="truncate text-xs text-muted-foreground">
{row.wallet_api_url ?? "—"}
</span>
{row.wallet_api_url ? (
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.columns.walletUrl"),
row.wallet_api_url ?? "",
)
}
/>
) : 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}
masked={row.sso_secret_masked}
copyLabel={t("integrationSites.copy")}
canCopy={canManage}
copying={secretCopyBusyKey === `${row.id}:sso`}
onCopy={() => void copySiteSecret(row, "sso")}
/>
</TableCell>
<TableCell>
<MaskedValueWithCopy
configured={row.has_wallet_api_key}
masked={row.wallet_api_key_masked}
copyLabel={t("integrationSites.copy")}
canCopy={canManage}
copying={secretCopyBusyKey === `${row.id}:wallet`}
onCopy={() => void copySiteSecret(row, "wallet")}
/>
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
@@ -434,6 +603,7 @@ export function IntegrationSitesConsole({
))}
</TableBody>
</Table>
</div>
)}
</AdminPageCard>
@@ -645,36 +815,30 @@ export function IntegrationSitesConsole({
<Label>{t("integrationSites.fields.ssoSecret")}</Label>
<div className="flex gap-2">
<Input readOnly value={secretsDialog.secrets.sso_jwt_secret} className="font-mono text-xs" />
<Button
type="button"
variant="outline"
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.fields.ssoSecret"),
secretsDialog.secrets.sso_jwt_secret,
)
}
>
{t("integrationSites.copy")}
</Button>
/>
</div>
</div>
<div className="space-y-2">
<Label>{t("integrationSites.fields.walletApiKey")}</Label>
<div className="flex gap-2">
<Input readOnly value={secretsDialog.secrets.wallet_api_key} className="font-mono text-xs" />
<Button
type="button"
variant="outline"
<CopyIconButton
label={t("integrationSites.copy")}
onClick={() =>
void copyText(
t("integrationSites.fields.walletApiKey"),
secretsDialog.secrets.wallet_api_key,
)
}
>
{t("integrationSites.copy")}
</Button>
/>
</div>
</div>
</div>

View File

@@ -17,6 +17,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -43,6 +44,11 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
formatRatioAsPercent,
percentUiToRatio,
ratioToPercentUi,
} from "@/lib/admin-rate-percent";
type Draft = {
contribution_rate: string;
@@ -63,9 +69,9 @@ type AdjustmentDraft = {
function toDraft(p: AdminJackpotPoolRow): Draft {
return {
contribution_rate: String(p.contribution_rate),
contribution_rate: ratioToPercentUi(p.contribution_rate),
trigger_threshold: formatAdminMinorDecimal(p.trigger_threshold, p.currency_code),
payout_rate: String(p.payout_rate),
payout_rate: ratioToPercentUi(p.payout_rate),
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
min_bet_amount: formatAdminMinorDecimal(p.min_bet_amount, p.currency_code),
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
@@ -148,9 +154,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
setSavingId(p.id);
try {
await putAdminJackpotPool(p.id, {
contribution_rate: Number(d.contribution_rate),
contribution_rate: percentUiToRatio(d.contribution_rate),
trigger_threshold: parseAdminMajorToMinor(d.trigger_threshold, p.currency_code) ?? 0,
payout_rate: Number(d.payout_rate),
payout_rate: percentUiToRatio(d.payout_rate),
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
min_bet_amount: parseAdminMajorToMinor(d.min_bet_amount, p.currency_code) ?? 0,
combo_trigger_play_codes: d.combo_trigger_play_codes
@@ -233,7 +239,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<div className={embedded ? "space-y-4" : "space-y-8"}>
{loading ? <AdminLoadingState minHeight="6rem" className="py-6" /> : null}
{!loading && items.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
<AdminNoResourceState />
) : null}
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
@@ -269,7 +275,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
</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">{d.payout_rate}</p>
<p className="mt-0.5 font-mono text-base 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>
@@ -381,6 +389,10 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
<Input
id={`pr-${p.id}`}
type="number"
min={0}
max={100}
step="0.01"
className="font-mono"
value={d.payout_rate}
placeholder={t("payoutRatePlaceholder")}
@@ -401,6 +413,10 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
<Input
id={`cr-${p.id}`}
type="number"
min={0}
max={100}
step="0.01"
className="font-mono"
value={d.contribution_rate}
placeholder={t("contributionRatePlaceholder")}

View File

@@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
@@ -254,11 +255,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
</TableHeader>
<TableBody>
{(payouts?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} className="py-10 text-center text-muted-foreground" />
) : (
(payouts?.items ?? []).map((r) => (
<TableRow key={r.id}>
@@ -303,11 +300,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
</TableHeader>
<TableBody>
{(contribs?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-10 text-center text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={8} className="py-10 text-center text-muted-foreground" />
) : (
(contribs?.items ?? []).map((r) => (
<TableRow key={r.id}>

View File

@@ -11,6 +11,7 @@ import { getAdminPlayer } from "@/api/admin-player";
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { buttonVariants } from "@/components/ui/button";
@@ -29,6 +30,13 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
import { formatAdminMinorUnits } from "@/lib/money";
import {
isCreditFundingPlayer,
playerAuthSourceLabel,
playerFundingModeLabel,
playerShowsTransferOrders,
} from "@/lib/player-funding";
import { formatPlayerCreditAmount } from "@/lib/admin-player-display";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
@@ -209,7 +217,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
}, [player, loadTxns]);
useAsyncEffect(() => {
if (!player) return;
if (!player || !playerShowsTransferOrders(player)) return;
void loadTransfers();
}, [player, loadTransfers]);
@@ -217,6 +225,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
return <AdminLoadingState minHeight="8rem" className="py-8" />;
}
const isCreditPlayer = player ? isCreditFundingPlayer(player) : false;
const showTransferTab = player ? playerShowsTransferOrders(player) : false;
if (playerErr || !player) {
return (
<div className="space-y-4">
@@ -224,7 +235,11 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<ArrowLeft className="size-4" aria-hidden />
{t("backToList")}
</Link>
<p className="text-sm text-destructive">{playerErr ?? t("states.noData", { ns: "common" })}</p>
{playerErr ? (
<p className="text-sm text-destructive">{playerErr}</p>
) : (
<AdminNoResourceState />
)}
</div>
);
}
@@ -263,11 +278,13 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
{t("tabTickets")}
</TabsTrigger>
<TabsTrigger value="wallet" className="rounded-none px-3">
{t("tabWalletTxns")}
</TabsTrigger>
<TabsTrigger value="transfers" className="rounded-none px-3">
{t("tabTransferOrders")}
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
</TabsTrigger>
{showTransferTab ? (
<TabsTrigger value="transfers" className="rounded-none px-3">
{t("tabTransferOrders")}
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="overview" className="mt-0 space-y-4">
@@ -289,6 +306,12 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</dl>
<dl className="space-y-3">
<ProfileField label={t("currency")}>{player.default_currency}</ProfileField>
<ProfileField label={t("fundingMode")}>
{playerFundingModeLabel(player, t)}
</ProfileField>
<ProfileField label={t("authSource")}>
{playerAuthSourceLabel(player, t)}
</ProfileField>
<ProfileField label={t("status")}>
<PlayerStatusBadge status={player.status} t={t} />
</ProfileField>
@@ -312,11 +335,40 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("walletsSection")}</CardTitle>
<CardTitle className="text-base">
{isCreditPlayer ? t("creditSection") : t("walletsSection")}
</CardTitle>
</CardHeader>
<CardContent>
{player.wallets.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
{isCreditPlayer ? (
<dl className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">{t("creditLimit")}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
{player.credit_limit != null
? formatPlayerCreditAmount(player.credit_limit, player.default_currency)
: "—"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("availableCredit")}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
{player.available_credit != null
? formatPlayerCreditAmount(player.available_credit, player.default_currency)
: "—"}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("usedCredit")}</p>
<p className="mt-1 text-sm tabular-nums text-muted-foreground">
{player.used_credit != null
? formatPlayerCreditAmount(player.used_credit, player.default_currency)
: "—"}
</p>
</div>
</dl>
) : player.wallets.length === 0 ? (
<AdminNoResourceState className="text-sm text-muted-foreground" />
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{player.wallets.map((w: AdminPlayerWalletRow) => (
@@ -399,11 +451,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!ticketsLoading && tickets.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={7} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>
@@ -428,7 +476,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<TabsContent value="wallet" className="mt-0">
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("tabWalletTxns")}</CardTitle>
<CardTitle className="admin-list-title">
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-table-shell">
@@ -466,11 +516,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!txnsLoading && txns.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>
@@ -492,6 +538,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</Card>
</TabsContent>
{showTransferTab ? (
<TabsContent value="transfers" className="mt-0">
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
@@ -531,11 +578,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableRow>
))}
{!transfersLoading && transfers.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
) : null}
</TableBody>
</Table>
@@ -556,6 +599,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</CardContent>
</Card>
</TabsContent>
) : null}
</Tabs>
</div>
);

View File

@@ -23,6 +23,7 @@ import {
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -61,9 +62,11 @@ import {
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
import type { AdminPlayerRow } from "@/types/api/admin-player";
function playerStatusLabelT(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
@@ -72,15 +75,6 @@ function playerStatusLabelT(status: number, t: (key: string) => string): string
return String(status);
}
function preferredDisplayWallet(row: AdminPlayerRow): AdminPlayerWalletRow | null {
const { wallets, default_currency } = row;
if (wallets.length === 0) {
return null;
}
const code = default_currency.trim().toUpperCase();
return wallets.find((w) => w.currency_code.toUpperCase() === code) ?? wallets[0];
}
const PLAYER_STATUS_OPTIONS = [
{ value: 0, label: "statusNormal" },
{ value: 1, label: "statusFrozen" },
@@ -108,6 +102,7 @@ export function PlayersConsole(): React.ReactElement {
const [perPage, setPerPage] = useState(10);
const [keyword, setKeyword] = useState(keywordFromUrl);
const [query, setQuery] = useState(keywordFromUrl);
const [siteFilter, setSiteFilter] = useState("");
const [items, setItems] = useState<AdminPlayerRow[]>([]);
const [total, setTotal] = useState(0);
@@ -138,14 +133,43 @@ export function PlayersConsole(): React.ReactElement {
[items, editingAccountId],
);
const showSiteFilter =
isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1;
const scopeHint = useMemo(() => {
if (isSuperAdmin) {
return siteFilter.trim() !== ""
? t("scopeFilteredSite", { site: siteFilter.trim() })
: t("scopeAllSites");
}
if (boundAgent) {
return t("scopeAgentLine", {
site: boundAgent.site_code,
name: boundAgent.name,
});
}
const sites = profile?.accessible_sites ?? [];
if (sites.length === 1) {
return t("scopeSingleSite", { site: sites[0].code });
}
if (sites.length > 1) {
return siteFilter.trim() !== ""
? t("scopeFilteredSite", { site: siteFilter.trim() })
: t("scopeMultiSite", { count: sites.length });
}
return "";
}, [boundAgent, isSuperAdmin, profile?.accessible_sites, siteFilter, t]);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const scopedSite = siteFilter.trim();
const data = await getAdminPlayers({
page,
per_page: perPage,
keyword: query.trim() || undefined,
...(scopedSite !== "" ? { site_code: scopedSite } : {}),
});
setItems(data.items);
setTotal(data.meta.total);
@@ -159,11 +183,11 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query]);
}, [page, perPage, query, siteFilter]);
useAsyncEffect(() => {
void load();
}, [page, perPage, query]);
}, [page, perPage, query, siteFilter]);
useAsyncEffect(() => {
setKeyword(keywordFromUrl);
@@ -385,7 +409,36 @@ export function PlayersConsole(): React.ReactElement {
</Button>
) : null}
</div>
{scopeHint ? (
<p className="text-xs text-muted-foreground">{scopeHint}</p>
) : null}
<div className="admin-list-toolbar">
{showSiteFilter ? (
<div className="admin-list-field">
<Label htmlFor="player-site-filter" className="sm:w-20 sm:shrink-0">
{t("filterSite")}
</Label>
<Select
value={siteFilter || "__all__"}
onValueChange={(value) => {
setSiteFilter(value === "__all__" ? "" : value);
setPage(1);
}}
>
<SelectTrigger id="player-site-filter" className="w-full sm:w-[12rem]">
<SelectValue placeholder={t("filterAllSites")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{(isSuperAdmin ? siteOptions : profile?.accessible_sites ?? []).map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.name ? `${site.name} (${site.code})` : site.code}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
{t("search")}
@@ -454,6 +507,7 @@ export function PlayersConsole(): React.ReactElement {
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap">{t("fundingMode")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
@@ -463,16 +517,12 @@ export function PlayersConsole(): React.ReactElement {
</TableHeader>
<TableBody>
{loading && items.length === 0 ? (
<AdminTableLoadingRow colSpan={12} />
<AdminTableLoadingRow colSpan={13} />
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={12} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={13} className="text-muted-foreground" />
) : (
items.map((row) => {
const displayWallet = preferredDisplayWallet(row);
const balances = playerBalanceCells(row, formatAdminMinorUnits);
return (
<TableRow key={row.id}>
<TableCell className="tabular-nums">#{row.id}</TableCell>
@@ -486,18 +536,14 @@ export function PlayersConsole(): React.ReactElement {
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell>{row.default_currency}</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{displayWallet
? formatAdminMinorUnits(displayWallet.balance, displayWallet.currency_code)
: "—"}
<TableCell>
<PlayerFundingModeBadge row={row} />
</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{displayWallet
? formatAdminMinorUnits(
displayWallet.available_balance,
displayWallet.currency_code,
)
: "—"}
{balances.balance}
</TableCell>
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{balances.available}
</TableCell>
<TableCell>
{row.status === 2 ? (

View File

@@ -14,6 +14,7 @@ import {
} from "@/api/admin-reconcile";
import { getAdminPlayers } from "@/api/admin-player";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -333,7 +334,7 @@ export function ReconcileConsole(): React.ReactElement {
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
) : playerResults.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
<AdminNoResourceState compact className="px-3 py-4" />
) : (
<div className="divide-y">
{playerResults.map((player) => {
@@ -456,11 +457,7 @@ export function ReconcileConsole(): React.ReactElement {
{jobsLoading && !jobs ? (
<AdminTableLoadingRow colSpan={10} />
) : jobs.items.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
) : (
jobs.items.map((row) => (
<TableRow key={row.id}>
@@ -616,11 +613,7 @@ export function ReconcileConsole(): React.ReactElement {
</TableHeader>
<TableBody>
{items.items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("noDetails")}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} />
) : (
items.items.map((r) => (
<TableRow

View File

@@ -9,6 +9,7 @@ import { Download, RefreshCw } from "lucide-react";
import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -115,11 +116,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
{loading ? (
<AdminTableLoadingRow colSpan={6} />
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("taskEmpty")}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} />
) : (
jobs.map((job) => (
<TableRow key={job.id}>

View File

@@ -48,6 +48,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Button } from "@/components/ui/button";
@@ -92,7 +93,7 @@ import type {
AdminReportRebateCommissionRow,
} from "@/types/api/admin-reports";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy";
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
type ExportFormat = "csv" | "excel";
@@ -191,7 +192,7 @@ const REPORTS: ReportDefinition[] = [
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
{ key: "rebate_commission", category: "wallet", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "rebate_commission", category: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
];
@@ -225,6 +226,8 @@ function categoryTone(category: ReportCategory): string {
return "border-red-200 bg-red-50 text-red-700";
case "audit":
return "border-slate-200 bg-slate-50 text-slate-700";
case "legacy":
return "border-amber-200 bg-amber-50 text-amber-800";
default:
return "border-blue-200 bg-blue-50 text-blue-700";
}
@@ -1248,11 +1251,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}
if (!result || result.rows.length === 0) {
return (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("preview.empty")}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={8} />
);
}
@@ -1507,6 +1506,15 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
))}
</div>
{selectedReport.key === "rebate_commission" ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
{t("items.rebate_commission.disclaimer", {
defaultValue:
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
})}
</div>
) : null}
<Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>

View File

@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
const tabs = [
{ category: "profit", href: "/admin/reports/profit" },
{ category: "wallet", href: "/admin/reports/wallet" },
{ category: "legacy", href: "/admin/reports/legacy" },
{ category: "risk", href: "/admin/reports/risk" },
{ category: "audit", href: "/admin/reports/audit" },
] as const;

View File

@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDraws } from "@/api/admin-draws";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button";
@@ -190,11 +191,7 @@ export function RiskIndexConsole() {
{loading && (data?.items.length ?? 0) === 0 ? (
<AdminTableLoadingRow colSpan={4} />
) : (data?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={4} className="text-muted-foreground" />
) : (
(data?.items ?? []).map((row: AdminDrawListItem) => (
<TableRow key={row.id}>

View File

@@ -14,6 +14,7 @@ import {
postAdminCurrency,
putAdminCurrency,
} from "@/api/admin-currencies";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -242,11 +243,7 @@ export function CurrencySettingsPanel() {
</TableCell>
</TableRow>
) : items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
{t("currencies.empty", { ns: "config" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={6} cellClassName="text-center" />
) : (
items.map((row) => (
<TableRow key={row.code}>

View File

@@ -0,0 +1,313 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementBill,
postSettlementBillAdjustment,
postSettlementBillBadDebtWriteOff,
postSettlementBillConfirm,
postSettlementBillPayment,
type RebateAllocationRow,
type SettlementBillRow,
type SettlementPaymentRow,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
share_profit?: number;
platform_share_profit?: number;
} {
if (metaJson == null || metaJson === "") {
return {};
}
try {
const parsed =
typeof metaJson === "string" ? (JSON.parse(metaJson) as Record<string, unknown>) : metaJson;
return {
share_profit: parsed.share_profit != null ? Number(parsed.share_profit) : undefined,
platform_share_profit:
parsed.platform_share_profit != null ? Number(parsed.platform_share_profit) : undefined,
};
} catch {
return {};
}
}
type AgentBillDetailProps = {
billId: number;
currencyCode: string;
canManage?: boolean;
onUpdated?: () => void;
};
export function AgentBillDetail({
billId,
currencyCode,
canManage = true,
onUpdated,
}: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [bill, setBill] = useState<SettlementBillRow | null>(null);
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
const [tierEdge, setTierEdge] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [adjustAmount, setAdjustAmount] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const data = await getSettlementBill(billId);
setBill(data.bill);
setPayments(data.payments ?? []);
setRebateAllocations(data.rebate_allocations ?? []);
setTierEdge(data.tier_edge ?? null);
setPayAmount(String(data.bill.unpaid_amount ?? 0));
} finally {
setLoading(false);
}
}, [billId]);
useEffect(() => {
void load();
}, [load]);
if (loading || !bill) {
return <AdminLoadingState />;
}
const owner =
bill.owner_label ??
`${bill.owner_type}#${bill.owner_id}`;
const counterparty =
bill.counterparty_label === "platform"
? t("settlementBills.platform", { defaultValue: "平台" })
: bill.counterparty_label ?? `${bill.counterparty_type}#${bill.counterparty_id}`;
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
const ownerOwes = bill.net_amount > 0;
const paymentTitle = ownerOwes
? t("settlementBills.recordReceipt", { defaultValue: "登记收款" })
: t("settlementBills.recordPayout", { defaultValue: "登记付款" });
const paymentSubmit = ownerOwes
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
const canWriteOff =
canManage &&
bill.unpaid_amount > 0 &&
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
const meta = parseBillMeta(bill.meta_json);
const hasSubtreeFields =
bill.gross_win_loss != null ||
bill.rebate_amount != null ||
bill.platform_rounding_adjustment != null ||
meta.share_profit != null;
return (
<div className="space-y-4 text-sm">
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.party", { defaultValue: "本方" })}: </span>
{owner}
</div>
<div>
<span className="text-muted-foreground">
{t("settlementBills.columns.counterparty", { defaultValue: "对方" })}:{" "}
</span>
{counterparty}
</div>
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.type", { defaultValue: "类型" })}: </span>
{bill.bill_type} / {bill.status}
{tierEdge ? ` · ${tierEdge}` : ""}
</div>
{hasSubtreeFields ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">
{t("settlementBills.subtreeSummary", { defaultValue: "子树汇总" })}
</p>
{bill.gross_win_loss != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.grossWinLoss", { defaultValue: "输赢 (gross_win_loss)" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.gross_win_loss, currencyCode)}
</div>
) : null}
{bill.rebate_amount != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.rebateAmount", { defaultValue: "回水" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.rebate_amount, currencyCode)}
</div>
) : null}
{meta.share_profit != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.shareProfit", { defaultValue: "占成利润" })}:{" "}
</span>
{formatDashboardMoneyMinor(meta.share_profit, currencyCode)}
</div>
) : null}
{bill.platform_rounding_adjustment != null && bill.platform_rounding_adjustment !== 0 ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.platformRounding", { defaultValue: "平台尾差" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.platform_rounding_adjustment, currencyCode)}
</div>
) : null}
</div>
) : null}
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.net", { defaultValue: "净额" })}: </span>
{formatDashboardMoneyMinor(bill.net_amount, currencyCode)}
</div>
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.unpaid", { defaultValue: "未结" })}: </span>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</div>
{rebateAllocations.length > 0 ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}</p>
<ul className="space-y-1 text-muted-foreground">
{rebateAllocations.map((row) => (
<li key={row.id}>
{row.participant_type}#{row.participant_id} · {row.allocation_rule} ·{" "}
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
</li>
))}
</ul>
</div>
) : null}
{payments.length > 0 ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}</p>
<ul className="space-y-1 text-muted-foreground">
{payments.map((p) => (
<li key={p.id}>
{formatDashboardMoneyMinor(p.amount, currencyCode)}
{p.method ? ` · ${p.method}` : ""}
{p.remark ? ` · ${p.remark}` : ""}
</li>
))}
</ul>
</div>
) : null}
{canManage && bill.status === "pending_confirm" ? (
<Button
type="button"
onClick={() =>
void postSettlementBillConfirm(billId)
.then(load)
.then(onUpdated)
.then(() => toast.success(t("settlementBills.confirmed", { defaultValue: "已确认" })))
}
>
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
) : null}
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<p className="font-medium">{paymentTitle}</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="space-y-1">
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t("settlementBills.paymentMethod", { defaultValue: "方式" })}</Label>
<Input value={payMethod} onChange={(e) => setPayMethod(e.target.value)} placeholder="cash" />
</div>
<div className="space-y-1 sm:col-span-2">
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
<Input value={payProof} onChange={(e) => setPayProof(e.target.value)} />
</div>
</div>
<Button
type="button"
onClick={() =>
void postSettlementBillPayment(billId, {
amount: Number(payAmount),
method: payMethod.trim() || undefined,
proof: payProof.trim() || undefined,
})
.then(load)
.then(onUpdated)
.then(() => toast.success(t("settlementBills.paid", { defaultValue: "已登记收付" })))
}
>
{paymentSubmit}
</Button>
</div>
) : null}
{canWriteOff ? (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}</p>
<div className="space-y-1">
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input value={badDebtReason} onChange={(e) => setBadDebtReason(e.target.value)} />
</div>
<Button
type="button"
variant="destructive"
onClick={() =>
void postSettlementBillBadDebtWriteOff(billId, {
reason: badDebtReason.trim() || undefined,
})
.then(load)
.then(onUpdated)
.then(() =>
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" })),
)
}
>
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</div>
) : null}
{canManage && locked ? (
<div className="space-y-2 rounded-md border border-dashed border-border/60 p-3">
<p className="font-medium">{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}</p>
<div className="space-y-1">
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
<Input value={adjustAmount} onChange={(e) => setAdjustAmount(e.target.value)} type="number" />
</div>
<Button
type="button"
variant="outline"
onClick={() =>
void postSettlementBillAdjustment(billId, {
amount: Number(adjustAmount),
reason: "manual_adjustment",
})
.then(() => toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })))
.then(onUpdated)
}
>
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
</Button>
</div>
) : null}
</div>
);
}

View File

@@ -1,79 +1,8 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { adminRequest } from "@/lib/admin-http";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type BillRow = {
id: number;
bill_type: string;
net_amount: number;
unpaid_amount: number;
status: string;
};
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
/** 兼容旧引用:结算中心完整表格化界面 */
export function AgentBillsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [rows, setRows] = useState<BillRow[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await adminRequest.get<{ items: BillRow[] }>("/admin/settlement-bills");
setRows(data.items ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<AdminPageCard title={t("agents:settlementBills.title", { defaultValue: "代理账单" })}>
{loading ? (
<AdminLoadingState />
) : rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("common:states.noData", { defaultValue: "暂无数据" })}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("agents:settlementBills.columns.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.type", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.net", { defaultValue: "净额" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.unpaid", { defaultValue: "未结" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.status", { defaultValue: "状态" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.bill_type}</TableCell>
<TableCell>{row.net_amount}</TableCell>
<TableCell>{row.unpaid_amount}</TableCell>
<TableCell>{row.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</AdminPageCard>
);
return <SettlementCenterShell />;
}

View File

@@ -0,0 +1,307 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementPeriods,
postSettlementPeriod,
postSettlementPeriodClose,
type SettlementPeriodCloseResult,
type SettlementPeriodRow,
} from "@/api/admin-agent-settlement";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
defaultSettlementPeriodPreset,
formatSettlementPeriodSpan,
settlementPeriodPresetRange,
type SettlementPeriodPresetKey,
} from "@/lib/agent-settlement-period-range";
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
type AgentPeriodsConsoleProps = {
adminSiteId: number;
canManagePeriods: boolean;
settlementCycle?: string | null;
siteCurrencyCode?: string;
/** 嵌入结算中心主区时不重复外层卡片标题 */
embedded?: boolean;
onPeriodsChange?: (periods: SettlementPeriodRow[]) => void;
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
};
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
export function AgentPeriodsConsole({
adminSiteId,
canManagePeriods,
settlementCycle,
siteCurrencyCode = "NPR",
embedded = false,
onPeriodsChange,
onPeriodClosed,
}: AgentPeriodsConsoleProps): React.ReactElement | null {
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
const [rows, setRows] = useState<SettlementPeriodRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const cycle = normalizeAgentSettlementCycle(settlementCycle);
const applyPreset = useCallback(
(key: SettlementPeriodPresetKey) => {
const range = settlementPeriodPresetRange(key);
setPeriodStart(range.period_start);
setPeriodEnd(range.period_end);
},
[],
);
const onPeriodsChangeRef = useRef(onPeriodsChange);
const onPeriodClosedRef = useRef(onPeriodClosed);
onPeriodsChangeRef.current = onPeriodsChange;
onPeriodClosedRef.current = onPeriodClosed;
const load = useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
const items = data.items ?? [];
setRows(items);
onPeriodsChangeRef.current?.(items);
} catch {
setRows([]);
setLoadError(true);
onPeriodsChangeRef.current?.([]);
} finally {
setLoading(false);
}
}, [adminSiteId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!canManagePeriods || periodStart !== "" || periodEnd !== "") {
return;
}
applyPreset(defaultSettlementPeriodPreset(cycle));
}, [applyPreset, canManagePeriods, cycle, periodEnd, periodStart]);
async function openPeriod(): Promise<void> {
if (!periodStart || !periodEnd) {
toast.error(t("settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
return;
}
try {
await postSettlementPeriod({
admin_site_id: adminSiteId,
period_start: periodStart,
period_end: periodEnd,
});
toast.success(t("settlementPeriods.opened", { defaultValue: "账期已开启" }));
await load();
} catch {
toast.error(t("settlementPeriods.openFailed", { defaultValue: "开期失败" }));
}
}
async function closePeriod(id: number): Promise<void> {
try {
const result = await postSettlementPeriodClose(id);
await load();
onPeriodClosedRef.current?.(result);
} catch {
toast.error(t("settlementPeriods.closeFailed", { defaultValue: "关账失败" }));
}
}
const presetLabel = (key: SettlementPeriodPresetKey): string => {
switch (key) {
case "this_week":
return t("settlementPeriods.presetThisWeek", { defaultValue: "本周" });
case "last_week":
return t("settlementPeriods.presetLastWeek", { defaultValue: "上周" });
case "this_month":
return t("settlementPeriods.presetThisMonth", { defaultValue: "本月" });
}
};
const body = (
<>
{canManagePeriods ? (
<div className={embedded ? "space-y-4" : "mb-4 space-y-3"}>
<div className="flex flex-wrap gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
type="button"
size="sm"
variant="outline"
onClick={() => applyPreset(key)}
>
{presetLabel(key)}
</Button>
))}
<Button type="button" size="sm" onClick={() => void openPeriod()}>
{t("settlementPeriods.openWithPreset", { defaultValue: "按上方时间开期" })}
</Button>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => setAdvancedOpen((open) => !open)}
>
{advancedOpen
? t("settlementPeriods.hideAdvanced", { defaultValue: "收起自定义时间" })
: t("settlementPeriods.showAdvanced", { defaultValue: "自定义起止时间" })}
</button>
{advancedOpen ? (
<div className="flex flex-wrap items-end gap-3 pt-1">
<div className="space-y-1">
<Label>{t("settlementPeriods.start", { defaultValue: "开始" })}</Label>
<Input
type="datetime-local"
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("settlementPeriods.end", { defaultValue: "结束" })}</Label>
<Input
type="datetime-local"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
/>
</div>
<Button type="button" variant="secondary" onClick={() => void openPeriod()}>
{t("settlementPeriods.open", { defaultValue: "开期" })}
</Button>
</div>
) : null}
</div>
) : null}
{loading ? (
<AdminLoadingState />
) : loadError ? (
<p className="text-sm text-destructive">
{t("settlementPeriods.loadFailed", { defaultValue: "账期列表加载失败,请稍后重试。" })}
</p>
) : rows.length === 0 ? (
<AdminNoResourceState />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("settlementPeriods.range", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("settlementPeriods.status", { defaultValue: "状态" })}</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.billCounts", { defaultValue: "账单笔数" })}
</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.pendingConfirm", { defaultValue: "待确认" })}
</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.awaitingPayment", { defaultValue: "待收付" })}
</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.totalUnpaid", { defaultValue: "未结合计" })}
</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const summary = row.summary;
const billCountLabel =
summary != null
? t("settlementPeriods.billCountsValue", {
defaultValue: "玩家 {{player}} · 代理 {{agent}}",
player: summary.player_bills,
agent: summary.agent_bills,
})
: "—";
return (
<TableRow key={row.id}>
<TableCell className="text-sm">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>
<span
className={cn(
"text-xs font-medium",
row.status === "open"
? "text-amber-700"
: row.status === "completed"
? "text-emerald-700"
: "text-muted-foreground",
)}
>
{settlementPeriodStatusLabel(row.status, t)}
</span>
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{billCountLabel}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{summary?.pending_confirm ?? "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{summary?.awaiting_payment ?? "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{summary != null
? formatDashboardMoneyMinor(summary.total_unpaid, siteCurrencyCode)
: "—"}
</TableCell>
<TableCell className="text-right">
{row.status === "open" ? (
<Button type="button" size="sm" onClick={() => void closePeriod(row.id)}>
{t("settlementPeriods.close", { defaultValue: "关账并生成账单" })}
</Button>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</>
);
if (embedded) {
return body;
}
return (
<AdminPageCard title={t("settlementPeriods.manageTitle", { defaultValue: "账期管理" })}>
{body}
</AdminPageCard>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useTranslation } from "react-i18next";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
export type AgentSettlementPeriodFilter = number | "all";
type AgentSettlementPeriodSelectProps = {
periods: SettlementPeriodRow[];
value: AgentSettlementPeriodFilter;
onChange: (value: AgentSettlementPeriodFilter) => void;
className?: string;
};
export function AgentSettlementPeriodSelect({
periods,
value,
onChange,
className,
}: AgentSettlementPeriodSelectProps): React.ReactElement {
const { t } = useTranslation("agents");
const sorted = [...periods].sort((a, b) => b.id - a.id);
return (
<Select
value={value === "all" ? "all" : String(value)}
onValueChange={(next) => {
onChange(next === "all" ? "all" : Number(next));
}}
>
<SelectTrigger className={className ?? "h-9 w-full max-w-md"}>
<SelectValue placeholder={t("settlementBills.periodPlaceholder", { defaultValue: "选择账期" })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("settlementBills.allPeriods", { defaultValue: "全部账期" })}
</SelectItem>
{sorted.map((row) => (
<SelectItem key={row.id} value={String(row.id)}>
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
{" · "}
{periodStatusLabel(row.status, t)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function periodStatusLabel(
status: string,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (status === "open") {
return t("settlementPeriods.statusOpen", { defaultValue: "进行中" });
}
if (status === "closed") {
return t("settlementPeriods.statusClosed", { defaultValue: "已关账" });
}
return status;
}

View File

@@ -0,0 +1,286 @@
"use client";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { useTranslation } from "react-i18next";
import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AgentSettlementReportType } from "@/api/admin-agent-settlement";
type AgentSettlementReportViewProps = {
reportType: AgentSettlementReportType;
data: unknown;
currencyCode: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asRows(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((row): row is Record<string, unknown> => row !== null && typeof row === "object");
}
function money(
value: unknown,
currencyCode: string,
): string {
return formatDashboardMoneyMinor(Number(value ?? 0), currencyCode);
}
function creditMoney(value: unknown, currencyCode: string): string {
return formatDashboardCreditMajor(Number(value ?? 0), currencyCode);
}
export function AgentSettlementReportView({
reportType,
data,
currencyCode,
}: AgentSettlementReportViewProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const root = asRecord(data);
if (reportType === "summary" && root) {
const stats = [
{ label: t("settlementReports.summary.billCount", { defaultValue: "账单数" }), value: String(root.bill_count ?? 0) },
{ label: t("settlementReports.summary.totalNet", { defaultValue: "净额合计" }), value: money(root.total_net, currencyCode) },
{ label: t("settlementReports.summary.totalUnpaid", { defaultValue: "未结合计" }), value: money(root.total_unpaid, currencyCode) },
{ label: t("settlementReports.summary.overdueCount", { defaultValue: "逾期账单" }), value: String(root.overdue_count ?? 0) },
{
label: t("settlementReports.summary.platformRounding", { defaultValue: "平台尾差合计" }),
value: money(root.platform_rounding_total, currencyCode),
},
];
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{stats.map((item) => (
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
</div>
))}
</div>
);
}
if (reportType === "rebate" && root) {
const byType = asRows(root.by_type);
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[
["accrued_total", t("settlementReports.rebate.accrued", { defaultValue: "应计" })],
["in_bill_total", t("settlementReports.rebate.inBill", { defaultValue: "已入账单" })],
["settled_total", t("settlementReports.rebate.settled", { defaultValue: "已结算" })],
["allocated_total", t("settlementReports.rebate.allocated", { defaultValue: "已分摊" })],
].map(([key, label]) => (
<div key={key} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{money(root[key], currencyCode)}</div>
</div>
))}
</div>
{byType.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("settlementReports.columns.rebateType", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("settlementReports.columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead className="text-right">{t("settlementReports.columns.amount", { defaultValue: "金额" })}</TableHead>
<TableHead className="text-right">{t("settlementReports.columns.count", { defaultValue: "笔数" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byType.map((row, idx) => (
<TableRow key={`${row.rebate_type}-${row.status}-${idx}`}>
<TableCell>{String(row.rebate_type ?? "")}</TableCell>
<TableCell>{String(row.status ?? "")}</TableCell>
<TableCell className="text-right tabular-nums">{money(row.total, currencyCode)}</TableCell>
<TableCell className="text-right tabular-nums">{String(row.count ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</div>
);
}
if (reportType === "credit" && root) {
const agents = asRows(root.agents);
const players = asRows(root.players);
return (
<div className="space-y-6">
<div>
<p className="mb-2 text-sm font-medium">{t("settlementReports.credit.agents", { defaultValue: "代理授信" })}</p>
<ReportTable
rows={agents}
columns={[
{ key: "code", header: t("settlementReports.columns.code", { defaultValue: "编码" }) },
{ key: "name", header: t("settlementReports.columns.name", { defaultValue: "名称" }) },
{ key: "credit_limit", header: t("settlementReports.columns.creditLimit", { defaultValue: "授信" }), creditMajor: true },
{ key: "allocated_credit", header: t("settlementReports.columns.allocated", { defaultValue: "已下发" }), creditMajor: true },
{ key: "available_credit", header: t("settlementReports.columns.available", { defaultValue: "可用" }), creditMajor: true },
]}
currencyCode={currencyCode}
/>
</div>
<div>
<p className="mb-2 text-sm font-medium">{t("settlementReports.credit.players", { defaultValue: "玩家授信" })}</p>
<ReportTable
rows={players}
columns={[
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
{ key: "credit_limit", header: t("settlementReports.columns.creditLimit", { defaultValue: "授信" }), creditMajor: true },
{ key: "used_credit", header: t("settlementReports.columns.used", { defaultValue: "已用" }), creditMajor: true },
{ key: "frozen_credit", header: t("settlementReports.columns.frozen", { defaultValue: "冻结" }), creditMajor: true },
{ key: "available_credit", header: t("settlementReports.columns.available", { defaultValue: "可用" }), creditMajor: true },
]}
currencyCode={currencyCode}
/>
</div>
</div>
);
}
if (reportType === "platform_pnl" && root) {
if (root.error) {
return (
<p className="text-sm text-amber-800">
{t("settlementReports.platformPnl.periodRequired", {
defaultValue: "请选择具体账期后查看平台盈亏(需 settlement_period_id。",
})}
</p>
);
}
const stats = [
{ label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), value: money(root.platform_bill_net, currencyCode) },
{
label: t("settlementReports.platformPnl.rounding", { defaultValue: "尾差调整" }),
value: money(root.platform_rounding_adjustment, currencyCode),
},
{
label: t("settlementReports.platformPnl.shareProfit", { defaultValue: "占成利润(元数据)" }),
value: money(root.share_profit_meta, currencyCode),
},
];
return (
<div className="grid gap-3 sm:grid-cols-3">
{stats.map((item) => (
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
</div>
))}
</div>
);
}
const items = asRows(root?.items ?? (reportType === "player_win_loss" || reportType === "agent_share" || reportType === "unpaid_bills" || reportType === "overdue" || reportType === "draw_period" ? data : null));
const columnSets: Record<string, { key: string; header: string; money?: boolean }[]> = {
player_win_loss: [
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
{ key: "game_type", header: t("settlementReports.columns.gameType", { defaultValue: "玩法" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
],
agent_share: [
{ key: "agent_node_id", header: t("settlementReports.columns.agentId", { defaultValue: "代理 ID" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
],
unpaid_bills: [
{ key: "bill_id", header: t("settlementReports.columns.billId", { defaultValue: "账单" }) },
{ key: "bill_type", header: t("settlementReports.columns.billType", { defaultValue: "类型" }) },
{ key: "unpaid_amount", header: t("settlementReports.columns.unpaid", { defaultValue: "未结" }), money: true },
{ key: "status", header: t("settlementReports.columns.status", { defaultValue: "状态" }) },
],
overdue: [
{ key: "bill_id", header: t("settlementReports.columns.billId", { defaultValue: "账单" }) },
{ key: "overdue_days", header: t("settlementReports.columns.overdueDays", { defaultValue: "逾期天数" }) },
{ key: "unpaid_amount", header: t("settlementReports.columns.unpaid", { defaultValue: "未结" }), money: true },
],
draw_period: [
{ key: "draw_no", header: t("settlementReports.columns.drawNo", { defaultValue: "期号" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
],
};
const columns = columnSets[reportType];
if (!columns) {
return (
<AdminNoResourceState className="text-sm text-muted-foreground" />
);
}
return <ReportTable rows={items} columns={columns} currencyCode={currencyCode} />;
}
function ReportTable({
rows,
columns,
currencyCode,
}: {
rows: Record<string, unknown>[];
columns: { key: string; header: string; money?: boolean; creditMajor?: boolean }[];
currencyCode: string;
}): React.ReactElement {
const { t } = useTranslation("common");
if (rows.length === 0) {
return <AdminNoResourceState className="text-sm text-muted-foreground" />;
}
return (
<div className="admin-table-shell max-h-96 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
key={col.key}
className={col.money || col.creditMajor ? "text-right" : undefined}
>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={idx}>
{columns.map((col) => (
<TableCell
key={col.key}
className={col.money || col.creditMajor ? "text-right tabular-nums" : undefined}
>
{col.creditMajor
? creditMoney(row[col.key], currencyCode)
: col.money
? money(row[col.key], currencyCode)
: String(row[col.key] ?? "—")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getAgentSettlementReport,
type AgentSettlementReportResponse,
type AgentSettlementReportType,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AgentSettlementReportView } from "@/modules/settlement/agent-settlement-report-view";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const REPORT_TYPES: AgentSettlementReportType[] = [
"summary",
"player_win_loss",
"agent_share",
"rebate",
"credit",
"unpaid_bills",
"overdue",
"platform_pnl",
"draw_period",
];
type AgentSettlementReportsPanelProps = {
adminSiteId: number;
settlementPeriodId: number | null;
currencyCode: string;
};
export function AgentSettlementReportsPanel({
adminSiteId,
settlementPeriodId,
currencyCode,
}: AgentSettlementReportsPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [reportType, setReportType] = useState<AgentSettlementReportType>("summary");
const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAgentSettlementReport({
type: reportType,
settlement_period_id: settlementPeriodId ?? undefined,
admin_site_id: adminSiteId,
});
setResponse(res);
} finally {
setLoading(false);
}
}, [adminSiteId, reportType, settlementPeriodId]);
useEffect(() => {
void load();
}, [load]);
return (
<div className="space-y-4 rounded-lg border border-border/60 p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>{t("settlementReports.type", { defaultValue: "报表类型" })}</Label>
<Select
value={reportType}
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
>
<SelectTrigger className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((key) => (
<SelectItem key={key} value={key}>
{t(`settlementReports.types.${key}`, { defaultValue: key })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{settlementPeriodId === null ? (
<p className="text-xs text-muted-foreground pb-1">
{t("settlementReports.noPeriodHint", {
defaultValue: "未选具体账期时使用近 7 日区间;平台盈亏需选择账期。",
})}
</p>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{t("settlementReports.footnote", {
defaultValue: "本组报表为信用占成盘账期口径,与「佣金/回水」旧钱包报表不同。",
})}
</p>
{loading ? (
<AdminLoadingState minHeight="8rem" />
) : response ? (
<AgentSettlementReportView
reportType={reportType}
data={response.data}
currencyCode={currencyCode}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementAdjustmentRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementAdjustmentsTableProps = {
rows: SettlementAdjustmentRow[];
loading: boolean;
currencyCode: string;
onOpenBill: (billId: number) => void;
};
export function SettlementAdjustmentsTable({
rows,
loading,
currencyCode,
onOpenBill,
}: SettlementAdjustmentsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.adjustmentType", { defaultValue: "调账类型" })}</TableHead>
<TableHead>{t("columns.originalBill", { defaultValue: "原账单" })}</TableHead>
<TableHead className="text-right">{t("columns.amount", { defaultValue: "调整金额" })}</TableHead>
<TableHead>{t("columns.reason", { defaultValue: "原因" })}</TableHead>
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>
{t(`adjustmentType.${row.adjustment_type}`, {
defaultValue: row.adjustment_type,
})}
</TableCell>
<TableCell className="tabular-nums">
{row.original_bill_id != null ? `#${row.original_bill_id}` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm">{row.reason ?? "—"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.created_at ?? "—"}</TableCell>
<TableCell>
{row.original_bill_id != null ? (
<button
type="button"
className="text-sm text-primary underline"
onClick={() => onOpenBill(row.original_bill_id!)}
>
{t("actions.viewBill", { defaultValue: "查看原账单" })}
</button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementAdjustmentRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
type SettlementBadDebtTableProps = {
rows: SettlementAdjustmentRow[];
loading: boolean;
currencyCode: string;
onOpenBill: (billId: number) => void;
};
export function SettlementBadDebtTable({
rows,
loading,
currencyCode,
onOpenBill,
}: SettlementBadDebtTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto rounded-lg border border-border/60">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.originalBill", { defaultValue: "原账单" })}</TableHead>
<TableHead className="text-right">{t("columns.badDebtAmount", { defaultValue: "核销金额" })}</TableHead>
<TableHead>{t("columns.reason", { defaultValue: "原因" })}</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-sm text-muted-foreground">
{row.period_start && row.period_end
? formatSettlementPeriodSpan(row.period_start, row.period_end)
: "—"}
</TableCell>
<TableCell className="tabular-nums">#{row.original_bill_id ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">
{row.reason?.trim() || "—"}
</TableCell>
<TableCell className="text-right">
{row.original_bill_id != null ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onOpenBill(row.original_bill_id!)}
>
{t("actions.viewBill", { defaultValue: "查看账单" })}
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -388,14 +389,10 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
onPageChange={setPage}
/>
</>
) : loading ? (
<AdminLoadingInline label={t("loadingDetails")} />
) : (
<p className="text-muted-foreground text-sm">
{loading ? (
<AdminLoadingInline label={t("loadingDetails")} />
) : (
t("states.noData", { ns: "common" })
)}
</p>
<AdminNoResourceState className="py-6" />
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,140 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementBills,
type SettlementBillListScope,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
export type BillCategory = "all" | "player" | "agent" | "pending_confirm" | "awaiting_payment";
const CATEGORY_OPTIONS: { value: BillCategory; labelKey: string }[] = [
{ value: "all", labelKey: "billsPanel.category.all" },
{ value: "player", labelKey: "billsPanel.category.player" },
{ value: "agent", labelKey: "billsPanel.category.agent" },
{ value: "pending_confirm", labelKey: "billsPanel.category.pendingConfirm" },
{ value: "awaiting_payment", labelKey: "billsPanel.category.awaitingPayment" },
];
function categoryQuery(category: BillCategory): {
bill_type?: string;
scope?: SettlementBillListScope;
} {
switch (category) {
case "player":
return { bill_type: "player" };
case "agent":
return { bill_type: "agent" };
case "pending_confirm":
return { scope: "pending_confirm" };
case "awaiting_payment":
return { scope: "awaiting_payment" };
default:
return {};
}
}
type SettlementBillsPanelProps = {
adminSiteId: number;
periodFilter: AgentSettlementPeriodFilter;
currencyCode: string;
onOpenDetail: (billId: number) => void;
initialCategory?: BillCategory;
refreshKey?: number;
};
export function SettlementBillsPanel({
adminSiteId,
periodFilter,
currencyCode,
onOpenDetail,
initialCategory = "all",
refreshKey = 0,
}: SettlementBillsPanelProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const [category, setCategory] = useState<BillCategory>(initialCategory);
useEffect(() => {
setCategory(initialCategory);
}, [initialCategory]);
const [rows, setRows] = useState<SettlementBillRow[]>([]);
const [loading, setLoading] = useState(true);
const periodId = periodFilter === "all" ? undefined : periodFilter;
const load = useCallback(async () => {
setLoading(true);
try {
const q = categoryQuery(category);
const data = await getSettlementBills({
admin_site_id: adminSiteId,
settlement_period_id: periodId,
bill_type: q.bill_type,
scope: q.scope,
});
setRows(data.items ?? []);
} catch (err: unknown) {
setRows([]);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("errors.loadBills", { defaultValue: "账单加载失败" }),
);
} finally {
setLoading(false);
}
}, [adminSiteId, category, periodId, t]);
useAsyncEffect(() => {
void load();
}, [load, refreshKey]);
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("billsPanel.intro", {
defaultValue: "关账后生成的占成账单;可按类型与状态筛选,行内打开详情进行确认与收付。",
})}
</p>
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
{CATEGORY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setCategory(opt.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
category === opt.value
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{t(opt.labelKey, { defaultValue: opt.value })}
</button>
))}
</div>
{loading && rows.length === 0 ? (
<AdminLoadingState />
) : (
<SettlementBillsTable
rows={rows}
loading={loading}
currencyCode={currencyCode}
onOpenDetail={onOpenDetail}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import {
settlementBillStatusLabel,
settlementBillTypeLabel,
} from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementBillsTableProps = {
rows: SettlementBillRow[];
loading: boolean;
currencyCode: string;
onOpenDetail: (billId: number) => void;
};
export function SettlementBillsTable({
rows,
loading,
currencyCode,
onOpenDetail,
}: SettlementBillsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
<TableHead>{t("columns.counterparty", { defaultValue: "对方" })}</TableHead>
<TableHead className="text-right">{t("columns.gross", { defaultValue: "输赢" })}</TableHead>
<TableHead className="text-right">{t("columns.net", { defaultValue: "净额" })}</TableHead>
<TableHead className="text-right">{t("columns.paid", { defaultValue: "已收付" })}</TableHead>
<TableHead className="text-right">{t("columns.unpaid", { defaultValue: "未结" })}</TableHead>
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<span>{row.owner_label ?? `${row.owner_type}#${row.owner_id}`}</span>
{row.owner_type === "player" && row.owner_funding_mode ? (
<PlayerFundingModeBadge
row={{
funding_mode: row.owner_funding_mode,
uses_credit: row.owner_funding_mode === "credit",
}}
/>
) : null}
</div>
</TableCell>
<TableCell>
{row.counterparty_label === "platform"
? t("agents:settlementBills.platform", { defaultValue: "平台" })
: row.counterparty_label ?? `${row.counterparty_type}#${row.counterparty_id}`}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{row.gross_win_loss != null
? formatDashboardMoneyMinor(row.gross_win_loss, currencyCode)
: "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.net_amount, currencyCode)}
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
</TableCell>
<TableCell>{settlementBillStatusLabel(row.status, t)}</TableCell>
<TableCell>
<button
type="button"
className="text-sm text-primary underline"
onClick={() => onOpenDetail(row.id)}
>
{t("actions.detail", { defaultValue: "详情 / 收付" })}
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
/** @deprecated 使用 SettlementCenterShell */
export function SettlementCenterConsole(): React.ReactElement {
return <SettlementCenterShell />;
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export type SettlementCenterSection =
| "overview"
| "periods"
| "ledger"
| "bills";
type TabDef = {
key: SettlementCenterSection;
labelKey: string;
defaultLabel: string;
group: "hub" | "finance";
badge?: string;
};
type SettlementCenterNavProps = {
active: SettlementCenterSection;
onChange: (section: SettlementCenterSection) => void;
counts: {
pendingConfirm: number;
awaitingPayment: number;
};
siteSelector?: React.ReactNode;
};
const TABS: TabDef[] = [
{ key: "overview", labelKey: "nav.overview", defaultLabel: "概览", group: "hub" },
{ key: "periods", labelKey: "nav.periods", defaultLabel: "账期管理", group: "hub" },
{ key: "ledger", labelKey: "nav.ledger", defaultLabel: "账务流水", group: "finance" },
{
key: "bills",
labelKey: "nav.bills",
defaultLabel: "账单",
group: "finance",
},
];
export function SettlementCenterNav({
active,
onChange,
counts,
siteSelector,
}: SettlementCenterNavProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const billBadge =
counts.pendingConfirm + counts.awaitingPayment > 0
? String(counts.pendingConfirm + counts.awaitingPayment)
: undefined;
const hubTabs = TABS.filter((tab) => tab.group === "hub");
const financeTabs = TABS.filter((tab) => tab.group === "finance");
function renderTab(tab: TabDef, showSeparatorBefore: boolean): React.ReactElement {
const isActive = active === tab.key;
const badge = tab.key === "bills" ? billBadge : tab.badge;
return (
<span key={tab.key} className="inline-flex items-center">
{showSeparatorBefore ? (
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
) : null}
<button
type="button"
onClick={() => onChange(tab.key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey, { defaultValue: tab.defaultLabel })}
{badge ? (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-amber-900">
{badge}
</span>
) : null}
</button>
</span>
);
}
return (
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
<nav
aria-label={t("subnav.label", { defaultValue: "结算中心导航" })}
className="inline-flex max-w-full flex-wrap items-center gap-1"
>
{hubTabs.map((tab) => renderTab(tab, false))}
{financeTabs.map((tab, index) => renderTab(tab, index === 0))}
</nav>
{siteSelector ?? null}
</div>
);
}

View File

@@ -0,0 +1,439 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarClock, CircleDollarSign, ClipboardCheck, Landmark } from "lucide-react";
import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { AgentPeriodsConsole } from "@/modules/settlement/agent-periods-console";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import {
SettlementCenterNav,
type SettlementCenterSection,
} from "@/modules/settlement/settlement-center-nav";
import {
SettlementBillsPanel,
type BillCategory,
} from "@/modules/settlement/settlement-bills-panel";
import { SettlementLedgerPanel } from "@/modules/settlement/settlement-ledger-panel";
import { SettlementPeriodToolbar } from "@/modules/settlement/settlement-period-toolbar";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAdminProfile } from "@/stores/admin-session";
type SiteOption = { id: number; label: string; currency_code: string };
function pickDefaultPeriodId(periods: SettlementPeriodRow[]): number | "all" {
const closed = periods
.filter((row) => row.status === "closed" || row.status === "completed")
.sort((a, b) => b.id - a.id);
if (closed[0]) {
return closed[0].id;
}
const open = periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id);
if (open[0]) {
return open[0].id;
}
return "all";
}
function sectionTitle(
section: SettlementCenterSection,
t: ReturnType<typeof useTranslation<["settlementCenter", "agents", "common"]>>["t"],
): string {
switch (section) {
case "overview":
return t("panels.overview.title", { defaultValue: "结算概览" });
case "periods":
return t("nav.periods", { defaultValue: "账期管理" });
case "ledger":
return t("panels.ledger.title", { defaultValue: "账务流水" });
case "bills":
return t("panels.bills.title", { defaultValue: "账单" });
default:
return "";
}
}
export function SettlementCenterShell(): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const canManagePeriods =
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const [activeSection, setActiveSection] = useState<SettlementCenterSection>("overview");
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
const [periodFilter, setPeriodFilter] = useState<AgentSettlementPeriodFilter>("all");
const [periodFilterReady, setPeriodFilterReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
const [listRevision, setListRevision] = useState(0);
useEffect(() => {
if (boundAgent?.admin_site_id) {
const label = boundAgent.name
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
: boundAgent.code;
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
setAdminSiteId(boundAgent.admin_site_id);
return;
}
void getAdminIntegrationSites().then((sites) => {
const options = (sites.items ?? []).map((site) => ({
id: site.id,
label: site.name ? `${site.name} (${site.code})` : site.code,
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
if (adminSiteId === null && options[0]) {
setAdminSiteId(options[0].id);
}
});
}, [adminSiteId, boundAgent]);
const loadPeriods = useCallback(async () => {
if (adminSiteId === null) {
setPeriods([]);
return;
}
try {
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
setPeriods(data.items ?? []);
} catch {
setPeriods([]);
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
}
}, [adminSiteId, t]);
useEffect(() => {
if (canManagePeriods || adminSiteId === null) {
return;
}
void loadPeriods();
}, [adminSiteId, canManagePeriods, loadPeriods]);
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
setPeriods(items);
}, []);
useEffect(() => {
if (periodFilterReady || adminSiteId === null) {
return;
}
setPeriodFilter(periods.length === 0 ? "all" : pickDefaultPeriodId(periods));
setPeriodFilterReady(true);
}, [adminSiteId, periodFilterReady, periods]);
const activeCurrency =
siteOptions.find((site) => site.id === adminSiteId)?.currency_code ?? "NPR";
const openPeriod = useMemo(
() => periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id)[0] ?? null,
[periods],
);
const summaryTotals = useMemo(
() =>
periods.reduce(
(acc, row) => {
acc.pendingConfirm += row.summary?.pending_confirm ?? 0;
acc.awaitingPayment += row.summary?.awaiting_payment ?? 0;
acc.totalUnpaid += row.summary?.total_unpaid ?? 0;
return acc;
},
{ pendingConfirm: 0, awaitingPayment: 0, totalUnpaid: 0 },
),
[periods],
);
const handlePeriodClosed = useCallback(
(result?: { unsettled_ticket_count?: number }) => {
void loadPeriods();
setActiveSection("bills");
setBillsInitialCategory("pending_confirm");
setListRevision((n) => n + 1);
const unsettled = result?.unsettled_ticket_count ?? 0;
if (unsettled > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "账期已关账;仍有 {{count}} 笔注单未结算,请尽快处理。",
count: unsettled,
}),
);
} else {
toast.success(t("toast.periodClosed", { defaultValue: "账期已关账" }));
}
},
[loadPeriods, t],
);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSiteLabel = siteOptions.find((site) => site.id === selectSiteId)?.label ?? null;
const panelTitle = sectionTitle(activeSection, t);
const allPeriodsCompleted =
periods.length > 0 && periods.every((row) => row.status === "completed");
const showPeriodToolbar =
(activeSection === "ledger" || activeSection === "bills") && periods.length > 0;
const selectedPeriod =
periodFilter !== "all" ? (periods.find((row) => row.id === periodFilter) ?? null) : openPeriod;
const pipelineCounts = selectedPeriod?.pipeline ?? {
credit_ledger_count: 0,
share_ledger_count: 0,
};
const overviewStats = [
{
label: t("overview.pendingConfirm", { defaultValue: "待确认" }),
value: String(summaryTotals.pendingConfirm),
icon: ClipboardCheck,
},
{
label: t("overview.awaitingPayment", { defaultValue: "待收付" }),
value: String(summaryTotals.awaitingPayment),
icon: CircleDollarSign,
},
{
label: t("overview.totalUnpaid", { defaultValue: "未结合计" }),
value: formatDashboardMoneyMinor(summaryTotals.totalUnpaid, activeCurrency),
icon: Landmark,
},
{
label: t("overview.openPeriod", { defaultValue: "进行中账期" }),
value: openPeriod
? formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end)
: "—",
icon: CalendarClock,
},
{
label: t("overview.creditLedger", { defaultValue: "信用流水(账期内)" }),
value: String(pipelineCounts.credit_ledger_count),
icon: CalendarClock,
},
{
label: t("overview.shareLedger", { defaultValue: "占成流水(账期内)" }),
value: String(pipelineCounts.share_ledger_count),
icon: CalendarClock,
},
];
function renderMainPanel(): React.ReactElement {
if (activeSection === "overview") {
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">
{t("overview.pipelineHint", {
defaultValue: "账单须关账后生成;下方为账期内实时流水笔数。",
})}
</p>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<button
key={stat.label}
type="button"
className="rounded-xl border border-border/70 bg-card px-4 py-4 text-left transition-colors hover:border-primary/30 hover:bg-muted/30"
onClick={() => {
if (stat.label === t("overview.pendingConfirm", { defaultValue: "待确认" })) {
setBillsInitialCategory("pending_confirm");
setActiveSection("bills");
} else if (
stat.label === t("overview.awaitingPayment", { defaultValue: "待收付" })
) {
setBillsInitialCategory("awaiting_payment");
setActiveSection("bills");
} else if (
stat.label === t("overview.creditLedger", { defaultValue: "信用流水(账期内)" })
) {
setActiveSection("ledger");
}
}}
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<Icon className="size-4 text-muted-foreground" />
</div>
<p className="mt-2 text-base font-semibold tabular-nums">{stat.value}</p>
</button>
);
})}
</div>
</div>
);
}
if (activeSection === "periods" && adminSiteId !== null) {
return (
<AgentPeriodsConsole
adminSiteId={adminSiteId}
canManagePeriods={canManagePeriods}
settlementCycle="weekly"
siteCurrencyCode={activeCurrency}
embedded
onPeriodsChange={handlePeriodsChange}
onPeriodClosed={handlePeriodClosed}
/>
);
}
if (activeSection === "ledger" && adminSiteId !== null && periodFilterReady) {
return (
<SettlementLedgerPanel
adminSiteId={adminSiteId}
periodFilter={periodFilter}
currencyCode={activeCurrency}
canManage={canManagePeriods}
onOpenBill={setDetailBillId}
refreshKey={listRevision}
/>
);
}
if (activeSection === "bills" && adminSiteId !== null && periodFilterReady) {
return (
<SettlementBillsPanel
adminSiteId={adminSiteId}
periodFilter={periodFilter}
currencyCode={activeCurrency}
onOpenDetail={setDetailBillId}
initialCategory={billsInitialCategory}
refreshKey={listRevision}
/>
);
}
return <AdminNoResourceState />;
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold tracking-tight">
{t("title", { defaultValue: "结算中心" })}
</h1>
<AdminStatusBadge
status={openPeriod ? "processing" : allPeriodsCompleted ? "completed" : "idle"}
>
{openPeriod
? t("header.statusRunning", { defaultValue: "账期进行中" })
: allPeriodsCompleted
? t("header.statusCompleted", { defaultValue: "账期已结清" })
: t("header.statusIdle", { defaultValue: "等待开期" })}
</AdminStatusBadge>
</div>
<p className="text-sm text-muted-foreground">
{t("header.subtitle", { defaultValue: "信用占成账务" })}
</p>
</div>
{siteOptions.length <= 1 && selectedSiteLabel ? (
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
) : null}
</header>
{adminSiteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择接入站点。" })}</p>
) : (
<div className="min-w-0 space-y-4">
<SettlementCenterNav
active={activeSection}
onChange={(section) => {
if (section === "bills") {
setBillsInitialCategory("all");
}
setActiveSection(section);
}}
counts={{
pendingConfirm: summaryTotals.pendingConfirm,
awaitingPayment: summaryTotals.awaitingPayment,
}}
siteSelector={
siteOptions.length > 1 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => {
setAdminSiteId(Number(value));
setPeriodFilter("all");
setPeriodFilterReady(false);
}}
>
<SelectTrigger className="h-9 w-[220px] bg-background">
<SelectValue>{selectedSiteLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null
}
/>
{showPeriodToolbar && periodFilterReady ? (
<SettlementPeriodToolbar
periods={periods}
value={periodFilter}
onChange={(next) => {
setPeriodFilter(next);
setPeriodFilterReady(true);
}}
/>
) : null}
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
</div>
)}
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{t("actions.billDetail", { defaultValue: "账单详情 · 确认 / 收付" })}
</DialogTitle>
</DialogHeader>
{detailBillId !== null ? (
<AgentBillDetail
billId={detailBillId}
currencyCode={activeCurrency}
canManage={canManagePeriods}
onUpdated={() => {
void loadPeriods();
setListRevision((n) => n + 1);
}}
/>
) : null}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { SettlementLedgerRowActions } from "@/modules/settlement/settlement-ledger-row-actions";
import {
creditLedgerReasonLabel,
settlementAdjustmentTypeLabel,
settlementBillStatusLabel,
} from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementCreditLedgerTableProps = {
rows: SettlementLedgerRow[];
loading: boolean;
currencyCode: string;
canManage: boolean;
onOpenBill: (billId: number) => void;
onRefresh: () => void;
showStatusColumn?: boolean;
};
function ledgerBizLabel(
row: SettlementLedgerRow,
t: ReturnType<typeof useTranslation<["settlementCenter", "agents"]>>["t"],
): string {
if (row.entry_kind === "payment") {
return t("creditLedger.reason.payment_record", { defaultValue: "账单收付" });
}
if (row.entry_kind === "adjustment") {
return settlementAdjustmentTypeLabel(row.biz_type, t);
}
return creditLedgerReasonLabel(row.biz_type, t);
}
function ledgerSourceForBadge(row: SettlementLedgerRow): string | null {
if (row.entry_kind === "credit") {
return "credit_ledger";
}
if (row.entry_kind === "payment") {
return "wallet_txn";
}
return null;
}
export function SettlementCreditLedgerTable({
rows,
loading,
currencyCode,
canManage,
onOpenBill,
onRefresh,
showStatusColumn = false,
}: SettlementCreditLedgerTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const formatDt = useAdminDateTimeFormatter();
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</TableHead>
<TableHead>{t("creditLedger.columns.player", { defaultValue: "玩家" })}</TableHead>
<TableHead>{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</TableHead>
<TableHead>{t("creditLedger.columns.ref", { defaultValue: "关联" })}</TableHead>
<TableHead className="text-right">
{t("creditLedger.columns.amount", { defaultValue: "金额" })}
</TableHead>
<TableHead>{t("creditLedger.columns.channel", { defaultValue: "渠道" })}</TableHead>
{showStatusColumn ? (
<TableHead>{t("creditLedger.columns.status", { defaultValue: "状态" })}</TableHead>
) : null}
<TableHead>{t("creditLedger.columns.time", { defaultValue: "时间" })}</TableHead>
<TableHead className="sticky right-0 z-10 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const signed = row.signed_amount ?? (row.direction === 1 ? row.amount : -row.amount);
const playerLabel =
row.username?.trim() ||
row.nickname?.trim() ||
row.site_player_id?.trim() ||
`#${row.player_id}`;
const badgeSource = ledgerSourceForBadge(row);
return (
<TableRow key={row.row_key ?? `${row.entry_kind}-${row.id}`}>
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
<TableCell>
<span className="font-medium">{playerLabel}</span>
<span className="ml-1 text-xs text-muted-foreground">#{row.player_id}</span>
</TableCell>
<TableCell>{ledgerBizLabel(row, t)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.biz_no ?? (row.settlement_bill_id ? `bill#${row.settlement_bill_id}` : "—")}
</TableCell>
<TableCell
className={`text-right tabular-nums font-medium ${signed < 0 ? "text-destructive" : "text-emerald-700"}`}
>
{signed < 0 ? "" : "+"}
{formatDashboardMoneyMinor(Math.abs(signed), row.currency_code || currencyCode)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{badgeSource ? (
<PlayerLedgerSourceBadge ledgerSource={badgeSource} />
) : (
t("creditLedger.entryKind.adjustment", { defaultValue: "调账流水" })
)}
</TableCell>
{showStatusColumn ? (
<TableCell>
{row.bill_status ? (
<AdminStatusBadge status={row.bill_status}>
{settlementBillStatusLabel(row.bill_status, t)}
</AdminStatusBadge>
) : (
<AdminStatusBadge status="posted">
{t("ledgerPanel.rowPosted", { defaultValue: "已记账" })}
</AdminStatusBadge>
)}
</TableCell>
) : null}
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{row.created_at ? formatDt(row.created_at) : "—"}
</TableCell>
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
<SettlementLedgerRowActions
row={row}
canManage={canManage}
onOpenBill={onOpenBill}
onRefresh={onRefresh}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,378 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCreditLedger, type SettlementLedgerRow } from "@/api/admin-agent-settlement";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementCreditLedgerTable } from "@/modules/settlement/settlement-credit-ledger-table";
import {
creditLedgerReasonLabel,
settlementAdjustmentTypeLabel,
settlementBillStatusLabel,
} from "@/modules/settlement/settlement-status-label";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
export type LedgerCategory =
| "all"
| "credit"
| "payment"
| "adjustment"
| "bad_debt"
| "actionable";
type LedgerFilters = {
txnNo: string;
playerAccount: string;
playerId: string;
bizType: string;
billStatus: string;
createdFrom: string;
createdTo: string;
};
const emptyFilters: LedgerFilters = {
txnNo: "",
playerAccount: "",
playerId: "",
bizType: "",
billStatus: "",
createdFrom: "",
createdTo: "",
};
/** 下拉「不限」哨兵;请求时转为空串 */
const FILTER_ALL = "__all__";
function ledgerFilterSelectLabel(
raw: unknown,
t: ReturnType<typeof useTranslation<"settlementCenter">>["t"],
kind: "biz" | "billStatus",
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === FILTER_ALL) {
return t("ledgerPanel.filterAll", { defaultValue: "不限" });
}
if (kind === "billStatus") {
return settlementBillStatusLabel(v, t);
}
if (v === "adjustment" || v === "reversal" || v === "bad_debt") {
return settlementAdjustmentTypeLabel(v, t);
}
return creditLedgerReasonLabel(v, t);
}
/** 与流水 biz_type / adjustment_type 一致 */
const CREDIT_BIZ_OPTIONS = [
"bet_hold",
"bet_hold_release",
"game_settlement_loss",
"settlement_confirm",
"payment_record",
"adjustment",
"reversal",
"bad_debt",
] as const;
/** 与 settlement_bills.status 一致 */
const BILL_STATUS_OPTIONS = [
"pending_confirm",
"confirmed",
"partial_paid",
"settled",
"overdue",
"reversed",
] as const;
const CATEGORY_OPTIONS: { value: LedgerCategory; labelKey: string }[] = [
{ value: "all", labelKey: "ledgerPanel.category.all" },
{ value: "credit", labelKey: "ledgerPanel.category.credit" },
{ value: "payment", labelKey: "ledgerPanel.category.payment" },
{ value: "adjustment", labelKey: "ledgerPanel.category.adjustment" },
{ value: "bad_debt", labelKey: "ledgerPanel.category.badDebt" },
{ value: "actionable", labelKey: "ledgerPanel.category.actionable" },
];
function categoryQueryParams(category: LedgerCategory): Record<string, string | boolean | undefined> {
switch (category) {
case "credit":
return { entry_kind: "credit" };
case "payment":
return { entry_kind: "payment" };
case "adjustment":
return { entry_kind: "adjustment" };
case "bad_debt":
return { bad_debt_only: true };
case "actionable":
return { actionable_only: true };
default:
return {};
}
}
type SettlementLedgerPanelProps = {
adminSiteId: number;
periodFilter: AgentSettlementPeriodFilter;
currencyCode: string;
canManage: boolean;
onOpenBill: (billId: number) => void;
refreshKey?: number;
};
export function SettlementLedgerPanel({
adminSiteId,
periodFilter,
currencyCode,
canManage,
onOpenBill,
refreshKey = 0,
}: SettlementLedgerPanelProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "common"]);
const [category, setCategory] = useState<LedgerCategory>("all");
const [draft, setDraft] = useState<LedgerFilters>(emptyFilters);
const [applied, setApplied] = useState<LedgerFilters>(emptyFilters);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [rows, setRows] = useState<SettlementLedgerRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const periodId = periodFilter === "all" ? undefined : periodFilter;
const load = useCallback(async () => {
setLoading(true);
try {
const player_id =
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const data = await getCreditLedger({
admin_site_id: adminSiteId,
settlement_period_id: periodId,
page,
per_page: perPage,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
? player_id
: undefined,
txn_no: applied.txnNo.trim() || undefined,
player_account: applied.playerAccount.trim() || undefined,
reason: applied.bizType.trim() || undefined,
bill_status: applied.billStatus.trim() || undefined,
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
...categoryQueryParams(category),
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
} catch (err: unknown) {
setRows([]);
setTotal(0);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("errors.loadCreditLedger", { defaultValue: "账务流水加载失败" }),
);
} finally {
setLoading(false);
}
}, [adminSiteId, applied, category, page, perPage, periodId, t]);
useAsyncEffect(() => {
void load();
}, [load, refreshKey]);
const runSearch = () => {
setApplied({ ...draft });
setPage(1);
};
const resetFilters = () => {
setDraft(emptyFilters);
setApplied(emptyFilters);
setPage(1);
};
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("creditLedger.intro")}</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="sl-txn">{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</Label>
<Input
id="sl-txn"
placeholder={t("ledgerPanel.search", { defaultValue: "搜索" })}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-account">{t("ledgerPanel.playerAccount", { defaultValue: "玩家账号" })}</Label>
<Input
id="sl-account"
placeholder={t("ledgerPanel.playerAccountPh", { defaultValue: "用户名 / 站点玩家 ID" })}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-player">{t("ledgerPanel.playerId", { defaultValue: "玩家 ID" })}</Label>
<Input
id="sl-player"
inputMode="numeric"
placeholder={t("ledgerPanel.optional", { defaultValue: "可选" })}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-biz">{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</Label>
<Select
modal={false}
value={draft.bizType === "" ? FILTER_ALL : draft.bizType}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="sl-biz" className="h-9 w-full">
<SelectValue>
{(v) => ledgerFilterSelectLabel(v, t, "biz")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
</SelectItem>
{CREDIT_BIZ_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{ledgerFilterSelectLabel(value, t, "biz")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-bill-status">{t("ledgerPanel.billStatus", { defaultValue: "账单状态" })}</Label>
<Select
modal={false}
value={draft.billStatus === "" ? FILTER_ALL : draft.billStatus}
onValueChange={(v) =>
setDraft((d) => ({
...d,
billStatus: v == null || v === FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="sl-bill-status" className="h-9 w-full">
<SelectValue>
{(v) => ledgerFilterSelectLabel(v, t, "billStatus")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
</SelectItem>
{BILL_STATUS_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{ledgerFilterSelectLabel(value, t, "billStatus")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-2">
<AdminDateRangeField
id="sl-created-range"
label={t("ledgerPanel.dateRange", { defaultValue: "时间范围" })}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
</Button>
</div>
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
{CATEGORY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setCategory(opt.value);
setPage(1);
}}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
category === opt.value
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{t(opt.labelKey, { defaultValue: opt.value })}
</button>
))}
</div>
{loading && rows.length === 0 ? (
<AdminLoadingState />
) : (
<>
<SettlementCreditLedgerTable
rows={rows}
loading={loading}
currencyCode={currencyCode}
canManage={canManage}
onOpenBill={onOpenBill}
onRefresh={() => void load()}
showStatusColumn
/>
<AdminListPaginationFooter
selectId="settlement-ledger-per-page"
total={total}
page={page}
lastPage={Math.max(1, Math.ceil(total / Math.max(1, perPage)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import {
CircleDollarSign,
ClipboardCheck,
Eye,
SlidersHorizontal,
TriangleAlert,
Undo2,
User,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postSettlementBillConfirm } from "@/api/admin-agent-settlement";
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { LotteryApiBizError } from "@/types/api/errors";
type SettlementLedgerRowActionsProps = {
row: SettlementLedgerRow;
canManage: boolean;
onOpenBill: (billId: number) => void;
onRefresh: () => void;
};
export function SettlementLedgerRowActions({
row,
canManage,
onOpenBill,
onRefresh,
}: SettlementLedgerRowActionsProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const { request: requestConfirm, ConfirmDialog, busy } = useConfirmAction();
const billId = row.settlement_bill_id ?? null;
const actions = row.available_actions ?? [];
const show = (code: string): boolean => actions.includes(code);
const billAction = (code: string): boolean =>
canManage && billId !== null && show(code);
return (
<>
<AdminRowActionsMenu
busy={busy}
actions={[
{
key: "view_player",
label: t("creditLedger.actions.viewPlayer", { defaultValue: "玩家详情" }),
icon: User,
href: adminPlayerDetailPath(row.player_id),
hidden: !show("view_player"),
},
{
key: "view_bill",
label: t("creditLedger.actions.viewBill", { defaultValue: "账单详情" }),
icon: Eye,
onClick: () => onOpenBill(billId!),
hidden: !show("view_bill") || billId === null,
},
{
key: "confirm",
label: t("creditLedger.actions.confirm", { defaultValue: "确认账单" }),
icon: ClipboardCheck,
hidden: !billAction("confirm"),
onClick: () =>
requestConfirm({
title: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
description: t("creditLedger.actions.confirmDesc", {
defaultValue: "确认后账单进入待收付状态。",
}),
onConfirm: async () => {
try {
await postSettlementBillConfirm(billId!);
toast.success(
t("agents:settlementBills.confirmed", { defaultValue: "已确认" }),
);
onRefresh();
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("common:states.error", { defaultValue: "操作失败" }),
);
}
},
}),
},
{
key: "payment",
label: t("creditLedger.actions.payment", { defaultValue: "登记收付" }),
icon: CircleDollarSign,
hidden: !billAction("payment"),
onClick: () => onOpenBill(billId!),
},
{
key: "adjustment",
label: t("creditLedger.actions.adjustment", { defaultValue: "调账" }),
icon: SlidersHorizontal,
hidden: !billAction("adjustment"),
onClick: () => onOpenBill(billId!),
},
{
key: "reversal",
label: t("creditLedger.actions.reversal", { defaultValue: "冲正" }),
icon: Undo2,
hidden: !billAction("reversal"),
onClick: () => onOpenBill(billId!),
},
{
key: "bad_debt",
label: t("creditLedger.actions.badDebt", { defaultValue: "坏账核销" }),
icon: TriangleAlert,
destructive: true,
hidden: !billAction("bad_debt"),
onClick: () => onOpenBill(billId!),
},
]}
/>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementPaymentRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { settlementBillTypeLabel } from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementPaymentsTableProps = {
rows: SettlementPaymentRow[];
loading: boolean;
currencyCode: string;
onOpenBill: (billId: number) => void;
};
export function SettlementPaymentsTable({
rows,
loading,
currencyCode,
onOpenBill,
}: SettlementPaymentsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("columns.payer", { defaultValue: "付款方" })}</TableHead>
<TableHead>{t("columns.payee", { defaultValue: "收款方" })}</TableHead>
<TableHead className="text-right">{t("columns.amount", { defaultValue: "金额" })}</TableHead>
<TableHead>{t("columns.method", { defaultValue: "方式" })}</TableHead>
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell className="tabular-nums">#{row.settlement_bill_id}</TableCell>
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
<TableCell>
{row.payer_type}#{row.payer_id}
</TableCell>
<TableCell>
{row.payee_type === "platform"
? t("agents:settlementBills.platform", { defaultValue: "平台" })
: `${row.payee_type}#${row.payee_id}`}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell>{row.method ?? "—"}</TableCell>
<TableCell>{row.status}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.confirmed_at ?? row.created_at ?? "—"}
</TableCell>
<TableCell>
<button
type="button"
className="text-sm text-primary underline"
onClick={() => onOpenBill(row.settlement_bill_id)}
>
{t("actions.viewBill", { defaultValue: "查看账单" })}
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
import {
AgentSettlementPeriodSelect,
type AgentSettlementPeriodFilter,
} from "@/modules/settlement/agent-settlement-period-select";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
type SettlementPeriodToolbarProps = {
periods: SettlementPeriodRow[];
value: AgentSettlementPeriodFilter;
onChange: (next: AgentSettlementPeriodFilter) => void;
};
export function SettlementPeriodToolbar({
periods,
value,
onChange,
}: SettlementPeriodToolbarProps): React.ReactElement | null {
const { t } = useTranslation("settlementCenter");
if (periods.length === 0) {
return null;
}
const selected =
typeof value === "number" ? periods.find((row) => row.id === value) ?? null : null;
return (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
<span className="text-sm font-medium text-foreground">
{t("filters.period", { defaultValue: "账期范围" })}
</span>
<AgentSettlementPeriodSelect periods={periods} value={value} onChange={onChange} />
{selected ? (
<span className="text-sm text-muted-foreground">
{formatSettlementPeriodSpan(selected.period_start, selected.period_end)}
{` · ${settlementPeriodStatusLabel(selected.status, t)}`}
</span>
) : (
<span className="text-sm text-muted-foreground">
{t("filters.allPeriods", { defaultValue: "全部账期" })}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import type { TFunction } from "i18next";
export function settlementBillStatusLabel(
status: string,
t: TFunction<"settlementCenter">,
): string {
const key = `billStatus.${status}` as const;
return t(key, { defaultValue: status });
}
export function settlementBillTypeLabel(
billType: string,
t: TFunction<["settlementCenter", "agents"]>,
): string {
if (billType === "player") {
return t("agents:settlementBills.typePlayer", { defaultValue: "玩家账单" });
}
if (billType === "agent") {
return t("agents:settlementBills.typeAgent", { defaultValue: "代理层级账单" });
}
if (billType === "adjustment") {
return t("settlementCenter:billType.adjustment", { defaultValue: "补差单" });
}
if (billType === "reversal") {
return t("settlementCenter:billType.reversal", { defaultValue: "冲正单" });
}
if (billType === "bad_debt") {
return t("settlementCenter:billType.badDebt", { defaultValue: "坏账核销" });
}
return billType;
}
export function settlementPeriodStatusLabel(
status: string,
t: TFunction<"settlementCenter">,
): string {
if (status === "open") {
return t("filters.statusOpen", { defaultValue: "进行中" });
}
if (status === "closed") {
return t("filters.statusClosed", { defaultValue: "已关账" });
}
if (status === "completed") {
return t("filters.statusCompleted", { defaultValue: "已结清" });
}
return status;
}
export function creditLedgerReasonLabel(
reason: string,
t: TFunction<"settlementCenter">,
): string {
const key = `creditLedger.reason.${reason}` as const;
return t(key, { defaultValue: reason });
}
export function settlementAdjustmentTypeLabel(
type: string,
t: TFunction<"settlementCenter">,
): string {
if (type === "bad_debt") {
return t("adjustmentType.bad_debt", { defaultValue: "坏账核销" });
}
const key = `adjustmentType.${type}` as const;
return t(key, { defaultValue: type });
}

View File

@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminTicketItems } from "@/api/admin-tickets";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
@@ -357,11 +358,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
{loading && !data ? (
<AdminTableLoadingRow colSpan={17} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={17} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={17} />
) : (
data.items.map((row) => {
const winLabel = row.jackpot_win_amount > 0

View File

@@ -28,6 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
@@ -51,7 +52,9 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { formatAdminMinorUnits } from "@/lib/money";
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
@@ -560,11 +563,7 @@ export function TransferOrdersPanel(): React.ReactElement {
{loading && !data ? (
<AdminTableLoadingRow colSpan={13} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={13} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>
@@ -637,6 +636,7 @@ export function TransferOrdersPanel(): React.ReactElement {
export function WalletTxnsPanel(): React.ReactElement {
const { t } = useTranslation(["wallet", "common"]);
const { t: tSettlement } = useTranslation("settlementCenter");
const tRef = useTranslationRef(["wallet", "common"]);
const exportLabels = useExportLabels("walletTransactions");
const formatTs = useAdminDateTimeFormatter();
@@ -863,6 +863,7 @@ export function WalletTxnsPanel(): React.ReactElement {
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
@@ -872,13 +873,9 @@ export function WalletTxnsPanel(): React.ReactElement {
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={11} />
<AdminTableLoadingRow colSpan={12} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={12} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>
@@ -890,7 +887,14 @@ export function WalletTxnsPanel(): React.ReactElement {
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
<TableCell>
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell>
<TableCell className="min-w-0 text-xs">
{row.ledger_source === "credit_ledger"
? creditLedgerReasonLabel(row.biz_type, tSettlement)
: row.biz_type}
</TableCell>
<TableCell className="tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? t("in") : t("out")})
</TableCell>
@@ -1006,11 +1010,7 @@ export function PlayerWalletPanel(): React.ReactElement {
</TableHeader>
<TableBody>
{result.wallets.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{t("noWalletRows")}
</TableCell>
</TableRow>
<AdminTableNoResourceRow colSpan={4} />
) : (
result.wallets.map((w) => (
<TableRow key={w.id}>

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
/** 钱包模块仅服务主站钱包玩家;信用盘流水在结算中心。 */
export function WalletScopeHint(): React.ReactElement {
const { t } = useTranslation("wallet");
const profile = useAdminProfile();
const canSettlement = adminHasAnyPermission(profile?.permissions, [
...PRD_SETTLEMENT_AGENT_ACCESS_ANY,
]);
return (
<p className="text-sm text-muted-foreground">
{t("scopeHint", {
defaultValue:
"本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看",
})}
{canSettlement ? (
<Link href="/admin/settlement-center" className="mx-1 text-primary underline">
{t("scopeHintSettlementLink", { defaultValue: "结算中心 → 信用流水" })}
</Link>
) : (
<span className="mx-1 font-medium text-foreground">
{t("scopeHintSettlement", { defaultValue: "结算中心 → 信用流水" })}
</span>
)}
</p>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { create } from "zustand";
type AgentManagementSiteState = {
adminSiteId: number | null;
setAdminSiteId: (adminSiteId: number | null) => void;
};
export const useAgentManagementSiteStore = create<AgentManagementSiteState>((set) => ({
adminSiteId: null,
setAdminSiteId: (adminSiteId) => set({ adminSiteId }),
}));

View File

@@ -1,19 +1,11 @@
export type AdminAgentLineProvisionPayload = {
site_code: string;
code: string;
name: string;
username: string;
password: string;
email?: string | null;
currency_code?: string;
status?: number;
wallet_api_url?: string | null;
wallet_debit_path?: string;
wallet_credit_path?: string;
wallet_balance_path?: string;
wallet_timeout_seconds?: number;
iframe_allowed_origins?: string[];
lottery_h5_base_url?: string | null;
notes?: string | null;
total_share_rate?: number;
credit_limit?: number;
rebate_limit?: number;
@@ -37,9 +29,4 @@ export type AdminAgentLineProvisionResult = {
site_code: string;
is_line_root: boolean;
};
secrets?: {
sso_jwt_secret: string;
wallet_api_key: string;
};
secrets_display_once?: boolean;
};

View File

@@ -13,9 +13,21 @@ export type AdminAgentContext = {
can_create_player: boolean;
};
export type AgentNodeProfileSummary = {
total_share_rate: number;
credit_limit: number;
allocated_credit: number;
used_credit: number;
available_credit: number;
rebate_limit: number;
default_player_rebate: number;
settlement_cycle: "daily" | "weekly" | "monthly";
};
export type AgentNodeRow = {
id: number;
admin_site_id: number;
site_code?: string | null;
parent_id: number | null;
path: string;
depth: number;
@@ -25,6 +37,7 @@ export type AgentNodeRow = {
is_root: boolean;
username?: string | null;
email?: string | null;
profile_summary?: AgentNodeProfileSummary | null;
children?: AgentNodeRow[];
};
@@ -42,6 +55,14 @@ export type AgentProfilePayload = {
can_grant_extra_rebate?: boolean;
can_create_child_agent?: boolean;
can_create_player?: boolean;
risk_tags?: string[];
};
export type AgentParentCaps = {
agent_node_id: number;
total_share_rate: number;
rebate_limit: number;
available_credit: number;
};
export type AgentProfileRow = AgentProfilePayload & {
@@ -49,6 +70,8 @@ export type AgentProfileRow = AgentProfilePayload & {
allocated_credit: number;
used_credit: number;
available_credit: number;
parent_caps?: AgentParentCaps | null;
risk_tags?: string[];
};
export type AgentNodeCreatePayload = {

View File

@@ -33,6 +33,8 @@ export type AdminProfile = {
operational_permissions?: string[];
/** 当前代理可下放给下级的 prd.* 上限(未配置 grants 时与操作权限一致) */
delegation_ceiling?: string[];
/** 平台账号可访问站点;代理账号为 undefined见 agent.site_code */
accessible_sites?: { id: number; code: string; name: string }[];
};
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */

View File

@@ -49,6 +49,27 @@ export type AdminDashboardCapabilities = {
wallet_transfer_view: boolean;
};
/** 代理经营账号首页摘要(`GET /api/v1/admin/dashboard` → `agent_overview` */
export type AdminDashboardAgentOverview = {
agent_node_id: number;
agent_code: string;
agent_name: string;
depth: number;
credit_limit: number;
allocated_credit: number;
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;
subtree_agent_count: number;
direct_player_count: number;
pending_bill_count: number;
pending_unpaid_minor: number;
};
/** 全站待审核开奖批次队列(不限于大厅当前期) */
export type AdminDashboardResultBatchQueue = {
pending_review_total: number;
@@ -105,4 +126,5 @@ export type AdminDashboardData = {
abnormal_transfer_total: number | null;
warnings: AdminDashboardWarning[];
capabilities: AdminDashboardCapabilities;
agent_overview: AdminDashboardAgentOverview | null;
};

View File

@@ -8,10 +8,10 @@ export type AdminDrawListItem = {
close_time: string | null;
draw_time: string | null;
cooling_end_time: string | null;
result_source: string | null;
current_result_version: number;
settle_version: number;
is_reopened: boolean;
result_source?: string | null;
current_result_version?: number;
settle_version?: number;
is_reopened?: boolean;
total_bet_minor?: number;
total_payout_minor?: number;
profit_loss_minor?: number;
@@ -69,17 +69,21 @@ export type AdminDrawShowData = {
close_time: string | null;
draw_time: string | null;
cooling_end_time: string | null;
result_source: string | null;
current_result_version: number;
settle_version: number;
is_reopened: boolean;
created_at: string | null;
updated_at: string | null;
result_source?: string | null;
current_result_version?: number;
settle_version?: number;
is_reopened?: boolean;
created_at?: string | null;
updated_at?: string | null;
result_batch_counts: {
total: number;
pending_review: number;
total?: number;
pending_review?: number;
published: number;
};
capabilities?: {
can_manage_draw_results: boolean;
can_view_draw_finance: boolean;
};
};
export type AdminDrawBatchItemRow = {
@@ -95,14 +99,14 @@ export type AdminDrawBatchItemRow = {
export type AdminDrawBatchRow = {
id: number;
result_version: number;
source_type: string;
rng_seed_hash: string | null;
source_type?: string;
rng_seed_hash?: string | null;
status: string;
created_by: number | null;
confirmed_by: number | null;
created_by?: number | null;
confirmed_by?: number | null;
confirmed_at: string | null;
created_at: string | null;
updated_at: string | null;
created_at?: string | null;
updated_at?: string | null;
items: AdminDrawBatchItemRow[];
};

View File

@@ -2,9 +2,11 @@ export type AdminIntegrationSiteRow = {
id: number;
code: string;
name: string;
has_line_root: boolean;
currency_code: string;
status: number;
wallet_api_url: string | null;
lottery_h5_base_url: string | null;
wallet_timeout_seconds: number;
has_sso_secret: boolean;
has_wallet_api_key: boolean;

View File

@@ -7,6 +7,13 @@ export type AdminPlayerWalletRow = {
status: number;
};
export type PlayerRebateProfileRow = {
game_type: string;
rebate_rate: number;
extra_rebate_rate: number;
inherit_from_agent: boolean;
};
export type AdminPlayerRow = {
id: number;
agent_node_id?: number | null;
@@ -14,6 +21,10 @@ export type AdminPlayerRow = {
agent_name?: string | null;
site_code: string;
site_player_id: string;
auth_source?: string;
funding_mode?: string;
uses_credit?: boolean;
rebate_inherited?: boolean;
username: string | null;
nickname: string | null;
default_currency: string;
@@ -21,6 +32,12 @@ export type AdminPlayerRow = {
last_login_at: string | null;
created_at: string;
wallets: AdminPlayerWalletRow[];
credit_limit?: number | null;
used_credit?: number | null;
available_credit?: number | null;
rebate_rate?: number | null;
risk_tags?: string[];
rebate_profiles?: PlayerRebateProfileRow[];
};
export type AdminPlayerListData = {
@@ -35,13 +52,17 @@ export type AdminPlayerListData = {
export type AdminPlayerCreatePayload = {
site_code: string;
site_player_id: string;
site_player_id?: string;
username?: string | null;
password?: string;
nickname?: string | null;
default_currency?: string;
status?: number;
/** 超管可选;未传时后端使用该主站根代理 */
agent_node_id?: number;
credit_limit?: number;
rebate_rate?: number;
extra_rebate_rate?: number;
};
export type AdminPlayerUpdatePayload = {
@@ -49,6 +70,16 @@ export type AdminPlayerUpdatePayload = {
nickname?: string | null;
default_currency?: string;
status?: number;
credit_limit?: number;
rebate_rate?: number;
extra_rebate_rate?: number;
risk_tags?: string[];
rebate_profiles?: Array<{
game_type: string;
rebate_rate?: number;
extra_rebate_rate?: number;
inherit_from_agent?: boolean;
}>;
};
export type AdminPlayerDeleteResult = {

View File

@@ -1,5 +1,12 @@
import type { AdminNavItem } from "@/modules/_config/admin-nav";
export type AdminUserSiteBinding = {
site_id: number;
site_code: string;
site_name: string;
role_slugs: string[];
};
export type AdminUserPermissionRow = {
id: number;
username: string;
@@ -8,6 +15,7 @@ export type AdminUserPermissionRow = {
status: number;
account_kind?: "platform" | "agent";
roles: string[];
site_bindings?: AdminUserSiteBinding[];
direct_permissions: string[];
effective_permissions: string[];
};
@@ -80,6 +88,7 @@ export type AdminUserPermissionSyncData = {
nickname: string;
account_kind?: "platform" | "agent";
roles: string[];
site_bindings?: AdminUserSiteBinding[];
direct_permissions: string[];
effective_permissions: string[];
};
@@ -93,7 +102,12 @@ export type AdminUserCreatePayload = {
email?: string | null;
password: string;
status?: number;
/** 默认站点角色,至少一项 */
admin_site_id: number;
role_slugs: string[];
};
export type AdminUserRoleSyncPayload = {
admin_site_id: number;
role_slugs: string[];
};

View File

@@ -45,8 +45,11 @@ export type AdminWalletTxnItem = {
site_player_id: string | null;
username: string | null;
nickname: string | null;
wallet_id: number;
wallet_id: number | null;
biz_type: string;
ledger_source?: "wallet_txn" | "credit_ledger";
funding_mode?: string;
auth_source?: string | null;
biz_no: string;
direction: number;
amount: number;