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