feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support
Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
57
src/components/admin/admin-agent-columns.tsx
Normal file
57
src/components/admin/admin-agent-columns.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { TableCell, TableHead } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type AdminAgentFields = {
|
||||
agent_node_id?: number | null;
|
||||
agent_code?: string | null;
|
||||
agent_name?: string | null;
|
||||
};
|
||||
|
||||
function cellText(value: string | null | undefined): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed !== "" ? trimmed : "—";
|
||||
}
|
||||
|
||||
export function adminAgentDisplayLabel(row: AdminAgentFields): string {
|
||||
const name = row.agent_name?.trim() ?? "";
|
||||
const code = row.agent_code?.trim() ?? "";
|
||||
if (name !== "" && code !== "") {
|
||||
return `${name} · ${code}`;
|
||||
}
|
||||
return name || code || "—";
|
||||
}
|
||||
|
||||
type HeadProps = { className?: string };
|
||||
type CellProps = { row: AdminAgentFields; className?: string };
|
||||
|
||||
export function AdminAgentHead({ className }: HeadProps): React.ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<TableHead className={cn("whitespace-nowrap", className)}>
|
||||
{t("agentColumns.agent")}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminAgentCell({ row, className }: CellProps): React.ReactElement {
|
||||
return (
|
||||
<TableCell className={cn("text-xs", className)}>
|
||||
<span className="font-medium">{cellText(row.agent_name)}</span>
|
||||
{row.agent_code ? (
|
||||
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">{row.agent_code}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminAgentIdentityHeads({ className }: { className?: string }): React.ReactElement {
|
||||
return <AdminAgentHead className={className} />;
|
||||
}
|
||||
|
||||
export function AdminAgentIdentityCells({ row, className }: CellProps): React.ReactElement {
|
||||
return <AdminAgentCell row={row} className={className} />;
|
||||
}
|
||||
83
src/components/admin/admin-agent-filter.tsx
Normal file
83
src/components/admin/admin-agent-filter.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAgentTree } from "@/api/admin-agents";
|
||||
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
value: number | undefined;
|
||||
onChange: (agentNodeId: number | undefined) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AdminAgentFilter({ id = "admin-agent-filter", value, onChange, className }: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
const profile = useAdminProfile();
|
||||
const [options, setOptions] = useState<FlatAgentOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getAgentTree(profile?.agent?.admin_site_id)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setOptions(flattenAgentTree(data.tree));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setOptions([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profile?.agent?.admin_site_id]);
|
||||
|
||||
const selectValue = value ? String(value) : ALL;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label htmlFor={id} className="text-xs text-muted-foreground">
|
||||
{t("agentColumns.filter")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(v) => onChange(v === ALL ? undefined : Number(v))}
|
||||
disabled={loading || options.length === 0}
|
||||
>
|
||||
<SelectTrigger id={id} className="mt-1 h-9 w-full min-w-[10rem]">
|
||||
<SelectValue placeholder={t("agentColumns.filterAll")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t("agentColumns.filterAll")}</SelectItem>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,10 @@ const SETTINGS_ROUTE_LABELS: Record<string, string> = {
|
||||
currencies: "currencies.title",
|
||||
};
|
||||
|
||||
const TOP_ROUTE_LABELS: Record<string, string> = {
|
||||
agents: "agents.title",
|
||||
};
|
||||
|
||||
const CONFIG_ROUTE_LABELS: Record<string, string> = {
|
||||
"integration-sites": "integrationSites.title",
|
||||
plays: "nav.items.plays",
|
||||
@@ -59,7 +63,7 @@ type BreadcrumbCrumb = {
|
||||
};
|
||||
|
||||
export function AdminBreadcrumb() {
|
||||
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports"]);
|
||||
const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports", "agents"]);
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const navItems = profile?.navigation ?? [];
|
||||
@@ -98,11 +102,14 @@ export function AdminBreadcrumb() {
|
||||
isCurrent: pathname === navItem.href || segments.length === 2,
|
||||
});
|
||||
} else {
|
||||
const topKey = TOP_ROUTE_LABELS[businessSegment];
|
||||
breadcrumbs.push({
|
||||
label: t(`nav.${businessSegment}`, {
|
||||
ns: "common",
|
||||
defaultValue: titleCase(businessSegment),
|
||||
}),
|
||||
label: topKey
|
||||
? t(topKey, { ns: "agents", defaultValue: titleCase(businessSegment) })
|
||||
: t(`nav.${businessSegment}`, {
|
||||
ns: "common",
|
||||
defaultValue: titleCase(businessSegment),
|
||||
}),
|
||||
href: `${ADMIN_BASE}/${businessSegment}`,
|
||||
isCurrent: segments.length === 2,
|
||||
});
|
||||
|
||||
94
src/components/admin/admin-loading-state.tsx
Normal file
94
src/components/admin/admin-loading-state.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
LoadingDots,
|
||||
type LoadingDotsSize,
|
||||
} from "@/components/ui/loading-dots";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 区块居中加载(列表、表单、详情页等) */
|
||||
export function AdminLoadingState({
|
||||
className,
|
||||
label,
|
||||
size = "md",
|
||||
showLabel = false,
|
||||
minHeight = "4rem",
|
||||
}: {
|
||||
className?: string;
|
||||
label?: string;
|
||||
size?: LoadingDotsSize;
|
||||
showLabel?: boolean;
|
||||
minHeight?: string | number;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedLabel = label ?? t("states.loading");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center py-8 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={{ minHeight: typeof minHeight === "number" ? `${minHeight}px` : minHeight }}
|
||||
>
|
||||
<LoadingDots size={size} label={resolvedLabel} showLabel={showLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 下拉、弹层等紧凑区域 */
|
||||
export function AdminLoadingInline({
|
||||
className,
|
||||
label,
|
||||
size = "sm",
|
||||
}: {
|
||||
className?: string;
|
||||
label?: string;
|
||||
size?: LoadingDotsSize;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedLabel = label ?? t("states.loading");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex justify-center py-3 text-muted-foreground", className)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<LoadingDots size={size} label={resolvedLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格内加载行(colSpan 对齐列数) */
|
||||
export function AdminTableLoadingRow({
|
||||
colSpan,
|
||||
label,
|
||||
size = "md",
|
||||
className,
|
||||
cellClassName,
|
||||
}: {
|
||||
colSpan: number;
|
||||
label?: string;
|
||||
size?: LoadingDotsSize;
|
||||
className?: string;
|
||||
cellClassName?: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedLabel = label ?? t("states.loading");
|
||||
|
||||
return (
|
||||
<TableRow className={className}>
|
||||
<TableCell colSpan={colSpan} className={cn("py-10", cellClassName)}>
|
||||
<div className="flex justify-center">
|
||||
<LoadingDots size={size} label={resolvedLabel} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
256
src/components/admin/admin-sidebar-nav.tsx
Normal file
256
src/components/admin/admin-sidebar-nav.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
ADMIN_NAV_GROUP_ICON,
|
||||
ADMIN_NAV_GROUP_ORDER,
|
||||
groupAdminNavItems,
|
||||
} from "@/lib/admin-nav-groups";
|
||||
import { adminNavLabel } from "@/lib/admin-nav-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
||||
import { ADMIN_BASE, type AdminNavGroup, type AdminNavItem } from "@/modules/_config/admin-nav";
|
||||
|
||||
const NAV_BTN =
|
||||
"h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4";
|
||||
const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm";
|
||||
const SUB_NAV =
|
||||
"h-8 min-h-8 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm";
|
||||
|
||||
function isActive(
|
||||
pathname: string,
|
||||
item: { href: string; activeMatchPrefix?: string; segment?: string },
|
||||
): boolean {
|
||||
const { href, activeMatchPrefix, segment } = item;
|
||||
const prefix = activeMatchPrefix ?? href;
|
||||
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
|
||||
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
|
||||
}
|
||||
if (segment === "settings") {
|
||||
return pathname === href;
|
||||
}
|
||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function defaultOpenGroups(
|
||||
groups: { group: AdminNavGroup; items: AdminNavItem[] }[],
|
||||
pathname: string,
|
||||
): Record<AdminNavGroup, boolean> {
|
||||
const open = Object.fromEntries(
|
||||
ADMIN_NAV_GROUP_ORDER.map((g) => [g, true]),
|
||||
) as Record<AdminNavGroup, boolean>;
|
||||
|
||||
for (const { group, items } of groups) {
|
||||
if (items.some((item) => isActive(pathname, item))) {
|
||||
open[group] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return open;
|
||||
}
|
||||
|
||||
function NavLeaf({
|
||||
item,
|
||||
pathname,
|
||||
t,
|
||||
}: {
|
||||
item: AdminNavItem;
|
||||
pathname: string;
|
||||
t: TFunction;
|
||||
}): ReactElement {
|
||||
const Icon = resolveAdminNavIcon(item.segment);
|
||||
const active = isActive(pathname, item);
|
||||
const label = adminNavLabel(item.segment, t, item.label);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
isActive={active}
|
||||
render={<Link href={item.href} />}
|
||||
className={cn(NAV_BTN, NAV_ACTIVE)}
|
||||
>
|
||||
<Icon data-icon="inline-start" aria-hidden />
|
||||
<span>{label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavSubLeaf({
|
||||
item,
|
||||
pathname,
|
||||
t,
|
||||
}: {
|
||||
item: AdminNavItem;
|
||||
pathname: string;
|
||||
t: TFunction;
|
||||
}): ReactElement {
|
||||
const active = isActive(pathname, item);
|
||||
const label = adminNavLabel(item.segment, t, item.label);
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
size="md"
|
||||
isActive={active}
|
||||
render={<Link href={item.href} />}
|
||||
className={cn(SUB_NAV, NAV_ACTIVE)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavCollapsibleGroup({
|
||||
group,
|
||||
items,
|
||||
pathname,
|
||||
open,
|
||||
onToggle,
|
||||
t,
|
||||
}: {
|
||||
group: AdminNavGroup;
|
||||
items: AdminNavItem[];
|
||||
pathname: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
t: TFunction;
|
||||
}): ReactElement {
|
||||
const GroupIcon = ADMIN_NAV_GROUP_ICON[group];
|
||||
const groupLabel = t(`sidebar.group.${group}`, { ns: "common", defaultValue: group });
|
||||
const hasActiveChild = items.some((item) => isActive(pathname, item));
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size="sm"
|
||||
type="button"
|
||||
data-open={open ? "" : undefined}
|
||||
isActive={hasActiveChild && !open}
|
||||
className={cn(NAV_BTN, hasActiveChild && !open && NAV_ACTIVE)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<GroupIcon aria-hidden />
|
||||
<span className="flex-1 truncate">{groupLabel}</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
{open ? (
|
||||
<SidebarMenuSub className="mx-2 gap-0.5 border-sidebar-border/50 px-1.5 py-0.5">
|
||||
{items.map((item) => (
|
||||
<NavSubLeaf key={item.segment} item={item} pathname={pathname} t={t} />
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSidebarNav({
|
||||
items,
|
||||
}: {
|
||||
items: readonly AdminNavItem[];
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const pathname = usePathname();
|
||||
const navGroups = useMemo(() => groupAdminNavItems(items), [items]);
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<Record<AdminNavGroup, boolean>>(() =>
|
||||
defaultOpenGroups(navGroups, pathname),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenGroups((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
for (const { group, items: groupItems } of navGroups) {
|
||||
if (groupItems.some((item) => isActive(pathname, item)) && !next[group]) {
|
||||
next[group] = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [pathname, navGroups]);
|
||||
|
||||
const overview = navGroups.find((g) => g.group === "overview");
|
||||
const collapsible = navGroups.filter((g) => g.group !== "overview");
|
||||
|
||||
return (
|
||||
<SidebarMenu className="gap-0.5 px-1.5 py-1.5">
|
||||
{overview?.items.map((item) => (
|
||||
<NavLeaf key={item.segment} item={item} pathname={pathname} t={t} />
|
||||
))}
|
||||
{collapsible.map(({ group, items: groupItems }) => (
|
||||
<NavCollapsibleGroup
|
||||
key={group}
|
||||
group={group}
|
||||
items={groupItems}
|
||||
pathname={pathname}
|
||||
open={openGroups[group] ?? true}
|
||||
onToggle={() =>
|
||||
setOpenGroups((prev) => ({
|
||||
...prev,
|
||||
[group]: !(prev[group] ?? true),
|
||||
}))
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminSidebarNavSkeleton(): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const widths = ["68%", "74%", "58%", "70%", "62%"] as const;
|
||||
|
||||
return (
|
||||
<SidebarMenu className="gap-0.5 px-1.5 py-1.5 motion-safe:opacity-90">
|
||||
<SidebarMenuItem>
|
||||
<div aria-hidden className="flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<span className="size-4 rounded-sm bg-white/12 motion-safe:animate-pulse" />
|
||||
<span className="h-2.5 w-14 rounded-full bg-white/12 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
{widths.map((width, i) => (
|
||||
<SidebarMenuItem key={i}>
|
||||
<div aria-hidden className="flex h-8 items-center gap-2 rounded-md px-2.5">
|
||||
<span
|
||||
className="size-4 rounded-sm bg-white/12 motion-safe:animate-pulse"
|
||||
style={{ animationDelay: `${i * 55}ms` }}
|
||||
/>
|
||||
<span
|
||||
className="h-2 rounded-full bg-white/12 motion-safe:animate-pulse"
|
||||
style={{ width, animationDelay: `${i * 55 + 40}ms` }}
|
||||
/>
|
||||
<span className="ml-auto size-3 rounded-sm bg-white/10 motion-safe:animate-pulse" />
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<span className="sr-only">{t("auth.checking")}</span>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AdminSidebarNav,
|
||||
AdminSidebarNavSkeleton,
|
||||
} from "@/components/admin/admin-sidebar-nav";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
@@ -18,63 +18,28 @@ import {
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
} 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 { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||
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">
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-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">
|
||||
<div className="flex h-10 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"
|
||||
className="h-auto max-h-10 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">
|
||||
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
@@ -82,81 +47,64 @@ function AdminSidebarSkeleton(): ReactElement {
|
||||
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-x-0 top-0 h-20 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>
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||
<AdminSidebarNavSkeleton />
|
||||
</div>
|
||||
</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 {
|
||||
const { href, activeMatchPrefix, segment } = item;
|
||||
const prefix = activeMatchPrefix ?? href;
|
||||
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
|
||||
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
|
||||
}
|
||||
// Keep "settings" independent from its child routes like /admin/settings/currencies.
|
||||
if (segment === "settings") {
|
||||
return pathname === href;
|
||||
}
|
||||
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
export function AdminAppSidebar() {
|
||||
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]);
|
||||
const pathname = usePathname();
|
||||
const shellAuthPending = useAdminSessionStore((s) => s.shellAuthPending);
|
||||
const profile = useAdminProfile();
|
||||
|
||||
if (shellAuthPending) {
|
||||
return <AdminSidebarSkeleton />;
|
||||
}
|
||||
const visibleNav = useMemo(
|
||||
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
||||
[profile?.navigation],
|
||||
);
|
||||
|
||||
if (shellAuthPending) {
|
||||
return <AdminSidebarSkeleton />;
|
||||
}
|
||||
|
||||
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">
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="flex shrink-0 flex-col gap-0 border-b border-sidebar-border px-2 py-2">
|
||||
<SidebarMenu className="h-full w-full">
|
||||
<SidebarMenuItem className="h-full">
|
||||
<SidebarMenuButton
|
||||
render={<Link href={ADMIN_BASE} />}
|
||||
className="h-full min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||
className="h-10 min-h-0 justify-start px-1 py-0 hover:bg-transparent group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<div className="flex h-12 w-full items-center group-data-[collapsible=icon]:size-10 group-data-[collapsible=icon]:justify-center">
|
||||
<div className="flex h-10 w-full items-center group-data-[collapsible=icon]:size-10 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 group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
className="h-auto max-h-10 w-full object-contain object-left group-data-[collapsible=icon]:max-h-8 group-data-[collapsible=icon]:object-center"
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{profile?.agent ? (
|
||||
<p
|
||||
className="truncate px-1 pb-1 text-[11px] leading-tight font-medium text-sidebar-foreground/60 group-data-[collapsible=icon]:hidden"
|
||||
title={profile.agent.name}
|
||||
>
|
||||
{profile.agent.name}
|
||||
<span className="text-sidebar-foreground/40"> · {profile.agent.code}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="relative overflow-hidden">
|
||||
<SidebarContent className="relative min-h-0 overflow-hidden p-0">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-[22rem] opacity-55 group-data-[collapsible=icon]:hidden"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-40 opacity-50 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<img
|
||||
@@ -164,32 +112,12 @@ export function AdminAppSidebar() {
|
||||
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-x-0 top-0 h-20 bg-linear-to-b from-sidebar to-transparent" />
|
||||
<div className="absolute inset-0 bg-sidebar/20" />
|
||||
</div>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleNav.map((item) => {
|
||||
const Icon = resolveAdminNavIcon(item.segment);
|
||||
return (
|
||||
<SidebarMenuItem key={item.segment}>
|
||||
<SidebarMenuButton
|
||||
tooltip={adminNavLabel(item.segment, t, item.label)}
|
||||
isActive={isActive(pathname, item)}
|
||||
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"
|
||||
>
|
||||
<Icon data-icon="inline-start" aria-hidden />
|
||||
<span>{adminNavLabel(item.segment, t, item.label)}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain pb-2">
|
||||
<AdminSidebarNav items={visibleNav} />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
<SidebarSeparator />
|
||||
<SidebarRail />
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
@@ -23,6 +24,7 @@ import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function LoginForm() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const tRef = useTranslationRef(["auth", "common"]);
|
||||
const router = useRouter();
|
||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||
@@ -42,7 +44,7 @@ export function LoginForm() {
|
||||
try {
|
||||
const data = await getAdminCaptcha();
|
||||
if (!data) {
|
||||
toast.error(t("captchaLoadFailed"));
|
||||
toast.error(tRef.current("captchaLoadFailed"));
|
||||
setCaptchaKey(null);
|
||||
setCaptchaSrc(null);
|
||||
|
||||
@@ -54,7 +56,7 @@ export function LoginForm() {
|
||||
} finally {
|
||||
setLoadingCaptcha(false);
|
||||
}
|
||||
}, [t]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
61
src/components/ui/loading-dots.tsx
Normal file
61
src/components/ui/loading-dots.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const dotSizeClass = {
|
||||
sm: "size-1",
|
||||
md: "size-1.5",
|
||||
lg: "size-2",
|
||||
} as const;
|
||||
|
||||
const gapClass = {
|
||||
sm: "gap-0.5",
|
||||
md: "gap-1",
|
||||
lg: "gap-1.5",
|
||||
} as const;
|
||||
|
||||
export type LoadingDotsSize = keyof typeof dotSizeClass;
|
||||
|
||||
/**
|
||||
* 全局加载指示:三个圆点依次跳动。
|
||||
* 用于表格、卡片、区块等;完整文案请用 `label` + `showLabel` 或外层 {@link AdminLoadingState}。
|
||||
*/
|
||||
export function LoadingDots({
|
||||
size = "md",
|
||||
className,
|
||||
label,
|
||||
showLabel = false,
|
||||
}: {
|
||||
size?: LoadingDotsSize;
|
||||
className?: string;
|
||||
/** 供屏幕阅读器;`showLabel` 为 true 时同时可见 */
|
||||
label?: string;
|
||||
showLabel?: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
className={cn("inline-flex items-center", gapClass[size], className)}
|
||||
>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<span
|
||||
key={index}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"rounded-full bg-current",
|
||||
dotSizeClass[size],
|
||||
"animate-loading-dot-bounce",
|
||||
)}
|
||||
style={{ animationDelay: `${index * 0.14}s` }}
|
||||
/>
|
||||
))}
|
||||
{label ? (
|
||||
<span className={showLabel ? "text-sm text-muted-foreground" : "sr-only"}>{label}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user