- {canCreatePlayer ? (
-
-
) : (
-
{t("analytics.noAgentData")}
+
)}
diff --git a/src/modules/dashboard/dashboard-chart-empty.tsx b/src/modules/dashboard/dashboard-chart-empty.tsx
index e5accd8..8600d60 100644
--- a/src/modules/dashboard/dashboard-chart-empty.tsx
+++ b/src/modules/dashboard/dashboard-chart-empty.tsx
@@ -2,23 +2,14 @@
import type { ReactElement } from "react";
-import { cn } from "@/lib/utils";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
export function DashboardChartEmpty({
message,
compact = false,
}: {
- message: string;
+ message?: string;
compact?: boolean;
}): ReactElement {
- return (
-
- {message}
-
- );
+ return
;
}
diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx
index e0324e2..e0648fa 100644
--- a/src/modules/dashboard/dashboard-console.tsx
+++ b/src/modules/dashboard/dashboard-console.tsx
@@ -31,6 +31,7 @@ import {
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -396,7 +397,7 @@ export function DashboardConsole(): ReactElement {
orders: lifetimeFinance.order_count,
tickets: lifetimeFinance.ticket_item_count,
})
- : t("states.noData", { ns: "common" })
+ : t("states.noResource", { ns: "common" })
}
actionLabel={t("actions.viewAll", { ns: "common" })}
icon={
}
@@ -446,9 +447,7 @@ export function DashboardConsole(): ReactElement {
) : finance ? (
) : (
-
- {t("states.noData", { ns: "common" })}
-
+
)}
@@ -505,9 +504,7 @@ export function DashboardConsole(): ReactElement {
) : finance ? (
) : (
-
- {t("states.noData", { ns: "common" })}
-
+
)}
@@ -550,9 +547,7 @@ export function DashboardConsole(): ReactElement {
) : finance ? (
) : (
-
- {t("states.noData", { ns: "common" })}
-
+
)}
diff --git a/src/modules/dashboard/dashboard-current-draw-card.tsx b/src/modules/dashboard/dashboard-current-draw-card.tsx
index adedef2..2552b94 100644
--- a/src/modules/dashboard/dashboard-current-draw-card.tsx
+++ b/src/modules/dashboard/dashboard-current-draw-card.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { ArrowRight, Clock, Ticket } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
@@ -45,9 +46,8 @@ export function DashboardCurrentDrawCard({
return (
-
{t("sections.currentDraw")}
- {t("states.noData", { ns: "common" })}
+
);
diff --git a/src/modules/dashboard/dashboard-page-client.tsx b/src/modules/dashboard/dashboard-page-client.tsx
new file mode 100644
index 0000000..2703885
--- /dev/null
+++ b/src/modules/dashboard/dashboard-page-client.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import type { ReactElement } from "react";
+
+import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
+import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
+import { useAdminProfile } from "@/stores/admin-session";
+
+/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */
+export function DashboardPageClient(): ReactElement {
+ const profile = useAdminProfile();
+ const isAgentOperator =
+ profile?.agent != null && profile.is_super_admin !== true;
+
+ if (isAgentOperator) {
+ return
;
+ }
+
+ return
;
+}
diff --git a/src/modules/dashboard/dashboard-trend-charts.tsx b/src/modules/dashboard/dashboard-trend-charts.tsx
index c423bcb..d12f3d1 100644
--- a/src/modules/dashboard/dashboard-trend-charts.tsx
+++ b/src/modules/dashboard/dashboard-trend-charts.tsx
@@ -80,7 +80,7 @@ export function DailyTrendChart({
);
if (series.length === 0) {
- return
;
+ return
;
}
const plotHeight = series.length <= 7 ? 240 : series.length <= 14 ? 260 : 280;
diff --git a/src/modules/dashboard/dashboard-visuals.tsx b/src/modules/dashboard/dashboard-visuals.tsx
index af5b983..51d6abf 100644
--- a/src/modules/dashboard/dashboard-visuals.tsx
+++ b/src/modules/dashboard/dashboard-visuals.tsx
@@ -20,6 +20,7 @@ import {
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
+import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChartContainer,
@@ -516,9 +517,7 @@ export function AbnormalTransferPanelFooter({
if (total == null) {
return (
-
- {t("states.noData", { ns: "common" })}
-
+
);
}
diff --git a/src/modules/dashboard/use-dashboard-analytics.ts b/src/modules/dashboard/use-dashboard-analytics.ts
index f4ab2d9..571e557 100644
--- a/src/modules/dashboard/use-dashboard-analytics.ts
+++ b/src/modules/dashboard/use-dashboard-analytics.ts
@@ -11,6 +11,7 @@ import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import {
coerceAdminMinor,
+ formatAdminCreditMajor,
formatAdminMinorUnits,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
@@ -33,6 +34,23 @@ export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
+/** 代理/玩家授信类字段:主货币整数 → 带小数展示(勿用 {@link formatDashboardMoneyMinor})。 */
+export function formatDashboardCreditMajor(major: number, currencyCode: string | null): string {
+ const code = (currencyCode ?? "NPR").toUpperCase();
+ const safeMajor = Number.isFinite(major) ? major : 0;
+ const decimals = getAdminCurrencyDecimalPlaces(code);
+ try {
+ return new Intl.NumberFormat(getAdminRequestLocale(), {
+ style: "currency",
+ currency: code,
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ }).format(safeMajor);
+ } catch {
+ return formatAdminCreditMajor(safeMajor, code, decimals);
+ }
+}
+
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx
index bad80f9..5ce1cae 100644
--- a/src/modules/draws/draw-detail-console.tsx
+++ b/src/modules/draws/draw-detail-console.tsx
@@ -17,12 +17,13 @@ import {
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
-import { adminHasAnyPermission } from "@/lib/admin-permissions";
+import { canManageDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
@@ -68,7 +69,7 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
const tRef = useTranslationRef(["draws", "common"]);
const idNum = Number(drawId);
const profile = useAdminProfile();
- const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
+ const canManageDraw = canManageDrawResults(profile?.permissions);
const canReopenDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_REOPEN_MANAGE]);
const canRunSettlement = adminHasAnyPermission(profile?.permissions, [
PRD_PAYOUT_MANAGE,
@@ -213,12 +214,18 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
return
;
}
- if (error || !data) {
- return
{error ?? t("states.noData", { ns: "common" })}
;
+ if (error) {
+ return
{error}
;
+ }
+ if (!data) {
+ return
;
}
const batch = data.result_batch_counts;
- const hasResultActivity = batch.total > 0 || batch.pending_review > 0 || batch.published > 0;
+ const pendingReview = batch.pending_review ?? 0;
+ const totalBatches = batch.total ?? batch.published;
+ const hasResultActivity =
+ (canManageDraw && (totalBatches > 0 || pendingReview > 0)) || batch.published > 0;
const showActions =
availableActions.length > 0 && (canManageDraw || canReopenDraw || canRunSettlement);
@@ -264,21 +271,25 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
{t("resultBatchesTitle")}
{hasResultActivity ? (
-
- {t("batchSummaryTotal", { count: batch.total })}
-
- {batch.pending_review > 0 ? (
-
- {t("batchSummaryPending", { count: batch.pending_review })}
-
- ) : (
-
- {t("batchSummaryPending", { count: 0 })}
+ {canManageDraw ? (
+
+ {t("batchSummaryTotal", { count: totalBatches })}
- )}
+ ) : null}
+ {canManageDraw ? (
+ pendingReview > 0 ? (
+
+ {t("batchSummaryPending", { count: pendingReview })}
+
+ ) : (
+
+ {t("batchSummaryPending", { count: 0 })}
+
+ )
+ ) : null}
{batch.published > 0 ? (
) : (
- {t("noResultBatchesYet")}{" "}
-
- {t("goToReviewTab")}
-
+ {t("noResultBatchesYet")}
+ {canManageDraw ? (
+ <>
+ {" "}
+
+ {t("goToReviewTab")}
+
+ >
+ ) : null}
)}
diff --git a/src/modules/draws/draw-finance-console.tsx b/src/modules/draws/draw-finance-console.tsx
index 095c996..07141aa 100644
--- a/src/modules/draws/draw-finance-console.tsx
+++ b/src/modules/draws/draw-finance-console.tsx
@@ -13,6 +13,7 @@ import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -96,8 +97,11 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
return ;
}
- if (err || !data) {
- return {err ?? t("states.noData", { ns: "common" })}
;
+ if (err) {
+ return {err}
;
+ }
+ if (!data) {
+ return ;
}
const currencyCode = data.currency_code ?? "NPR";
@@ -180,7 +184,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
{data.settlement_batches.length === 0 ? (
- {t("noSettlementBatches")}
+
) : (
diff --git a/src/modules/draws/draw-publish-console.tsx b/src/modules/draws/draw-publish-console.tsx
index c8ba78e..d29b79d 100644
--- a/src/modules/draws/draw-publish-console.tsx
+++ b/src/modules/draws/draw-publish-console.tsx
@@ -16,6 +16,7 @@ import {
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -119,8 +120,11 @@ export function DrawPublishConsole({ drawId, batchId }: { drawId: string; batchI
return
;
}
- if (error || !data) {
- return
{error ?? t("states.noData", { ns: "common" })}
;
+ if (error) {
+ return
{error}
;
+ }
+ if (!data) {
+ return
;
}
if (!batch) {
diff --git a/src/modules/draws/draw-results-console.tsx b/src/modules/draws/draw-results-console.tsx
index 1d39c56..c0e74b4 100644
--- a/src/modules/draws/draw-results-console.tsx
+++ b/src/modules/draws/draw-results-console.tsx
@@ -9,6 +9,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminDrawResultBatches } from "@/api/admin-draws";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -20,22 +21,19 @@ import {
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
-import { adminHasAnyPermission } from "@/lib/admin-permissions";
+import { canManageDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawBatchRow, AdminDrawBatchesData } from "@/types/api/admin-draws";
import { drawPrizeTypeLabel } from "./draw-display";
-import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
export function DrawResultsConsole({ drawId }: { drawId: string }) {
const { t } = useTranslation(["draws", "common"]);
const tRef = useTranslationRef(["draws", "common"]);
const profile = useAdminProfile();
- const canManageDraw = adminHasAnyPermission(profile?.permissions, [
- PRD_DRAW_RESULT_MANAGE,
- ]);
+ const canManageDraw = canManageDrawResults(profile?.permissions);
const idNum = Number(drawId);
const [data, setData] = useState
(null);
const [error, setError] = useState(null);
@@ -67,8 +65,11 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
return ;
}
- if (error || !data) {
- return {error ?? t("states.noData", { ns: "common" })}
;
+ if (error) {
+ return {error}
;
+ }
+ if (!data) {
+ return ;
}
const published = data.batches.filter((b) => b.status === "published");
@@ -82,41 +83,57 @@ export function DrawResultsConsole({ drawId }: { drawId: string }) {
{t("drawNo")} {data.draw_no} ·
-
- {canManageDraw ? t("reviewAndPublish") : t("viewReviewQueue")}
-
+ {canManageDraw ? (
+
+ {t("reviewAndPublish")}
+
+ ) : null}
{published.length === 0 ? (
-
- {t("noPublishedBatch")}
+
+
) : (
- published.map((batch) => )
+ published.map((batch) => (
+
+ ))
)}
);
}
-function BatchTable({ batch }: { batch: AdminDrawBatchRow }) {
+function BatchTable({
+ batch,
+ showOperationalMeta,
+}: {
+ batch: AdminDrawBatchRow;
+ showOperationalMeta: boolean;
+}) {
const { t } = useTranslation("draws");
const formatDt = useAdminDateTimeFormatter();
return (
{t("version", { version: batch.result_version })}
-
- {t("sourceType", {
- source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
- })}{" "}
- · {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
- {t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
-
+ {showOperationalMeta ? (
+
+ {t("sourceType", {
+ source: batch.source_type === "manual" ? t("manualEntry") : t("rng"),
+ })}{" "}
+ · {t("rngSummary", { hash: batch.rng_seed_hash ?? "—" })} ·{" "}
+ {t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
+
+ ) : (
+
+ {t("confirmedAt", { time: formatDt(batch.confirmed_at) })}
+
+ )}
diff --git a/src/modules/draws/draw-review-console.tsx b/src/modules/draws/draw-review-console.tsx
index cc71f74..f642db5 100644
--- a/src/modules/draws/draw-review-console.tsx
+++ b/src/modules/draws/draw-review-console.tsx
@@ -16,6 +16,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
@@ -152,8 +153,11 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
return ;
}
- if (error || !data) {
- return {error ?? t("states.noData", { ns: "common" })}
;
+ if (error) {
+ return {error}
;
+ }
+ if (!data) {
+ return ;
}
return (
@@ -233,9 +237,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
{pending.length === 0 ? (
-
- {t("noPendingBatches")}
-
+
) : (
diff --git a/src/modules/draws/draw-subnav.tsx b/src/modules/draws/draw-subnav.tsx
index 9a697bd..7dfe6de 100644
--- a/src/modules/draws/draw-subnav.tsx
+++ b/src/modules/draws/draw-subnav.tsx
@@ -2,18 +2,35 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
+import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
+import { adminHasAnyPermission } from "@/lib/admin-permissions";
+import { PRD_DRAW_FINANCE_ACCESS_ANY, PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
+import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
+import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
const segments = [
- { suffix: "", key: "status", label: "subnav.status" },
- { suffix: "/results", key: "results", label: "subnav.results" },
- { suffix: "/finance", key: "finance", label: "subnav.finance" },
- { suffix: "/review", key: "review", label: "subnav.review" },
- { suffix: "/risk/occupancy", key: "riskLockLogs", label: "subnav.riskLockLogs" },
- { suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
+ { suffix: "", key: "status", label: "subnav.status", requiresManage: false },
+ { suffix: "/results", key: "results", label: "subnav.results", requiresManage: false },
+ { suffix: "/finance", key: "finance", label: "subnav.finance", requiresManage: false },
+ { suffix: "/review", key: "review", label: "subnav.review", requiresManage: true },
+ {
+ suffix: "/risk/occupancy",
+ key: "riskLockLogs",
+ label: "subnav.riskLockLogs",
+ requiresManage: false,
+ requiresRisk: true,
+ },
+ {
+ suffix: "/risk/pools",
+ key: "riskPools",
+ label: "subnav.riskPools",
+ requiresManage: false,
+ requiresRisk: true,
+ },
] as const;
function isRiskPoolsTabActive(pathname: string, base: string): boolean {
@@ -23,6 +40,7 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
}
const rest = pathname.slice(riskPrefix.length);
+
return (
rest === "pools"
|| rest.startsWith("pools/")
@@ -34,21 +52,50 @@ function isRiskPoolsTabActive(pathname: string, base: string): boolean {
function isReviewTabActive(pathname: string, base: string): boolean {
const reviewPrefix = `${base}/review`;
const publishPrefix = `${base}/publish`;
+
return (
- pathname === reviewPrefix ||
- pathname.startsWith(`${reviewPrefix}/`) ||
- pathname.startsWith(`${publishPrefix}/`)
+ pathname === reviewPrefix
+ || pathname.startsWith(`${reviewPrefix}/`)
+ || pathname.startsWith(`${publishPrefix}/`)
);
}
-export function DrawSubnav({ drawId }: { drawId: string }) {
+export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
const { t } = useTranslation("draws");
const pathname = usePathname();
const base = `/admin/draws/${drawId}`;
+ const profile = useAdminProfile();
+ const perms = profile?.permissions ?? [];
+
+ const canViewDraw = canViewDrawResults(perms);
+ const canManageDraw = canManageDrawResults(perms);
+ const canViewFinance = canViewDrawFinance(perms);
+ const canViewRisk = adminHasAnyPermission(perms, [...PRD_RISK_ACCESS_ANY]);
+
+ const visibleSegments = useMemo(
+ () =>
+ segments.filter((segment) => {
+ if (!canViewDraw) {
+ return false;
+ }
+ if (segment.requiresManage && !canManageDraw) {
+ return false;
+ }
+ if (segment.key === "finance" && !canViewFinance) {
+ return false;
+ }
+ if ("requiresRisk" in segment && segment.requiresRisk && !canViewRisk) {
+ return false;
+ }
+
+ return true;
+ }),
+ [canManageDraw, canViewDraw, canViewRisk],
+ );
return (
{loading ? (
-
+
) : data === null || data.items.length === 0 ? (
-
-
- {t("states.noData", { ns: "common" })}
-
-
+
) : (
data.items.map((row: AdminDrawListItem) => (
@@ -431,26 +434,30 @@ export function DrawsIndexConsole() {
label={drawStatusLabel(row.status, t)}
/>
-
- {row.total_bet_minor != null
- ? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
- : "—"}
-
-
- {row.total_payout_minor != null
- ? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
- : "—"}
-
-
- {row.profit_loss_minor != null
- ? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
- : "—"}
-
+ {canViewFinance ? (
+ <>
+
+ {row.total_bet_minor != null
+ ? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
+ : "—"}
+
+
+ {row.total_payout_minor != null
+ ? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
+ : "—"}
+
+
+ {row.profit_loss_minor != null
+ ? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
+ : "—"}
+
+ >
+ ) : null}
void;
+ disabled?: boolean;
+ busy?: boolean;
+}): React.ReactElement {
+ return (
+
+
+
+ );
+}
+
+function MaskedValueWithCopy({
+ configured,
+ masked,
+ copyLabel,
+ canCopy,
+ copying,
+ onCopy,
+}: {
+ configured: boolean;
+ masked: string | null;
+ copyLabel: string;
+ canCopy: boolean;
+ copying: boolean;
+ onCopy: () => void;
+}): React.ReactElement {
+ return (
+
+
+ {configured ? (masked ?? "••••••••") : "—"}
+
+ {configured && canCopy ? (
+
+ ) : null}
+
+ );
+}
+
type FormState = {
code: string;
name: string;
@@ -140,7 +196,7 @@ function formToPayload(
}
type IntegrationSitesConsoleProps = {
- /** 代理线路内站点列表:仅超管可新建站点,普通账号走「开通线路」。 */
+ /** 为 true 时仅超管可新建站点(默认有 integration.site.manage 即可创建)。 */
restrictCreateToSuperAdmin?: boolean;
};
@@ -180,9 +236,12 @@ export function IntegrationSitesConsole({
const [connectivityResult, setConnectivityResult] =
useState(null);
const [exportBusyId, setExportBusyId] = useState(null);
+ const [secretCopyBusyKey, setSecretCopyBusyKey] = useState(null);
+ const secretsCacheRef = useRef(new Map());
const load = useCallback(async () => {
setLoading(true);
+ secretsCacheRef.current.clear();
try {
const data = await getAdminIntegrationSites();
setItems(data.items);
@@ -273,6 +332,7 @@ export function IntegrationSitesConsole({
const result = await postAdminIntegrationSiteRotateSecrets(rotateTarget.id);
toast.success(t("integrationSites.rotateSuccess", { code: rotateTarget.code }));
setRotateTarget(null);
+ secretsCacheRef.current.delete(rotateTarget.id);
showSecretsOnce(result);
await load();
} catch (error) {
@@ -350,6 +410,55 @@ export function IntegrationSitesConsole({
}
}
+ async function resolveSiteSecrets(siteId: number): Promise {
+ const cached = secretsCacheRef.current.get(siteId);
+ if (cached) {
+ return cached;
+ }
+ const secrets = await getAdminIntegrationSiteSecrets(siteId);
+ secretsCacheRef.current.set(siteId, secrets);
+ return secrets;
+ }
+
+ async function copySiteSecret(
+ row: AdminIntegrationSiteRow,
+ field: "sso" | "wallet",
+ ): Promise {
+ if (!canManage) {
+ toast.error(t("integrationSites.secretCopyRequiresManage"));
+ return;
+ }
+
+ const configured = field === "sso" ? row.has_sso_secret : row.has_wallet_api_key;
+ if (!configured) {
+ toast.error(t("integrationSites.secretNotConfigured"));
+ return;
+ }
+
+ const busyKey = `${row.id}:${field}`;
+ setSecretCopyBusyKey(busyKey);
+ try {
+ const secrets = await resolveSiteSecrets(row.id);
+ const value = field === "sso" ? secrets.sso_jwt_secret : secrets.wallet_api_key;
+ if (!value) {
+ toast.error(t("integrationSites.secretNotConfigured"));
+ return;
+ }
+ await copyText(
+ field === "sso"
+ ? t("integrationSites.fields.ssoSecret")
+ : t("integrationSites.fields.walletApiKey"),
+ value,
+ );
+ } catch (error) {
+ toast.error(
+ error instanceof LotteryApiBizError ? error.message : t("integrationSites.copyFailed"),
+ );
+ } finally {
+ setSecretCopyBusyKey(null);
+ }
+ }
+
return (
<>
) : items.length === 0 ? (
- {t("integrationSites.empty")}
+
) : (
+
{t("integrationSites.columns.code")}
{t("integrationSites.columns.name")}
+ {t("integrationSites.columns.currency")}
{t("integrationSites.columns.status")}
+ {t("integrationSites.columns.lineRoot")}
{t("integrationSites.columns.walletUrl")}
+ {t("integrationSites.columns.h5Url")}
+ {t("integrationSites.columns.ssoSecret")}
+ {t("integrationSites.columns.walletApiKey")}
{t("integrationSites.columns.actions")}
{items.map((row) => (
- {row.code}
+
+
+ {row.code}
+ void copyText(t("integrationSites.columns.code"), row.code)}
+ />
+
+
{row.name}
+ {row.currency_code}
-
- {row.wallet_api_url ?? "—"}
+
+
+ {row.has_line_root
+ ? t("integrationSites.lineRootBound")
+ : t("integrationSites.lineRootUnbound")}
+
+
+
+
+
+ {row.wallet_api_url ?? "—"}
+
+ {row.wallet_api_url ? (
+
+ void copyText(
+ t("integrationSites.columns.walletUrl"),
+ row.wallet_api_url ?? "",
+ )
+ }
+ />
+ ) : null}
+
+
+
+ {row.lottery_h5_base_url ?? "—"}
+
+
+ void copySiteSecret(row, "sso")}
+ />
+
+
+ void copySiteSecret(row, "wallet")}
+ />
+
)}
@@ -645,36 +815,30 @@ export function IntegrationSitesConsole({
-
void copyText(
t("integrationSites.fields.ssoSecret"),
secretsDialog.secrets.sso_jwt_secret,
)
}
- >
- {t("integrationSites.copy")}
-
+ />
diff --git a/src/modules/jackpot/jackpot-pools-console.tsx b/src/modules/jackpot/jackpot-pools-console.tsx
index f2512c3..4bfe8e2 100644
--- a/src/modules/jackpot/jackpot-pools-console.tsx
+++ b/src/modules/jackpot/jackpot-pools-console.tsx
@@ -17,6 +17,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_JACKPOT_MANAGE, PRD_JACKPOT_MANUAL_BURST } from "@/lib/admin-prd";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
+import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -43,6 +44,11 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
+import {
+ formatRatioAsPercent,
+ percentUiToRatio,
+ ratioToPercentUi,
+} from "@/lib/admin-rate-percent";
type Draft = {
contribution_rate: string;
@@ -63,9 +69,9 @@ type AdjustmentDraft = {
function toDraft(p: AdminJackpotPoolRow): Draft {
return {
- contribution_rate: String(p.contribution_rate),
+ contribution_rate: ratioToPercentUi(p.contribution_rate),
trigger_threshold: formatAdminMinorDecimal(p.trigger_threshold, p.currency_code),
- payout_rate: String(p.payout_rate),
+ payout_rate: ratioToPercentUi(p.payout_rate),
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
min_bet_amount: formatAdminMinorDecimal(p.min_bet_amount, p.currency_code),
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","),
@@ -148,9 +154,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
setSavingId(p.id);
try {
await putAdminJackpotPool(p.id, {
- contribution_rate: Number(d.contribution_rate),
+ contribution_rate: percentUiToRatio(d.contribution_rate),
trigger_threshold: parseAdminMajorToMinor(d.trigger_threshold, p.currency_code) ?? 0,
- payout_rate: Number(d.payout_rate),
+ payout_rate: percentUiToRatio(d.payout_rate),
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
min_bet_amount: parseAdminMajorToMinor(d.min_bet_amount, p.currency_code) ?? 0,
combo_trigger_play_codes: d.combo_trigger_play_codes
@@ -233,7 +239,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
{loading ?
: null}
{!loading && items.length === 0 ? (
-
{t("noPoolData")}
+
) : null}
{items.map((p) => {
const d = drafts[p.id] ?? toDraft(p);
@@ -269,7 +275,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
{t("payoutRate")}
-
{d.payout_rate}
+
+ {formatRatioAsPercent(percentUiToRatio(d.payout_rate))}
+
{t("forceTriggerGap")}
@@ -381,6 +389,10 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
{t("contributionRate")}
{(payouts?.items ?? []).length === 0 ? (
-
-
- {t("states.noData", { ns: "common" })}
-
-
+
) : (
(payouts?.items ?? []).map((r) => (
@@ -303,11 +300,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
{(contribs?.items ?? []).length === 0 ? (
-
-
- {t("states.noData", { ns: "common" })}
-
-
+
) : (
(contribs?.items ?? []).map((r) => (
diff --git a/src/modules/players/player-detail-console.tsx b/src/modules/players/player-detail-console.tsx
index 1dae4bc..4baf4e0 100644
--- a/src/modules/players/player-detail-console.tsx
+++ b/src/modules/players/player-detail-console.tsx
@@ -11,6 +11,7 @@ import { getAdminPlayer } from "@/api/admin-player";
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
+import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { buttonVariants } from "@/components/ui/button";
@@ -29,6 +30,13 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
import { formatAdminMinorUnits } from "@/lib/money";
+import {
+ isCreditFundingPlayer,
+ playerAuthSourceLabel,
+ playerFundingModeLabel,
+ playerShowsTransferOrders,
+} from "@/lib/player-funding";
+import { formatPlayerCreditAmount } from "@/lib/admin-player-display";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
@@ -209,7 +217,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
}, [player, loadTxns]);
useAsyncEffect(() => {
- if (!player) return;
+ if (!player || !playerShowsTransferOrders(player)) return;
void loadTransfers();
}, [player, loadTransfers]);
@@ -217,6 +225,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
return ;
}
+ const isCreditPlayer = player ? isCreditFundingPlayer(player) : false;
+ const showTransferTab = player ? playerShowsTransferOrders(player) : false;
+
if (playerErr || !player) {
return (
@@ -224,7 +235,11 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
{t("backToList")}
-
{playerErr ?? t("states.noData", { ns: "common" })}
+ {playerErr ? (
+
{playerErr}
+ ) : (
+
+ )}
);
}
@@ -263,11 +278,13 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
{t("tabTickets")}
- {t("tabWalletTxns")}
-
-
- {t("tabTransferOrders")}
+ {isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
+ {showTransferTab ? (
+
+ {t("tabTransferOrders")}
+
+ ) : null}
@@ -289,6 +306,12 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
{player.default_currency}
+
+ {playerFundingModeLabel(player, t)}
+
+
+ {playerAuthSourceLabel(player, t)}
+
@@ -312,11 +335,40 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
- {t("walletsSection")}
+
+ {isCreditPlayer ? t("creditSection") : t("walletsSection")}
+
- {player.wallets.length === 0 ? (
- {t("states.noData", { ns: "common" })}
+ {isCreditPlayer ? (
+
+
+
{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 ? (
+
) : (
{player.wallets.map((w: AdminPlayerWalletRow) => (
@@ -399,11 +451,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
))}
{!ticketsLoading && tickets.length === 0 ? (
-
-
- {t("states.noData", { ns: "common" })}
-
-
+
) : null}
@@ -428,7 +476,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
- {t("tabWalletTxns")}
+
+ {isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
+
@@ -466,11 +516,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
))}
{!txnsLoading && txns.length === 0 ? (
-
-
- {t("states.noData", { ns: "common" })}
-
-
+
) : null}
@@ -492,6 +538,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
+ {showTransferTab ? (
@@ -531,11 +578,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
))}
{!transfersLoading && transfers.length === 0 ? (
-
-
- {t("states.noData", { ns: "common" })}
-
-
+
) : null}
@@ -556,6 +599,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
+ ) : null}
);
diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx
index a333fe4..a709256 100644
--- a/src/modules/players/players-console.tsx
+++ b/src/modules/players/players-console.tsx
@@ -23,6 +23,7 @@ import {
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
+import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
@@ -61,9 +62,11 @@ import {
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
+import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
+import { playerBalanceCells } from "@/lib/admin-player-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
-import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
+import type { AdminPlayerRow } from "@/types/api/admin-player";
function playerStatusLabelT(status: number, t: (key: string) => string): string {
if (status === 0) return t("statusNormal");
@@ -72,15 +75,6 @@ function playerStatusLabelT(status: number, t: (key: string) => string): string
return String(status);
}
-function preferredDisplayWallet(row: AdminPlayerRow): AdminPlayerWalletRow | null {
- const { wallets, default_currency } = row;
- if (wallets.length === 0) {
- return null;
- }
- const code = default_currency.trim().toUpperCase();
- return wallets.find((w) => w.currency_code.toUpperCase() === code) ?? wallets[0];
-}
-
const PLAYER_STATUS_OPTIONS = [
{ value: 0, label: "statusNormal" },
{ value: 1, label: "statusFrozen" },
@@ -108,6 +102,7 @@ export function PlayersConsole(): React.ReactElement {
const [perPage, setPerPage] = useState(10);
const [keyword, setKeyword] = useState(keywordFromUrl);
const [query, setQuery] = useState(keywordFromUrl);
+ const [siteFilter, setSiteFilter] = useState("");
const [items, setItems] = useState