From c6119da9f8ba276f18ba04d785485f22998941e5 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 22 Jun 2026 10:50:59 +0800 Subject: [PATCH] feat(admin, players): enhance player management and admin interfaces Updated the player management components to improve error handling and display for various operations, including ticket loading and transaction retrieval. Introduced new status badges for better visual representation of player statuses. Enhanced localization support for error messages and improved the overall user experience in the admin console. Refactored components for better maintainability and clarity. --- src/api/admin-agent-settlement.ts | 4 +- src/app/admin/(shell)/agents/list/page.tsx | 16 +- src/app/globals.css | 2 +- src/components/admin/admin-document-title.tsx | 16 +- .../admin/admin-numeric-stepper.tsx | 6 +- src/components/admin/admin-sidebar-nav.tsx | 3 +- .../admin/player-funding-badges.tsx | 29 +- src/components/ui/table.tsx | 4 +- src/lib/admin-player-display.ts | 4 +- src/lib/admin-status-tone.ts | 69 +++- src/lib/resolve-admin-site-id.ts | 28 ++ .../account/account-settings-console.tsx | 2 +- .../admin-roles/admin-roles-console.tsx | 2 +- .../admin-users/admin-users-console.tsx | 2 +- src/modules/agents/agents-console.tsx | 10 + src/modules/agents/agents-players-panel.tsx | 12 +- src/modules/audit/audit-logs-console.tsx | 2 +- src/modules/dashboard/dashboard-console.tsx | 34 +- src/modules/jackpot/jackpot-pools-console.tsx | 9 +- .../players/player-credit-ledger-tab.tsx | 317 ++++++++++++++++++ src/modules/players/player-detail-console.tsx | 168 ++++++---- src/modules/players/players-console.tsx | 69 ++-- src/modules/reconcile/reconcile-console.tsx | 2 +- src/modules/reports/reports-console.tsx | 17 +- src/modules/risk/risk-index-console.tsx | 239 ------------- src/modules/risk/risk-pools-console.tsx | 16 +- src/modules/settlement/agent-bill-detail.tsx | 23 +- .../settlement/settlement-center-shell.tsx | 100 ++++-- .../settlement-credit-ledger-panel.tsx | 11 +- src/modules/wallet/wallet-console.tsx | 6 +- src/stores/admin-session.ts | 18 +- 31 files changed, 777 insertions(+), 463 deletions(-) create mode 100644 src/lib/resolve-admin-site-id.ts create mode 100644 src/modules/players/player-credit-ledger-tab.tsx delete mode 100644 src/modules/risk/risk-index-console.tsx diff --git a/src/api/admin-agent-settlement.ts b/src/api/admin-agent-settlement.ts index bf754ef..eef9c3a 100644 --- a/src/api/admin-agent-settlement.ts +++ b/src/api/admin-agent-settlement.ts @@ -172,7 +172,9 @@ export type SettlementCreditLedgerRow = { export async function getCreditLedger(params: { admin_site_id: number; - settlement_period_id: number; + /** 0 或不传表示不限账期(玩家详情等场景) */ + settlement_period_id?: number; + player_id?: number; entry_kind?: "credit"; bet_flow_only?: boolean; bet_flow_display?: "simple"; diff --git a/src/app/admin/(shell)/agents/list/page.tsx b/src/app/admin/(shell)/agents/list/page.tsx index 72544f4..b3882d2 100644 --- a/src/app/admin/(shell)/agents/list/page.tsx +++ b/src/app/admin/(shell)/agents/list/page.tsx @@ -1,10 +1,24 @@ import type { Metadata } from "next"; +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { AgentsDirectoryConsole } from "@/modules/agents/agents-directory-console"; +import { + PRD_AGENT_LINE_PROVISION_ACCESS_ANY, + PRD_AGENTS_ACCESS_ANY, +} from "@/lib/admin-prd"; import { buildPageMetadata } from "@/lib/page-metadata"; export const metadata: Metadata = buildPageMetadata("agents", "listTitle"); export default function AgentsListPage() { - return ; + return ( + + + + + + ); } diff --git a/src/app/globals.css b/src/app/globals.css index ac86ec3..3ee2169 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -141,7 +141,7 @@ } .admin-list-title { - @apply text-[1.05rem] font-semibold tracking-tight text-[#13315f]; + @apply text-[1.05rem] font-semibold tracking-tight text-foreground; } .admin-list-content { diff --git a/src/components/admin/admin-document-title.tsx b/src/components/admin/admin-document-title.tsx index 2c1d6f3..bd49983 100644 --- a/src/components/admin/admin-document-title.tsx +++ b/src/components/admin/admin-document-title.tsx @@ -5,6 +5,8 @@ import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; import { resolveAdminPageTitle } from "@/lib/admin-page-title"; +import { hydrateAdminUiLocale } from "@/lib/admin-locale"; +import { normalizeAdminLanguage } from "@/i18n/languages"; /** 随界面语言更新 `document.title`(弥补 Next 静态 metadata 无法按用户语言切换)。 */ export function AdminDocumentTitle() { @@ -22,7 +24,19 @@ export function AdminDocumentTitle() { const pageTitle = t(spec.key, { ns: spec.ns, ...(spec.params ?? {}) }); document.title = `${pageTitle} · ${appTitle}`; - }, [pathname, i18n.language, t]); + }, [pathname, i18n.language, i18n.resolvedLanguage, t]); + + useEffect(() => { + const locale = hydrateAdminUiLocale(); + if (!locale) { + return; + } + const normalized = normalizeAdminLanguage(locale); + const current = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); + if (normalized !== current) { + void i18n.changeLanguage(normalized); + } + }, [i18n]); return null; } diff --git a/src/components/admin/admin-numeric-stepper.tsx b/src/components/admin/admin-numeric-stepper.tsx index 0818322..8e6858f 100644 --- a/src/components/admin/admin-numeric-stepper.tsx +++ b/src/components/admin/admin-numeric-stepper.tsx @@ -1,6 +1,7 @@ "use client"; import { Minus, Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -71,6 +72,7 @@ export function AdminNumericStepper({ className, suffix, }: AdminNumericStepperProps): React.ReactElement { + const { t } = useTranslation("common"); const current = parseNumeric(value, integer); const atMin = min !== undefined && current <= min; const atMax = max !== undefined && current >= max; @@ -122,7 +124,7 @@ export function AdminNumericStepper({ size="icon-sm" className="h-auto shrink-0 rounded-none border-r border-input/80 px-3 hover:bg-muted/60" disabled={disabled || atMin} - aria-label="减少" + aria-label={t("decrease", { defaultValue: "减少" })} onClick={() => applyDelta(-step)} > @@ -170,7 +172,7 @@ export function AdminNumericStepper({ size="icon-sm" className="h-auto shrink-0 rounded-none border-l border-input/80 px-3 hover:bg-muted/60" disabled={disabled || atMax} - aria-label="增加" + aria-label={t("increase", { defaultValue: "增加" })} onClick={() => applyDelta(step)} > diff --git a/src/components/admin/admin-sidebar-nav.tsx b/src/components/admin/admin-sidebar-nav.tsx index b43465d..dbf027c 100644 --- a/src/components/admin/admin-sidebar-nav.tsx +++ b/src/components/admin/admin-sidebar-nav.tsx @@ -28,7 +28,8 @@ import { ADMIN_BASE, type AdminNavGroup, type AdminNavItem } from "@/modules/_co const NAV_BTN = "h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4"; -const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm"; +const NAV_ACTIVE = + "data-active:bg-sidebar-primary/90 data-active:text-sidebar-primary-foreground data-active:font-medium data-active:shadow-sm"; const SUB_NAV = "h-8 min-h-8 gap-2 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm [&_svg]:size-4"; diff --git a/src/components/admin/player-funding-badges.tsx b/src/components/admin/player-funding-badges.tsx index 69f8cfd..827883c 100644 --- a/src/components/admin/player-funding-badges.tsx +++ b/src/components/admin/player-funding-badges.tsx @@ -2,13 +2,16 @@ import { useTranslation } from "react-i18next"; -import { playerFundingModeLabel } from "@/lib/admin-player-display"; +import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; +import { isCreditFundingPlayer, playerFundingModeLabel } from "@/lib/admin-player-display"; +import { resolveLedgerSourceTone } from "@/lib/admin-status-tone"; import { cn } from "@/lib/utils"; type FundingRow = { funding_mode?: string | null; uses_credit?: boolean; - auth_source?: string | null; + available_credit?: number | null; + credit_limit?: number | null; }; export function PlayerFundingModeBadge({ @@ -19,7 +22,13 @@ export function PlayerFundingModeBadge({ className?: string; }): React.ReactElement { const { t } = useTranslation("players"); - const isCredit = row.funding_mode === "credit" || row.uses_credit === true; + const normalized = { + funding_mode: row.funding_mode ?? undefined, + uses_credit: row.uses_credit, + available_credit: row.available_credit ?? undefined, + credit_limit: row.credit_limit ?? undefined, + }; + const isCredit = isCreditFundingPlayer(normalized); return ( - {playerFundingModeLabel(row, t)} + {playerFundingModeLabel(normalized, t)} ); } @@ -54,8 +63,6 @@ export function PlayerLedgerSourceBadge({ return null; } - const sourceClass = "border-border bg-muted/30 text-muted-foreground"; - const label = ledgerSource === "wallet_txn" ? t("ledgerWallet", { defaultValue: "钱包流水" }) @@ -68,14 +75,8 @@ export function PlayerLedgerSourceBadge({ : t("ledgerCredit", { defaultValue: "信用流水" }); return ( - + {label} - + ); } diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 8e1ba47..fb2b1a5 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) { ) { , t: (key: string, opts?: { defaultValue?: string }) => string, ): string { - if (row.funding_mode === "credit") { + if (isCreditFundingPlayer(row)) { return t("players:fundingCredit", { defaultValue: "信用" }); } if (row.funding_mode === "wallet") { diff --git a/src/lib/admin-status-tone.ts b/src/lib/admin-status-tone.ts index 854c11c..5eb7e19 100644 --- a/src/lib/admin-status-tone.ts +++ b/src/lib/admin-status-tone.ts @@ -58,10 +58,29 @@ const STATUS_TONE_MAP: Record = { completed: "success", failed: "danger", - // 代理账期账单 + // 代理账期 / 账单 confirmed: "warning", partial_paid: "warning", overdue: "danger", + reversed: "neutral", + + // 报表导出任务 + queued: "info", + + // 开奖结果批次 + published: "success", + + // 对账明细 + resolved: "success", + unresolved: "warning", + stale_processing: "warning", + missing_wallet_txn: "danger", + unexpected_wallet_txn: "danger", + missing_refund: "warning", + missing_reversal: "warning", + main_site_record_missing: "warning", + main_site_failed: "danger", + main_site_unavailable: "neutral", // 注单 pending_confirm: "info", @@ -70,12 +89,15 @@ const STATUS_TONE_MAP: Record = { pending_payout: "warning", settled_win: "success", settled_lose: "neutral", + placed: "neutral", + pending_draw: "info", + partial_failed: "warning", + refunded: "neutral", // 钱包 / 划转 processing: "info", posted: "success", pending_reconcile: "warning", - reversed: "neutral", manually_processed: "indigo", // 对账 @@ -117,6 +139,49 @@ export function resolveRoleStatusTone(status: number): AdminStatusTone { return status === 1 ? "success" : "neutral"; } +/** 风控号码池:售罄 / 高占用 / 正常 */ +export function resolveRiskPoolStatusTone( + row: { is_sold_out?: boolean; usage_ratio?: number | null }, + highRiskThreshold = 0.8, +): AdminStatusTone { + if (row.is_sold_out) { + return "danger"; + } + if ((row.usage_ratio ?? 0) >= highRiskThreshold) { + return "warning"; + } + return "success"; +} + +/** 账务流水行:优先账单状态,否则入账状态 */ +export function resolveCreditLedgerRowStatusTone( + row: { bill_status?: string | null; status?: string | null }, +): AdminStatusTone { + if (row.bill_status) { + return resolveAdminStatusTone(row.bill_status); + } + return resolveAdminStatusTone(row.status ?? "posted"); +} + +/** 流水来源渠道色标 */ +export function resolveLedgerSourceTone( + ledgerSource: string | null | undefined, +): AdminStatusTone { + switch (ledgerSource) { + case "wallet_txn": + case "payment_record": + return "success"; + case "credit_ledger": + return "info"; + case "settlement_adjustment": + return "warning"; + case "share_ledger": + return "indigo"; + default: + return "neutral"; + } +} + export function resolveAdminStatusTone( status: string | number | boolean | null | undefined, ): AdminStatusTone { diff --git a/src/lib/resolve-admin-site-id.ts b/src/lib/resolve-admin-site-id.ts new file mode 100644 index 0000000..bf2a343 --- /dev/null +++ b/src/lib/resolve-admin-site-id.ts @@ -0,0 +1,28 @@ +import type { AdminProfile } from "@/types/api/admin-auth"; + +/** 按玩家 site_code 从登录态解析 admin_sites.id(结算/信用流水 API 必填)。 */ +export function resolveAdminSiteId( + siteCode: string, + profile: AdminProfile | null | undefined, +): number | null { + const code = siteCode.trim().toUpperCase(); + if (code === "") { + return null; + } + + if (profile?.agent?.site_code?.trim().toUpperCase() === code) { + return profile.agent.admin_site_id; + } + if (profile?.site?.code?.trim().toUpperCase() === code) { + return profile.site.id; + } + + const fromAccessible = profile?.accessible_sites?.find( + (site) => site.code.trim().toUpperCase() === code, + ); + if (fromAccessible) { + return fromAccessible.id; + } + + return null; +} diff --git a/src/modules/account/account-settings-console.tsx b/src/modules/account/account-settings-console.tsx index 542ca69..8560e07 100644 --- a/src/modules/account/account-settings-console.tsx +++ b/src/modules/account/account-settings-console.tsx @@ -82,7 +82,7 @@ export function AccountSettingsConsole() { return (
-

+

{t("accountSettings")}

diff --git a/src/modules/admin-roles/admin-roles-console.tsx b/src/modules/admin-roles/admin-roles-console.tsx index 1124b8d..a33be4d 100644 --- a/src/modules/admin-roles/admin-roles-console.tsx +++ b/src/modules/admin-roles/admin-roles-console.tsx @@ -275,7 +275,7 @@ export function AdminRolesConsole(): React.ReactElement { defaultValue: "可新增自定义角色并配置权限;内置角色(超级管理员、站点管理员、代理)不可删除。", })}

- {err ?

{err}

: null} + {err ?

{err}

: null}
diff --git a/src/modules/admin-users/admin-users-console.tsx b/src/modules/admin-users/admin-users-console.tsx index b108630..332ac7e 100644 --- a/src/modules/admin-users/admin-users-console.tsx +++ b/src/modules/admin-users/admin-users-console.tsx @@ -451,7 +451,7 @@ export function AdminUsersConsole(): React.ReactElement { - {err ?

{err}

: null} + {err ?

{err}

: null}
diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx index cc04983..ed06360 100644 --- a/src/modules/agents/agents-console.tsx +++ b/src/modules/agents/agents-console.tsx @@ -127,6 +127,7 @@ export function AgentsConsole(): React.ReactElement { const [profileSaving, setProfileSaving] = useState(false); const [selectedProfile, setSelectedProfile] = useState(null); const [selectedProfileLoading, setSelectedProfileLoading] = useState(false); + const [selectedProfileErr, setSelectedProfileErr] = useState(null); const [playerCreateRequestKey, setPlayerCreateRequestKey] = useState(0); const [nodeDialogOpen, setNodeDialogOpen] = useState(false); const [nodeDialogMode, setNodeDialogMode] = useState<"create" | "edit">("create"); @@ -450,12 +451,14 @@ export function AgentsConsole(): React.ReactElement { if (selectedNode === null) { setSelectedProfile(null); setSelectedProfileLoading(false); + setSelectedProfileErr(null); return; } const nodeId = selectedNode.id; let cancelled = false; setSelectedProfileLoading(true); + setSelectedProfileErr(null); void getAgentNodeProfile(nodeId) .then((row) => { @@ -470,6 +473,9 @@ export function AgentsConsole(): React.ReactElement { .catch(() => { if (!cancelled) { setSelectedProfile(null); + setSelectedProfileErr( + t("profile.loadFailed", { defaultValue: "代理档案加载失败,请稍后重试。" }), + ); } }) .finally(() => { @@ -1024,6 +1030,10 @@ export function AgentsConsole(): React.ReactElement { /> ) : null} + {selectedProfileErr ? ( +

{selectedProfileErr}

+ ) : null} + (null); const [dialogOpen, setDialogOpen] = useState(false); const [saving, setSaving] = useState(false); @@ -252,6 +253,7 @@ export function AgentsPlayersPanel({ } setLoading(true); + setListErr(null); try { const data = await getAdminPlayers({ page, @@ -262,10 +264,11 @@ export function AgentsPlayersPanel({ setItems(data.items); setTotal(data.meta.total); setLastPage(Math.max(1, data.meta.last_page)); - } catch { + } catch (e) { setItems([]); setTotal(0); setLastPage(1); + setListErr(e instanceof LotteryApiBizError ? e.message : t("common:loadFailed")); } finally { setLoading(false); } @@ -819,6 +822,9 @@ export function AgentsPlayersPanel({ ) : ( <> + {listErr ? ( +

{listErr}

+ ) : null}
@@ -910,7 +916,7 @@ export function AgentsPlayersPanel({ {!embedded ? ( - + {playerStatusLabel(row.status, t)} diff --git a/src/modules/audit/audit-logs-console.tsx b/src/modules/audit/audit-logs-console.tsx index 0f8bb53..8cc4617 100644 --- a/src/modules/audit/audit-logs-console.tsx +++ b/src/modules/audit/audit-logs-console.tsx @@ -202,7 +202,7 @@ export function AuditLogsConsole(): React.ReactElement { - {err ?

{err}

: null} + {err ?

{err}

: null} {(loading && !data) || data ? ( <>
diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx index d089d74..553e6bc 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -262,12 +262,18 @@ export function DashboardConsole(): ReactElement { const platformLocked = coerceAdminMinor(platformRisk?.locked_amount); const platformCap = coerceAdminMinor(platformRisk?.cap_amount); const rawPlatformUsagePct = platformRisk?.usage_percent; - const platformUsagePct = - typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct) - ? Math.min(100, Math.max(0, rawPlatformUsagePct)) - : platformCap > 0 - ? (platformLocked / platformCap) * 100 - : 0; + const useCurrentDrawRisk = drawId != null; + const capUsageLocked = useCurrentDrawRisk ? riskLocked : platformLocked; + const capUsageCap = useCurrentDrawRisk ? riskCap : platformCap; + const capUsagePct = + capUsageCap > 0 + ? (capUsageLocked / capUsageCap) * 100 + : typeof rawPlatformUsagePct === "number" && Number.isFinite(rawPlatformUsagePct) + ? Math.min(100, Math.max(0, rawPlatformUsagePct)) + : platformCap > 0 + ? (platformLocked / platformCap) * 100 + : 0; + const showCapUsageCard = useCurrentDrawRisk || platformRisk != null; const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]); @@ -403,25 +409,25 @@ export function DashboardConsole(): ReactElement { } accent={ - platformUsagePct >= 90 + capUsagePct >= 90 ? "destructive" - : platformUsagePct >= 70 + : capUsagePct >= 70 ? "primary" : "muted" } loading={loading} > - {platformRisk != null ? ( + {showCapUsageCard ? (

{t("status")}

-

- {statusOn ? t("enabled") : t("disabled")} -

+
+ + {statusOn ? t("enabled") : t("disabled")} + +

{t("payoutRate")}

diff --git a/src/modules/players/player-credit-ledger-tab.tsx b/src/modules/players/player-credit-ledger-tab.tsx new file mode 100644 index 0000000..fe459b1 --- /dev/null +++ b/src/modules/players/player-credit-ledger-tab.tsx @@ -0,0 +1,317 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; +import { useTranslationRef } from "@/hooks/use-translation-ref"; + +import { + getCreditLedger, + type SettlementCreditLedgerRow, +} from "@/api/admin-agent-settlement"; +import { getAdminIntegrationSites } from "@/api/admin-integration-sites"; +import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; +import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; +import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; +import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges"; +import { AdminTableMoney } from "@/components/admin/admin-table-money"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { resolveAdminSiteId } from "@/lib/resolve-admin-site-id"; +import { resolveCreditLedgerRowStatusTone } from "@/lib/admin-status-tone"; +import { formatAdminMinorUnits } from "@/lib/money"; +import { cn } from "@/lib/utils"; +import { + creditLedgerReasonLabel, + settlementBillStatusLabel, +} from "@/modules/settlement/settlement-status-label"; +import { useAdminProfile } from "@/stores/admin-session"; +import { LotteryApiBizError } from "@/types/api/errors"; + +function signedLedgerAmount(row: SettlementCreditLedgerRow): number { + if (typeof row.signed_amount === "number") { + return row.signed_amount; + } + + return row.direction === 1 ? row.amount : -row.amount; +} + +function signedLedgerAmountClass(signed: number): string { + if (signed < 0) { + return "font-medium text-destructive"; + } + if (signed > 0) { + return "font-medium text-emerald-700 dark:text-emerald-400"; + } + + return "text-muted-foreground"; +} + +function formatSignedLedgerAmount(signed: number, currencyCode: string): string { + if (signed === 0) { + return formatAdminMinorUnits(0, currencyCode); + } + + const prefix = signed < 0 ? "−" : "+"; + return `${prefix}${formatAdminMinorUnits(Math.abs(signed), currencyCode)}`; +} + +function CreditLedgerReasonBadge({ reason }: { reason: string }): React.ReactElement { + const { t } = useTranslation(["settlementCenter"]); + const label = creditLedgerReasonLabel(reason, t); + + return ( + + {label} + + ); +} + +function refLabel(row: SettlementCreditLedgerRow): string { + const parts: string[] = []; + if (row.biz_no) { + parts.push(row.biz_no); + } + if (row.draw_no) { + parts.push(row.draw_no); + } + if (row.play_code) { + parts.push(row.play_code); + } + if (row.ticket_item_id) { + parts.push(`#${row.ticket_item_id}`); + } + if (row.settlement_bill_id) { + parts.push(`#${row.settlement_bill_id}`); + } + + return parts.length > 0 ? parts.join(" · ") : "—"; +} + +type PlayerCreditLedgerTabProps = { + playerId: number; + siteCode: string; + currencyCode: string; +}; + +export function PlayerCreditLedgerTab({ + playerId, + siteCode, + currencyCode, +}: PlayerCreditLedgerTabProps): React.ReactElement { + const { t } = useTranslation(["players", "settlementCenter", "common"]); + const tRef = useTranslationRef(["players", "common"]); + const formatTs = useAdminDateTimeFormatter(); + const profile = useAdminProfile(); + + const [adminSiteId, setAdminSiteId] = useState(() => + resolveAdminSiteId(siteCode, profile), + ); + const [siteResolveErr, setSiteResolveErr] = useState(null); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [loading, setLoading] = useState(true); + const [loadErr, setLoadErr] = useState(null); + + useAsyncEffect(() => { + let cancelled = false; + const fromProfile = resolveAdminSiteId(siteCode, profile); + if (fromProfile !== null) { + setAdminSiteId(fromProfile); + setSiteResolveErr(null); + return; + } + + setSiteResolveErr(null); + void getAdminIntegrationSites() + .then((data) => { + if (cancelled) { + return; + } + const code = siteCode.trim().toUpperCase(); + const match = data.items.find((site) => site.code.trim().toUpperCase() === code); + if (match) { + setAdminSiteId(match.id); + } else { + setAdminSiteId(null); + setSiteResolveErr(tRef.current("creditLedgerSiteNotFound", { defaultValue: "无法解析玩家所属站点" })); + } + }) + .catch(() => { + if (!cancelled) { + setAdminSiteId(null); + setSiteResolveErr(tRef.current("loadFailed")); + } + }); + + return () => { + cancelled = true; + }; + }, [profile, siteCode, tRef]); + + const load = useCallback(async () => { + if (adminSiteId === null) { + setRows([]); + setTotal(0); + setLoading(false); + return; + } + + setLoading(true); + setLoadErr(null); + try { + const res = await getCreditLedger({ + admin_site_id: adminSiteId, + player_id: playerId, + page, + per_page: perPage, + }); + setRows(res.items ?? []); + setTotal(res.total ?? 0); + } catch (e) { + setRows([]); + setTotal(0); + setLoadErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); + } finally { + setLoading(false); + } + }, [adminSiteId, page, perPage, playerId, tRef]); + + useAsyncEffect(() => { + if (adminSiteId === null) { + setLoading(false); + return; + } + void load(); + }, [adminSiteId, load]); + + const lastPage = Math.max(1, Math.ceil(total / Math.max(1, perPage))); + const colSpan = 7; + + if (siteResolveErr) { + return

{siteResolveErr}

; + } + + if (adminSiteId === null && !loading) { + return ( +

+ {t("creditLedgerSiteNotFound", { defaultValue: "无法解析玩家所属站点" })} +

+ ); + } + + return ( +
+ {loadErr ? ( +
+

{loadErr}

+ +
+ ) : null} + +
+
+ + + {t("creditLedger.columns.txn", { ns: "settlementCenter", defaultValue: "流水号" })} + {t("creditLedger.columns.channel", { ns: "settlementCenter", defaultValue: "渠道" })} + {t("creditLedger.columns.reason", { ns: "settlementCenter", defaultValue: "业务类型" })} + {t("creditLedger.columns.amount", { ns: "settlementCenter", defaultValue: "金额" })} + {t("creditLedger.columns.ref", { ns: "settlementCenter", defaultValue: "关联" })} + {t("columns.status", { ns: "settlementCenter", defaultValue: "状态" })} + {t("creditLedger.columns.time", { ns: "settlementCenter", defaultValue: "时间" })} + + + + {loading && rows.length === 0 ? : null} + {rows.map((row) => { + const signed = signedLedgerAmount(row); + const statusText = row.bill_status + ? settlementBillStatusLabel(row.bill_status, t) + : row.status === "posted" + ? t("creditLedger.status.posted", { ns: "settlementCenter", defaultValue: "已入账" }) + : row.status || "—"; + + return ( + + {row.txn_no} + + + + + + + + + {row.biz_type === "bet_hold" + ? t("creditLedger.reason.freezeAmount", { + ns: "settlementCenter", + defaultValue: "冻结 {{amount}}", + amount: formatAdminMinorUnits(row.amount, currencyCode), + }) + : formatSignedLedgerAmount(signed, currencyCode)} + + + + {row.ticket_item_id ? ( + + {refLabel(row)} + + ) : ( + refLabel(row) + )} + + + + {statusText} + + + + {row.created_at ? formatTs(row.created_at) : "—"} + + + ); + })} + {!loading && rows.length === 0 && !loadErr ? ( + + ) : null} + +
+
+ + { + setPerPage(n); + setPage(1); + }} + onPageChange={setPage} + /> +
+ ); +} diff --git a/src/modules/players/player-detail-console.tsx b/src/modules/players/player-detail-console.tsx index 29539d8..1affc23 100644 --- a/src/modules/players/player-detail-console.tsx +++ b/src/modules/players/player-detail-console.tsx @@ -29,7 +29,9 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog"; import { resolvePlayerStatusTone } from "@/lib/admin-status-tone"; +import { AdminTableMoney } from "@/components/admin/admin-table-money"; import { formatAdminMinorUnits } from "@/lib/money"; +import { PlayerCreditLedgerTab } from "@/modules/players/player-credit-ledger-tab"; import { isCreditFundingPlayer, playerAuthSourceLabel, @@ -115,6 +117,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { const [ticketTotal, setTicketTotal] = useState(0); const [ticketLastPage, setTicketLastPage] = useState(1); const [ticketsLoading, setTicketsLoading] = useState(false); + const [ticketsErr, setTicketsErr] = useState(null); const [txnPage, setTxnPage] = useState(1); const [txnPerPage, setTxnPerPage] = useState(10); @@ -122,6 +125,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { const [txnTotal, setTxnTotal] = useState(0); const [txnLastPage, setTxnLastPage] = useState(1); const [txnsLoading, setTxnsLoading] = useState(false); + const [txnsErr, setTxnsErr] = useState(null); const [transferPage, setTransferPage] = useState(1); const [transferPerPage, setTransferPerPage] = useState(10); @@ -129,6 +133,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { const [transferTotal, setTransferTotal] = useState(0); const [transferLastPage, setTransferLastPage] = useState(1); const [transfersLoading, setTransfersLoading] = useState(false); + const [transfersErr, setTransfersErr] = useState(null); const loadPlayer = useCallback(async () => { setPlayerLoading(true); @@ -145,6 +150,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { const loadTickets = useCallback(async () => { setTicketsLoading(true); + setTicketsErr(null); try { const d = await getAdminPlayerTicketItems(playerId, { page: ticketPage, @@ -153,17 +159,19 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { setTickets(d.items); setTicketTotal(d.total); setTicketLastPage(Math.max(1, d.last_page)); - } catch { + } catch (e) { setTickets([]); setTicketTotal(0); setTicketLastPage(1); + setTicketsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); } finally { setTicketsLoading(false); } - }, [playerId, ticketPage, ticketPerPage]); + }, [playerId, ticketPage, ticketPerPage, tRef]); const loadTxns = useCallback(async () => { setTxnsLoading(true); + setTxnsErr(null); try { const d = await getAdminWalletTransactions({ player_id: playerId, @@ -173,17 +181,19 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { setTxns(d.items); setTxnTotal(d.total); setTxnLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1)); - } catch { + } catch (e) { setTxns([]); setTxnTotal(0); setTxnLastPage(1); + setTxnsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); } finally { setTxnsLoading(false); } - }, [playerId, txnPage, txnPerPage]); + }, [playerId, txnPage, txnPerPage, tRef]); const loadTransfers = useCallback(async () => { setTransfersLoading(true); + setTransfersErr(null); try { const d = await getAdminTransferOrders({ player_id: playerId, @@ -193,14 +203,15 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { setTransfers(d.items); setTransferTotal(d.total); setTransferLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1)); - } catch { + } catch (e) { setTransfers([]); setTransferTotal(0); setTransferLastPage(1); + setTransfersErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); } finally { setTransfersLoading(false); } - }, [playerId, transferPage, transferPerPage]); + }, [playerId, transferPage, transferPerPage, tRef]); useAsyncEffect(() => { void loadPlayer(); @@ -212,7 +223,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { }, [player, loadTickets]); useAsyncEffect(() => { - if (!player) return; + if (!player || isCreditFundingPlayer(player)) return; void loadTxns(); }, [player, loadTxns]); @@ -344,27 +355,27 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {

{t("creditLimit")}

-

+ {player.credit_limit != null ? formatPlayerCreditAmount(player.credit_limit, player.default_currency) : "—"} -

+

{t("availableCredit")}

-

+ {player.available_credit != null ? formatPlayerCreditAmount(player.available_credit, player.default_currency) : "—"} -

+

{t("usedCredit")}

-

+ {player.used_credit != null ? formatPlayerCreditAmount(player.used_credit, player.default_currency) : "—"} -

+
) : player.wallets.length === 0 ? ( @@ -417,6 +428,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
+ {ticketsErr ? ( +

{ticketsErr}

+ ) : null}
@@ -507,59 +521,76 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { -
-
- - - {t("txnNo", { ns: "wallet" })} - {t("bizType", { ns: "wallet" })} - {t("txnAmount")} - {t("balanceAfterTxn")} - {t("status")} - {t("createdAt")} - - - - {txnsLoading && txns.length === 0 ? : null} - {txns.map((row) => ( - - {row.txn_no} - {row.biz_type} - - {formatAdminMinorUnits(row.amount, player.default_currency)} - - - {formatAdminMinorUnits(row.balance_after, player.default_currency)} - - - - {walletStatusLabel(row.status, t)} - - - - {row.created_at ? formatDt(row.created_at) : "—"} - - - ))} - {!txnsLoading && txns.length === 0 ? ( - - ) : null} - -
-
- { - setTxnPerPage(n); - setTxnPage(1); - }} - onPageChange={setTxnPage} - /> + {isCreditPlayer ? ( + + ) : ( + <> + {txnsErr ? ( +

{txnsErr}

+ ) : null} +
+ + + + {t("txnNo", { ns: "wallet" })} + {t("bizType", { ns: "wallet" })} + {t("txnAmount")} + {t("balanceAfterTxn")} + {t("status")} + {t("createdAt")} + + + + {txnsLoading && txns.length === 0 ? : null} + {txns.map((row) => ( + + {row.txn_no} + {row.biz_type} + + + {row.amount_formatted ?? formatAdminMinorUnits(row.amount, player.default_currency)} + + + + + {formatAdminMinorUnits(row.balance_after, player.default_currency)} + + + + + {walletStatusLabel(row.status, t)} + + + + {row.created_at ? formatDt(row.created_at) : "—"} + + + ))} + {!txnsLoading && txns.length === 0 && !txnsErr ? ( + + ) : null} + +
+
+ { + setTxnPerPage(n); + setTxnPage(1); + }} + onPageChange={setTxnPage} + /> + + )}
@@ -571,6 +602,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { {t("tabTransferOrders")} + {transfersErr ? ( +

{transfersErr}

+ ) : null}
@@ -603,7 +637,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) { ))} - {!transfersLoading && transfers.length === 0 ? ( + {!transfersLoading && transfers.length === 0 && !transfersErr ? ( ) : null} diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index b2c8a80..ec0b887 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -27,6 +27,7 @@ import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-st 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"; +import { AdminTableMoney } from "@/components/admin/admin-table-money"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { ConfirmableSwitch } from "@/components/admin/confirmable-switch"; import { resolvePlayerStatusTone } from "@/lib/admin-status-tone"; @@ -531,23 +532,23 @@ export function PlayersConsole(): React.ReactElement { - {err ?

{err}

: null} + {err ?

{err}

: null}
{t("table.id", { ns: "common" })} {t("site")} + {t("fundingMode")} + {t("balance")} + {t("available")} + {t("status")} {t("sitePlayerId")} {t("username")} {t("nickname")} {t("riskTags", { defaultValue: "风控标签" })} {t("currency")} - {t("fundingMode")} - {t("balance")} - {t("available")} - {t("status")} {t("lastLogin")} {t("table.actions", { ns: "common" })} @@ -567,37 +568,14 @@ export function PlayersConsole(): React.ReactElement { {row.site_code} - - - {row.site_player_id} - - {row.username ?? "—"} - {row.nickname ?? "—"} - - {riskTags.length > 0 ? ( -
- {riskTags.map((tag) => ( - - {tag} - - ))} -
- ) : ( - - )} -
- {row.default_currency} - - {balances.balance} + + {balances.balance} - - {balances.available} + + {balances.available} {row.status === 2 ? ( @@ -632,11 +610,34 @@ export function PlayersConsole(): React.ReactElement { /> ) : ( - + {playerStatusLabelT(row.status, t)} - + )} + + + {row.site_player_id} + + {row.username ?? "—"} + {row.nickname ?? "—"} + + {riskTags.length > 0 ? ( +
+ {riskTags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( + + )} +
+ {row.default_currency} {row.last_login_at ? formatDt(row.last_login_at) : "—"} diff --git a/src/modules/reconcile/reconcile-console.tsx b/src/modules/reconcile/reconcile-console.tsx index c9b35fd..4eacdd2 100644 --- a/src/modules/reconcile/reconcile-console.tsx +++ b/src/modules/reconcile/reconcile-console.tsx @@ -478,7 +478,7 @@ export function ReconcileConsole(): React.ReactElement { - {jobsErr ?

{jobsErr}

: null} + {jobsErr ?

{jobsErr}

: null} {jobs ? ( <>
diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index f25b71f..d43f3f3 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -24,6 +24,8 @@ import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws"; +import { DrawStatusBadge } from "@/modules/draws/draw-status-badge"; +import { drawStatusLabel } from "@/modules/draws/draw-display"; import { getAdminPlayers } from "@/api/admin-player"; import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs"; import { @@ -50,6 +52,7 @@ import { 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 { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { Button } from "@/components/ui/button"; @@ -1214,7 +1217,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa }} > {item.draw_no} - {item.status} + )) ) : null} @@ -1363,7 +1366,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa <> {summary.draw_no} - {summary.draw_status} + + + {summary.order_count} {summary.ticket_item_count} {formatPlainMoney(summary.total_bet_minor, summary.currency_code)} @@ -1376,7 +1381,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa {summary.settlement_batches.map((batch) => ( #{batch.id} - {batch.status} + + {batch.status} + {batch.total_ticket_count} {batch.total_win_count} - @@ -1395,7 +1402,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa {item.transfer_no} {optionText(item.username, item.nickname) || item.player_id} {item.direction} - {item.status} + + {item.status} + {item.currency_code} {item.amount} {item.external_ref_no || "-"} {item.fail_reason || "-"} diff --git a/src/modules/risk/risk-index-console.tsx b/src/modules/risk/risk-index-console.tsx deleted file mode 100644 index 1945bb3..0000000 --- a/src/modules/risk/risk-index-console.tsx +++ /dev/null @@ -1,239 +0,0 @@ -"use client"; - -import { Shield } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; -import { useExportLabels } from "@/hooks/use-export-labels"; -import { useTranslation } from "react-i18next"; -import { useAsyncEffect } from "@/hooks/use-async-effect"; -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"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { DrawStatusBadge } from "@/modules/draws/draw-status-badge"; -import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; -import { LotteryApiBizError } from "@/types/api/errors"; -import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws"; - -const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [ - { value: "pending", label: "statusOptions.pending" }, - { value: "open", label: "statusOptions.open" }, - { value: "closing", label: "statusOptions.closing" }, - { value: "closed", label: "statusOptions.closed" }, - { value: "drawing", label: "statusOptions.drawing" }, - { value: "review", label: "statusOptions.review" }, - { value: "cooldown", label: "statusOptions.cooldown" }, - { value: "settling", label: "statusOptions.settling" }, - { value: "settled", label: "statusOptions.settled" }, - { value: "cancelled", label: "statusOptions.cancelled" }, -]; - -export function RiskIndexConsole() { - const { t } = useTranslation(["risk", "common"]); - const tRef = useTranslationRef(["risk", "common"]); - const exportLabels = useExportLabels("riskIndex"); - const formatDt = useAdminDateTimeFormatter(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const [page, setPage] = useState(1); - const [perPage, setPerPage] = useState(10); - const [drawNoInput, setDrawNoInput] = useState(""); - const [drawNoQuery, setDrawNoQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState(""); - - const riskStatusTriggerLabel = useMemo(() => { - if (statusFilter === "") { - return t("all"); - } - const key = DRAW_STATUS_OPTIONS.find((o) => o.value === statusFilter)?.label; - return key ? t(key) : statusFilter; - }, [statusFilter, t]); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const d = await getAdminDraws({ - page, - per_page: perPage, - ...(drawNoQuery.trim() !== "" ? { draw_no: drawNoQuery.trim() } : {}), - ...(statusFilter !== "" ? { status: statusFilter } : {}), - }); - setData(d); - } catch (e) { - const msg = - e instanceof LotteryApiBizError ? e.message : tRef.current("loadDrawListFailed"); - setError(msg); - setData(null); - } finally { - setLoading(false); - } - }, [page, perPage, drawNoQuery, statusFilter]); - - useAsyncEffect(() => { - void load(); - }, [page, perPage, drawNoQuery, statusFilter]); - - function applySearch(): void { - setDrawNoQuery(drawNoInput.trim()); - setPage(1); - } - - const total = data?.meta.total ?? 0; - const lastPage = Math.max(1, data?.meta.last_page ?? 1); - - return ( - - - {t("center")} -
-
- - setDrawNoInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - applySearch(); - } - }} - /> -
-
- - -
-
- - - -
-
-
- - {error ?

{error}

: null} -
-
- - - {t("drawNo")} - {t("status")} - {t("closeTime")} - {t("table.actions", { ns: "common" })} - - - - {loading && (data?.items.length ?? 0) === 0 ? ( - - ) : (data?.items ?? []).length === 0 ? ( - - ) : ( - (data?.items ?? []).map((row: AdminDrawListItem) => ( - - {row.draw_no} - - - - - {row.close_time ? formatDt(row.close_time) : "—"} - - - - - - )) - )} - -
-
- { - setPerPage(n); - setPage(1); - }} - onPageChange={setPage} - /> -
- - ); -} diff --git a/src/modules/risk/risk-pools-console.tsx b/src/modules/risk/risk-pools-console.tsx index 94a7aba..9f99c20 100644 --- a/src/modules/risk/risk-pools-console.tsx +++ b/src/modules/risk/risk-pools-console.tsx @@ -14,6 +14,7 @@ import { } from "@/api/admin-risk"; 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"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -42,6 +43,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { formatAdminMinorUnits } from "@/lib/money"; +import { resolveRiskPoolStatusTone } from "@/lib/admin-status-tone"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; import type { RiskPoolsPageTitleKey } from "@/modules/risk/risk-display"; @@ -315,18 +317,12 @@ export function RiskPoolsConsole({ {row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"} - {row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")} - + ([]); const [downlineShares, setDownlineShares] = useState(null); const [loading, setLoading] = useState(true); + const [loadErr, setLoadErr] = useState(null); const [payAmount, setPayAmount] = useState(""); const [payMethod, setPayMethod] = useState(""); const [payProof, setPayProof] = useState(""); @@ -65,6 +66,7 @@ export function AgentBillDetail({ const load = useCallback(async () => { setLoading(true); + setLoadErr(null); try { const data = await getSettlementBill(billId); setBill(data.bill); @@ -72,19 +74,36 @@ export function AgentBillDetail({ setRebateAllocations(data.rebate_allocations ?? []); setDownlineShares(data.downline_shares ?? null); setPayAmount(formatAdminMinorDecimal(data.bill.unpaid_amount ?? 0, currencyCode)); + } catch (e) { + setBill(null); + setPayments([]); + setRebateAllocations([]); + setDownlineShares(null); + setLoadErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed", { ns: "common" })); } finally { setLoading(false); } - }, [billId, currencyCode]); + }, [billId, currencyCode, t]); useEffect(() => { void load(); }, [load]); - if (loading || !bill) { + if (loading && !bill) { return ; } + if (loadErr || !bill) { + return ( +
+

{loadErr ?? t("loadFailed", { ns: "common" })}

+ +
+ ); + } + const direction = describeBillPaymentDirection(bill, t); const canOperateBill = canManage && settlementBillOperableByBoundAgent(bill, boundAgent); const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status); diff --git a/src/modules/settlement/settlement-center-shell.tsx b/src/modules/settlement/settlement-center-shell.tsx index 060889e..850b394 100644 --- a/src/modules/settlement/settlement-center-shell.tsx +++ b/src/modules/settlement/settlement-center-shell.tsx @@ -59,6 +59,7 @@ export function SettlementCenterShell(): React.ReactElement { const [siteOptions, setSiteOptions] = useState([]); const [sitesReady, setSitesReady] = useState(() => boundAgent?.admin_site_id != null); + const [sitesLoadErr, setSitesLoadErr] = useState(null); const [adminSiteId, setAdminSiteId] = useState(null); const [sitePickerOpen, setSitePickerOpen] = useState(false); const [siteKeyword, setSiteKeyword] = useState(""); @@ -68,50 +69,74 @@ export function SettlementCenterShell(): React.ReactElement { const [refreshKey, setRefreshKey] = useState(0); const [periodLookupDone, setPeriodLookupDone] = useState(false); - useEffect(() => { + const loadIntegrationSites = useCallback(async (): Promise => { if (boundAgent?.admin_site_id) { const label = formatAdminSiteLabel( boundAgent.admin_site_name, boundAgent.site_code ?? boundAgent.code, ); - setSiteOptions([{ - id: boundAgent.admin_site_id, - label, - code: boundAgent.site_code ?? boundAgent.code ?? "", - currency_code: "NPR", - }]); - setAdminSiteId(boundAgent.admin_site_id); - setSitesReady(true); + const siteCode = boundAgent.site_code ?? boundAgent.code ?? ""; + const siteId = boundAgent.admin_site_id; + + setSitesReady(false); + setSitesLoadErr(null); + try { + const sites = await getAdminIntegrationSites(); + const match = sites.items.find((site) => site.id === siteId); + setSiteOptions([{ + id: siteId, + label, + code: siteCode, + currency_code: match?.currency_code ?? "NPR", + }]); + setAdminSiteId(siteId); + } catch { + setSiteOptions([{ + id: siteId, + label, + code: siteCode, + currency_code: "NPR", + }]); + setAdminSiteId(siteId); + } finally { + setSitesReady(true); + } return; } setSitesReady(false); - void getAdminIntegrationSites() - .then((sites) => { - const options = (sites.items ?? []).map((site) => ({ - id: site.id, - label: formatAdminSiteLabel(site.name, site.code), - code: site.code, - currency_code: site.currency_code ?? "NPR", - })); - setSiteOptions(options); - setAdminSiteId((current) => { - if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) { - return siteFromUrl; - } - if (current !== null && options.some((site) => site.id === current)) { - return current; - } - return options[0]?.id ?? null; - }); - }) - .catch(() => { - setSiteOptions([]); - }) - .finally(() => { - setSitesReady(true); + setSitesLoadErr(null); + try { + const sites = await getAdminIntegrationSites(); + const options = (sites.items ?? []).map((site) => ({ + id: site.id, + label: formatAdminSiteLabel(site.name, site.code), + code: site.code, + currency_code: site.currency_code ?? "NPR", + })); + setSiteOptions(options); + setAdminSiteId((current) => { + if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) { + return siteFromUrl; + } + if (current !== null && options.some((site) => site.id === current)) { + return current; + } + return options[0]?.id ?? null; }); - }, [boundAgent, siteFromUrl]); + } catch { + setSiteOptions([]); + setSitesLoadErr( + t("sites.loadFailed", { defaultValue: "站点列表加载失败,请稍后重试。" }), + ); + } finally { + setSitesReady(true); + } + }, [boundAgent, siteFromUrl, t]); + + useEffect(() => { + void loadIntegrationSites(); + }, [loadIntegrationSites]); const siteId = adminSiteId ?? siteOptions[0]?.id ?? null; const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null; @@ -300,6 +325,13 @@ export function SettlementCenterShell(): React.ReactElement {
{shellBootstrapping ? ( + ) : sitesLoadErr ? ( +
+

{sitesLoadErr}

+ +
) : siteId === null && siteOptions.length === 0 && boundAgent === null ? ( ) : siteId === null ? ( diff --git a/src/modules/settlement/settlement-credit-ledger-panel.tsx b/src/modules/settlement/settlement-credit-ledger-panel.tsx index 35590e1..ada07c1 100644 --- a/src/modules/settlement/settlement-credit-ledger-panel.tsx +++ b/src/modules/settlement/settlement-credit-ledger-panel.tsx @@ -13,6 +13,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns"; +import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -33,6 +34,7 @@ import { TableRow, } from "@/components/ui/table"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { resolveCreditLedgerRowStatusTone } from "@/lib/admin-status-tone"; import { formatAdminMinorUnits } from "@/lib/money"; import { cn } from "@/lib/utils"; import { creditLedgerReasonLabel, settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label"; @@ -367,8 +369,13 @@ export function SettlementCreditLedgerPanel({ {row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"} - - {statusLabel(row)} + + + {statusLabel(row)} + diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index e49b112..1ca1f62 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -542,7 +542,7 @@ export function TransferOrdersPanel(): React.ReactElement {
- {err ?

{err}

: null} + {err ?

{err}

: null} {(loading && !data) || data ? ( <>
@@ -860,7 +860,7 @@ export function WalletTxnsPanel(): React.ReactElement {
- {err ?

{err}

: null} + {err ?

{err}

: null} {(loading && !data) || data ? ( <>
@@ -1012,7 +1012,7 @@ export function PlayerWalletPanel(): React.ReactElement { {loading ? t("querying") : t("query")}
- {err ?

{err}

: null} + {err ?

{err}

: null} {result ? (

diff --git a/src/stores/admin-session.ts b/src/stores/admin-session.ts index 6f19bcf..3bb0692 100644 --- a/src/stores/admin-session.ts +++ b/src/stores/admin-session.ts @@ -16,18 +16,6 @@ import { readProfile, writeProfile } from "@/stores/admin-profile"; import { readToken, writeToken } from "@/stores/admin-token"; import type { AdminProfile } from "@/types/api/admin-auth"; -/** 避免 rehydrate 先用 localStorage 里的旧 navigation 渲染侧栏 */ -function profileForRehydrate(profile: AdminProfile | null): AdminProfile | null { - if (!profile) { - return null; - } - - return { - ...profile, - navigation: [], - }; -} - export type AdminSessionState = { bearerToken: string | null; adminProfile: AdminProfile | null; @@ -86,7 +74,7 @@ export const useAdminSessionStore = create((set, get) => ({ const profile = readProfile(); if (token) { setAdminBearerToken(token); - set({ bearerToken: token, adminProfile: profileForRehydrate(profile) }); + set({ bearerToken: token, adminProfile: profile }); void get().refreshAdminProfile(); } else { setAdminBearerToken(null); @@ -112,9 +100,7 @@ export const useAdminSessionStore = create((set, get) => ({ const cached = get().adminProfile ?? readProfile(); if (cached) { - const withoutNav = profileForRehydrate(cached); - writeProfile(withoutNav); - set({ adminProfile: withoutNav }); + set({ adminProfile: cached }); } } },