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