refactor(layout, i18n, admin): 优化布局结构与多语言支持
调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
This commit is contained in:
@@ -38,12 +38,10 @@ module.exports = {
|
|||||||
env: {
|
env: {
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
PORT: "3801",
|
PORT: "3801",
|
||||||
|
|
||||||
// Laravel 根地址(无尾部 /);同机部署填 http://127.0.0.1:8000
|
|
||||||
LOTTERY_API_UPSTREAM: "http://127.0.0.1:8000",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// env_file: path.join(APP_CWD, ".env"),
|
// LOTTERY_API_UPSTREAM 只写在 .env,勿在此硬编码(会覆盖 .env 导致 API 502)
|
||||||
|
env_file: path.join(APP_CWD, ".env"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
public/illustration.png
Normal file
BIN
public/illustration.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -7,8 +7,8 @@ export default function AdminShellLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ShellAuthGate>
|
<AdminShell>
|
||||||
<AdminShell>{children}</AdminShell>
|
<ShellAuthGate>{children}</ShellAuthGate>
|
||||||
</ShellAuthGate>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const metadata: Metadata = buildPageMetadata("dashboard", "title");
|
|||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
return (
|
return (
|
||||||
<ModuleScaffold className="max-w-7xl">
|
<ModuleScaffold>
|
||||||
<AdminPermissionGate requiredAny={PRD_DASHBOARD_ACCESS_ANY}>
|
<AdminPermissionGate requiredAny={PRD_DASHBOARD_ACCESS_ANY}>
|
||||||
<DashboardConsole />
|
<DashboardConsole />
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
|
|||||||
8
src/app/admin/login/layout.tsx
Normal file
8
src/app/admin/login/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** 登录页锁定一屏高度,避免整页随内容滚动 */
|
||||||
|
export default function AdminLoginLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return <div className="h-dvh max-h-dvh min-h-0 overflow-hidden">{children}</div>;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -37,7 +38,9 @@ export default function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="flex min-h-full flex-col">
|
<body className="flex min-h-full flex-col">
|
||||||
<script
|
<Script
|
||||||
|
id="lottery-admin-locale-bootstrap"
|
||||||
|
strategy="beforeInteractive"
|
||||||
dangerouslySetInnerHTML={{ __html: ADMIN_LOCALE_BOOTSTRAP }}
|
dangerouslySetInnerHTML={{ __html: ADMIN_LOCALE_BOOTSTRAP }}
|
||||||
/>
|
/>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
|
|||||||
147
src/components/admin/admin-auth-checking.tsx
Normal file
147
src/components/admin/admin-auth-checking.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
|
||||||
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
|
|
||||||
|
function LoginCheckingBackdrop(): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(59_130_246/0.09),transparent)] dark:bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(56_189_248/0.12),transparent)]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,transparent_0%,var(--background)_100%)] opacity-90"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.35] dark:opacity-[0.2]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(to right, var(--border) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, var(--border) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: "48px 48px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginCheckingSkeletonCard(): ReactElement {
|
||||||
|
const { t } = useTranslation("auth");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="relative w-full max-w-[420px] rounded-2xl border border-border/70 bg-card/90 shadow-2xl shadow-black/[0.06] ring-1 ring-black/[0.03] backdrop-blur-md dark:border-border/50 dark:bg-card/85 dark:shadow-black/25 dark:ring-white/[0.06]">
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3.5 border-b border-border/60 px-6 py-6 sm:px-8">
|
||||||
|
<Skeleton className="size-10 shrink-0 rounded-xl" />
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-3.5 w-48 max-w-full" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 px-6 pb-8 pt-6 sm:px-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-11 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-11 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-14" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Skeleton className="h-11 min-w-0 flex-1 rounded-md" />
|
||||||
|
<Skeleton className="h-11 w-[156px] shrink-0 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="mt-2 h-11 w-full rounded-md" />
|
||||||
|
</CardContent>
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("loginTitle", { defaultValue: authModuleMeta.title })}
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工作台内容区骨架(外层已由 {@link AdminShell} 提供侧栏与顶栏) */
|
||||||
|
export function AdminShellContentSkeleton(): ReactElement {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModuleScaffold className="gap-5">
|
||||||
|
<span className="sr-only" role="status" aria-live="polite">
|
||||||
|
{t("auth.checking", { defaultValue: "Checking sign-in status…" })}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
<Skeleton className="h-3 w-36" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-[5.5rem] w-full rounded-xl" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[11.5rem] w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||||
|
<Skeleton className="h-72 rounded-xl xl:col-span-8" />
|
||||||
|
<Skeleton className="h-72 rounded-xl xl:col-span-4" />
|
||||||
|
</div>
|
||||||
|
</ModuleScaffold>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAuthCheckingScreen({
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
variant: "login" | "shell";
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
|
if (variant === "login") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex h-dvh max-h-dvh w-full min-h-0 flex-col overflow-hidden lg:flex-row"
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("auth.checking", { defaultValue: "Checking sign-in status…" })}
|
||||||
|
>
|
||||||
|
<LoginCheckingBackdrop />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex min-h-0 max-h-[38dvh] shrink-0 items-center justify-center overflow-hidden px-6 py-4 lg:max-h-none lg:w-1/2 lg:flex-1 lg:px-10 lg:py-8">
|
||||||
|
<Image
|
||||||
|
src="/illustration.png"
|
||||||
|
alt=""
|
||||||
|
width={1024}
|
||||||
|
height={1536}
|
||||||
|
priority
|
||||||
|
className="mx-auto max-h-full w-auto max-w-full object-contain opacity-95"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-6 lg:w-1/2 lg:py-8">
|
||||||
|
<div className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6">
|
||||||
|
<AdminLanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
<LoginCheckingSkeletonCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminShellContentSkeleton />;
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { adminNavLabel } from "@/lib/admin-nav-label";
|
||||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@@ -21,29 +22,6 @@ const DRAW_ROUTE_LABELS: Record<string, string> = {
|
|||||||
results: "Results",
|
results: "Results",
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_TRANSLATION_KEYS: Record<string, string> = {
|
|
||||||
dashboard: "dashboard",
|
|
||||||
admin_users: "admin_users",
|
|
||||||
admin_roles: "admin_roles",
|
|
||||||
players: "players",
|
|
||||||
currencies: "currencies",
|
|
||||||
wallet: "wallet",
|
|
||||||
draws: "draws",
|
|
||||||
rules_plays: "rules_plays",
|
|
||||||
rules_odds: "rules_odds",
|
|
||||||
jackpot: "jackpot",
|
|
||||||
risk_cap: "risk_cap",
|
|
||||||
risk: "risk",
|
|
||||||
settlement: "settlement",
|
|
||||||
reconcile: "reconcile",
|
|
||||||
reports: "reports",
|
|
||||||
tickets: "tickets",
|
|
||||||
audit: "audit",
|
|
||||||
settings: "settings",
|
|
||||||
integration: "integration",
|
|
||||||
config: "config",
|
|
||||||
};
|
|
||||||
|
|
||||||
const RULES_ROUTE_LABELS: Record<string, string> = {
|
const RULES_ROUTE_LABELS: Record<string, string> = {
|
||||||
plays: "nav.items.plays",
|
plays: "nav.items.plays",
|
||||||
odds: "nav.rulesOddsTitle",
|
odds: "nav.rulesOddsTitle",
|
||||||
@@ -113,13 +91,7 @@ export function AdminBreadcrumb() {
|
|||||||
.sort((a, b) => b.href.length - a.href.length)[0];
|
.sort((a, b) => b.href.length - a.href.length)[0];
|
||||||
|
|
||||||
if (navItem && navItem.href !== ADMIN_BASE) {
|
if (navItem && navItem.href !== ADMIN_BASE) {
|
||||||
const translatedNavLabel =
|
const translatedNavLabel = adminNavLabel(navItem.segment, t, navItem.label);
|
||||||
NAV_TRANSLATION_KEYS[navItem.segment] != null
|
|
||||||
? t(`nav.${NAV_TRANSLATION_KEYS[navItem.segment]}`, {
|
|
||||||
ns: "common",
|
|
||||||
defaultValue: navItem.label,
|
|
||||||
})
|
|
||||||
: navItem.label;
|
|
||||||
breadcrumbs.push({
|
breadcrumbs.push({
|
||||||
label: translatedNavLabel,
|
label: translatedNavLabel,
|
||||||
href: navItem.href,
|
href: navItem.href,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon, GlobeIcon } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, GlobeIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -22,12 +22,6 @@ import {
|
|||||||
} from "@/lib/admin-locale";
|
} from "@/lib/admin-locale";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const LOCALE_FLAGS: Record<AdminApiLocale, string> = {
|
|
||||||
zh: "🇨🇳",
|
|
||||||
en: "🇺🇸",
|
|
||||||
ne: "🇳🇵",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminLanguageSwitcher() {
|
export function AdminLanguageSwitcher() {
|
||||||
const { i18n, t } = useTranslation("common");
|
const { i18n, t } = useTranslation("common");
|
||||||
// Match SSR: do not read document/localStorage until after mount.
|
// Match SSR: do not read document/localStorage until after mount.
|
||||||
@@ -57,25 +51,27 @@ export function AdminLanguageSwitcher() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentFlag = LOCALE_FLAGS[locale];
|
const currentLabel = t(`language.${locale}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="inline-flex h-8 items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2 text-left text-slate-700 shadow-[0_1px_2px_rgba(15,23,42,0.04)] outline-none transition hover:border-slate-300 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-ring">
|
<DropdownMenuTrigger
|
||||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs">
|
aria-label={t("language.title")}
|
||||||
{currentFlag}
|
className="inline-flex h-9 max-w-[9rem] items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground outline-none transition-colors hover:bg-muted/80 focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
</span>
|
>
|
||||||
<GlobeIcon
|
<GlobeIcon
|
||||||
className="size-4 shrink-0 stroke-[1.75] text-slate-400 sm:hidden"
|
className="size-4 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 truncate">{currentLabel}</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="size-3.5 shrink-0 text-muted-foreground/80"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent align="end" className="min-w-[10.5rem]">
|
||||||
align="end"
|
<DropdownMenuGroup>
|
||||||
className="w-[188px] overflow-hidden rounded-xl border border-slate-200 bg-white p-1 shadow-[0_16px_40px_rgba(15,23,42,0.12)]"
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
>
|
|
||||||
<DropdownMenuGroup className="space-y-0.5">
|
|
||||||
<DropdownMenuLabel className="sr-only">
|
|
||||||
{t("language.title")}
|
{t("language.title")}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{ADMIN_API_LOCALES.map((code) => {
|
{ADMIN_API_LOCALES.map((code) => {
|
||||||
@@ -85,29 +81,17 @@ export function AdminLanguageSwitcher() {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={code}
|
key={code}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[42px] items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-slate-700 outline-none transition",
|
"flex items-center justify-between gap-2",
|
||||||
active
|
active && "bg-accent text-accent-foreground",
|
||||||
? "border-rose-100 bg-rose-50 text-rose-600"
|
|
||||||
: "hover:bg-slate-50 focus:bg-slate-50",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => void onSelectLocale(code)}
|
onClick={() => void onSelectLocale(code)}
|
||||||
>
|
>
|
||||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-white text-lg shadow-[inset_0_0_0_1px_rgba(148,163,184,0.16)]">
|
<span className="truncate font-medium">
|
||||||
{LOCALE_FLAGS[code]}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"block truncate text-[14px] font-semibold leading-5",
|
|
||||||
active ? "text-rose-600" : "text-slate-800",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{ADMIN_LOCALE_LABELS[code]}
|
{ADMIN_LOCALE_LABELS[code]}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
{active ? (
|
||||||
<span className="flex w-3 shrink-0 justify-end">
|
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||||
{active ? <CheckIcon className="size-3.5 text-rose-500" /> : null}
|
) : null}
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
115
src/components/admin/admin-row-actions-menu.tsx
Normal file
115
src/components/admin/admin-row-actions-menu.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import { Loader2, MoreHorizontal, type LucideIcon } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type AdminRowActionItem = {
|
||||||
|
key: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminRowActionsMenuProps = {
|
||||||
|
actions: AdminRowActionItem[];
|
||||||
|
busy?: boolean;
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
triggerClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 表格行操作:省略号触发,下拉展示带图标的菜单项;破坏性操作前自动加分隔线。 */
|
||||||
|
export function AdminRowActionsMenu({
|
||||||
|
actions,
|
||||||
|
busy = false,
|
||||||
|
align = "end",
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
triggerClassName,
|
||||||
|
}: AdminRowActionsMenuProps) {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const visible = actions.filter((item) => !item.hidden);
|
||||||
|
|
||||||
|
if (visible.length === 0) {
|
||||||
|
return <span className={cn("text-xs text-muted-foreground", className)}>—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex justify-center", className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={ariaLabel ?? t("aria.rowActionsMenu")}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline", size: "icon-sm" }),
|
||||||
|
"text-muted-foreground hover:text-foreground",
|
||||||
|
triggerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="size-4" aria-hidden />
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="min-w-[10.5rem]">
|
||||||
|
{visible.map((action, index) => {
|
||||||
|
const prev = visible[index - 1];
|
||||||
|
const showSeparator = Boolean(action.destructive && prev && !prev.destructive);
|
||||||
|
const Icon = action.icon;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
{Icon ? <Icon aria-hidden /> : null}
|
||||||
|
{action.label}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const item = action.href ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={action.key}
|
||||||
|
disabled={action.disabled || busy}
|
||||||
|
variant={action.destructive ? "destructive" : "default"}
|
||||||
|
render={<Link href={action.href} />}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={action.key}
|
||||||
|
disabled={action.disabled || busy}
|
||||||
|
variant={action.destructive ? "destructive" : "default"}
|
||||||
|
onClick={() => action.onClick?.()}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={action.key}>
|
||||||
|
{showSeparator ? <DropdownMenuSeparator /> : null}
|
||||||
|
{item}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
|||||||
import { AdminAppSidebar } from "@/components/admin/admin-sidebar";
|
import { AdminAppSidebar } from "@/components/admin/admin-sidebar";
|
||||||
import { ShellToolbar } from "@/components/admin/toolbar";
|
import { ShellToolbar } from "@/components/admin/toolbar";
|
||||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { AdminDocumentTitle } from "@/components/admin/admin-document-title";
|
import { AdminDocumentTitle } from "@/components/admin/admin-document-title";
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
@@ -14,6 +15,8 @@ import {
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
export function AdminShell({ children }: { children: ReactNode }) {
|
export function AdminShell({ children }: { children: ReactNode }) {
|
||||||
|
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen>
|
<SidebarProvider defaultOpen>
|
||||||
<AdminDocumentTitle />
|
<AdminDocumentTitle />
|
||||||
@@ -22,9 +25,27 @@ export function AdminShell({ children }: { children: ReactNode }) {
|
|||||||
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md">
|
<header className="sticky top-0 z-30 flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card/90 px-4 shadow-[0_1px_0_rgb(216_230_251_/_45%)] backdrop-blur-md">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<Separator orientation="vertical" className="mr-1.5 h-4" />
|
<Separator orientation="vertical" className="mr-1.5 h-4" />
|
||||||
|
{shellAuthPending ? (
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-muted-foreground/80"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<span className="h-3 w-12 rounded-full bg-muted/50 motion-safe:animate-pulse" />
|
||||||
|
<span className="size-3 rounded-full bg-muted/40 motion-safe:animate-pulse" />
|
||||||
|
<span className="h-3 w-28 max-w-[36vw] rounded-full bg-muted/50 motion-safe:animate-pulse [animation-delay:80ms]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<AdminBreadcrumb />
|
<AdminBreadcrumb />
|
||||||
|
)}
|
||||||
<div className="ml-auto flex shrink-0 items-center">
|
<div className="ml-auto flex shrink-0 items-center">
|
||||||
|
{shellAuthPending ? (
|
||||||
|
<div className="flex items-center gap-2.5" aria-hidden>
|
||||||
|
<span className="hidden h-8 w-[4.5rem] rounded-lg bg-muted/40 motion-safe:animate-pulse sm:block" />
|
||||||
|
<span className="size-8 rounded-lg bg-muted/45 motion-safe:animate-pulse [animation-delay:120ms]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ShellToolbar />
|
<ShellToolbar />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex min-w-0 flex-1 flex-col overflow-x-clip px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6">
|
<div className="flex min-w-0 flex-1 flex-col overflow-x-clip px-4 pt-4 pb-6 md:px-5 md:pt-4 md:pb-6">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +18,94 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { adminNavLabel } from "@/lib/admin-nav-label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
||||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile, useAdminSessionStore } from "@/stores/admin-session";
|
||||||
|
|
||||||
|
/** 与常见导航项文字宽度接近,避免整齐灰条 */
|
||||||
|
const SIDEBAR_NAV_SKELETON_WIDTHS = ["68%", "82%", "58%", "74%", "64%", "78%", "55%", "70%", "62%"] as const;
|
||||||
|
|
||||||
|
function SidebarNavSkeletonRow({
|
||||||
|
labelWidth,
|
||||||
|
delayMs,
|
||||||
|
}: {
|
||||||
|
labelWidth: string;
|
||||||
|
delayMs: number;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="flex h-8 w-full items-center gap-2 rounded-md px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1.5"
|
||||||
|
style={{ animationDelay: `${delayMs}ms` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-4 shrink-0 rounded-[4px] bg-white/12 motion-safe:animate-pulse"
|
||||||
|
style={{ animationDelay: `${delayMs}ms` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="h-2.5 shrink-0 rounded-full bg-white/12 motion-safe:animate-pulse group-data-[collapsible=icon]:hidden"
|
||||||
|
style={{ width: labelWidth, animationDelay: `${delayMs + 40}ms` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminSidebarSkeleton(): ReactElement {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" className="overflow-hidden">
|
||||||
|
<SidebarHeader className="flex h-14 shrink-0 items-center gap-0 border-b border-sidebar-border p-0 px-2">
|
||||||
|
<SidebarMenu className="h-full w-full">
|
||||||
|
<SidebarMenuItem className="h-full">
|
||||||
|
<div className="flex h-12 w-full items-center px-1 group-data-[collapsible=icon]:justify-center">
|
||||||
|
<img
|
||||||
|
src="/logo.png"
|
||||||
|
alt="N lotto"
|
||||||
|
className="h-auto max-h-11 w-full object-contain object-left opacity-95 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent className="relative overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/image6.png"
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover object-bottom"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-28 bg-linear-to-b from-sidebar to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-sidebar/20" />
|
||||||
|
</div>
|
||||||
|
<SidebarGroup className="relative z-10">
|
||||||
|
<SidebarGroupLabel className="text-sidebar-foreground/55">
|
||||||
|
{t("sidebar.workspace", { defaultValue: "Workspace" })}
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu className={cn("gap-0.5", "motion-safe:opacity-90")}>
|
||||||
|
{SIDEBAR_NAV_SKELETON_WIDTHS.map((width, i) => (
|
||||||
|
<SidebarNavSkeletonRow key={i} labelWidth={width} delayMs={i * 55} />
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarSeparator />
|
||||||
|
<SidebarRail />
|
||||||
|
<span className="sr-only" role="status" aria-live="polite">
|
||||||
|
{t("auth.checking")}
|
||||||
|
</span>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string; segment?: string }): boolean {
|
function isActive(pathname: string, item: { href: string; activeMatchPrefix?: string; segment?: string }): boolean {
|
||||||
const { href, activeMatchPrefix, segment } = item;
|
const { href, activeMatchPrefix, segment } = item;
|
||||||
@@ -38,7 +123,12 @@ function isActive(pathname: string, item: { href: string; activeMatchPrefix?: st
|
|||||||
export function AdminAppSidebar() {
|
export function AdminAppSidebar() {
|
||||||
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]);
|
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
|
|
||||||
|
if (shellAuthPending) {
|
||||||
|
return <AdminSidebarSkeleton />;
|
||||||
|
}
|
||||||
const visibleNav = useMemo(
|
const visibleNav = useMemo(
|
||||||
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
||||||
[profile?.navigation],
|
[profile?.navigation],
|
||||||
@@ -86,13 +176,13 @@ export function AdminAppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={item.segment}>
|
<SidebarMenuItem key={item.segment}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}
|
tooltip={adminNavLabel(item.segment, t, item.label)}
|
||||||
isActive={isActive(pathname, item)}
|
isActive={isActive(pathname, item)}
|
||||||
render={<Link href={item.href} />}
|
render={<Link href={item.href} />}
|
||||||
className="font-medium text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-active:bg-red-600 data-active:text-white data-active:shadow-sm"
|
className="font-medium text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-active:bg-red-600 data-active:text-white data-active:shadow-sm"
|
||||||
>
|
>
|
||||||
<Icon data-icon="inline-start" aria-hidden />
|
<Icon data-icon="inline-start" aria-hidden />
|
||||||
<span>{t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}</span>
|
<span>{adminNavLabel(item.segment, t, item.label)}</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,40 +2,86 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
|
import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking";
|
||||||
|
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { readToken } from "@/stores/admin-token";
|
import { readToken } from "@/stores/admin-token";
|
||||||
|
|
||||||
type ShellAuthGateProps = {
|
type ShellAuthGateProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GateStatus = "pending" | "authed" | "guest";
|
||||||
|
|
||||||
|
function hasAdminToken(bearerToken: string | null): boolean {
|
||||||
|
const token = bearerToken ?? readToken();
|
||||||
|
return token != null && token.trim() !== "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell route guard. Reads the auth token from localStorage on the client and
|
* Shell 路由守卫:无 Token 或 `/auth/me` 校验失败时跳转登录页。
|
||||||
* redirects to the login page when no token is present.
|
|
||||||
*/
|
*/
|
||||||
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||||
const { t } = useTranslation("common");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [allowed, setAllowed] = useState(false);
|
const bearerToken = useAdminSessionStore((s) => s.bearerToken);
|
||||||
|
const [status, setStatus] = useState<GateStatus>("pending");
|
||||||
|
|
||||||
|
const setShellAuthPending = useAdminSessionStore((s) => s.setShellAuthPending);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = readToken();
|
let cancelled = false;
|
||||||
if (!token) {
|
setShellAuthPending(true);
|
||||||
router.replace("/admin/login");
|
|
||||||
|
async function run() {
|
||||||
|
if (!hasAdminToken(bearerToken)) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("guest");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queueMicrotask(() => {
|
|
||||||
setAllowed(true);
|
|
||||||
});
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
if (!allowed) {
|
if (!cancelled) {
|
||||||
return (
|
setStatus("pending");
|
||||||
<div className="flex min-h-[50vh] w-full flex-1 items-center justify-center text-sm text-muted-foreground">
|
}
|
||||||
{t("auth.checking", { defaultValue: "Checking sign-in status…" })}
|
|
||||||
</div>
|
const ok = await verifyStoredAdminSession();
|
||||||
);
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
setStatus("authed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("guest");
|
||||||
|
}
|
||||||
|
|
||||||
|
void run().finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setShellAuthPending(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
setShellAuthPending(false);
|
||||||
|
};
|
||||||
|
}, [bearerToken, setShellAuthPending]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "guest") {
|
||||||
|
router.replace("/admin/login");
|
||||||
|
}
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
if (status === "pending") {
|
||||||
|
return <AdminAuthCheckingScreen variant="shell" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "guest") {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ShieldCheckIcon } from "lucide-react";
|
import { ShieldCheckIcon } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
import { AdminAuthCheckingScreen } from "@/components/admin/admin-auth-checking";
|
||||||
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
|
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
import { getAdminCaptcha, postAdminLogin } from "@/api";
|
||||||
|
import { verifyStoredAdminSession } from "@/lib/admin-session-verify";
|
||||||
import { readToken } from "@/stores/admin-token";
|
import { readToken } from "@/stores/admin-token";
|
||||||
import { authModuleMeta } from "@/modules/auth/meta";
|
import { authModuleMeta } from "@/modules/auth/meta";
|
||||||
import { useAdminSessionStore } from "@/stores/admin-session";
|
import { useAdminSessionStore } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation(["auth", "common"]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||||
@@ -32,6 +35,7 @@ export function LoginForm() {
|
|||||||
|
|
||||||
const [loadingCaptcha, setLoadingCaptcha] = useState(false);
|
const [loadingCaptcha, setLoadingCaptcha] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
|
|
||||||
const loadCaptcha = useCallback(async () => {
|
const loadCaptcha = useCallback(async () => {
|
||||||
setLoadingCaptcha(true);
|
setLoadingCaptcha(true);
|
||||||
@@ -53,16 +57,37 @@ export function LoginForm() {
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (readToken()) {
|
let cancelled = false;
|
||||||
router.replace("/admin");
|
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
if (!readToken()) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCheckingSession(false);
|
||||||
|
void loadCaptcha();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const t = window.setTimeout(() => {
|
|
||||||
void loadCaptcha();
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => window.clearTimeout(t);
|
const ok = await verifyStoredAdminSession();
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
router.replace("/admin");
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckingSession(false);
|
||||||
|
void loadCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [loadCaptcha, router]);
|
}, [loadCaptcha, router]);
|
||||||
|
|
||||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -107,11 +132,12 @@ export function LoginForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (checkingSession) {
|
||||||
|
return <AdminAuthCheckingScreen variant="login" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-full flex-1 flex-col items-center justify-center overflow-hidden px-4 py-14 sm:py-20">
|
<div className="relative flex h-dvh max-h-dvh w-full min-h-0 flex-col overflow-hidden lg:flex-row">
|
||||||
<div className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6">
|
|
||||||
<AdminLanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(59_130_246/0.09),transparent)] dark:bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(56_189_248/0.12),transparent)]"
|
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(59_130_246/0.09),transparent)] dark:bg-[radial-gradient(ellipse_85%_50%_at_50%_-5%,rgb(56_189_248/0.12),transparent)]"
|
||||||
@@ -130,19 +156,41 @@ export function LoginForm() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="relative w-full max-w-[420px] rounded-2xl border border-border/70 bg-card/90 shadow-2xl shadow-black/[0.06] ring-1 ring-black/[0.03] backdrop-blur-md dark:border-border/50 dark:bg-card/85 dark:shadow-black/25 dark:ring-white/[0.06]">
|
<div className="relative z-10 flex min-h-0 max-h-[38dvh] shrink-0 items-center justify-center overflow-hidden px-6 py-4 lg:max-h-none lg:w-1/2 lg:flex-1 lg:px-10 lg:py-8">
|
||||||
<CardHeader className="space-y-5 pb-2 text-center sm:px-8 sm:pt-10">
|
<Image
|
||||||
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/8 text-primary shadow-inner ring-1 ring-primary/10 dark:bg-primary/15 dark:ring-primary/20">
|
src="/illustration.png"
|
||||||
<ShieldCheckIcon className="size-6" strokeWidth={1.75} aria-hidden />
|
alt=""
|
||||||
|
width={1024}
|
||||||
|
height={1536}
|
||||||
|
priority
|
||||||
|
className="mx-auto max-h-full w-auto max-w-full object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<CardTitle className="text-balance text-2xl font-semibold tracking-tight">
|
<div className="relative z-10 flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-4 py-6 lg:w-1/2 lg:py-8">
|
||||||
|
<div className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6">
|
||||||
|
<AdminLanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="relative w-full max-w-[420px] rounded-2xl border border-border/70 bg-card/90 shadow-2xl shadow-black/[0.06] ring-1 ring-black/[0.03] backdrop-blur-md dark:border-border/50 dark:bg-card/85 dark:shadow-black/25 dark:ring-white/[0.06]">
|
||||||
|
<CardHeader className="flex flex-row items-center gap-3.5 border-b border-border/60 px-6 py-6 sm:px-8">
|
||||||
|
<div
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ShieldCheckIcon className="size-5" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<CardTitle className="text-lg font-semibold leading-snug tracking-tight">
|
||||||
{t("loginTitle", { defaultValue: authModuleMeta.title })}
|
{t("loginTitle", { defaultValue: authModuleMeta.title })}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
|
{t("loginSubtitle")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<CardContent className="flex flex-col gap-5 sm:px-8">
|
<CardContent className="flex flex-col gap-5 px-6 pt-6 sm:px-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="admin-account" className="text-sm font-medium">
|
<Label htmlFor="admin-account" className="text-sm font-medium">
|
||||||
{t("account")}
|
{t("account")}
|
||||||
@@ -176,7 +224,7 @@ export function LoginForm() {
|
|||||||
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
|
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-8 flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="admin-captcha" className="text-sm font-medium">
|
<Label htmlFor="admin-captcha" className="text-sm font-medium">
|
||||||
{t("captcha")}
|
{t("captcha")}
|
||||||
</Label>
|
</Label>
|
||||||
@@ -220,7 +268,7 @@ export function LoginForm() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex-col gap-0 border-t border-border/60 pb-10 pt-8 sm:px-8">
|
<CardFooter className="flex-col gap-0 border-t border-border/60 px-6 pb-8 pt-6 sm:px-8">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -233,5 +281,6 @@ export function LoginForm() {
|
|||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "Login",
|
"title": "Login",
|
||||||
"loginTitle": "Admin Login",
|
"loginTitle": "Admin Login",
|
||||||
|
"loginSubtitle": "Sign in with your admin account",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"accountPlaceholder": "Login account",
|
"accountPlaceholder": "Login account",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse"
|
"collapse": "Collapse",
|
||||||
|
"rowActionsMenu": "Row actions menu"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"drawsList": { "filename": "draws-list", "sheetName": "Draws" },
|
"drawsList": { "filename": "draws-list", "sheetName": "Draws" },
|
||||||
@@ -155,7 +156,9 @@
|
|||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "Checking sign-in status…"
|
"checking": "Checking sign-in status…",
|
||||||
|
"checkingShort": "Loading workspace…",
|
||||||
|
"sessionExpired": "Your session has expired. Please sign in again."
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@@ -353,6 +353,26 @@
|
|||||||
},
|
},
|
||||||
"odds": {
|
"odds": {
|
||||||
"sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.",
|
"sectionHint": "Pick a version to edit prize-tier odds; publishing applies to new tickets immediately.",
|
||||||
|
"sections": {
|
||||||
|
"playScope": "Play scope",
|
||||||
|
"oddsConfig": "Odds"
|
||||||
|
},
|
||||||
|
"currentSelection": "Selection: {{category}} / {{play}}",
|
||||||
|
"playGroups": {
|
||||||
|
"bigSmall": "Big / small",
|
||||||
|
"combo4": "4D position",
|
||||||
|
"number3": "3D position",
|
||||||
|
"number2": "2D position",
|
||||||
|
"other": "Other"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "Summary",
|
||||||
|
"version": "Version",
|
||||||
|
"statusLabel": "Status",
|
||||||
|
"readOnlyTag": "Read-only",
|
||||||
|
"readOnlyHint": "This version is read-only. Create a draft to make changes.",
|
||||||
|
"activeHint": "This version is active; new tickets use these settings."
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "All"
|
"all": "All"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
"lifetime": "All-time totals",
|
"lifetime": "All-time totals",
|
||||||
"currentDraw": "Current draw",
|
"currentDraw": "Current draw",
|
||||||
"currentDrawDetail": "Current draw · {{drawNo}}",
|
"currentDrawDetail": "Current draw · {{drawNo}}",
|
||||||
"operations": "Operations (current draw)"
|
"operations": "Operations (current draw)",
|
||||||
|
"snapshot": "Current draw snapshot"
|
||||||
},
|
},
|
||||||
|
"countdownToClose": "Time to close",
|
||||||
|
"scheduledDrawTime": "Draw at {{time}}",
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Financial analytics",
|
"title": "Finance overview",
|
||||||
"periodLabel": "Period",
|
"periodLabel": "Period",
|
||||||
"metricLabel": "Metric",
|
"metricLabel": "Metric",
|
||||||
"playLabel": "Play filter",
|
"playLabel": "Play filter",
|
||||||
@@ -22,8 +25,16 @@
|
|||||||
"summaryBet": "Period bet",
|
"summaryBet": "Period bet",
|
||||||
"summaryPayout": "Period payout",
|
"summaryPayout": "Period payout",
|
||||||
"summaryProfit": "Period profit",
|
"summaryProfit": "Period profit",
|
||||||
"dailyTrend": "Daily trend",
|
"dailyTrend": "Period trend",
|
||||||
|
"granularityDay": "By day",
|
||||||
"playBreakdown": "Play breakdown",
|
"playBreakdown": "Play breakdown",
|
||||||
|
"playRanking": "Top 5 plays",
|
||||||
|
"rankingMetricLabel": "Ranking metric",
|
||||||
|
"rankingMetrics": {
|
||||||
|
"bet": "By bet amount",
|
||||||
|
"payout": "By payout",
|
||||||
|
"profit": "By profit"
|
||||||
|
},
|
||||||
"periodDistribution": "Period structure",
|
"periodDistribution": "Period structure",
|
||||||
"noPlayData": "No play data in this period",
|
"noPlayData": "No play data in this period",
|
||||||
"periods": {
|
"periods": {
|
||||||
@@ -57,6 +68,7 @@
|
|||||||
"currentDrawPayout": "Draw payout",
|
"currentDrawPayout": "Draw payout",
|
||||||
"currentDrawProfit": "Draw profit",
|
"currentDrawProfit": "Draw profit",
|
||||||
"drawFinanceDetails": "Draw finance details",
|
"drawFinanceDetails": "Draw finance details",
|
||||||
|
"detailsShort": "Details",
|
||||||
"todayBetTotal": "Today's total bet",
|
"todayBetTotal": "Today's total bet",
|
||||||
"todayPayout": "Today's payout",
|
"todayPayout": "Today's payout",
|
||||||
"todayProfit": "Today's profit",
|
"todayProfit": "Today's profit",
|
||||||
@@ -97,6 +109,10 @@
|
|||||||
"soldOutTotal": "Total sold out",
|
"soldOutTotal": "Total sold out",
|
||||||
"pendingReviewResults": "Pending result review",
|
"pendingReviewResults": "Pending result review",
|
||||||
"abnormalTransferOrders": "Abnormal transfer orders",
|
"abnormalTransferOrders": "Abnormal transfer orders",
|
||||||
|
"abnormalTransferScope": "Flagged by wallet reconciliation",
|
||||||
|
"abnormalTransferPending": "{{count}} pending review",
|
||||||
|
"abnormalTransferAllClear": "Reconciliation clear",
|
||||||
|
"abnormalTransferAction": "Open transfer orders to resolve",
|
||||||
"viewTransferOrders": "View transfer orders",
|
"viewTransferOrders": "View transfer orders",
|
||||||
"noSoldOutNumbers": "No sold-out numbers",
|
"noSoldOutNumbers": "No sold-out numbers",
|
||||||
"noPoolData": "No pool data for this dimension",
|
"noPoolData": "No pool data for this dimension",
|
||||||
@@ -121,7 +137,11 @@
|
|||||||
"results": "Results",
|
"results": "Results",
|
||||||
"tickets": "Ticket management",
|
"tickets": "Ticket management",
|
||||||
"walletTransactions": "Wallet transactions",
|
"walletTransactions": "Wallet transactions",
|
||||||
"auditLogs": "Audit logs"
|
"auditLogs": "Audit logs",
|
||||||
|
"reports": "Reports",
|
||||||
|
"payoutRules": "Odds & rebate",
|
||||||
|
"riskMonitor": "Risk monitor",
|
||||||
|
"systemSettings": "System settings"
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
|
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "लगइन",
|
"title": "लगइन",
|
||||||
"loginTitle": "एडमिन लगइन",
|
"loginTitle": "एडमिन लगइन",
|
||||||
|
"loginSubtitle": "कृपया एडमिन खाताबाट लगइन गर्नुहोस्",
|
||||||
"account": "खाता",
|
"account": "खाता",
|
||||||
"accountPlaceholder": "लगइन खाता",
|
"accountPlaceholder": "लगइन खाता",
|
||||||
"password": "पासवर्ड",
|
"password": "पासवर्ड",
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "खोल्नुहोस्",
|
"expand": "खोल्नुहोस्",
|
||||||
"collapse": "बन्द गर्नुहोस्"
|
"collapse": "बन्द गर्नुहोस्",
|
||||||
|
"rowActionsMenu": "पङ्क्ति कार्य मेनु"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"drawsList": { "filename": "draw-suchi", "sheetName": "Draw" },
|
"drawsList": { "filename": "draw-suchi", "sheetName": "Draw" },
|
||||||
@@ -155,7 +156,9 @@
|
|||||||
"workspace": "कार्यस्थान"
|
"workspace": "कार्यस्थान"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "लगइन स्थिति जाँच हुँदैछ…"
|
"checking": "लगइन स्थिति जाँच हुँदैछ…",
|
||||||
|
"checkingShort": "कार्यस्थान खोल्दै…",
|
||||||
|
"sessionExpired": "लगइन समाप्त भयो। कृपया पुनः लगइन गर्नुहोस्।"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancel": "रद्द",
|
"cancel": "रद्द",
|
||||||
|
|||||||
@@ -353,6 +353,26 @@
|
|||||||
},
|
},
|
||||||
"odds": {
|
"odds": {
|
||||||
"sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।",
|
"sectionHint": "संस्करण छानेर पुरस्कार-स्तर बाधा सम्पादन गर्नुहोस्; प्रकाशनपछि नयाँ टिकटमा लागू हुन्छ।",
|
||||||
|
"sections": {
|
||||||
|
"playScope": "खेल दायरा",
|
||||||
|
"oddsConfig": "बाधा सेटिङ"
|
||||||
|
},
|
||||||
|
"currentSelection": "हालको छनोट: {{category}} / {{play}}",
|
||||||
|
"playGroups": {
|
||||||
|
"bigSmall": "ठूलो / सानो",
|
||||||
|
"combo4": "4D स्थिति",
|
||||||
|
"number3": "3D स्थिति",
|
||||||
|
"number2": "2D स्थिति",
|
||||||
|
"other": "अन्य"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "सारांश",
|
||||||
|
"version": "संस्करण",
|
||||||
|
"statusLabel": "स्थिति",
|
||||||
|
"readOnlyTag": "पढ्न मात्र",
|
||||||
|
"readOnlyHint": "यो संस्करण पढ्न मात्र हो। परिवर्तन गर्न ड्राफ्ट बनाउनुहोस्।",
|
||||||
|
"activeHint": "यो संस्करण सक्रिय छ; नयाँ टिकट यही सेटिङ प्रयोग गर्छ।"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "सबै"
|
"all": "सबै"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
"lifetime": "ऐतिहासिक कुल",
|
"lifetime": "ऐतिहासिक कुल",
|
||||||
"currentDraw": "हालको ड्रअ",
|
"currentDraw": "हालको ड्रअ",
|
||||||
"currentDrawDetail": "हालको ड्रअ · {{drawNo}}",
|
"currentDrawDetail": "हालको ड्रअ · {{drawNo}}",
|
||||||
"operations": "सञ्चालन (हालको ड्रअ)"
|
"operations": "सञ्चालन (हालको ड्रअ)",
|
||||||
|
"snapshot": "हालको ड्रअ स्न्यापसट"
|
||||||
},
|
},
|
||||||
|
"countdownToClose": "बन्द हुन बाँकी",
|
||||||
|
"scheduledDrawTime": "ड्रअ {{time}}",
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "वित्त विश्लेषण",
|
"title": "वित्त सारांश",
|
||||||
"periodLabel": "अवधि",
|
"periodLabel": "अवधि",
|
||||||
"metricLabel": "मेट्रिक",
|
"metricLabel": "मेट्रिक",
|
||||||
"playLabel": "प्ले फिल्टर",
|
"playLabel": "प्ले फिल्टर",
|
||||||
@@ -22,8 +25,16 @@
|
|||||||
"summaryBet": "अवधि बेट",
|
"summaryBet": "अवधि बेट",
|
||||||
"summaryPayout": "अवधि भुक्तानी",
|
"summaryPayout": "अवधि भुक्तानी",
|
||||||
"summaryProfit": "अवधि नाफा",
|
"summaryProfit": "अवधि नाफा",
|
||||||
"dailyTrend": "दैनिक ट्रेन्ड",
|
"dailyTrend": "अवधि ट्रेन्ड",
|
||||||
|
"granularityDay": "दैनिक",
|
||||||
"playBreakdown": "प्ले विभाजन",
|
"playBreakdown": "प्ले विभाजन",
|
||||||
|
"playRanking": "शीर्ष ५ प्ले",
|
||||||
|
"rankingMetricLabel": "रैंकिङ मेट्रिक",
|
||||||
|
"rankingMetrics": {
|
||||||
|
"bet": "बेट रकम",
|
||||||
|
"payout": "भुक्तानी",
|
||||||
|
"profit": "नाफा"
|
||||||
|
},
|
||||||
"periodDistribution": "अवधि संरचना",
|
"periodDistribution": "अवधि संरचना",
|
||||||
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
|
"noPlayData": "यस अवधिमा प्ले डाटा छैन",
|
||||||
"periods": {
|
"periods": {
|
||||||
@@ -57,6 +68,7 @@
|
|||||||
"currentDrawPayout": "हालको भुक्तानी",
|
"currentDrawPayout": "हालको भुक्तानी",
|
||||||
"currentDrawProfit": "हालको नाफा/नोक्सान",
|
"currentDrawProfit": "हालको नाफा/नोक्सान",
|
||||||
"drawFinanceDetails": "ड्रअ वित्त विवरण",
|
"drawFinanceDetails": "ड्रअ वित्त विवरण",
|
||||||
|
"detailsShort": "विवरण",
|
||||||
"todayBetTotal": "आजको कुल बेट",
|
"todayBetTotal": "आजको कुल बेट",
|
||||||
"todayPayout": "आजको भुक्तानी",
|
"todayPayout": "आजको भुक्तानी",
|
||||||
"todayProfit": "आजको नाफा/नोक्सान",
|
"todayProfit": "आजको नाफा/नोक्सान",
|
||||||
@@ -96,6 +108,10 @@
|
|||||||
"soldOutTotal": "कुल बिक्री समाप्त",
|
"soldOutTotal": "कुल बिक्री समाप्त",
|
||||||
"pendingReviewResults": "समीक्षा बाँकी परिणाम",
|
"pendingReviewResults": "समीक्षा बाँकी परिणाम",
|
||||||
"abnormalTransferOrders": "असामान्य ट्रान्सफर अर्डर",
|
"abnormalTransferOrders": "असामान्य ट्रान्सफर अर्डर",
|
||||||
|
"abnormalTransferScope": "वालेट मिलानबाट चिनिएको",
|
||||||
|
"abnormalTransferPending": "{{count}} समीक्षा बाँकी",
|
||||||
|
"abnormalTransferAllClear": "मिलान ठीक, असामान्य छैन",
|
||||||
|
"abnormalTransferAction": "समाधान गर्न ट्रान्सफर सूची खोल्नुहोस्",
|
||||||
"viewTransferOrders": "ट्रान्सफर अर्डर हेर्नुहोस्",
|
"viewTransferOrders": "ट्रान्सफर अर्डर हेर्नुहोस्",
|
||||||
"noSoldOutNumbers": "बिक्री समाप्त नम्बर छैन",
|
"noSoldOutNumbers": "बिक्री समाप्त नम्बर छैन",
|
||||||
"noPoolData": "यस डाइमेन्सनमा पूल डाटा छैन",
|
"noPoolData": "यस डाइमेन्सनमा पूल डाटा छैन",
|
||||||
@@ -120,7 +136,11 @@
|
|||||||
"results": "परिणाम",
|
"results": "परिणाम",
|
||||||
"tickets": "टिकट व्यवस्थापन",
|
"tickets": "टिकट व्यवस्थापन",
|
||||||
"walletTransactions": "वालेट कारोबार",
|
"walletTransactions": "वालेट कारोबार",
|
||||||
"auditLogs": "अडिट लग"
|
"auditLogs": "अडिट लग",
|
||||||
|
"reports": "रिपोर्ट केन्द्र",
|
||||||
|
"payoutRules": "बाधा र रिबेट",
|
||||||
|
"riskMonitor": "जोखिम निगरानी",
|
||||||
|
"systemSettings": "प्रणाली सेटिङ"
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
|
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
"loginTitle": "后台登录",
|
"loginTitle": "后台登录",
|
||||||
|
"loginSubtitle": "请使用管理员账号登录",
|
||||||
"account": "账号",
|
"account": "账号",
|
||||||
"accountPlaceholder": "登录账号",
|
"accountPlaceholder": "登录账号",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
},
|
},
|
||||||
"aria": {
|
"aria": {
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"collapse": "收起"
|
"collapse": "收起",
|
||||||
|
"rowActionsMenu": "行操作菜单"
|
||||||
},
|
},
|
||||||
"export": {
|
"export": {
|
||||||
"drawsList": { "filename": "期号列表", "sheetName": "期号列表" },
|
"drawsList": { "filename": "期号列表", "sheetName": "期号列表" },
|
||||||
@@ -148,14 +149,16 @@
|
|||||||
"audit": "审计日志",
|
"audit": "审计日志",
|
||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"account": "账号设置",
|
"account": "账号设置",
|
||||||
"integration": "主站接入站点",
|
"integration": "接入站点",
|
||||||
"config": "运营配置"
|
"config": "运营配置"
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"workspace": "工作台"
|
"workspace": "工作台"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"checking": "正在校验登录状态…"
|
"checking": "正在校验登录状态…",
|
||||||
|
"checkingShort": "正在进入工作台…",
|
||||||
|
"sessionExpired": "登录已失效,请重新登录"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
|||||||
@@ -30,11 +30,11 @@
|
|||||||
"jackpotDesc": "奖池参数与进账流水",
|
"jackpotDesc": "奖池参数与进账流水",
|
||||||
"riskCapTitle": "限额版本",
|
"riskCapTitle": "限额版本",
|
||||||
"riskCapDesc": "号码赔付封顶与占用视图",
|
"riskCapDesc": "号码赔付封顶与占用视图",
|
||||||
"integrationTitle": "主站接入站点",
|
"integrationTitle": "接入站点",
|
||||||
"integrationDesc": "site_code、JWT 密钥、主站钱包 URL 与 iframe 白名单"
|
"integrationDesc": "site_code、JWT 密钥、主站钱包 URL 与 iframe 白名单"
|
||||||
},
|
},
|
||||||
"integrationSites": {
|
"integrationSites": {
|
||||||
"title": "主站接入站点",
|
"title": "接入站点",
|
||||||
"description": "由运营在后台维护各主站对接参数,并通过权限控制谁能查看或修改。site_code 创建后不可修改。",
|
"description": "由运营在后台维护各主站对接参数,并通过权限控制谁能查看或修改。site_code 创建后不可修改。",
|
||||||
"create": "新建站点",
|
"create": "新建站点",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
@@ -353,6 +353,26 @@
|
|||||||
},
|
},
|
||||||
"odds": {
|
"odds": {
|
||||||
"sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。",
|
"sectionHint": "选择版本后可编辑各奖级赔率;发布后立即作用于新注单。",
|
||||||
|
"sections": {
|
||||||
|
"playScope": "玩法范围",
|
||||||
|
"oddsConfig": "赔率配置"
|
||||||
|
},
|
||||||
|
"currentSelection": "当前选择:{{category}} / {{play}}",
|
||||||
|
"playGroups": {
|
||||||
|
"bigSmall": "大小类",
|
||||||
|
"combo4": "组合类",
|
||||||
|
"number3": "号码类",
|
||||||
|
"number2": "2D 位置类",
|
||||||
|
"other": "其他类"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"title": "配置摘要",
|
||||||
|
"version": "版本",
|
||||||
|
"statusLabel": "状态",
|
||||||
|
"readOnlyTag": "只读",
|
||||||
|
"readOnlyHint": "当前为只读版本,如需修改请先创建草稿。",
|
||||||
|
"activeHint": "当前版本已生效,新注单将按此配置计算。"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "全部"
|
"all": "全部"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,10 +7,13 @@
|
|||||||
"lifetime": "历史累计",
|
"lifetime": "历史累计",
|
||||||
"currentDraw": "当前期号",
|
"currentDraw": "当前期号",
|
||||||
"currentDrawDetail": "当期明细 · {{drawNo}}",
|
"currentDrawDetail": "当期明细 · {{drawNo}}",
|
||||||
"operations": "运营监控(当期)"
|
"operations": "运营监控(当期)",
|
||||||
|
"snapshot": "当期快照"
|
||||||
},
|
},
|
||||||
|
"countdownToClose": "距截止投注",
|
||||||
|
"scheduledDrawTime": "开奖 {{time}}",
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "财务分析",
|
"title": "财务概览",
|
||||||
"periodLabel": "统计区间",
|
"periodLabel": "统计区间",
|
||||||
"metricLabel": "指标类型",
|
"metricLabel": "指标类型",
|
||||||
"playLabel": "玩法筛选",
|
"playLabel": "玩法筛选",
|
||||||
@@ -22,8 +25,16 @@
|
|||||||
"summaryBet": "区间下注",
|
"summaryBet": "区间下注",
|
||||||
"summaryPayout": "区间派彩",
|
"summaryPayout": "区间派彩",
|
||||||
"summaryProfit": "区间盈亏",
|
"summaryProfit": "区间盈亏",
|
||||||
"dailyTrend": "每日趋势",
|
"dailyTrend": "区间趋势",
|
||||||
|
"granularityDay": "按天",
|
||||||
"playBreakdown": "玩法拆解 Top",
|
"playBreakdown": "玩法拆解 Top",
|
||||||
|
"playRanking": "玩法排行榜 Top 5",
|
||||||
|
"rankingMetricLabel": "排行维度",
|
||||||
|
"rankingMetrics": {
|
||||||
|
"bet": "按投注金额",
|
||||||
|
"payout": "按派彩金额",
|
||||||
|
"profit": "按盈亏"
|
||||||
|
},
|
||||||
"periodDistribution": "区间结构对比",
|
"periodDistribution": "区间结构对比",
|
||||||
"noPlayData": "该区间暂无玩法数据",
|
"noPlayData": "该区间暂无玩法数据",
|
||||||
"periods": {
|
"periods": {
|
||||||
@@ -57,6 +68,7 @@
|
|||||||
"currentDrawPayout": "当期派彩",
|
"currentDrawPayout": "当期派彩",
|
||||||
"currentDrawProfit": "当期盈亏",
|
"currentDrawProfit": "当期盈亏",
|
||||||
"drawFinanceDetails": "期号财务详情",
|
"drawFinanceDetails": "期号财务详情",
|
||||||
|
"detailsShort": "详情",
|
||||||
"todayBetTotal": "今日下注总额",
|
"todayBetTotal": "今日下注总额",
|
||||||
"todayPayout": "今日派彩",
|
"todayPayout": "今日派彩",
|
||||||
"todayProfit": "今日盈亏",
|
"todayProfit": "今日盈亏",
|
||||||
@@ -97,6 +109,10 @@
|
|||||||
"soldOutTotal": "售罄合计",
|
"soldOutTotal": "售罄合计",
|
||||||
"pendingReviewResults": "待审核开奖",
|
"pendingReviewResults": "待审核开奖",
|
||||||
"abnormalTransferOrders": "异常转账单",
|
"abnormalTransferOrders": "异常转账单",
|
||||||
|
"abnormalTransferScope": "钱包对账标记的异常转账",
|
||||||
|
"abnormalTransferPending": "{{count}} 笔待核对",
|
||||||
|
"abnormalTransferAllClear": "对账正常,暂无异常",
|
||||||
|
"abnormalTransferAction": "前往转账单列表处理",
|
||||||
"viewTransferOrders": "查看转账单",
|
"viewTransferOrders": "查看转账单",
|
||||||
"noSoldOutNumbers": "暂无售罄号码",
|
"noSoldOutNumbers": "暂无售罄号码",
|
||||||
"noPoolData": "该维度暂无池数据",
|
"noPoolData": "该维度暂无池数据",
|
||||||
@@ -121,7 +137,11 @@
|
|||||||
"results": "开奖结果",
|
"results": "开奖结果",
|
||||||
"tickets": "注单管理",
|
"tickets": "注单管理",
|
||||||
"walletTransactions": "钱包流水",
|
"walletTransactions": "钱包流水",
|
||||||
"auditLogs": "审计日志"
|
"auditLogs": "审计日志",
|
||||||
|
"reports": "报表中心",
|
||||||
|
"payoutRules": "赔付规则",
|
||||||
|
"riskMonitor": "风控监控",
|
||||||
|
"systemSettings": "系统设置"
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
|
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
|
||||||
|
|||||||
122
src/lib/admin-auth-reject.ts
Normal file
122
src/lib/admin-auth-reject.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { getAdminSessionState } from "@/stores/admin-session";
|
||||||
|
import { readToken } from "@/stores/admin-token";
|
||||||
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
|
||||||
|
/** 与后端 {@see ErrorCode::AdminUnauthenticated} 一致 */
|
||||||
|
export const ADMIN_UNAUTHENTICATED_CODE = 8110;
|
||||||
|
|
||||||
|
/** 与后端 {@see ErrorCode::AdminAccountDisabled} 一致 */
|
||||||
|
export const ADMIN_ACCOUNT_DISABLED_CODE = 8113;
|
||||||
|
|
||||||
|
/** 登录验证码错误等,HTTP 可能为 401/422,不应踢出会话 */
|
||||||
|
const LOGIN_FORM_ERROR_CODES = new Set([8111, 8112]);
|
||||||
|
|
||||||
|
let authRejectHandling = false;
|
||||||
|
|
||||||
|
function isAdminLoginPath(): boolean {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.location.pathname === "/admin/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未登录 / Token 失效 / 账号禁用(非普通权限 403) */
|
||||||
|
export function isAdminAuthRejected(err: unknown): boolean {
|
||||||
|
if (err instanceof LotteryApiBizError) {
|
||||||
|
return (
|
||||||
|
err.code === ADMIN_UNAUTHENTICATED_CODE ||
|
||||||
|
err.code === ADMIN_ACCOUNT_DISABLED_CODE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAxiosError(err)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = err.response?.data;
|
||||||
|
const envelopeCode =
|
||||||
|
body && typeof body === "object" && "code" in body
|
||||||
|
? (body as { code?: unknown }).code
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof envelopeCode === "number" &&
|
||||||
|
LOGIN_FORM_ERROR_CODES.has(envelopeCode)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
envelopeCode === ADMIN_UNAUTHENTICATED_CODE ||
|
||||||
|
envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isApiEnvelope(body) && body.code !== 0) {
|
||||||
|
return (
|
||||||
|
body.code === ADMIN_UNAUTHENTICATED_CODE ||
|
||||||
|
body.code === ADMIN_ACCOUNT_DISABLED_CODE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (status === 401) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 403 && envelopeCode === ADMIN_ACCOUNT_DISABLED_CODE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectToAdminLogin(): void {
|
||||||
|
if (typeof window === "undefined" || isAdminLoginPath()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginPath = "/admin/login";
|
||||||
|
if (window.location.pathname !== loginPath) {
|
||||||
|
window.location.replace(loginPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除本地会话并跳转登录页。可在 axios 拦截器、/auth/me 刷新等任意上下文调用。
|
||||||
|
*/
|
||||||
|
export function handleAdminAuthRejected(): void {
|
||||||
|
if (typeof window === "undefined" || isAdminLoginPath()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authRejectHandling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authRejectHandling = true;
|
||||||
|
|
||||||
|
const hadSession = readToken() != null;
|
||||||
|
getAdminSessionState().clearSession();
|
||||||
|
|
||||||
|
if (hadSession) {
|
||||||
|
toast.error(
|
||||||
|
i18n.t("auth.sessionExpired", {
|
||||||
|
ns: "common",
|
||||||
|
defaultValue: "Sign-in expired. Please log in again.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectToAdminLogin();
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
authRejectHandling = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
15
src/lib/admin-fetch-me.ts
Normal file
15
src/lib/admin-fetch-me.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { getAdminMe } from "@/api/admin-auth";
|
||||||
|
import type { AdminAuthMeResponse } from "@/types/api/admin-auth";
|
||||||
|
|
||||||
|
let inflightMe: Promise<AdminAuthMeResponse> | null = null;
|
||||||
|
|
||||||
|
/** 合并并发的 `/auth/me` 请求,避免 rehydrate 与 ShellAuthGate 重复打接口 */
|
||||||
|
export function fetchAdminMeDeduped(): Promise<AdminAuthMeResponse> {
|
||||||
|
if (!inflightMe) {
|
||||||
|
inflightMe = getAdminMe().finally(() => {
|
||||||
|
inflightMe = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return inflightMe;
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import axios, {
|
|||||||
} from "axios";
|
} from "axios";
|
||||||
|
|
||||||
import { withAdminAuthHeader } from "@/lib/admin-auth";
|
import { withAdminAuthHeader } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
handleAdminAuthRejected,
|
||||||
|
isAdminAuthRejected,
|
||||||
|
} from "@/lib/admin-auth-reject";
|
||||||
import { withAdminLocaleHeaders } from "@/lib/admin-locale";
|
import { withAdminLocaleHeaders } from "@/lib/admin-locale";
|
||||||
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
|
import { LotteryApiBizError, LotteryApiEnvelopeError } from "@/types/api/errors";
|
||||||
import { isApiEnvelope } from "@/types/api/envelope";
|
import { isApiEnvelope } from "@/types/api/envelope";
|
||||||
@@ -18,6 +22,23 @@ export const adminHttp = axios.create({
|
|||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
adminHttp.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: unknown) => {
|
||||||
|
if (isAdminAuthRejected(error)) {
|
||||||
|
handleAdminAuthRejected();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function rejectAfterAuthCheck(err: unknown): never {
|
||||||
|
if (isAdminAuthRejected(err)) {
|
||||||
|
handleAdminAuthRejected();
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
export function unwrapData<T>(payload: unknown): T {
|
export function unwrapData<T>(payload: unknown): T {
|
||||||
if (!isApiEnvelope(payload)) {
|
if (!isApiEnvelope(payload)) {
|
||||||
throw new LotteryApiEnvelopeError();
|
throw new LotteryApiEnvelopeError();
|
||||||
@@ -48,7 +69,7 @@ export async function publicAdminRequest<T>(
|
|||||||
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
rejectAfterAuthCheck(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +85,7 @@ export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
|||||||
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
throw new LotteryApiBizError(body.msg, body.code, body.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
rejectAfterAuthCheck(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src/lib/admin-nav-label.ts
Normal file
38
src/lib/admin-nav-label.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
|
/** 与 {@link AdminBreadcrumb} / 侧栏共用:仅用 i18n,避免 API 旧 label 作 defaultValue 导致水合不一致 */
|
||||||
|
const NAV_SEGMENT_I18N_KEYS: Record<string, string> = {
|
||||||
|
dashboard: "dashboard",
|
||||||
|
admin_users: "admin_users",
|
||||||
|
admin_roles: "admin_roles",
|
||||||
|
players: "players",
|
||||||
|
currencies: "currencies",
|
||||||
|
wallet: "wallet",
|
||||||
|
draws: "draws",
|
||||||
|
rules_plays: "rules_plays",
|
||||||
|
rules_odds: "rules_odds",
|
||||||
|
jackpot: "jackpot",
|
||||||
|
risk_cap: "risk_cap",
|
||||||
|
risk: "risk",
|
||||||
|
settlement: "settlement",
|
||||||
|
reconcile: "reconcile",
|
||||||
|
reports: "reports",
|
||||||
|
tickets: "tickets",
|
||||||
|
audit: "audit",
|
||||||
|
settings: "settings",
|
||||||
|
integration: "integration",
|
||||||
|
config: "config",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function adminNavLabel(
|
||||||
|
segment: string,
|
||||||
|
t: TFunction,
|
||||||
|
apiLabel?: string | null,
|
||||||
|
): string {
|
||||||
|
const key = NAV_SEGMENT_I18N_KEYS[segment];
|
||||||
|
if (key) {
|
||||||
|
return t(`nav.${key}`, { ns: "common" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiLabel?.trim() ? apiLabel : segment;
|
||||||
|
}
|
||||||
28
src/lib/admin-session-verify.ts
Normal file
28
src/lib/admin-session-verify.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
|
||||||
|
import { isAdminAuthRejected } from "@/lib/admin-auth-reject";
|
||||||
|
import { getAdminSessionState } from "@/stores/admin-session";
|
||||||
|
import { readToken } from "@/stores/admin-token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 `/auth/me` 校验本地 Token 是否仍有效;失败时清会话(不跳转,由调用方决定)。
|
||||||
|
*/
|
||||||
|
export async function verifyStoredAdminSession(): Promise<boolean> {
|
||||||
|
const token = readToken();
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getAdminSessionState();
|
||||||
|
session.setBearerToken(token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetchAdminMeDeduped();
|
||||||
|
session.setAdminProfile(result.admin);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (isAdminAuthRejected(err)) {
|
||||||
|
session.clearSession();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/lib/admin-token-constants.ts
Normal file
5
src/lib/admin-token-constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/** localStorage / Cookie 共用键名,须与 {@link middleware} 一致 */
|
||||||
|
export const ADMIN_TOKEN_STORAGE_KEY = "lottery_admin_token";
|
||||||
|
|
||||||
|
/** 与后端 `lottery.admin_api.token_ttl_days` 默认 7 天对齐(秒) */
|
||||||
|
export const ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
||||||
61
src/lib/admin-token-cookie.ts
Normal file
61
src/lib/admin-token-cookie.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS,
|
||||||
|
ADMIN_TOKEN_STORAGE_KEY,
|
||||||
|
} from "@/lib/admin-token-constants";
|
||||||
|
|
||||||
|
export function readAdminTokenFromCookieString(
|
||||||
|
cookieHeader: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!cookieHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${ADMIN_TOKEN_STORAGE_KEY}=`;
|
||||||
|
for (const part of cookieHeader.split(";")) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const raw = trimmed.slice(prefix.length);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(raw).trim();
|
||||||
|
return decoded !== "" ? decoded : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAdminTokenFromDocumentCookie(): string | null {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readAdminTokenFromCookieString(document.cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeAdminTokenCookie(token: string | null): void {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secure =
|
||||||
|
typeof window !== "undefined" && window.location.protocol === "https:";
|
||||||
|
const base = `path=/; SameSite=Lax`;
|
||||||
|
|
||||||
|
if (!token || token.trim() === "") {
|
||||||
|
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=; ${base}; max-age=0`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = encodeURIComponent(token.trim());
|
||||||
|
const maxAge = `max-age=${ADMIN_TOKEN_COOKIE_MAX_AGE_SECONDS}`;
|
||||||
|
document.cookie = `${ADMIN_TOKEN_STORAGE_KEY}=${value}; ${base}; ${maxAge}${
|
||||||
|
secure ? "; Secure" : ""
|
||||||
|
}`;
|
||||||
|
}
|
||||||
45
src/middleware.ts
Normal file
45
src/middleware.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants";
|
||||||
|
import { readAdminTokenFromCookieString } from "@/lib/admin-token-cookie";
|
||||||
|
|
||||||
|
const ADMIN_LOGIN_PATH = "/admin/login";
|
||||||
|
|
||||||
|
function isAdminLoginPath(pathname: string): boolean {
|
||||||
|
return pathname === ADMIN_LOGIN_PATH || pathname.startsWith(`${ADMIN_LOGIN_PATH}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTokenFromRequest(request: NextRequest): string | null {
|
||||||
|
const fromCookie = request.cookies.get(ADMIN_TOKEN_STORAGE_KEY)?.value?.trim();
|
||||||
|
if (fromCookie) {
|
||||||
|
return fromCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readAdminTokenFromCookieString(request.headers.get("cookie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (!pathname.startsWith("/admin")) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminLoginPath(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = readTokenFromRequest(request);
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = request.nextUrl.clone();
|
||||||
|
loginUrl.pathname = ADMIN_LOGIN_PATH;
|
||||||
|
loginUrl.search = "";
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/admin", "/admin/:path*"],
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
putAdminRole,
|
putAdminRole,
|
||||||
putAdminRolePermissions,
|
putAdminRolePermissions,
|
||||||
} from "@/api/admin-users";
|
} from "@/api/admin-users";
|
||||||
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||||
@@ -342,7 +343,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
<TableHead>{t("roleTable.status")}</TableHead>
|
<TableHead>{t("roleTable.status")}</TableHead>
|
||||||
<TableHead>{t("roleTable.users")}</TableHead>
|
<TableHead>{t("roleTable.users")}</TableHead>
|
||||||
<TableHead>{t("roleTable.permissions")}</TableHead>
|
<TableHead>{t("roleTable.permissions")}</TableHead>
|
||||||
<TableHead>{t("roleTable.actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("roleTable.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -377,25 +378,32 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
{canManageRoles ? (
|
{canManageRoles ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<AdminRowActionsMenu
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
|
actions={[
|
||||||
{t("roleActions.permissions")}
|
{
|
||||||
</Button>
|
key: "permissions",
|
||||||
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
|
label: t("roleActions.permissions"),
|
||||||
{t("actions.edit")}
|
icon: KeyRound,
|
||||||
</Button>
|
onClick: () => openRolePermissionEditor(role),
|
||||||
<Button
|
},
|
||||||
type="button"
|
{
|
||||||
size="sm"
|
key: "edit",
|
||||||
variant="destructive"
|
label: t("actions.edit"),
|
||||||
disabled={role.is_system || role.user_count > 0}
|
icon: Pencil,
|
||||||
onClick={() => setRoleDeleteTarget(role)}
|
onClick: () => openEditRole(role),
|
||||||
>
|
},
|
||||||
{t("actions.delete")}
|
{
|
||||||
</Button>
|
key: "delete",
|
||||||
</div>
|
label: t("actions.delete"),
|
||||||
|
icon: Trash2,
|
||||||
|
destructive: true,
|
||||||
|
disabled: role.is_system || role.user_count > 0,
|
||||||
|
onClick: () => setRoleDeleteTarget(role),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
putAdminUserRoles,
|
putAdminUserRoles,
|
||||||
} from "@/api/admin-users";
|
} from "@/api/admin-users";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -371,7 +373,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
||||||
<TableHead>{t("table.roles")}</TableHead>
|
<TableHead>{t("table.roles")}</TableHead>
|
||||||
<TableHead>{t("table.effective")}</TableHead>
|
<TableHead>{t("table.effective")}</TableHead>
|
||||||
<TableHead className="w-[15rem] whitespace-nowrap text-center">{t("table.actions")}</TableHead>
|
<TableHead className="w-14 whitespace-nowrap text-center">{t("table.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -412,44 +414,34 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex w-full flex-nowrap justify-center gap-1 whitespace-nowrap">
|
|
||||||
{canManageUsers ? (
|
{canManageUsers ? (
|
||||||
<Button
|
<AdminRowActionsMenu
|
||||||
type="button"
|
actions={[
|
||||||
size="sm"
|
{
|
||||||
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
|
key: "permissions",
|
||||||
onClick={() => openPermissionEditor(row)}
|
label: t("actions.permissions"),
|
||||||
>
|
icon: KeyRound,
|
||||||
{t("actions.permissions")}
|
onClick: () => openPermissionEditor(row),
|
||||||
</Button>
|
},
|
||||||
) : null}
|
{
|
||||||
{canManageUsers ? (
|
key: "edit",
|
||||||
<Button
|
label: t("actions.edit"),
|
||||||
type="button"
|
icon: Pencil,
|
||||||
size="sm"
|
onClick: () => openEditAccount(row),
|
||||||
variant={accountOpen && editingAccountId === row.id ? "secondary" : "outline"}
|
},
|
||||||
onClick={() => openEditAccount(row)}
|
{
|
||||||
>
|
key: "delete",
|
||||||
{t("actions.edit")}
|
label: t("actions.delete"),
|
||||||
</Button>
|
icon: Trash2,
|
||||||
) : null}
|
destructive: true,
|
||||||
{canManageUsers ? (
|
disabled: profile?.id === row.id,
|
||||||
<Button
|
onClick: () => setDeleteTarget(row),
|
||||||
type="button"
|
},
|
||||||
size="sm"
|
]}
|
||||||
variant="destructive"
|
/>
|
||||||
disabled={profile?.id === row.id}
|
) : (
|
||||||
title={
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
profile?.id === row.id
|
)}
|
||||||
? t("delete.currentUserBlocked")
|
|
||||||
: t("delete.rowActionTitle")
|
|
||||||
}
|
|
||||||
onClick={() => setDeleteTarget(row)}
|
|
||||||
>
|
|
||||||
{t("actions.delete")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
40
src/modules/config/config-workflow-section.tsx
Normal file
40
src/modules/config/config-workflow-section.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ConfigWorkflowSectionProps = {
|
||||||
|
step: number;
|
||||||
|
title: string;
|
||||||
|
description?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 编号步骤卡片,用于赔率/回水等合并配置页主栏分区。 */
|
||||||
|
export function ConfigWorkflowSection({
|
||||||
|
step,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
}: ConfigWorkflowSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className={cn("overflow-hidden rounded-xl border border-border/60 bg-card", className)}>
|
||||||
|
<div className="flex gap-3 border-b border-border/50 px-4 py-3.5 sm:px-5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground"
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 space-y-0.5">
|
||||||
|
<h3 className="text-base font-semibold tracking-tight">{title}</h3>
|
||||||
|
{description ? <div className="text-sm text-muted-foreground">{description}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn("px-4 py-4 sm:px-5", contentClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -50,6 +51,16 @@ import type {
|
|||||||
OddsVersionDetail,
|
OddsVersionDetail,
|
||||||
} from "@/types/api/admin-config";
|
} from "@/types/api/admin-config";
|
||||||
|
|
||||||
|
import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
|
||||||
|
import {
|
||||||
|
OddsConfigSummaryPanel,
|
||||||
|
playRebatePercentFromScopes,
|
||||||
|
} from "@/modules/config/doc/odds-config-summary-panel";
|
||||||
|
import {
|
||||||
|
buildOddsPlayFilterGroups,
|
||||||
|
filterOddsPlayTypesByCategory,
|
||||||
|
type OddsCategoryTab,
|
||||||
|
} from "@/modules/config/doc/odds-play-type-groups";
|
||||||
import {
|
import {
|
||||||
PRIZE_SCOPE_MULTIPLIER_HINT,
|
PRIZE_SCOPE_MULTIPLIER_HINT,
|
||||||
PRIZE_SCOPE_ORDER,
|
PRIZE_SCOPE_ORDER,
|
||||||
@@ -57,7 +68,7 @@ import {
|
|||||||
type PrizeScopeCode,
|
type PrizeScopeCode,
|
||||||
} from "@/modules/config/doc/prize-scopes";
|
} from "@/modules/config/doc/prize-scopes";
|
||||||
|
|
||||||
type CatTab = "all" | "d4" | "d3" | "d2";
|
type CatTab = OddsCategoryTab;
|
||||||
|
|
||||||
function oddsMultiplierLabel(oddsValue: number): string {
|
function oddsMultiplierLabel(oddsValue: number): string {
|
||||||
return (oddsValue / 10000).toFixed(4);
|
return (oddsValue / 10000).toFixed(4);
|
||||||
@@ -72,17 +83,13 @@ function parseOddsMultiplierInput(raw: string): number {
|
|||||||
return Number.isSafeInteger(scaled) ? scaled : 0;
|
return Number.isSafeInteger(scaled) ? scaled : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] {
|
|
||||||
if (tab === "all") {
|
|
||||||
return types;
|
|
||||||
}
|
|
||||||
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
|
|
||||||
return types.filter((t) => t.dimension === dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
type OddsConfigDocScreenProps = {
|
type OddsConfigDocScreenProps = {
|
||||||
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
|
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** 合并页:左侧三步骤 + 右侧配置摘要(参考设计稿) */
|
||||||
|
mergedLayout?: boolean;
|
||||||
|
/** 合并页第 3 步:佣金 / 回水 */
|
||||||
|
rebateSection?: ReactNode;
|
||||||
/** 合并页共享数据层(避免与回水区块重复拉取版本详情) */
|
/** 合并页共享数据层(避免与回水区块重复拉取版本详情) */
|
||||||
workspace?: OddsConfigWorkspace;
|
workspace?: OddsConfigWorkspace;
|
||||||
/** 与回水分区共用版本选择(无 workspace 时) */
|
/** 与回水分区共用版本选择(无 workspace 时) */
|
||||||
@@ -92,6 +99,8 @@ type OddsConfigDocScreenProps = {
|
|||||||
|
|
||||||
export function OddsConfigDocScreen({
|
export function OddsConfigDocScreen({
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
mergedLayout = false,
|
||||||
|
rebateSection,
|
||||||
workspace,
|
workspace,
|
||||||
versionId: controlledVersionId,
|
versionId: controlledVersionId,
|
||||||
onVersionIdChange,
|
onVersionIdChange,
|
||||||
@@ -234,7 +243,15 @@ export function OddsConfigDocScreen({
|
|||||||
[resolvedTypes],
|
[resolvedTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]);
|
const filteredTypes = useMemo(
|
||||||
|
() => filterOddsPlayTypesByCategory(catTab, sortedTypes),
|
||||||
|
[catTab, sortedTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const playFilterGroups = useMemo(
|
||||||
|
() => buildOddsPlayFilterGroups(catTab, sortedTypes),
|
||||||
|
[catTab, sortedTypes],
|
||||||
|
);
|
||||||
|
|
||||||
const resolvedPlayCode = useMemo(() => {
|
const resolvedPlayCode = useMemo(() => {
|
||||||
if (filteredTypes.length === 0) {
|
if (filteredTypes.length === 0) {
|
||||||
@@ -483,8 +500,13 @@ export function OddsConfigDocScreen({
|
|||||||
{ id: "d2", label: "2D" },
|
{ id: "d2", label: "2D" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const filtersBlock = (
|
const activeCatLabel = catTabs.find((tab) => tab.id === catTab)?.label ?? catTab;
|
||||||
<div className={cn("space-y-3", embedded ? "border-t border-border/50 px-3 py-3 sm:px-4" : "rounded-xl border border-border/60 bg-card p-4")}>
|
const activePlayLabel = resolvedPlayCode
|
||||||
|
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const filtersInner = (
|
||||||
|
<>
|
||||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||||
{catTabs.map((tab) => (
|
{catTabs.map((tab) => (
|
||||||
<ConfigChip
|
<ConfigChip
|
||||||
@@ -496,6 +518,30 @@ export function OddsConfigDocScreen({
|
|||||||
</ConfigChip>
|
</ConfigChip>
|
||||||
))}
|
))}
|
||||||
</ConfigChipGroup>
|
</ConfigChipGroup>
|
||||||
|
{mergedLayout ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{playFilterGroups.length === 0 ? (
|
||||||
|
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||||
|
) : (
|
||||||
|
playFilterGroups.map((group) => (
|
||||||
|
<ConfigChipGroup
|
||||||
|
key={group.key}
|
||||||
|
label={t(`odds.playGroups.${group.key}`, { ns: "config" })}
|
||||||
|
>
|
||||||
|
{group.types.map((type) => (
|
||||||
|
<ConfigChip
|
||||||
|
key={type.play_code}
|
||||||
|
active={resolvedPlayCode === type.play_code}
|
||||||
|
onClick={() => setPlayCode(type.play_code)}
|
||||||
|
>
|
||||||
|
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
|
||||||
|
</ConfigChip>
|
||||||
|
))}
|
||||||
|
</ConfigChipGroup>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
|
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
|
||||||
{filteredTypes.length === 0 ? (
|
{filteredTypes.length === 0 ? (
|
||||||
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||||
@@ -514,6 +560,20 @@ export function OddsConfigDocScreen({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ConfigChipGroup>
|
</ConfigChipGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtersBlock = mergedLayout ? (
|
||||||
|
filtersInner
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"space-y-3",
|
||||||
|
embedded ? "border-t border-border/50 px-3 py-3 sm:px-4" : "rounded-xl border border-border/60 bg-card p-4",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filtersInner}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -578,16 +638,12 @@ export function OddsConfigDocScreen({
|
|||||||
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
||||||
|
|
||||||
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className={cn("text-center text-sm text-muted-foreground", mergedLayout ? "py-6" : "py-8")}>
|
||||||
{t("odds.loadingDetails", { ns: "config" })}
|
{t("odds.loadingDetails", { ns: "config" })}
|
||||||
</p>
|
</p>
|
||||||
) : resolvedPlayCode ? (
|
) : resolvedPlayCode ? (
|
||||||
<div
|
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
|
||||||
className={cn(
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
||||||
embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
|
|
||||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
{PRIZE_SCOPE_ORDER.map((scope) => {
|
||||||
const row = scopeRows[scope];
|
const row = scopeRows[scope];
|
||||||
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||||
@@ -721,6 +777,43 @@ export function OddsConfigDocScreen({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embedded && mergedLayout) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_min(100%,300px)] xl:items-start">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ConfigWorkflowSection step={1} title={t("odds.sections.playScope", { ns: "config" })}>
|
||||||
|
{filtersBlock}
|
||||||
|
</ConfigWorkflowSection>
|
||||||
|
<ConfigWorkflowSection
|
||||||
|
step={2}
|
||||||
|
title={t("odds.sections.oddsConfig", { ns: "config" })}
|
||||||
|
description={t("odds.currentSelection", {
|
||||||
|
ns: "config",
|
||||||
|
category: activeCatLabel,
|
||||||
|
play: activePlayLabel,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{mainBlock}
|
||||||
|
</ConfigWorkflowSection>
|
||||||
|
{rebateSection}
|
||||||
|
</div>
|
||||||
|
<OddsConfigSummaryPanel
|
||||||
|
catTabLabel={activeCatLabel}
|
||||||
|
playLabel={activePlayLabel}
|
||||||
|
detail={resolvedDetail}
|
||||||
|
draftRows={resolvedDraftRows}
|
||||||
|
types={sortedTypes}
|
||||||
|
scopeRows={scopeRows}
|
||||||
|
playRebatePercent={playRebatePercentFromScopes(scopeRows, PRIZE_SCOPE_ORDER)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{dialogs}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
157
src/modules/config/doc/odds-config-summary-panel.tsx
Normal file
157
src/modules/config/doc/odds-config-summary-panel.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FileText, Info } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
||||||
|
import { inferRebatePercentFromDimension, rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
|
||||||
|
import { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AdminPlayTypeRow, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
|
||||||
|
|
||||||
|
function oddsMultiplierLabel(oddsValue: number): string {
|
||||||
|
return (oddsValue / 10000).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SummaryRow = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OddsConfigSummaryPanelProps = {
|
||||||
|
catTabLabel: string;
|
||||||
|
playLabel: string;
|
||||||
|
detail: OddsVersionDetail | null;
|
||||||
|
draftRows: OddsItemRow[];
|
||||||
|
types: AdminPlayTypeRow[];
|
||||||
|
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>;
|
||||||
|
playRebatePercent: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OddsConfigSummaryPanel({
|
||||||
|
catTabLabel,
|
||||||
|
playLabel,
|
||||||
|
detail,
|
||||||
|
draftRows,
|
||||||
|
types,
|
||||||
|
scopeRows,
|
||||||
|
playRebatePercent,
|
||||||
|
className,
|
||||||
|
}: OddsConfigSummaryPanelProps) {
|
||||||
|
const { t } = useTranslation("config");
|
||||||
|
|
||||||
|
const isDraft = detail?.status === "draft";
|
||||||
|
const isActive = detail?.status === "active";
|
||||||
|
|
||||||
|
const rows: SummaryRow[] = [
|
||||||
|
{ label: t("odds.category"), value: catTabLabel },
|
||||||
|
{ label: t("odds.playType"), value: playLabel || "—" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scope of ["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]) {
|
||||||
|
const row = scopeRows[scope];
|
||||||
|
rows.push({
|
||||||
|
label: prizeScopeLabel(scope, t),
|
||||||
|
value: row ? oddsMultiplierLabel(row.odds_value) : "—",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
label: t("odds.rebateRate"),
|
||||||
|
value: playRebatePercent,
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
{
|
||||||
|
label: t("rebate.fields.d2"),
|
||||||
|
value: inferRebatePercentFromDimension(2, draftRows, types),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("rebate.fields.d3"),
|
||||||
|
value: inferRebatePercentFromDimension(3, draftRows, types),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("rebate.fields.d4"),
|
||||||
|
value: inferRebatePercentFromDimension(4, draftRows, types),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const versionLabel = detail ? `v${detail.version_no}` : "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"lg:sticky lg:top-24 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border/50 px-4 py-3.5">
|
||||||
|
<FileText className="size-4 text-primary" aria-hidden />
|
||||||
|
<h3 className="text-base font-semibold">{t("odds.summary.title")}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-4 py-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{t("odds.summary.version")}</span>
|
||||||
|
<span className="font-mono text-sm font-medium">{versionLabel}</span>
|
||||||
|
{detail ? <ConfigStatusBadge status={detail.status} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">{t("odds.summary.statusLabel")}</span>
|
||||||
|
{detail && !isDraft ? (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-border/60 bg-muted/40 px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{t("odds.summary.readOnlyTag")}
|
||||||
|
</span>
|
||||||
|
) : isDraft ? (
|
||||||
|
<span className="inline-flex items-center rounded-md border border-primary/25 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{t("versionStatus.draft")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="space-y-2.5">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.label} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{row.label}</dt>
|
||||||
|
<dd className="font-mono text-right tabular-nums text-foreground">{row.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{detail && !isDraft ? (
|
||||||
|
<Alert className="border-sky-500/30 bg-sky-500/5 text-foreground">
|
||||||
|
<Info className="size-4 text-sky-600 dark:text-sky-400" aria-hidden />
|
||||||
|
<AlertDescription className="text-xs leading-relaxed">
|
||||||
|
{t("odds.summary.readOnlyHint")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : isActive ? (
|
||||||
|
<Alert className="border-emerald-500/30 bg-emerald-500/5 text-foreground">
|
||||||
|
<AlertDescription className="text-xs leading-relaxed">
|
||||||
|
{t("odds.summary.activeHint")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前玩法在摘要中展示的回水百分比(与赔率区输入一致)。 */
|
||||||
|
export function playRebatePercentFromScopes(
|
||||||
|
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>,
|
||||||
|
order: readonly PrizeScopeCode[],
|
||||||
|
): string {
|
||||||
|
const first = order.map((s) => scopeRows[s]).find(Boolean);
|
||||||
|
if (!first) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return rateToPercentUi(String(first.rebate_rate));
|
||||||
|
}
|
||||||
60
src/modules/config/doc/odds-play-type-groups.ts
Normal file
60
src/modules/config/doc/odds-play-type-groups.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type { AdminPlayTypeRow } from "@/types/api/admin-config";
|
||||||
|
|
||||||
|
export type OddsCategoryTab = "all" | "d4" | "d3" | "d2";
|
||||||
|
|
||||||
|
export type OddsPlayFilterGroupKey = "bigSmall" | "combo4" | "number3" | "number2" | "other";
|
||||||
|
|
||||||
|
type GroupDef = {
|
||||||
|
key: OddsPlayFilterGroupKey;
|
||||||
|
match: (row: AdminPlayTypeRow) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ODDS_PLAY_FILTER_GROUPS: GroupDef[] = [
|
||||||
|
{
|
||||||
|
key: "bigSmall",
|
||||||
|
match: (row) => row.play_code === "big" || row.play_code === "small",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "combo4",
|
||||||
|
match: (row) => row.category === "position" && row.dimension === 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "number3",
|
||||||
|
match: (row) => row.category === "position" && row.dimension === 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "number2",
|
||||||
|
match: (row) => row.category === "position" && row.dimension === 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
match: (row) =>
|
||||||
|
row.category === "box"
|
||||||
|
|| row.category === "attribute"
|
||||||
|
|| (row.category === "standard" && row.play_code !== "big" && row.play_code !== "small"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function filterOddsPlayTypesByCategory(
|
||||||
|
tab: OddsCategoryTab,
|
||||||
|
types: AdminPlayTypeRow[],
|
||||||
|
): AdminPlayTypeRow[] {
|
||||||
|
if (tab === "all") {
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2;
|
||||||
|
return types.filter((t) => t.dimension === dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOddsPlayFilterGroups(
|
||||||
|
tab: OddsCategoryTab,
|
||||||
|
types: AdminPlayTypeRow[],
|
||||||
|
): { key: OddsPlayFilterGroupKey; types: AdminPlayTypeRow[] }[] {
|
||||||
|
const filtered = filterOddsPlayTypesByCategory(tab, types);
|
||||||
|
return ODDS_PLAY_FILTER_GROUPS.map((def) => ({
|
||||||
|
key: def.key,
|
||||||
|
types: filtered
|
||||||
|
.filter(def.match)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)),
|
||||||
|
})).filter((group) => group.types.length > 0);
|
||||||
|
}
|
||||||
29
src/modules/config/doc/odds-rebate-rates.ts
Normal file
29
src/modules/config/doc/odds-rebate-rates.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||||
|
import type { AdminPlayTypeRow, OddsItemRow } from "@/types/api/admin-config";
|
||||||
|
|
||||||
|
export function rateToPercentUi(rateStr: string): string {
|
||||||
|
const n = Number.parseFloat(rateStr);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
return (Math.round(n * 10000) / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferRebatePercentFromDimension(
|
||||||
|
dim: 2 | 3 | 4,
|
||||||
|
rows: OddsItemRow[],
|
||||||
|
typeList: AdminPlayTypeRow[],
|
||||||
|
): string {
|
||||||
|
const codes = typeList
|
||||||
|
.filter((t) => (t.dimension ?? 2) === dim)
|
||||||
|
.map((t) => t.play_code)
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
const scope = PRIZE_SCOPE_ORDER[0];
|
||||||
|
for (const code of codes) {
|
||||||
|
const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope);
|
||||||
|
if (hit) {
|
||||||
|
return rateToPercentUi(String(hit.rebate_rate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
@@ -51,34 +51,16 @@ import type {
|
|||||||
OddsVersionDetail,
|
OddsVersionDetail,
|
||||||
} from "@/types/api/admin-config";
|
} from "@/types/api/admin-config";
|
||||||
|
|
||||||
|
import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
|
||||||
|
import {
|
||||||
|
inferRebatePercentFromDimension,
|
||||||
|
rateToPercentUi,
|
||||||
|
} from "@/modules/config/doc/odds-rebate-rates";
|
||||||
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes";
|
||||||
|
|
||||||
const SETTLEMENT_GROUP = "settlement";
|
const SETTLEMENT_GROUP = "settlement";
|
||||||
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
|
const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout";
|
||||||
|
|
||||||
function rateToPercentUi(rateStr: string): string {
|
|
||||||
const n = Number.parseFloat(rateStr);
|
|
||||||
if (!Number.isFinite(n)) {
|
|
||||||
return "0.00";
|
|
||||||
}
|
|
||||||
return (Math.round(n * 10000) / 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPlayTypeRow[]): string {
|
|
||||||
const codes = typeList
|
|
||||||
.filter((t) => (t.dimension ?? 2) === dim)
|
|
||||||
.map((t) => t.play_code)
|
|
||||||
.sort((a, b) => a.localeCompare(b));
|
|
||||||
const scope = PRIZE_SCOPE_ORDER[0];
|
|
||||||
for (const code of codes) {
|
|
||||||
const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope);
|
|
||||||
if (hit) {
|
|
||||||
return rateToPercentUi(String(hit.rebate_rate));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
function dimensionDistinctPrimaryScopePercents(
|
function dimensionDistinctPrimaryScopePercents(
|
||||||
dim: 2 | 3 | 4,
|
dim: 2 | 3 | 4,
|
||||||
rows: OddsItemRow[],
|
rows: OddsItemRow[],
|
||||||
@@ -101,6 +83,8 @@ function dimensionDistinctPrimaryScopePercents(
|
|||||||
|
|
||||||
type RebateConfigDocScreenProps = {
|
type RebateConfigDocScreenProps = {
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** 合并页第 3 步卡片 */
|
||||||
|
mergedSection?: boolean;
|
||||||
workspace?: OddsConfigWorkspace;
|
workspace?: OddsConfigWorkspace;
|
||||||
versionId?: string;
|
versionId?: string;
|
||||||
onVersionIdChange?: (id: string) => void;
|
onVersionIdChange?: (id: string) => void;
|
||||||
@@ -108,6 +92,7 @@ type RebateConfigDocScreenProps = {
|
|||||||
|
|
||||||
export function RebateConfigDocScreen({
|
export function RebateConfigDocScreen({
|
||||||
embedded = false,
|
embedded = false,
|
||||||
|
mergedSection = false,
|
||||||
workspace,
|
workspace,
|
||||||
versionId: controlledVersionId,
|
versionId: controlledVersionId,
|
||||||
onVersionIdChange,
|
onVersionIdChange,
|
||||||
@@ -205,9 +190,9 @@ export function RebateConfigDocScreen({
|
|||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setP2(inferPercentFrom(2, workspace.draftRows, workspace.types));
|
setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types));
|
||||||
setP3(inferPercentFrom(3, workspace.draftRows, workspace.types));
|
setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types));
|
||||||
setP4(inferPercentFrom(4, workspace.draftRows, workspace.types));
|
setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types));
|
||||||
}, [workspace?.draftRows, workspace?.types, workspace]);
|
}, [workspace?.draftRows, workspace?.types, workspace]);
|
||||||
|
|
||||||
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
|
async function handleWinEnjoyChange(checked: boolean): Promise<void> {
|
||||||
@@ -236,9 +221,9 @@ export function RebateConfigDocScreen({
|
|||||||
const rows = d.items.map((it) => ({ ...it }));
|
const rows = d.items.map((it) => ({ ...it }));
|
||||||
setDetail(d);
|
setDetail(d);
|
||||||
setDraftRows(rows);
|
setDraftRows(rows);
|
||||||
setP2(inferPercentFrom(2, rows, typeList));
|
setP2(inferRebatePercentFromDimension(2, rows, typeList));
|
||||||
setP3(inferPercentFrom(3, rows, typeList));
|
setP3(inferRebatePercentFromDimension(3, rows, typeList));
|
||||||
setP4(inferPercentFrom(4, rows, typeList));
|
setP4(inferRebatePercentFromDimension(4, rows, typeList));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }));
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
@@ -357,9 +342,9 @@ export function RebateConfigDocScreen({
|
|||||||
setDetail(d);
|
setDetail(d);
|
||||||
setDraftRows(rows);
|
setDraftRows(rows);
|
||||||
}
|
}
|
||||||
setP2(inferPercentFrom(2, rows, resolvedTypes));
|
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
|
||||||
setP3(inferPercentFrom(3, rows, resolvedTypes));
|
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
|
||||||
setP4(inferPercentFrom(4, rows, resolvedTypes));
|
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
|
||||||
toast.success(t("versionActions.saveDraft", { ns: "config" }));
|
toast.success(t("versionActions.saveDraft", { ns: "config" }));
|
||||||
void (workspace?.refreshList() ?? refreshList());
|
void (workspace?.refreshList() ?? refreshList());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -383,9 +368,9 @@ export function RebateConfigDocScreen({
|
|||||||
setDetail(d);
|
setDetail(d);
|
||||||
setDraftRows(rows);
|
setDraftRows(rows);
|
||||||
}
|
}
|
||||||
setP2(inferPercentFrom(2, rows, resolvedTypes));
|
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
|
||||||
setP3(inferPercentFrom(3, rows, resolvedTypes));
|
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
|
||||||
setP4(inferPercentFrom(4, rows, resolvedTypes));
|
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
|
||||||
toast.success(t("rebate.publishSuccess", { ns: "config" }));
|
toast.success(t("rebate.publishSuccess", { ns: "config" }));
|
||||||
void (workspace?.refreshList() ?? refreshList());
|
void (workspace?.refreshList() ?? refreshList());
|
||||||
setSelectedId(String(d.id));
|
setSelectedId(String(d.id));
|
||||||
@@ -414,9 +399,9 @@ export function RebateConfigDocScreen({
|
|||||||
setDetail(d);
|
setDetail(d);
|
||||||
setDraftRows(rows);
|
setDraftRows(rows);
|
||||||
}
|
}
|
||||||
setP2(inferPercentFrom(2, rows, resolvedTypes));
|
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
|
||||||
setP3(inferPercentFrom(3, rows, resolvedTypes));
|
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
|
||||||
setP4(inferPercentFrom(4, rows, resolvedTypes));
|
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -457,9 +442,9 @@ export function RebateConfigDocScreen({
|
|||||||
setDetail(d);
|
setDetail(d);
|
||||||
setDraftRows(rows);
|
setDraftRows(rows);
|
||||||
}
|
}
|
||||||
setP2(inferPercentFrom(2, rows, resolvedTypes));
|
setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes));
|
||||||
setP3(inferPercentFrom(3, rows, resolvedTypes));
|
setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes));
|
||||||
setP4(inferPercentFrom(4, rows, resolvedTypes));
|
setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes));
|
||||||
setRollbackOpen(false);
|
setRollbackOpen(false);
|
||||||
setRollbackTarget(null);
|
setRollbackTarget(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -658,6 +643,23 @@ export function RebateConfigDocScreen({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embedded && mergedSection) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigWorkflowSection
|
||||||
|
step={3}
|
||||||
|
title={t("nav.items.rebate", { ns: "config" })}
|
||||||
|
description={t("rebate.sectionHint", { ns: "config" })}
|
||||||
|
contentClassName="space-y-5"
|
||||||
|
>
|
||||||
|
{fieldsBlock}
|
||||||
|
</ConfigWorkflowSection>
|
||||||
|
{rollbackDialog}
|
||||||
|
<ConfirmDialog />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
publishRiskCapVersion,
|
publishRiskCapVersion,
|
||||||
putRiskCapItems,
|
putRiskCapItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
import {
|
import {
|
||||||
@@ -505,7 +507,7 @@ export function RiskCapDocScreen() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
<TableHead className="w-14 text-center">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -549,17 +551,20 @@ export function RiskCapDocScreen() {
|
|||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
{canEditDraft ? (
|
{canEditDraft ? (
|
||||||
<Button
|
<AdminRowActionsMenu
|
||||||
type="button"
|
busy={saving}
|
||||||
variant="ghost"
|
actions={[
|
||||||
className="text-destructive"
|
{
|
||||||
disabled={saving}
|
key: "delete",
|
||||||
onClick={() => removeRow(idx)}
|
label: t("actions.delete", { ns: "adminUsers" }),
|
||||||
>
|
icon: Trash2,
|
||||||
{t("actions.delete", { ns: "adminUsers" })}
|
destructive: true,
|
||||||
</Button>
|
onClick: () => removeRow(idx),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
|
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { format, subDays } from "date-fns";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
|
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
|
||||||
|
|
||||||
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -20,160 +18,80 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
|
||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
|
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
|
||||||
import {
|
import {
|
||||||
DailyTrendChart,
|
DailyTrendChart,
|
||||||
PeriodCompareStrip,
|
|
||||||
PlayBreakdownChart,
|
PlayBreakdownChart,
|
||||||
} from "@/modules/dashboard/dashboard-trend-charts";
|
} from "@/modules/dashboard/dashboard-trend-charts";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import {
|
||||||
import type {
|
DASHBOARD_ANALYTICS_PERIODS,
|
||||||
AdminDashboardAnalyticsData,
|
DASHBOARD_RANKING_METRICS,
|
||||||
DashboardAnalyticsMetric,
|
useDashboardAnalytics,
|
||||||
DashboardAnalyticsPeriod,
|
type DashboardAnalyticsState,
|
||||||
} from "@/types/api/admin-dashboard-analytics";
|
} from "@/modules/dashboard/use-dashboard-analytics";
|
||||||
|
|
||||||
const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
|
function computeDeltaPercent(series: number[]): string | null {
|
||||||
"today",
|
if (series.length < 2) {
|
||||||
"last_7_days",
|
|
||||||
"last_30_days",
|
|
||||||
"this_month",
|
|
||||||
"lifetime",
|
|
||||||
"custom",
|
|
||||||
];
|
|
||||||
|
|
||||||
const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
|
|
||||||
|
|
||||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
|
||||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
|
||||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
|
||||||
const major = minor / 10 ** decimals;
|
|
||||||
try {
|
|
||||||
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
|
||||||
style: "currency",
|
|
||||||
currency: code,
|
|
||||||
minimumFractionDigits: decimals,
|
|
||||||
maximumFractionDigits: decimals,
|
|
||||||
}).format(major);
|
|
||||||
} catch {
|
|
||||||
return formatAdminMinorUnits(minor, code, decimals);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
|
||||||
if (minor === 0) {
|
|
||||||
return formatMoneyMinor(0, currencyCode);
|
|
||||||
}
|
|
||||||
const s = minor > 0 ? "+" : "−";
|
|
||||||
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardAnalyticsPanel({
|
|
||||||
enabled,
|
|
||||||
playOptions,
|
|
||||||
}: {
|
|
||||||
enabled: boolean;
|
|
||||||
playOptions: { code: string; label: string }[];
|
|
||||||
}): ReactNode {
|
|
||||||
const { t } = useTranslation(["dashboard", "common"]);
|
|
||||||
const playLabel = useAdminPlayCodeLabel();
|
|
||||||
|
|
||||||
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
|
||||||
const [metric, setMetric] = useState<DashboardAnalyticsMetric>("overview");
|
|
||||||
const [playCode, setPlayCode] = useState<string>("");
|
|
||||||
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
|
|
||||||
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
if (!enabled) {
|
|
||||||
setLoading(false);
|
|
||||||
setData(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await getAdminDashboardAnalytics({
|
|
||||||
period,
|
|
||||||
metric,
|
|
||||||
play_code: playCode !== "" ? playCode : undefined,
|
|
||||||
...(period === "custom"
|
|
||||||
? { date_from: customFrom, date_to: customTo }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
setData(payload);
|
|
||||||
} catch (e) {
|
|
||||||
setData(null);
|
|
||||||
const raw = e instanceof LotteryApiBizError ? e.message : "";
|
|
||||||
const needsAuthSync =
|
|
||||||
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
|
||||||
setError(
|
|
||||||
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [enabled, period, metric, playCode, customFrom, customTo, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
void load();
|
|
||||||
}, 0);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const currency = data?.currency_code ?? null;
|
|
||||||
const summary = data?.summary;
|
|
||||||
|
|
||||||
const periodRangeLabel = useMemo(() => {
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return data.date_from === data.date_to
|
const prev = series[series.length - 2];
|
||||||
? data.date_from
|
const last = series[series.length - 1];
|
||||||
: `${data.date_from} — ${data.date_to}`;
|
if (prev === 0) {
|
||||||
}, [data]);
|
return null;
|
||||||
|
|
||||||
const metricLabel = useMemo(
|
|
||||||
() => t(`analytics.metrics.${metric}`),
|
|
||||||
[metric, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const playFilterLabel = useMemo(() => {
|
|
||||||
if (playCode === "") {
|
|
||||||
return t("analytics.allPlays");
|
|
||||||
}
|
}
|
||||||
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
|
const pct = ((last - prev) / Math.abs(prev)) * 100;
|
||||||
}, [playCode, playOptions, t]);
|
const sign = pct >= 0 ? "▲" : "▼";
|
||||||
|
return `${sign} ${Math.abs(pct).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
const resolvePlayLabel = useCallback(
|
function deltaClassName(series: number[]): string {
|
||||||
(code: string, dimension: number) => {
|
if (series.length < 2) {
|
||||||
const base = playLabel(code);
|
return "text-muted-foreground";
|
||||||
return dimension > 0 ? `${base} · ${dimension}D` : base;
|
}
|
||||||
},
|
const last = series[series.length - 1];
|
||||||
[playLabel],
|
const prev = series[series.length - 2];
|
||||||
);
|
if (last >= prev) {
|
||||||
|
return "text-emerald-600 dark:text-emerald-400";
|
||||||
|
}
|
||||||
|
return "text-destructive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
|
||||||
|
const { t } = useTranslation(["dashboard", "common"]);
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
playCode,
|
||||||
|
setPlayCode,
|
||||||
|
customFrom,
|
||||||
|
setCustomFrom,
|
||||||
|
customTo,
|
||||||
|
setCustomTo,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
data,
|
||||||
|
currency,
|
||||||
|
summary,
|
||||||
|
periodRangeLabel,
|
||||||
|
playFilterLabel,
|
||||||
|
playOptions,
|
||||||
|
sparklines,
|
||||||
|
formatMoney,
|
||||||
|
formatSignedMoney,
|
||||||
|
} = analytics;
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<Card className="admin-list-card min-w-0 overflow-hidden py-0">
|
||||||
<Card className="border-border/80 shadow-sm">
|
<CardHeader className="space-y-3 border-b border-border/60 px-4 py-3">
|
||||||
<CardHeader className="space-y-4 pb-2">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<CardTitle className="text-base">{t("analytics.title")}</CardTitle>
|
<CardTitle className="text-base font-semibold">{t("analytics.title")}</CardTitle>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/reports"
|
href="/admin/reports"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
|
||||||
@@ -184,7 +102,7 @@ export function DashboardAnalyticsPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
|
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
|
||||||
{PERIOD_OPTIONS.map((p) => (
|
{DASHBOARD_ANALYTICS_PERIODS.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -201,7 +119,7 @@ export function DashboardAnalyticsPanel({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[1fr_auto_auto] lg:items-end">
|
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||||
{period === "custom" ? (
|
{period === "custom" ? (
|
||||||
<AdminDateRangeField
|
<AdminDateRangeField
|
||||||
id="dashboard-analytics-range"
|
id="dashboard-analytics-range"
|
||||||
@@ -214,29 +132,13 @@ export function DashboardAnalyticsPanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground lg:col-span-1">
|
<p className="text-sm text-muted-foreground">
|
||||||
{periodRangeLabel
|
{periodRangeLabel
|
||||||
? t("analytics.rangeHint", { range: periodRangeLabel })
|
? t("analytics.rangeHint", { range: periodRangeLabel })
|
||||||
: t("analytics.selectPeriod")}
|
: t("analytics.selectPeriod")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">{t("analytics.metricLabel")}</Label>
|
|
||||||
<Select value={metric} onValueChange={(v) => setMetric(v as DashboardAnalyticsMetric)}>
|
|
||||||
<SelectTrigger className="w-full min-w-[140px]">
|
|
||||||
<SelectValue>{metricLabel}</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{METRIC_OPTIONS.map((m) => (
|
|
||||||
<SelectItem key={m} value={m}>
|
|
||||||
{t(`analytics.metrics.${m}`)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
|
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -259,7 +161,7 @@ export function DashboardAnalyticsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-4 px-4 py-4">
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
@@ -277,25 +179,33 @@ export function DashboardAnalyticsPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
<Skeleton key={i} className="h-28 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<StatCard
|
<DashboardKpiCard
|
||||||
label={t("analytics.summaryBet")}
|
label={t("analytics.summaryBet")}
|
||||||
value={formatMoneyMinor(summary.total_bet_minor, currency)}
|
value={formatMoney(summary.total_bet_minor, currency)}
|
||||||
hint={t("lifetimeActivityHint", {
|
hint={t("lifetimeActivityHint", {
|
||||||
draws: summary.draw_count.toLocaleString(getAdminRequestLocale()),
|
draws: summary.draw_count.toLocaleString(getAdminRequestLocale()),
|
||||||
days: summary.business_day_count.toLocaleString(getAdminRequestLocale()),
|
days: summary.business_day_count.toLocaleString(getAdminRequestLocale()),
|
||||||
})}
|
})}
|
||||||
icon={<Wallet className="size-5" aria-hidden />}
|
icon={<Wallet className="size-4" aria-hidden />}
|
||||||
|
sparklineValues={sparklines.bet}
|
||||||
|
deltaLabel={
|
||||||
|
computeDeltaPercent(sparklines.bet) ? (
|
||||||
|
<span className={deltaClassName(sparklines.bet)}>
|
||||||
|
{computeDeltaPercent(sparklines.bet)}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DashboardKpiCard
|
||||||
label={t("analytics.summaryPayout")}
|
label={t("analytics.summaryPayout")}
|
||||||
value={formatMoneyMinor(summary.total_payout_minor, currency)}
|
value={formatMoney(summary.total_payout_minor, currency)}
|
||||||
hint={
|
hint={
|
||||||
summary.total_bet_minor > 0
|
summary.total_bet_minor > 0
|
||||||
? t("payoutRateOfBet", {
|
? t("payoutRateOfBet", {
|
||||||
@@ -303,12 +213,20 @@ export function DashboardAnalyticsPanel({
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={<Gift className="size-5" aria-hidden />}
|
icon={<Gift className="size-4" aria-hidden />}
|
||||||
accent="destructive"
|
accent="destructive"
|
||||||
|
sparklineValues={sparklines.payout}
|
||||||
|
deltaLabel={
|
||||||
|
computeDeltaPercent(sparklines.payout) ? (
|
||||||
|
<span className={deltaClassName(sparklines.payout)}>
|
||||||
|
{computeDeltaPercent(sparklines.payout)}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<DashboardKpiCard
|
||||||
label={t("analytics.summaryProfit")}
|
label={t("analytics.summaryProfit")}
|
||||||
value={formatSignedMoneyMinor(summary.approx_house_gross_minor, currency)}
|
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
|
||||||
hint={
|
hint={
|
||||||
summary.total_bet_minor > 0
|
summary.total_bet_minor > 0
|
||||||
? t("marginRate", {
|
? t("marginRate", {
|
||||||
@@ -316,72 +234,141 @@ export function DashboardAnalyticsPanel({
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={<TrendingUp className="size-5" aria-hidden />}
|
icon={<TrendingUp className="size-4" aria-hidden />}
|
||||||
|
sparklineValues={sparklines.profit}
|
||||||
|
deltaLabel={
|
||||||
|
computeDeltaPercent(sparklines.profit) ? (
|
||||||
|
<span className={deltaClassName(sparklines.profit)}>
|
||||||
|
{computeDeltaPercent(sparklines.profit)}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
|
<div className="rounded-xl border border-border/60 bg-card">
|
||||||
<Card className="flex h-full flex-col border-border/80 shadow-sm">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2.5">
|
||||||
<CardHeader className="pb-2">
|
<p className="text-sm font-semibold">{t("analytics.dailyTrend")}</p>
|
||||||
<CardTitle className="text-base">{t("analytics.dailyTrend")}</CardTitle>
|
<span className="text-xs text-muted-foreground">{t("analytics.granularityDay")}</span>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="pb-4">
|
<div className="px-3 py-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-[220px] w-full" />
|
<Skeleton className="h-[260px] w-full" />
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<DailyTrendChart
|
<DailyTrendChart
|
||||||
series={data.daily_series}
|
series={data.daily_series}
|
||||||
metric={metric}
|
metric="overview"
|
||||||
formatMoney={formatMoneyMinor}
|
formatMoney={formatMoney}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{t("states.noData", { ns: "common" })}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<Card className="flex h-full flex-col border-border/80 shadow-sm">
|
export function DashboardPlayRankingCard({
|
||||||
<CardHeader className="pb-2">
|
analytics,
|
||||||
<CardTitle className="text-base">{t("analytics.playBreakdown")}</CardTitle>
|
}: {
|
||||||
|
analytics: DashboardAnalyticsState;
|
||||||
|
}): ReactNode {
|
||||||
|
const { t } = useTranslation(["dashboard", "common"]);
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
rankingMetric,
|
||||||
|
setRankingMetric,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
loading,
|
||||||
|
data,
|
||||||
|
currency,
|
||||||
|
topPlayRows,
|
||||||
|
resolvePlayLabel,
|
||||||
|
formatMoney,
|
||||||
|
} = analytics;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
|
||||||
|
<CardHeader className="space-y-3 border-b border-border/60 px-4 py-3">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("analytics.playRanking")}</CardTitle>
|
||||||
|
<div className="flex flex-wrap gap-1" role="tablist" aria-label={t("analytics.rankingMetricLabel")}>
|
||||||
|
{DASHBOARD_RANKING_METRICS.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={rankingMetric === m}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
|
||||||
|
rankingMetric === m
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
onClick={() => setRankingMetric(m)}
|
||||||
|
>
|
||||||
|
{t(`analytics.rankingMetrics.${m}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Select value={period} onValueChange={(v) => setPeriod(v as typeof period)}>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue>{t(`analytics.periods.${period}`)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DASHBOARD_ANALYTICS_PERIODS.filter((p) => p !== "custom").map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{t(`analytics.periods.${p}`)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-4">
|
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-[220px] w-full" />
|
<Skeleton className="h-[200px] w-full" />
|
||||||
) : data ? (
|
) : data && topPlayRows.length > 0 ? (
|
||||||
<div className="max-h-[280px] overflow-y-auto pr-1">
|
|
||||||
<PlayBreakdownChart
|
<PlayBreakdownChart
|
||||||
rows={data.play_breakdown}
|
rows={topPlayRows}
|
||||||
metric={metric}
|
metric={rankingMetric}
|
||||||
formatMoney={formatMoneyMinor}
|
formatMoney={formatMoney}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
playLabel={resolvePlayLabel}
|
playLabel={resolvePlayLabel}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{t("analytics.noPlayData")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{data && !loading ? (
|
/** 单列堆叠布局(兼容旧用法) */
|
||||||
<Card className="border-border/80 shadow-sm">
|
export function DashboardAnalyticsPanel({
|
||||||
<CardHeader className="pb-2">
|
enabled,
|
||||||
<CardTitle className="text-base">{t("analytics.periodDistribution")}</CardTitle>
|
playOptions,
|
||||||
</CardHeader>
|
}: {
|
||||||
<CardContent>
|
enabled: boolean;
|
||||||
<PeriodCompareStrip
|
playOptions: { code: string; label: string }[];
|
||||||
series={data.daily_series}
|
}): ReactNode {
|
||||||
formatMoney={formatMoneyMinor}
|
const analytics = useDashboardAnalytics({ enabled, playOptions });
|
||||||
currency={currency}
|
return (
|
||||||
/>
|
<section className="space-y-4">
|
||||||
</CardContent>
|
<DashboardAnalyticsMain analytics={analytics} />
|
||||||
</Card>
|
<DashboardPlayRankingCard analytics={analytics} />
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
export function DashboardChartEmpty({ message }: { message: string }): ReactElement {
|
import { cn } from "@/lib/utils";
|
||||||
return <p className="py-10 text-center text-sm text-muted-foreground">{message}</p>;
|
|
||||||
|
export function DashboardChartEmpty({
|
||||||
|
message,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-center text-muted-foreground",
|
||||||
|
compact ? "py-1 text-[11px] leading-snug" : "py-10 text-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
FileSearch,
|
FileSearch,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Ticket,
|
Ticket,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
BarChart3,
|
||||||
|
Scale,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { getAdminDashboard } from "@/api/admin-dashboard";
|
import { getAdminDashboard } from "@/api/admin-dashboard";
|
||||||
@@ -23,20 +26,25 @@ import {
|
|||||||
getCachedAdminPlayTypes,
|
getCachedAdminPlayTypes,
|
||||||
resolveAdminPlayTypeDisplayName,
|
resolveAdminPlayTypeDisplayName,
|
||||||
} from "@/lib/admin-play-types";
|
} from "@/lib/admin-play-types";
|
||||||
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
|
import {
|
||||||
|
DashboardAnalyticsMain,
|
||||||
|
DashboardPlayRankingCard,
|
||||||
|
} from "@/modules/dashboard/dashboard-analytics-panel";
|
||||||
|
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
|
AbnormalTransferPanelFooter,
|
||||||
CapUsageBar,
|
CapUsageBar,
|
||||||
FinanceStructureChart,
|
FinanceStructureChart,
|
||||||
HotUsageBars,
|
HotUsageBars,
|
||||||
PayoutCompositionChart,
|
PayoutPanelSnapshot,
|
||||||
ResultBatchProgress,
|
ResultBatchProgress,
|
||||||
StatCard,
|
DashboardPanelCard,
|
||||||
SettlementStatusChart,
|
SettlementStatusChart,
|
||||||
SoldOutRing,
|
|
||||||
} from "@/modules/dashboard/dashboard-visuals";
|
} from "@/modules/dashboard/dashboard-visuals";
|
||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||||
@@ -52,14 +60,6 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
|||||||
|
|
||||||
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
type HotPlayTab = "4D" | "3D" | "2D" | "special";
|
||||||
|
|
||||||
type SoldOutBuckets = {
|
|
||||||
d4: number;
|
|
||||||
d3: number;
|
|
||||||
d2: number;
|
|
||||||
special: number;
|
|
||||||
other: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
const code = (currencyCode ?? "NPR").toUpperCase();
|
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
@@ -76,6 +76,14 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawScopedHref(
|
||||||
|
drawId: number | null,
|
||||||
|
suffix = "",
|
||||||
|
fallback = "/admin/draws",
|
||||||
|
): string {
|
||||||
|
return drawId != null ? `/admin/draws/${drawId}${suffix}` : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
|
||||||
const raw = normalizedNumber.trim();
|
const raw = normalizedNumber.trim();
|
||||||
const digits = raw.replace(/\D/g, "");
|
const digits = raw.replace(/\D/g, "");
|
||||||
@@ -130,7 +138,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
const [riskLocked, setRiskLocked] = useState(0);
|
const [riskLocked, setRiskLocked] = useState(0);
|
||||||
const [riskCap, setRiskCap] = useState(0);
|
const [riskCap, setRiskCap] = useState(0);
|
||||||
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
|
||||||
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
|
|
||||||
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
|
||||||
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
|
||||||
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
|
||||||
@@ -171,7 +178,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setRiskLocked(0);
|
setRiskLocked(0);
|
||||||
setRiskCap(0);
|
setRiskCap(0);
|
||||||
setHotPoolSample([]);
|
setHotPoolSample([]);
|
||||||
setSoldOutBuckets(null);
|
|
||||||
setAbnormalTransferTotal(null);
|
setAbnormalTransferTotal(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -194,7 +200,6 @@ export function DashboardConsole(): ReactElement {
|
|||||||
setRiskLocked(d.risk.locked_amount);
|
setRiskLocked(d.risk.locked_amount);
|
||||||
setRiskCap(d.risk.cap_amount);
|
setRiskCap(d.risk.cap_amount);
|
||||||
setHotPoolSample(d.risk.hot_pool_rows);
|
setHotPoolSample(d.risk.hot_pool_rows);
|
||||||
setSoldOutBuckets(d.risk.sold_out_buckets);
|
|
||||||
}
|
}
|
||||||
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
setAbnormalTransferTotal(d.abnormal_transfer_total);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -220,42 +225,45 @@ export function DashboardConsole(): ReactElement {
|
|||||||
|
|
||||||
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
|
||||||
|
|
||||||
const hallStatusLabel = hall?.status ?? "—";
|
const analytics = useDashboardAnalytics({ enabled: canFinance, playOptions });
|
||||||
const isOpenLike =
|
const showAnalytics = canFinance;
|
||||||
hallStatusLabel.toLowerCase().includes("open") ||
|
|
||||||
hallStatusLabel.toLowerCase().includes("sale");
|
|
||||||
|
|
||||||
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
|
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
|
||||||
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-5" /> },
|
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-4" /> },
|
||||||
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-5" /> },
|
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-4" /> },
|
||||||
{
|
{
|
||||||
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
|
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
|
||||||
label: t("quickLinks.results"),
|
label: t("quickLinks.results"),
|
||||||
icon: <FileSearch className="size-5" />,
|
icon: <FileSearch className="size-4" />,
|
||||||
},
|
},
|
||||||
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-5" /> },
|
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-4" /> },
|
||||||
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-5" /> },
|
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-4" /> },
|
||||||
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
|
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-4" /> },
|
||||||
|
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <BarChart3 className="size-4" /> },
|
||||||
|
{ href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: <Scale className="size-4" /> },
|
||||||
|
{ href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: <Shield className="size-4" /> },
|
||||||
|
{ href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: <Settings className="size-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-10">
|
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t("title")}</h1>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="admin-list-title">{t("title")}</h1>
|
||||||
<span className="text-sm text-muted-foreground">{todayLabel}</span>
|
<p className="mt-0.5 text-xs text-muted-foreground">{todayLabel}</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
disabled={loading || refreshing}
|
disabled={loading || refreshing}
|
||||||
onClick={() => void load(true)}
|
onClick={() => void load(true)}
|
||||||
>
|
>
|
||||||
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
|
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||||
{t("actions.refresh", { ns: "common" })}
|
{t("actions.refresh", { ns: "common" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
|
||||||
@@ -271,180 +279,120 @@ export function DashboardConsole(): ReactElement {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!loading && hall ? (
|
<section className="flex min-w-0 flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm">
|
<DashboardCurrentDrawCard
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
key={`${hall?.draw_no ?? "empty"}:${hall?.seconds_to_close ?? 0}:${loading ? "loading" : "ready"}`}
|
||||||
<Ticket className="size-5 text-primary" aria-hidden />
|
hall={hall}
|
||||||
<div>
|
drawId={drawId}
|
||||||
<p className="text-xs text-muted-foreground">{t("sections.currentDraw")}</p>
|
loading={loading}
|
||||||
<p className="font-mono text-lg font-semibold text-foreground">{hall.draw_no}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"size-1.5 rounded-full",
|
|
||||||
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
{hallStatusLabel}
|
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
</span>
|
<DashboardPanelCard
|
||||||
</div>
|
href={drawScopedHref(drawId, "/review")}
|
||||||
{drawId != null ? (
|
title={t("pendingReviewResults")}
|
||||||
<Link
|
|
||||||
href={`/admin/draws/${drawId}/finance`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-xs")}
|
|
||||||
>
|
|
||||||
{t("drawFinanceDetails")}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
label={t("pendingReviewResults")}
|
|
||||||
value={pendingReview ?? "—"}
|
value={pendingReview ?? "—"}
|
||||||
hint={t("resultBatches")}
|
subtitle={t("resultBatches")}
|
||||||
|
actionLabel={
|
||||||
|
(pendingReview ?? 0) > 0
|
||||||
|
? t("actions.reviewNow", { ns: "common" })
|
||||||
|
: t("drawDetails")
|
||||||
|
}
|
||||||
icon={<ClipboardList className="size-5" aria-hidden />}
|
icon={<ClipboardList className="size-5" aria-hidden />}
|
||||||
accent={(pendingReview ?? 0) > 0 ? "destructive" : "muted"}
|
accent={(pendingReview ?? 0) > 0 ? "destructive" : "muted"}
|
||||||
/>
|
highlight={(pendingReview ?? 0) > 0}
|
||||||
<StatCard
|
loading={loading}
|
||||||
label={t("abnormalTransferOrders")}
|
>
|
||||||
value={abnormalTransferTotal ?? "—"}
|
{drawPanel ? <ResultBatchProgress draw={drawPanel} compact /> : null}
|
||||||
hint={t("viewTransferOrders")}
|
</DashboardPanelCard>
|
||||||
icon={<AlertTriangle className="size-5" aria-hidden />}
|
|
||||||
accent={(abnormalTransferTotal ?? 0) > 0 ? "destructive" : "muted"}
|
<DashboardPanelCard
|
||||||
/>
|
href="/admin/wallet/transfer-orders"
|
||||||
<StatCard
|
title={t("abnormalTransferOrders")}
|
||||||
label={t("riskCapUsage")}
|
value={abnormalTransferTotal ?? "—"}
|
||||||
value={`${usagePct.toFixed(1)}%`}
|
subtitle={t("abnormalTransferScope")}
|
||||||
hint={t("lockedAndCap", { locked: formatMoneyMinor(riskLocked, currency), cap: formatMoneyMinor(riskCap, currency) })}
|
actionLabel={t("actions.viewAll", { ns: "common" })}
|
||||||
icon={<Shield className="size-5" aria-hidden />}
|
icon={<AlertTriangle className="size-5" aria-hidden />}
|
||||||
accent={usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"}
|
accent={(abnormalTransferTotal ?? 0) > 0 ? "warning" : "muted"}
|
||||||
/>
|
loading={loading}
|
||||||
<StatCard
|
highlight={(abnormalTransferTotal ?? 0) > 0}
|
||||||
label={t("sections.currentDraw")}
|
>
|
||||||
value={hall?.draw_no ?? "—"}
|
<AbnormalTransferPanelFooter
|
||||||
hint={t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}
|
total={abnormalTransferTotal}
|
||||||
icon={<Ticket className="size-5" aria-hidden />}
|
walletPermission={capabilities?.wallet_transfer_view ?? true}
|
||||||
accent="primary"
|
/>
|
||||||
/>
|
</DashboardPanelCard>
|
||||||
</div>
|
|
||||||
|
<DashboardPanelCard
|
||||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
href={drawScopedHref(drawId, "/risk/occupancy", "/admin/risk")}
|
||||||
<Card className="border-border/80 shadow-sm">
|
title={t("riskCapUsage")}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
value={`${usagePct.toFixed(1)}%`}
|
||||||
<CardTitle className="text-base">{t("riskCapUsage")}</CardTitle>
|
subtitle={t("lockedAndCap", {
|
||||||
{drawId != null ? (
|
locked: formatMoneyMinor(riskLocked, currency),
|
||||||
<Link
|
cap: formatMoneyMinor(riskCap, currency),
|
||||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
})}
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
actionLabel={t("occupancyDetails")}
|
||||||
|
icon={<Shield className="size-5" aria-hidden />}
|
||||||
|
accent={
|
||||||
|
usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"
|
||||||
|
}
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
{t("occupancyDetails")}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-44 w-full" />
|
|
||||||
) : (
|
|
||||||
<CapUsageBar
|
<CapUsageBar
|
||||||
locked={riskLocked}
|
locked={riskLocked}
|
||||||
cap={riskCap}
|
cap={riskCap}
|
||||||
usagePct={usagePct}
|
usagePct={usagePct}
|
||||||
formatMoney={formatMoneyMinor}
|
formatMoney={formatMoneyMinor}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
)}
|
</DashboardPanelCard>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-border/80 shadow-sm">
|
<DashboardPanelCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
href={drawScopedHref(drawId, "/finance")}
|
||||||
<CardTitle className="text-base">{t("soldOutDistribution")}</CardTitle>
|
title={t("payoutComposition")}
|
||||||
{drawId != null ? (
|
value={
|
||||||
<Link
|
finance
|
||||||
href={`/admin/draws/${drawId}/risk/sold-out`}
|
? formatMoneyMinor(finance.total_payout_minor, currency)
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
: "—"
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
finance
|
||||||
|
? t("orderAndTicket", {
|
||||||
|
orders: finance.order_count,
|
||||||
|
tickets: finance.ticket_item_count,
|
||||||
|
})
|
||||||
|
: t("states.noData", { ns: "common" })
|
||||||
|
}
|
||||||
|
actionLabel={t("detailsShort")}
|
||||||
|
icon={<Wallet className="size-5" aria-hidden />}
|
||||||
|
accent="primary"
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
{t("actions.viewAll", { ns: "common" })}
|
{finance ? (
|
||||||
</Link>
|
<PayoutPanelSnapshot finance={finance} formatMoney={formatMoneyMinor} />
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</DashboardPanelCard>
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-44 w-full" />
|
|
||||||
) : soldOutBuckets ? (
|
|
||||||
<SoldOutRing buckets={soldOutBuckets} />
|
|
||||||
) : (
|
|
||||||
<p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-border/80 shadow-sm">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-base">{t("resultBatches")}</CardTitle>
|
|
||||||
{drawId != null ? (
|
|
||||||
<Link
|
|
||||||
href={`/admin/draws/${drawId}`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
|
||||||
>
|
|
||||||
{t("drawDetails")}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-44 w-full" />
|
|
||||||
) : drawPanel ? (
|
|
||||||
<ResultBatchProgress draw={drawPanel} />
|
|
||||||
) : (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-border/80 shadow-sm">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-base">{t("payoutComposition")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-44 w-full" />
|
|
||||||
) : finance ? (
|
|
||||||
<PayoutCompositionChart finance={finance} formatMoney={formatMoneyMinor} />
|
|
||||||
) : (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
|
<section
|
||||||
|
className={cn(
|
||||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
"grid min-w-0 grid-cols-1 gap-4",
|
||||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
showAnalytics ? "xl:grid-cols-12" : "xl:grid-cols-1",
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-base">{t("financeStructure")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{loading ? (
|
|
||||||
<Skeleton className="h-40 w-full" />
|
|
||||||
) : finance ? (
|
|
||||||
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
|
||||||
) : (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-col gap-4",
|
||||||
|
showAnalytics ? "xl:col-span-8" : "xl:col-span-12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showAnalytics ? <DashboardAnalyticsMain analytics={analytics} /> : null}
|
||||||
|
|
||||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card className="admin-list-card py-0">
|
||||||
<CardTitle className="text-base">{t("settlementOverview")}</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b border-border/60 px-4 py-3">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("settlementOverview")}</CardTitle>
|
||||||
{drawId != null ? (
|
{drawId != null ? (
|
||||||
<Link
|
<Link
|
||||||
href="/admin/settlement-batches"
|
href="/admin/settlement-batches"
|
||||||
@@ -454,22 +402,24 @@ export function DashboardConsole(): ReactElement {
|
|||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="px-4 py-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="h-40 w-full" />
|
<Skeleton className="h-40 w-full" />
|
||||||
) : finance ? (
|
) : finance ? (
|
||||||
<SettlementStatusChart finance={finance} />
|
<SettlementStatusChart finance={finance} />
|
||||||
) : (
|
) : (
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
<p className="py-10 text-center text-xs text-muted-foreground">
|
||||||
|
{t("states.noData", { ns: "common" })}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-border/80 shadow-sm xl:col-span-1">
|
<Card className="admin-list-card py-0">
|
||||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 border-b border-border/60 px-4 py-3">
|
||||||
<CardTitle className="text-base">{t("hotNumbersTop10")}</CardTitle>
|
<CardTitle className="text-sm font-semibold">{t("hotNumbersTop10")}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1">
|
<div role="tablist" aria-label={t("playDimension")} className="flex gap-0.5">
|
||||||
{([
|
{([
|
||||||
{ value: "4D", label: t("tabs.4d") },
|
{ value: "4D", label: t("tabs.4d") },
|
||||||
{ value: "3D", label: t("tabs.3d") },
|
{ value: "3D", label: t("tabs.3d") },
|
||||||
@@ -482,8 +432,10 @@ export function DashboardConsole(): ReactElement {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={hotTab === tab.value}
|
aria-selected={hotTab === tab.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
|
"rounded px-2 py-0.5 text-[11px] font-medium transition-colors",
|
||||||
hotTab === tab.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
|
hotTab === tab.value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
onClick={() => setHotTab(tab.value)}
|
onClick={() => setHotTab(tab.value)}
|
||||||
>
|
>
|
||||||
@@ -491,82 +443,103 @@ export function DashboardConsole(): ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{drawId != null ? (
|
|
||||||
<Link
|
|
||||||
href={`/admin/draws/${drawId}/risk/hot`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
|
|
||||||
>
|
|
||||||
{t("actions.viewAll", { ns: "common" })}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{loading ? <Skeleton className="h-64 w-full" /> : <HotUsageBars rows={hotRows} />}</CardContent>
|
<CardContent className="px-4 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
) : (
|
||||||
|
<HotUsageBars rows={hotRows} compact />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
|
{!showAnalytics ? (
|
||||||
|
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<Card className="admin-list-card min-w-0 py-0">
|
||||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
<div className="flex gap-4">
|
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm">
|
|
||||||
<ClipboardList className="size-5" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t("pendingReviewResults")}</p>
|
|
||||||
<p className="mt-1 text-4xl font-bold tabular-nums text-destructive">{pendingReview ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{drawId != null ? (
|
|
||||||
<Link
|
|
||||||
href={`/admin/draws/${drawId}/review`}
|
|
||||||
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
|
|
||||||
>
|
|
||||||
{t("actions.reviewNow", { ns: "common" })}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-amber-500 text-white shadow-sm">
|
|
||||||
<AlertTriangle className="size-5" aria-hidden />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground">{t("abnormalTransferOrders")}</p>
|
|
||||||
<p className="mt-1 text-4xl font-bold tabular-nums text-amber-600">{abnormalTransferTotal ?? "—"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/admin/wallet/transfer-orders"
|
|
||||||
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
|
|
||||||
>
|
|
||||||
{t("viewTransferOrders")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<Card className="border-border/80 shadow-sm">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-base">{t("quickLinksTitle")}</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap justify-center gap-3 py-2 sm:gap-5">
|
<CardContent className="px-4 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-52 w-full" />
|
||||||
|
) : finance ? (
|
||||||
|
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-xs text-muted-foreground">
|
||||||
|
{t("states.noData", { ns: "common" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="admin-list-card min-w-0 py-0">
|
||||||
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
|
||||||
{quickLinks.map((q) => (
|
{quickLinks.map((q) => (
|
||||||
<Link
|
<Link
|
||||||
key={q.label}
|
key={q.href + q.label}
|
||||||
href={q.href}
|
href={q.href}
|
||||||
className="flex w-24 flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted/50"
|
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm">
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
|
||||||
{q.icon}
|
{q.icon}
|
||||||
</span>
|
</span>
|
||||||
{q.label}
|
<span className="line-clamp-2">{q.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAnalytics ? (
|
||||||
|
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
|
||||||
|
<DashboardPlayRankingCard analytics={analytics} />
|
||||||
|
|
||||||
|
<Card className="admin-list-card min-w-0 py-0">
|
||||||
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-52 w-full" />
|
||||||
|
) : finance ? (
|
||||||
|
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-xs text-muted-foreground">
|
||||||
|
{t("states.noData", { ns: "common" })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="admin-list-card py-0">
|
||||||
|
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
|
||||||
|
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
|
||||||
|
{quickLinks.map((q) => (
|
||||||
|
<Link
|
||||||
|
key={q.href + q.label}
|
||||||
|
href={q.href}
|
||||||
|
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
|
||||||
|
{q.icon}
|
||||||
|
</span>
|
||||||
|
<span className="line-clamp-2">{q.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
128
src/modules/dashboard/dashboard-current-draw-card.tsx
Normal file
128
src/modules/dashboard/dashboard-current-draw-card.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowRight, Clock, Ticket } from "lucide-react";
|
||||||
|
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
|
||||||
|
|
||||||
|
function isOpenLikeStatus(status: string): boolean {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
return lower.includes("open") || lower.includes("sale");
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardCurrentDrawCardProps = {
|
||||||
|
hall: DrawCurrentSnapshot | null;
|
||||||
|
drawId: number | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardCurrentDrawCard({
|
||||||
|
hall,
|
||||||
|
drawId,
|
||||||
|
loading = false,
|
||||||
|
}: DashboardCurrentDrawCardProps): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="admin-list-card overflow-hidden py-0">
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<Skeleton className="h-[4.5rem] w-full rounded-xl" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hall) {
|
||||||
|
return (
|
||||||
|
<Card className="admin-list-card overflow-hidden py-0">
|
||||||
|
<CardContent className="flex min-h-[5.5rem] flex-col items-center justify-center gap-2 p-6 text-center">
|
||||||
|
<Ticket className="size-9 text-muted-foreground/40" aria-hidden />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">{t("sections.currentDraw")}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openLike = isOpenLikeStatus(hall.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="admin-list-card overflow-hidden border-primary/15 py-0 shadow-sm">
|
||||||
|
<CardContent className="relative p-0">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/[0.07] via-transparent to-transparent"
|
||||||
|
/>
|
||||||
|
<div className="relative flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
|
||||||
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Ticket className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{t("sections.currentDraw")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-2xl font-bold leading-none tracking-tight text-foreground sm:text-[1.65rem]">
|
||||||
|
{hall.draw_no}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||||
|
openLike
|
||||||
|
? "bg-emerald-500/12 text-emerald-700 ring-1 ring-emerald-500/20 dark:text-emerald-400"
|
||||||
|
: "bg-muted/80 text-muted-foreground ring-1 ring-border/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
openLike ? "bg-emerald-500" : "bg-muted-foreground/70",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{hall.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 sm:justify-end">
|
||||||
|
{hall.draw_time ? (
|
||||||
|
<p className="inline-flex items-center gap-1.5 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground ring-1 ring-border/50">
|
||||||
|
<Clock className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
<span>{t("scheduledDrawTime", { time: formatDt(hall.draw_time) })}</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{drawId != null ? (
|
||||||
|
<Link
|
||||||
|
href={`/admin/draws/${drawId}/finance`}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "default", size: "sm" }),
|
||||||
|
"h-9 gap-1.5 px-4 shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("drawFinanceDetails")}
|
||||||
|
<ArrowRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -175,12 +175,14 @@ export function PlayBreakdownChart({
|
|||||||
formatMoney,
|
formatMoney,
|
||||||
currency,
|
currency,
|
||||||
playLabel,
|
playLabel,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
rows: AdminDashboardAnalyticsPlayRow[];
|
rows: AdminDashboardAnalyticsPlayRow[];
|
||||||
metric: DashboardAnalyticsMetric;
|
metric: DashboardAnalyticsMetric;
|
||||||
formatMoney: MoneyFormatter;
|
formatMoney: MoneyFormatter;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
playLabel: (code: string, dimension: number) => string;
|
playLabel: (code: string, dimension: number) => string;
|
||||||
|
compact?: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const activeMetric = metric === "overview" ? "bet" : metric;
|
const activeMetric = metric === "overview" ? "bet" : metric;
|
||||||
@@ -216,19 +218,21 @@ export function PlayBreakdownChart({
|
|||||||
return <DashboardChartEmpty message={t("analytics.noPlayData")} />;
|
return <DashboardChartEmpty message={t("analytics.noPlayData")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartHeight = Math.min(480, Math.max(180, rows.length * 36 + 48));
|
const chartHeight = compact
|
||||||
|
? Math.max(160, rows.length * 32 + 24)
|
||||||
|
: Math.min(480, Math.max(180, rows.length * 36 + 48));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig}
|
config={chartConfig}
|
||||||
className="aspect-auto w-full"
|
className="aspect-auto w-full min-w-0"
|
||||||
style={{ height: chartHeight }}
|
style={{ height: chartHeight }}
|
||||||
>
|
>
|
||||||
<BarChart
|
<BarChart
|
||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
data={chartData}
|
data={chartData}
|
||||||
margin={{ top: 4, right: 16, bottom: 4, left: 4 }}
|
margin={{ top: 4, right: 8, bottom: 4, left: 0 }}
|
||||||
>
|
>
|
||||||
<XAxis type="number" hide />
|
<XAxis type="number" hide />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -255,7 +259,7 @@ export function PlayBreakdownChart({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" radius={4} barSize={14}>
|
<Bar dataKey="value" radius={4} barSize={compact ? 12 : 14}>
|
||||||
{chartData.map((entry) => (
|
{chartData.map((entry) => (
|
||||||
<Cell key={entry.id} fill={entry.fill} />
|
<Cell key={entry.id} fill={entry.fill} />
|
||||||
))}
|
))}
|
||||||
@@ -263,10 +267,13 @@ export function PlayBreakdownChart({
|
|||||||
<YAxis
|
<YAxis
|
||||||
type="category"
|
type="category"
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
width={100}
|
width={compact ? 76 : 100}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tick={{ fontSize: 11 }}
|
tick={{ fontSize: 10 }}
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
typeof value === "string" && value.length > 10 ? `${value.slice(0, 10)}…` : String(value)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartLegend,
|
ChartLegend,
|
||||||
@@ -87,18 +90,137 @@ function settlementBarColor(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DashboardKpiAccent = "primary" | "destructive" | "muted";
|
||||||
|
|
||||||
|
function kpiAccentClass(accent: DashboardKpiAccent): string {
|
||||||
|
switch (accent) {
|
||||||
|
case "destructive":
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
case "muted":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "bg-primary/10 text-primary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 财务概览区紧凑 KPI,避免 StatCard 在窄栅格内撑破布局 */
|
||||||
|
export function DashboardKpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
icon,
|
||||||
|
accent = "primary",
|
||||||
|
sparklineValues,
|
||||||
|
deltaLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
hint?: ReactNode;
|
||||||
|
icon: ReactNode;
|
||||||
|
accent?: DashboardKpiAccent;
|
||||||
|
sparklineValues?: number[];
|
||||||
|
deltaLabel?: ReactNode;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
|
||||||
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-lg",
|
||||||
|
kpiAccentClass(accent),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sparklineValues && sparklineValues.length >= 2 ? (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<MiniSparkline
|
||||||
|
values={sparklineValues}
|
||||||
|
strokeClass={
|
||||||
|
accent === "destructive"
|
||||||
|
? "stroke-destructive"
|
||||||
|
: accent === "muted"
|
||||||
|
? "stroke-muted-foreground"
|
||||||
|
: "stroke-primary"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{hint ? (
|
||||||
|
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSparkline({
|
||||||
|
values,
|
||||||
|
strokeClass,
|
||||||
|
}: {
|
||||||
|
values: number[];
|
||||||
|
strokeClass: string;
|
||||||
|
}): ReactElement | null {
|
||||||
|
if (values.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const width = 88;
|
||||||
|
const height = 32;
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const min = Math.min(...values, 0);
|
||||||
|
const range = Math.max(max - min, 1);
|
||||||
|
const points = values
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = (i / (values.length - 1)) * width;
|
||||||
|
const y = height - ((v - min) / range) * (height - 4) - 2;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="h-8 w-[5.5rem] shrink-0"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
points={points}
|
||||||
|
className={strokeClass}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StatCard({
|
export function StatCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
icon,
|
icon,
|
||||||
accent = "primary",
|
accent = "primary",
|
||||||
|
href,
|
||||||
|
sparklineValues,
|
||||||
|
deltaLabel,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
hint?: ReactNode;
|
hint?: ReactNode;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
accent?: "primary" | "destructive" | "muted";
|
accent?: "primary" | "destructive" | "muted";
|
||||||
|
/** 整张卡片可点击跳转 */
|
||||||
|
href?: string;
|
||||||
|
sparklineValues?: number[];
|
||||||
|
deltaLabel?: ReactNode;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const accentClass =
|
const accentClass =
|
||||||
accent === "destructive"
|
accent === "destructive"
|
||||||
@@ -107,9 +229,15 @@ export function StatCard({
|
|||||||
? "bg-muted text-foreground"
|
? "bg-muted text-foreground"
|
||||||
: "bg-primary text-primary-foreground";
|
: "bg-primary text-primary-foreground";
|
||||||
|
|
||||||
return (
|
const card = (
|
||||||
<Card className="border-border/80 py-0 shadow-sm">
|
<Card
|
||||||
<CardContent className="flex gap-4 p-5">
|
className={cn(
|
||||||
|
"flex h-full flex-col border-border/80 py-0 shadow-sm transition-colors",
|
||||||
|
href &&
|
||||||
|
"group-hover/stat border-primary/30 bg-muted/15 shadow-md",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex flex-1 items-start gap-4 p-5">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
|
||||||
@@ -118,14 +246,292 @@ export function StatCard({
|
|||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||||
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">{value}</p>
|
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
|
||||||
{hint ? <div className="mt-2 text-xs text-muted-foreground">{hint}</div> : null}
|
{value}
|
||||||
|
</p>
|
||||||
|
{deltaLabel ? (
|
||||||
|
<p className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</p>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-auto min-h-10 pt-2 text-xs leading-snug",
|
||||||
|
hint
|
||||||
|
? href
|
||||||
|
? "font-medium text-primary group-hover/stat:underline"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
: "text-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hint ?? "\u00a0"}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{sparklineValues ? (
|
||||||
|
<MiniSparkline
|
||||||
|
values={sparklineValues}
|
||||||
|
strokeClass={
|
||||||
|
accent === "destructive"
|
||||||
|
? "stroke-destructive"
|
||||||
|
: accent === "muted"
|
||||||
|
? "stroke-muted-foreground"
|
||||||
|
: "stroke-primary"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : href ? (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 transition group-hover/stat:text-primary"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const shellClass = "flex h-full min-h-0 rounded-2xl";
|
||||||
|
|
||||||
|
if (!href) {
|
||||||
|
return <div className={shellClass}>{card}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
shellClass,
|
||||||
|
"group/stat outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardPanelAccent = "primary" | "destructive" | "warning" | "muted";
|
||||||
|
|
||||||
|
function panelAccentClass(accent: DashboardPanelAccent): string {
|
||||||
|
switch (accent) {
|
||||||
|
case "destructive":
|
||||||
|
return "bg-destructive/10 text-destructive";
|
||||||
|
case "warning":
|
||||||
|
return "bg-amber-500/15 text-amber-700 dark:text-amber-400";
|
||||||
|
case "muted":
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
default:
|
||||||
|
return "bg-primary/10 text-primary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仪表盘 KPI:整卡可点,主指标 + 可选底部可视化 */
|
||||||
|
export function DashboardPanelCard({
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
actionLabel,
|
||||||
|
accent = "primary",
|
||||||
|
loading = false,
|
||||||
|
highlight = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
value: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
actionLabel: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
accent?: DashboardPanelAccent;
|
||||||
|
loading?: boolean;
|
||||||
|
/** 有异常/待办时强调边框 */
|
||||||
|
highlight?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
}): ReactElement {
|
||||||
|
const hasFooter = children != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
aria-label={`${title},${actionLabel}`}
|
||||||
|
className="group/panel flex h-full min-w-0 w-full rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"admin-list-card flex h-full min-w-0 w-full flex-col py-0 transition-all duration-200",
|
||||||
|
"hover:border-primary/30 hover:shadow-md",
|
||||||
|
highlight && "border-amber-400/50 ring-1 ring-amber-400/25 dark:border-amber-500/40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[1.125rem]",
|
||||||
|
panelAccentClass(accent),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 transition-colors group-hover/panel:bg-primary/10 group-hover/panel:text-primary"
|
||||||
|
title={actionLabel}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon
|
||||||
|
className="size-4 transition-transform group-hover/panel:translate-x-0.5"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{title}</p>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="mt-2 h-8 w-24 rounded-md" />
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{subtitle && !loading ? (
|
||||||
|
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFooter ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 flex min-h-[3.25rem] items-center justify-center",
|
||||||
|
loading && "items-stretch",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-[3.25rem] w-full rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full border-t border-dashed border-border/60 pt-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常转账 KPI 底部:待办提示或正常态 */
|
||||||
|
export function AbnormalTransferPanelFooter({
|
||||||
|
total,
|
||||||
|
walletPermission = true,
|
||||||
|
}: {
|
||||||
|
total: number | null;
|
||||||
|
walletPermission?: boolean;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
|
||||||
|
if (!walletPermission) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] leading-snug text-muted-foreground ring-1 ring-border/50">
|
||||||
|
{t("warnings.walletPermission")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total == null) {
|
||||||
|
return (
|
||||||
|
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
|
||||||
|
{t("states.noData", { ns: "common" })}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 rounded-lg bg-amber-500/10 px-3 py-2.5 ring-1 ring-amber-500/20">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
|
||||||
|
{t("abnormalTransferPending", { count: total })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] leading-snug text-amber-800/90 dark:text-amber-300/90">
|
||||||
|
{t("abnormalTransferAction")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/8 px-3 py-2.5 ring-1 ring-emerald-500/15">
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
|
||||||
|
<p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">
|
||||||
|
{t("abnormalTransferAllClear")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 派彩 KPI 底部:投注/中奖/奖池拆分,有派彩时再附饼图 */
|
||||||
|
export function PayoutPanelSnapshot({
|
||||||
|
finance,
|
||||||
|
formatMoney,
|
||||||
|
}: {
|
||||||
|
finance: AdminDrawFinanceSummaryData;
|
||||||
|
formatMoney: MoneyFormatter;
|
||||||
|
}): ReactElement {
|
||||||
|
const { t } = useTranslation("dashboard");
|
||||||
|
const currency = finance.currency_code;
|
||||||
|
const bet = finance.total_bet_minor;
|
||||||
|
const win = finance.total_win_payout_minor;
|
||||||
|
const jackpot = finance.total_jackpot_win_minor;
|
||||||
|
const hasPayout = win + jackpot > 0;
|
||||||
|
|
||||||
|
if (bet <= 0 && !hasPayout) {
|
||||||
|
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cells = [
|
||||||
|
{ key: "bet", label: t("currentDrawBetTotal"), amount: bet, emphasize: bet > 0 },
|
||||||
|
{ key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
|
||||||
|
{ key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
{cells.map((cell) => (
|
||||||
|
<div
|
||||||
|
key={cell.key}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-1.5 py-2 ring-1",
|
||||||
|
cell.emphasize
|
||||||
|
? "bg-primary/6 ring-primary/15"
|
||||||
|
: "bg-muted/30 ring-border/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
|
||||||
|
cell.emphasize ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(cell.amount, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasPayout ? (
|
||||||
|
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
|
||||||
|
{t("noPayoutYet")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CapUsageBar({
|
export function CapUsageBar({
|
||||||
@@ -134,12 +540,15 @@ export function CapUsageBar({
|
|||||||
usagePct,
|
usagePct,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
currency,
|
currency,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
locked: number;
|
locked: number;
|
||||||
cap: number;
|
cap: number;
|
||||||
usagePct: number;
|
usagePct: number;
|
||||||
formatMoney: MoneyFormatter;
|
formatMoney: MoneyFormatter;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
|
/** 嵌入 DashboardPanelCard 时隐藏底部说明、缩小图表 */
|
||||||
|
compact?: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const pct = Math.min(100, Math.max(0, usagePct));
|
const pct = Math.min(100, Math.max(0, usagePct));
|
||||||
@@ -150,6 +559,24 @@ export function CapUsageBar({
|
|||||||
);
|
);
|
||||||
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
|
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-2 overflow-hidden rounded-full bg-muted"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={pct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={t("riskCapUsage")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-[width] duration-500"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: fill }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -178,7 +605,9 @@ export function CapUsageBar({
|
|||||||
const { cx, cy } = viewBox as { cx: number; cy: number };
|
const { cx, cy } = viewBox as { cx: number; cy: number };
|
||||||
return (
|
return (
|
||||||
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
|
||||||
<tspan className="fill-foreground text-2xl font-bold">{pct.toFixed(1)}%</tspan>
|
<tspan className="fill-foreground text-2xl font-bold">
|
||||||
|
{pct.toFixed(1)}%
|
||||||
|
</tspan>
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -240,7 +669,7 @@ export function FinanceStructureChart({
|
|||||||
<YAxis type="category" dataKey="segment" hide width={0} />
|
<YAxis type="category" dataKey="segment" hide width={0} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent formatter={(value, _name) => formatMoney(Number(value), currency)} />
|
<ChartTooltipContent formatter={(value) => formatMoney(Number(value), currency)} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
|
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
|
||||||
@@ -259,9 +688,11 @@ export function FinanceStructureChart({
|
|||||||
export function PayoutCompositionChart({
|
export function PayoutCompositionChart({
|
||||||
finance,
|
finance,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
finance: AdminDrawFinanceSummaryData;
|
finance: AdminDrawFinanceSummaryData;
|
||||||
formatMoney: MoneyFormatter;
|
formatMoney: MoneyFormatter;
|
||||||
|
compact?: boolean;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const currency = finance.currency_code;
|
const currency = finance.currency_code;
|
||||||
@@ -279,7 +710,7 @@ export function PayoutCompositionChart({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (total <= 0) {
|
if (total <= 0) {
|
||||||
return <DashboardChartEmpty message={t("noPayoutYet")} />;
|
return <DashboardChartEmpty message={t("noPayoutYet")} compact={compact} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieData = [
|
const pieData = [
|
||||||
@@ -288,7 +719,13 @@ export function PayoutCompositionChart({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[220px] w-full max-w-[280px]">
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className={cn(
|
||||||
|
"mx-auto aspect-square w-full",
|
||||||
|
compact ? "h-[72px] max-w-[88px]" : "h-[220px] max-w-[280px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
@@ -310,13 +747,21 @@ export function PayoutCompositionChart({
|
|||||||
<Cell key={entry.key} fill={entry.fill} />
|
<Cell key={entry.key} fill={entry.fill} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
{compact ? null : (
|
||||||
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
|
||||||
|
)}
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
|
export function HotUsageBars({
|
||||||
|
rows,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
rows: AdminRiskPoolRow[];
|
||||||
|
compact?: boolean;
|
||||||
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
|
||||||
|
|
||||||
@@ -337,7 +782,9 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
|
|||||||
return <DashboardChartEmpty message={t("noPoolData")} />;
|
return <DashboardChartEmpty message={t("noPoolData")} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
|
const chartHeight = compact
|
||||||
|
? Math.min(220, Math.max(120, rows.length * 22 + 36))
|
||||||
|
: Math.min(420, Math.max(160, rows.length * 32 + 48));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -445,7 +892,13 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
|
export function ResultBatchProgress({
|
||||||
|
draw,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
draw: AdminDashboardDrawPanel;
|
||||||
|
compact?: boolean;
|
||||||
|
}): ReactElement {
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
const { total, pending_review, published } = draw.result_batch_counts;
|
const { total, pending_review, published } = draw.result_batch_counts;
|
||||||
const other = Math.max(0, total - pending_review - published);
|
const other = Math.max(0, total - pending_review - published);
|
||||||
@@ -462,6 +915,43 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
|
|||||||
|
|
||||||
const chartData = [{ row: "batches", pending: pending_review, published, other }];
|
const chartData = [{ row: "batches", pending: pending_review, published, other }];
|
||||||
|
|
||||||
|
const statCells = (
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="rounded-lg bg-amber-500/8 px-2 py-2 ring-1 ring-amber-500/15">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-bold tabular-nums text-amber-700 dark:text-amber-400",
|
||||||
|
compact ? "text-lg" : "text-2xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pending_review}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-emerald-500/8 px-2 py-2 ring-1 ring-emerald-500/15">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-bold tabular-nums text-emerald-700 dark:text-emerald-400",
|
||||||
|
compact ? "text-lg" : "text-2xl",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{published}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
|
||||||
|
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
|
||||||
|
{total}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchTotal")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return statCells;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
|
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
|
||||||
@@ -478,20 +968,7 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
|
|||||||
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
|
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
{statCells}
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
|
||||||
<p className="text-2xl font-bold tabular-nums text-amber-600">{pending_review}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchPending")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
|
||||||
<p className="text-2xl font-bold tabular-nums text-emerald-600">{published}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchPublished")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
|
|
||||||
<p className="text-2xl font-bold tabular-nums">{total}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t("batchTotal")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
193
src/modules/dashboard/use-dashboard-analytics.ts
Normal file
193
src/modules/dashboard/use-dashboard-analytics.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { format, subDays } from "date-fns";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
|
||||||
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||||
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
|
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||||
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
|
import type {
|
||||||
|
AdminDashboardAnalyticsData,
|
||||||
|
DashboardAnalyticsMetric,
|
||||||
|
DashboardAnalyticsPeriod,
|
||||||
|
} from "@/types/api/admin-dashboard-analytics";
|
||||||
|
|
||||||
|
export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
|
||||||
|
"today",
|
||||||
|
"last_7_days",
|
||||||
|
"last_30_days",
|
||||||
|
"this_month",
|
||||||
|
"lifetime",
|
||||||
|
"custom",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
|
||||||
|
|
||||||
|
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
|
const code = (currencyCode ?? "NPR").toUpperCase();
|
||||||
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
|
const major = minor / 10 ** decimals;
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||||
|
style: "currency",
|
||||||
|
currency: code,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(major);
|
||||||
|
} catch {
|
||||||
|
return formatAdminMinorUnits(minor, code, decimals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDashboardSignedMoneyMinor(minor: number, currencyCode: string | null): string {
|
||||||
|
if (minor === 0) {
|
||||||
|
return formatDashboardMoneyMinor(0, currencyCode);
|
||||||
|
}
|
||||||
|
const s = minor > 0 ? "+" : "−";
|
||||||
|
return `${s}${formatDashboardMoneyMinor(Math.abs(minor), currencyCode)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardAnalytics({
|
||||||
|
enabled,
|
||||||
|
playOptions,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
playOptions: { code: string; label: string }[];
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["dashboard", "common"]);
|
||||||
|
const playLabel = useAdminPlayCodeLabel();
|
||||||
|
|
||||||
|
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
|
||||||
|
const [rankingMetric, setRankingMetric] = useState<DashboardAnalyticsMetric>("bet");
|
||||||
|
const [playCode, setPlayCode] = useState<string>("");
|
||||||
|
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
|
||||||
|
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!enabled) {
|
||||||
|
setLoading(false);
|
||||||
|
setData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await getAdminDashboardAnalytics({
|
||||||
|
period,
|
||||||
|
metric: "overview",
|
||||||
|
play_code: playCode !== "" ? playCode : undefined,
|
||||||
|
...(period === "custom" ? { date_from: customFrom, date_to: customTo } : {}),
|
||||||
|
});
|
||||||
|
setData(payload);
|
||||||
|
} catch (e) {
|
||||||
|
setData(null);
|
||||||
|
const raw = e instanceof LotteryApiBizError ? e.message : "";
|
||||||
|
const needsAuthSync =
|
||||||
|
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
|
||||||
|
setError(
|
||||||
|
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [enabled, period, playCode, customFrom, customTo, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void load();
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const currency = data?.currency_code ?? null;
|
||||||
|
const summary = data?.summary;
|
||||||
|
|
||||||
|
const periodRangeLabel = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.date_from === data.date_to
|
||||||
|
? data.date_from
|
||||||
|
: `${data.date_from} — ${data.date_to}`;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const playFilterLabel = useMemo(() => {
|
||||||
|
if (playCode === "") {
|
||||||
|
return t("analytics.allPlays");
|
||||||
|
}
|
||||||
|
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
|
||||||
|
}, [playCode, playOptions, t]);
|
||||||
|
|
||||||
|
const resolvePlayLabel = useCallback(
|
||||||
|
(code: string, dimension: number) => {
|
||||||
|
const base = playLabel(code);
|
||||||
|
return dimension > 0 ? `${base} · ${dimension}D` : base;
|
||||||
|
},
|
||||||
|
[playLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
const topPlayRows = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const rows = [...data.play_breakdown];
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (rankingMetric === "payout") {
|
||||||
|
return b.total_payout_minor - a.total_payout_minor;
|
||||||
|
}
|
||||||
|
if (rankingMetric === "profit") {
|
||||||
|
return b.approx_house_gross_minor - a.approx_house_gross_minor;
|
||||||
|
}
|
||||||
|
return b.total_bet_minor - a.total_bet_minor;
|
||||||
|
});
|
||||||
|
return rows.slice(0, 5);
|
||||||
|
}, [data, rankingMetric]);
|
||||||
|
|
||||||
|
const sparklines = useMemo(() => {
|
||||||
|
const series = data?.daily_series ?? [];
|
||||||
|
return {
|
||||||
|
bet: series.map((d) => d.total_bet_minor),
|
||||||
|
payout: series.map((d) => d.total_payout_minor),
|
||||||
|
profit: series.map((d) => d.approx_house_gross_minor),
|
||||||
|
};
|
||||||
|
}, [data?.daily_series]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
rankingMetric,
|
||||||
|
setRankingMetric,
|
||||||
|
playCode,
|
||||||
|
setPlayCode,
|
||||||
|
customFrom,
|
||||||
|
setCustomFrom,
|
||||||
|
customTo,
|
||||||
|
setCustomTo,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
data,
|
||||||
|
currency,
|
||||||
|
summary,
|
||||||
|
periodRangeLabel,
|
||||||
|
playFilterLabel,
|
||||||
|
playOptions,
|
||||||
|
resolvePlayLabel,
|
||||||
|
topPlayRows,
|
||||||
|
sparklines,
|
||||||
|
formatMoney: formatDashboardMoneyMinor,
|
||||||
|
formatSignedMoney: formatDashboardSignedMoneyMinor,
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DashboardAnalyticsState = ReturnType<typeof useDashboardAnalytics>;
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Rocket } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
|
import { getAdminDrawResultBatches, postAdminCreateManualResultBatch } from "@/api/admin-draws";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -204,7 +204,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
<TableHead>{t("batchId")}</TableHead>
|
<TableHead>{t("batchId")}</TableHead>
|
||||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||||
<TableHead>{t("numberCount")}</TableHead>
|
<TableHead>{t("numberCount")}</TableHead>
|
||||||
<TableHead className="text-center">{t("actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -215,12 +215,16 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
<TableCell>{b.items.length}</TableCell>
|
<TableCell>{b.items.length}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{canManageDraw ? (
|
{canManageDraw ? (
|
||||||
<Link
|
<AdminRowActionsMenu
|
||||||
href={`/admin/draws/${drawId}/publish/${b.id}`}
|
actions={[
|
||||||
className={cn(buttonVariants({ size: "sm" }))}
|
{
|
||||||
>
|
key: "publish",
|
||||||
{t("reviewAndPublishAction")}
|
label: t("reviewAndPublishAction"),
|
||||||
</Link>
|
icon: Rocket,
|
||||||
|
href: `/admin/draws/${drawId}/publish/${b.id}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
|
<span className="text-xs text-muted-foreground">{t("noPublishPermission")}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Ban, Eye, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
} from "@/api/admin-draws";
|
} from "@/api/admin-draws";
|
||||||
import { formatAdminInstant } from "@/lib/admin-datetime";
|
import { formatAdminInstant } from "@/lib/admin-datetime";
|
||||||
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
@@ -404,7 +405,7 @@ export function DrawsIndexConsole() {
|
|||||||
<TableHead className="text-center">{t("betTotal")}</TableHead>
|
<TableHead className="text-center">{t("betTotal")}</TableHead>
|
||||||
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||||
<TableHead className="text-center">{t("profitLoss")}</TableHead>
|
<TableHead className="text-center">{t("profitLoss")}</TableHead>
|
||||||
<TableHead className="text-center">{t("actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -462,60 +463,29 @@ export function DrawsIndexConsole() {
|
|||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
<AdminRowActionsMenu
|
||||||
<Link
|
actions={[
|
||||||
href={`/admin/draws/${row.id}`}
|
{
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
key: "view",
|
||||||
>
|
label: t("viewDetails"),
|
||||||
{t("viewDetails")}
|
icon: Eye,
|
||||||
</Link>
|
href: `/admin/draws/${row.id}`,
|
||||||
{canManageDraw && canEditDrawRow(row) ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditDraw(row)}
|
|
||||||
>
|
|
||||||
{t("editDraw.action")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canManageDraw && canDeleteDrawRow(row) ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
requestConfirm({
|
|
||||||
title: t("deleteDraw.title"),
|
|
||||||
description: t("deleteDraw.description", { drawNo: row.draw_no }),
|
|
||||||
confirmVariant: "destructive",
|
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await deleteAdminDraw(row.id);
|
|
||||||
toast.success(t("deleteDraw.success"));
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof LotteryApiBizError
|
|
||||||
? e.message
|
|
||||||
: t("deleteDraw.failed"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
{
|
||||||
}
|
key: "edit",
|
||||||
>
|
label: t("editDraw.action"),
|
||||||
{t("deleteDraw.action")}
|
icon: Pencil,
|
||||||
</Button>
|
hidden: !(canManageDraw && canEditDrawRow(row)),
|
||||||
) : null}
|
onClick: () => setEditDraw(row),
|
||||||
{canManageDraw &&
|
},
|
||||||
canCancelDrawRow(row) &&
|
{
|
||||||
!canDeleteDrawRow(row) ? (
|
key: "cancel",
|
||||||
<Button
|
label: t("cancelFromList.action"),
|
||||||
type="button"
|
icon: Ban,
|
||||||
variant="outline"
|
hidden: !(
|
||||||
size="sm"
|
canManageDraw && canCancelDrawRow(row) && !canDeleteDrawRow(row)
|
||||||
onClick={() =>
|
),
|
||||||
|
onClick: () =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
title: t("cancelFromList.title"),
|
title: t("cancelFromList.title"),
|
||||||
description: t("cancelFromList.description", {
|
description: t("cancelFromList.description", {
|
||||||
@@ -534,13 +504,36 @@ export function DrawsIndexConsole() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: t("deleteDraw.action"),
|
||||||
|
icon: Trash2,
|
||||||
|
destructive: true,
|
||||||
|
hidden: !(canManageDraw && canDeleteDrawRow(row)),
|
||||||
|
onClick: () =>
|
||||||
|
requestConfirm({
|
||||||
|
title: t("deleteDraw.title"),
|
||||||
|
description: t("deleteDraw.description", { drawNo: row.draw_no }),
|
||||||
|
confirmVariant: "destructive",
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteAdminDraw(row.id);
|
||||||
|
toast.success(t("deleteDraw.success"));
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof LotteryApiBizError
|
||||||
|
? e.message
|
||||||
|
: t("deleteDraw.failed"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
>
|
},
|
||||||
{t("cancelFromList.action")}
|
}),
|
||||||
</Button>
|
},
|
||||||
) : null}
|
]}
|
||||||
</div>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Download, Link2, Pencil, ShieldAlert } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
putAdminIntegrationSite,
|
putAdminIntegrationSite,
|
||||||
} from "@/api/admin-integration-sites";
|
} from "@/api/admin-integration-sites";
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -361,7 +363,7 @@ export function IntegrationSitesConsole() {
|
|||||||
<TableHead>{t("integrationSites.columns.name")}</TableHead>
|
<TableHead>{t("integrationSites.columns.name")}</TableHead>
|
||||||
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
<TableHead>{t("integrationSites.columns.status")}</TableHead>
|
||||||
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
|
||||||
<TableHead className="text-right">{t("integrationSites.columns.actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("integrationSites.columns.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -381,46 +383,40 @@ export function IntegrationSitesConsole() {
|
|||||||
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
|
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
|
||||||
{row.wallet_api_url ?? "—"}
|
{row.wallet_api_url ?? "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-center">
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<AdminRowActionsMenu
|
||||||
<Button
|
busy={exportBusyId === row.id}
|
||||||
type="button"
|
actions={[
|
||||||
variant="outline"
|
{
|
||||||
size="sm"
|
key: "connectivity",
|
||||||
onClick={() => openConnectivity(row)}
|
label: t("integrationSites.connectivityTest"),
|
||||||
>
|
icon: Link2,
|
||||||
{t("integrationSites.connectivityTest")}
|
onClick: () => openConnectivity(row),
|
||||||
</Button>
|
},
|
||||||
<Button
|
{
|
||||||
type="button"
|
key: "export",
|
||||||
variant="outline"
|
label: t("integrationSites.exportParams"),
|
||||||
size="sm"
|
icon: Download,
|
||||||
disabled={exportBusyId === row.id}
|
disabled: exportBusyId === row.id,
|
||||||
onClick={() => void exportParameterSheet(row)}
|
onClick: () => void exportParameterSheet(row),
|
||||||
>
|
},
|
||||||
{t("integrationSites.exportParams")}
|
{
|
||||||
</Button>
|
key: "edit",
|
||||||
{canManage ? (
|
label: t("integrationSites.edit"),
|
||||||
<Button
|
icon: Pencil,
|
||||||
type="button"
|
hidden: !canManage,
|
||||||
variant="outline"
|
onClick: () => void openEdit(row),
|
||||||
size="sm"
|
},
|
||||||
onClick={() => void openEdit(row)}
|
{
|
||||||
>
|
key: "rotate",
|
||||||
{t("integrationSites.edit")}
|
label: t("integrationSites.rotateSecrets"),
|
||||||
</Button>
|
icon: ShieldAlert,
|
||||||
) : null}
|
destructive: true,
|
||||||
{canManage ? (
|
hidden: !canManage,
|
||||||
<Button
|
onClick: () => setRotateTarget(row),
|
||||||
type="button"
|
},
|
||||||
variant="destructive"
|
]}
|
||||||
size="sm"
|
/>
|
||||||
onClick={() => setRotateTarget(row)}
|
|
||||||
>
|
|
||||||
{t("integrationSites.rotateSecrets")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
putAdminPlayer,
|
putAdminPlayer,
|
||||||
} from "@/api/admin-player";
|
} from "@/api/admin-player";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||||
@@ -392,7 +394,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
||||||
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
|
||||||
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -471,32 +473,25 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
{canManagePlayers || canFreezePlayers ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{canManagePlayers ? (
|
{canManagePlayers ? (
|
||||||
<>
|
<AdminRowActionsMenu
|
||||||
<Button
|
actions={[
|
||||||
type="button"
|
{
|
||||||
size="sm"
|
key: "edit",
|
||||||
variant={
|
label: t("edit"),
|
||||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
icon: Pencil,
|
||||||
}
|
onClick: () => openEditAccount(row),
|
||||||
onClick={() => openEditAccount(row)}
|
},
|
||||||
>
|
{
|
||||||
{t("edit")}
|
key: "delete",
|
||||||
</Button>
|
label: t("delete"),
|
||||||
<Button
|
icon: Trash2,
|
||||||
type="button"
|
destructive: true,
|
||||||
size="sm"
|
onClick: () => setDeleteTarget(row),
|
||||||
variant="destructive"
|
},
|
||||||
onClick={() => setDeleteTarget(row)}
|
]}
|
||||||
>
|
/>
|
||||||
{t("delete")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
import { getAdminPlayers } from "@/api/admin-player";
|
import { getAdminPlayers } from "@/api/admin-player";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -373,7 +375,7 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead>{t("period")}</TableHead>
|
<TableHead>{t("period")}</TableHead>
|
||||||
<TableHead>{t("createdAt")}</TableHead>
|
<TableHead>{t("createdAt")}</TableHead>
|
||||||
<TableHead className="sticky right-0 z-20 w-28 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
<TableHead className="sticky right-0 z-20 w-14 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||||
{t("operate")}
|
{t("operate")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -410,18 +412,20 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
{formatTs(row.created_at)}
|
{formatTs(row.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
|
||||||
<Button
|
<AdminRowActionsMenu
|
||||||
type="button"
|
actions={[
|
||||||
size="sm"
|
{
|
||||||
variant="outline"
|
key: "view",
|
||||||
onClick={() => {
|
label: t("view"),
|
||||||
|
icon: Eye,
|
||||||
|
onClick: () => {
|
||||||
setSelectedId(row.id);
|
setSelectedId(row.id);
|
||||||
setItemsPage(1);
|
setItemsPage(1);
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
}}
|
},
|
||||||
>
|
},
|
||||||
{t("view")}
|
]}
|
||||||
</Button>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { toast } from "sonner";
|
|||||||
import { Download, RefreshCw } from "lucide-react";
|
import { Download, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs";
|
import { downloadAdminReportJob, getAdminReportJobs } from "@/api/admin-report-jobs";
|
||||||
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -105,7 +106,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
<TableHead>{t("tasks.columns.format")}</TableHead>
|
<TableHead>{t("tasks.columns.format")}</TableHead>
|
||||||
<TableHead>{t("tasks.columns.status")}</TableHead>
|
<TableHead>{t("tasks.columns.status")}</TableHead>
|
||||||
<TableHead>{t("tasks.columns.createdAt")}</TableHead>
|
<TableHead>{t("tasks.columns.createdAt")}</TableHead>
|
||||||
<TableHead>{t("tasks.columns.actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("tasks.columns.actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -135,17 +136,19 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
|
|||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatTs(job.created_at ?? job.finished_at)}
|
{formatTs(job.created_at ?? job.finished_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
<Button
|
<AdminRowActionsMenu
|
||||||
type="button"
|
busy={downloadingId === job.id}
|
||||||
variant="outline"
|
actions={[
|
||||||
size="sm"
|
{
|
||||||
disabled={!canExport || job.status !== "completed" || downloadingId === job.id}
|
key: "download",
|
||||||
onClick={() => void handleDownload(job)}
|
label: t("tasks.download"),
|
||||||
>
|
icon: Download,
|
||||||
<Download data-icon="inline-start" />
|
disabled: !canExport || job.status !== "completed",
|
||||||
{t("tasks.download")}
|
onClick: () => void handleDownload(job),
|
||||||
</Button>
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Shield } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { getAdminDraws } from "@/api/admin-draws";
|
import { getAdminDraws } from "@/api/admin-draws";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -29,7 +30,6 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
|
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ export function RiskIndexConsole() {
|
|||||||
<TableHead>{t("drawNo")}</TableHead>
|
<TableHead>{t("drawNo")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead>{t("closeTime")}</TableHead>
|
<TableHead>{t("closeTime")}</TableHead>
|
||||||
<TableHead className="text-center">{t("actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -205,12 +205,16 @@ export function RiskIndexConsole() {
|
|||||||
{row.close_time ? formatDt(row.close_time) : "—"}
|
{row.close_time ? formatDt(row.close_time) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Link
|
<AdminRowActionsMenu
|
||||||
href={`/admin/draws/${row.id}/risk/occupancy`}
|
actions={[
|
||||||
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
{
|
||||||
>
|
key: "risk",
|
||||||
{t("enterRisk")}
|
label: t("enterRisk"),
|
||||||
</Link>
|
icon: Shield,
|
||||||
|
href: `/admin/draws/${row.id}/risk/occupancy`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Eye, Lock, Unlock } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
postAdminRiskPoolRecover,
|
postAdminRiskPoolRecover,
|
||||||
} from "@/api/admin-risk";
|
} from "@/api/admin-risk";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -254,7 +254,7 @@ export function RiskPoolsConsole({
|
|||||||
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
|
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
|
||||||
<TableHead className="text-center">{t("usageRatio")}</TableHead>
|
<TableHead className="text-center">{t("usageRatio")}</TableHead>
|
||||||
<TableHead>{t("poolStatus")}</TableHead>
|
<TableHead>{t("poolStatus")}</TableHead>
|
||||||
<TableHead className="text-center">{t("actions")}</TableHead>
|
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -302,39 +302,39 @@ export function RiskPoolsConsole({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex justify-center gap-2">
|
<AdminRowActionsMenu
|
||||||
{canManageRiskPools ? (
|
busy={acting}
|
||||||
<Button
|
actions={[
|
||||||
type="button"
|
{
|
||||||
size="sm"
|
key: "view",
|
||||||
variant={row.is_sold_out ? "outline" : "destructive"}
|
label: t("view"),
|
||||||
disabled={acting}
|
icon: Eye,
|
||||||
onClick={() =>
|
href: `/admin/draws/${drawId}/risk/pools/${row.normalized_number}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "toggle",
|
||||||
|
label: row.is_sold_out ? t("recover") : t("close"),
|
||||||
|
icon: row.is_sold_out ? Unlock : Lock,
|
||||||
|
destructive: !row.is_sold_out,
|
||||||
|
hidden: !canManageRiskPools,
|
||||||
|
onClick: () =>
|
||||||
requestConfirm({
|
requestConfirm({
|
||||||
title: row.is_sold_out
|
title: row.is_sold_out
|
||||||
? t("confirm.recoverTitle")
|
? t("confirm.recoverTitle")
|
||||||
: t("confirm.closeTitle"),
|
: t("confirm.closeTitle"),
|
||||||
description: row.is_sold_out
|
description: row.is_sold_out
|
||||||
? t("confirm.recoverDescription", { number: row.normalized_number })
|
? t("confirm.recoverDescription", {
|
||||||
: t("confirm.closeDescription", { number: row.normalized_number }),
|
number: row.normalized_number,
|
||||||
|
})
|
||||||
|
: t("confirm.closeDescription", {
|
||||||
|
number: row.normalized_number,
|
||||||
|
}),
|
||||||
confirmVariant: row.is_sold_out ? "default" : "destructive",
|
confirmVariant: row.is_sold_out ? "default" : "destructive",
|
||||||
onConfirm: () => handleManualStatus(row),
|
onConfirm: () => handleManualStatus(row),
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
>
|
]}
|
||||||
{row.is_sold_out ? t("recover") : t("close")}
|
/>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Link
|
|
||||||
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "link", size: "sm" }),
|
|
||||||
"h-8 px-0",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("view")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
|
||||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
||||||
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
|
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
|
||||||
import { useOddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
|
import { useOddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
|
||||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||||
|
|
||||||
/** 赔率与回水:共用赔率版本线,单页上下分区。 */
|
/** 赔率与回水:共用赔率版本线,主栏三步骤 + 右侧配置摘要。 */
|
||||||
export function RulesOddsConfigScreen() {
|
export function RulesOddsConfigScreen() {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
const [sharedVersionId, setSharedVersionId] = useState("");
|
const [sharedVersionId, setSharedVersionId] = useState("");
|
||||||
const workspace = useOddsConfigWorkspace(sharedVersionId, setSharedVersionId);
|
const workspace = useOddsConfigWorkspace(sharedVersionId, setSharedVersionId);
|
||||||
const rebateSectionRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [rebateMounted, setRebateMounted] = useState(
|
|
||||||
() => typeof window !== "undefined" && window.location.hash === "#rebate",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollToRebate = () => {
|
const scrollToRebate = () => {
|
||||||
@@ -34,44 +29,26 @@ export function RulesOddsConfigScreen() {
|
|||||||
return () => window.removeEventListener("hashchange", scrollToRebate);
|
return () => window.removeEventListener("hashchange", scrollToRebate);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const rebateSection = (
|
||||||
if (rebateMounted) {
|
<div id="rebate">
|
||||||
return;
|
<RebateConfigDocScreen embedded mergedSection workspace={workspace} />
|
||||||
}
|
</div>
|
||||||
const node = rebateSectionRef.current;
|
|
||||||
if (!node) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry?.isIntersecting) {
|
|
||||||
setRebateMounted(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "240px 0px" },
|
|
||||||
);
|
);
|
||||||
observer.observe(node);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [rebateMounted]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||||
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-8">
|
<ConfigDocPage
|
||||||
<ConfigSection title={t("nav.items.odds")}>
|
title={t("nav.rulesOddsTitle")}
|
||||||
<OddsConfigDocScreen embedded workspace={workspace} />
|
description={t("nav.rulesOddsDescription")}
|
||||||
</ConfigSection>
|
contentClassName="pt-2"
|
||||||
<ConfigSection id="rebate" title={t("nav.items.rebate")}>
|
>
|
||||||
<div ref={rebateSectionRef}>
|
<OddsConfigDocScreen
|
||||||
{rebateMounted ? (
|
embedded
|
||||||
<RebateConfigDocScreen embedded workspace={workspace} />
|
mergedLayout
|
||||||
) : (
|
workspace={workspace}
|
||||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
rebateSection={rebateSection}
|
||||||
{t("rebate.lazyLoadHint", { ns: "config" })}
|
/>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ConfigSection>
|
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
</RulesPageShell>
|
</RulesPageShell>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
putAdminCurrency,
|
putAdminCurrency,
|
||||||
} from "@/api/admin-currencies";
|
} from "@/api/admin-currencies";
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
@@ -228,7 +230,7 @@ export function CurrencySettingsPanel() {
|
|||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currencies.table.decimals", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currencies.table.enabled", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currencies.table.bettable", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap text-center">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
<TableHead className="w-14 whitespace-nowrap text-center">{t("currencies.table.actions", { ns: "config" })}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -267,14 +269,23 @@ export function CurrencySettingsPanel() {
|
|||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<AdminRowActionsMenu
|
||||||
<Button variant="outline" size="sm" onClick={() => openEdit(row)}>
|
actions={[
|
||||||
{t("currencies.actions.edit", { ns: "config" })}
|
{
|
||||||
</Button>
|
key: "edit",
|
||||||
<Button variant="destructive" size="sm" onClick={() => setDeleteTarget(row)}>
|
label: t("currencies.actions.edit", { ns: "config" }),
|
||||||
{t("currencies.actions.delete", { ns: "config" })}
|
icon: Pencil,
|
||||||
</Button>
|
onClick: () => openEdit(row),
|
||||||
</div>
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: t("currencies.actions.delete", { ns: "config" }),
|
||||||
|
icon: Trash2,
|
||||||
|
destructive: true,
|
||||||
|
onClick: () => setDeleteTarget(row),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { Check, Eye, HandCoins, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -13,10 +13,11 @@ import {
|
|||||||
postAdminRejectSettlementBatch,
|
postAdminRejectSettlementBatch,
|
||||||
} from "@/api/admin-settlement";
|
} from "@/api/admin-settlement";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -287,51 +288,45 @@ export function SettlementBatchesConsole() {
|
|||||||
{settlementStatusText(row.status, t)}
|
{settlementStatusText(row.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<AdminRowActionsMenu
|
||||||
<Link
|
busy={actingId === row.id}
|
||||||
href={`/admin/settlement-batches/${row.id}/details`}
|
actions={[
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}
|
{
|
||||||
>
|
key: "details",
|
||||||
{t("details")}
|
label: t("details"),
|
||||||
</Link>
|
icon: Eye,
|
||||||
{canReviewSettlement ? (
|
href: `/admin/settlement-batches/${row.id}/details`,
|
||||||
<Button
|
},
|
||||||
type="button"
|
{
|
||||||
size="sm"
|
key: "approve",
|
||||||
variant="outline"
|
label: t("pass"),
|
||||||
disabled={actingId !== null || row.status !== "pending_review"}
|
icon: Check,
|
||||||
onClick={() => openActionDialog(row, "approve")}
|
hidden: !canReviewSettlement,
|
||||||
>
|
disabled: actingId !== null || row.status !== "pending_review",
|
||||||
{t("pass")}
|
onClick: () => openActionDialog(row, "approve"),
|
||||||
</Button>
|
},
|
||||||
) : null}
|
{
|
||||||
{canReviewSettlement ? (
|
key: "reject",
|
||||||
<Button
|
label: t("reject"),
|
||||||
type="button"
|
icon: X,
|
||||||
size="sm"
|
hidden: !canReviewSettlement,
|
||||||
variant="outline"
|
disabled: actingId !== null || row.status !== "pending_review",
|
||||||
disabled={actingId !== null || row.status !== "pending_review"}
|
onClick: () => openActionDialog(row, "reject"),
|
||||||
onClick={() => openActionDialog(row, "reject")}
|
},
|
||||||
>
|
{
|
||||||
{t("reject")}
|
key: "payout",
|
||||||
</Button>
|
label: t("payout"),
|
||||||
) : null}
|
icon: HandCoins,
|
||||||
{canManagePayout ? (
|
hidden: !canManagePayout,
|
||||||
<Button
|
disabled:
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={
|
|
||||||
actingId !== null
|
actingId !== null
|
||||||
|| row.status !== "approved"
|
|| row.status !== "approved"
|
||||||
|| row.review_status !== "approved"
|
|| row.review_status !== "approved",
|
||||||
}
|
onClick: () => openActionDialog(row, "payout"),
|
||||||
onClick={() => openActionDialog(row, "payout")}
|
},
|
||||||
>
|
]}
|
||||||
{t("payout")}
|
/>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Copy, Loader2, MoreHorizontal } from "lucide-react";
|
import { Copy, RotateCcw, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -15,16 +15,10 @@ import {
|
|||||||
} from "@/api/admin-wallet";
|
} from "@/api/admin-wallet";
|
||||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
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 { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -278,55 +272,34 @@ function TransferOrderRowActions({
|
|||||||
onManualProcess,
|
onManualProcess,
|
||||||
t,
|
t,
|
||||||
}: TransferOrderRowActionsProps): React.ReactElement {
|
}: TransferOrderRowActionsProps): React.ReactElement {
|
||||||
const showComplete = canCompleteTransferInCredit(row, canWriteWallet);
|
|
||||||
const showReverse = canReverseTransferOrder(row, canWriteWallet);
|
|
||||||
const showManual = canManuallyProcessTransferOrder(row, canWriteWallet);
|
|
||||||
|
|
||||||
if (!showComplete && !showReverse && !showManual) {
|
|
||||||
return <span className="text-xs text-muted-foreground">—</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<AdminRowActionsMenu
|
||||||
<DropdownMenuTrigger
|
busy={busy}
|
||||||
disabled={busy}
|
ariaLabel={t("actionsMenuAriaLabel")}
|
||||||
aria-label={t("actionsMenuAriaLabel")}
|
actions={[
|
||||||
className={cn(
|
{
|
||||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
key: "complete",
|
||||||
"text-muted-foreground hover:text-foreground",
|
label: t("completeCredit"),
|
||||||
)}
|
hidden: !canCompleteTransferInCredit(row, canWriteWallet),
|
||||||
>
|
onClick: () => onCompleteCredit(row.transfer_no),
|
||||||
{busy ? (
|
},
|
||||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
{
|
||||||
) : (
|
key: "manual",
|
||||||
<MoreHorizontal className="size-4" aria-hidden />
|
label: t("manualProcess"),
|
||||||
)}
|
icon: Wrench,
|
||||||
</DropdownMenuTrigger>
|
hidden: !canManuallyProcessTransferOrder(row, canWriteWallet),
|
||||||
<DropdownMenuContent align="end" className="min-w-[11rem]">
|
onClick: () => onManualProcess(row.transfer_no),
|
||||||
{showComplete ? (
|
},
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => onCompleteCredit(row.transfer_no)}>
|
{
|
||||||
{t("completeCredit")}
|
key: "reverse",
|
||||||
</DropdownMenuItem>
|
label: t("reverse"),
|
||||||
) : null}
|
icon: RotateCcw,
|
||||||
{showManual ? (
|
destructive: true,
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => onManualProcess(row.transfer_no)}>
|
hidden: !canReverseTransferOrder(row, canWriteWallet),
|
||||||
{t("manualProcess")}
|
onClick: () => onReverse(row.transfer_no),
|
||||||
</DropdownMenuItem>
|
},
|
||||||
) : null}
|
]}
|
||||||
{showReverse ? (
|
/>
|
||||||
<>
|
|
||||||
{showComplete || showManual ? <DropdownMenuSeparator /> : null}
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onReverse(row.transfer_no)}
|
|
||||||
>
|
|
||||||
{t("reverse")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
* - **组件内**:`useAdminProfile()`、`useAdminSessionStore(...)`
|
||||||
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
* - **组件外**(axios、工具函数):`getAdminProfile()`、`useAdminSessionStore.getState()`
|
||||||
*/
|
*/
|
||||||
import { isAxiosError } from "axios";
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
import { getAdminMe } from "@/api/admin-auth";
|
import { fetchAdminMeDeduped } from "@/lib/admin-fetch-me";
|
||||||
import { setAdminBearerToken } from "@/lib/admin-auth";
|
import { setAdminBearerToken } from "@/lib/admin-auth";
|
||||||
|
import {
|
||||||
|
handleAdminAuthRejected,
|
||||||
|
isAdminAuthRejected,
|
||||||
|
} from "@/lib/admin-auth-reject";
|
||||||
import { readProfile, writeProfile } from "@/stores/admin-profile";
|
import { readProfile, writeProfile } from "@/stores/admin-profile";
|
||||||
import { readToken, writeToken } from "@/stores/admin-token";
|
import { readToken, writeToken } from "@/stores/admin-token";
|
||||||
import type { AdminProfile } from "@/types/api/admin-auth";
|
import type { AdminProfile } from "@/types/api/admin-auth";
|
||||||
@@ -25,28 +28,12 @@ function profileForRehydrate(profile: AdminProfile | null): AdminProfile | null
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAdminAuthRejected(err: unknown): boolean {
|
|
||||||
if (!isAxiosError(err)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = err.response?.status;
|
|
||||||
if (status === 401 || status === 403) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = err.response?.data;
|
|
||||||
if (body && typeof body === "object" && "code" in body) {
|
|
||||||
const code = (body as { code?: unknown }).code;
|
|
||||||
return code === 401 || code === 403;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdminSessionState = {
|
export type AdminSessionState = {
|
||||||
bearerToken: string | null;
|
bearerToken: string | null;
|
||||||
adminProfile: AdminProfile | null;
|
adminProfile: AdminProfile | null;
|
||||||
|
/** Shell 路由守卫正在校验 `/auth/me` 时为 true,用于侧栏/顶栏骨架屏 */
|
||||||
|
shellAuthPending: boolean;
|
||||||
|
setShellAuthPending: (pending: boolean) => void;
|
||||||
setBearerToken: (token: string | null) => void;
|
setBearerToken: (token: string | null) => void;
|
||||||
setAdminProfile: (profile: AdminProfile | null) => void;
|
setAdminProfile: (profile: AdminProfile | null) => void;
|
||||||
clearSession: () => void;
|
clearSession: () => void;
|
||||||
@@ -60,6 +47,11 @@ export type AdminSessionState = {
|
|||||||
export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
||||||
bearerToken: null,
|
bearerToken: null,
|
||||||
adminProfile: null,
|
adminProfile: null,
|
||||||
|
shellAuthPending: false,
|
||||||
|
|
||||||
|
setShellAuthPending: (pending) => {
|
||||||
|
set({ shellAuthPending: pending });
|
||||||
|
},
|
||||||
|
|
||||||
setBearerToken: (token) => {
|
setBearerToken: (token) => {
|
||||||
const normalized = token?.trim() ? token.trim() : null;
|
const normalized = token?.trim() ? token.trim() : null;
|
||||||
@@ -109,12 +101,12 @@ export const useAdminSessionStore = create<AdminSessionState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getAdminMe();
|
const result = await fetchAdminMeDeduped();
|
||||||
writeProfile(result.admin);
|
writeProfile(result.admin);
|
||||||
set({ adminProfile: result.admin });
|
set({ adminProfile: result.admin });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isAdminAuthRejected(err)) {
|
if (isAdminAuthRejected(err)) {
|
||||||
get().clearSession();
|
handleAdminAuthRejected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
const KEY = "lottery_admin_token";
|
import { ADMIN_TOKEN_STORAGE_KEY } from "@/lib/admin-token-constants";
|
||||||
|
import {
|
||||||
|
readAdminTokenFromDocumentCookie,
|
||||||
|
writeAdminTokenCookie,
|
||||||
|
} from "@/lib/admin-token-cookie";
|
||||||
|
|
||||||
export function readToken(): string | null {
|
export function readToken(): string | null {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const raw = window.localStorage.getItem(KEY)?.trim();
|
|
||||||
|
|
||||||
return raw && raw !== "" ? raw : null;
|
const fromStorage = window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim();
|
||||||
|
if (fromStorage) {
|
||||||
|
return fromStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readAdminTokenFromDocumentCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function writeToken(t: string | null): void {
|
export function writeToken(t: string | null): void {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t && t.trim() !== "") {
|
if (t && t.trim() !== "") {
|
||||||
window.localStorage.setItem(KEY, t.trim());
|
const normalized = t.trim();
|
||||||
|
window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, normalized);
|
||||||
|
writeAdminTokenCookie(normalized);
|
||||||
} else {
|
} else {
|
||||||
window.localStorage.removeItem(KEY);
|
window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||||
|
writeAdminTokenCookie(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user