feat(admin, i18n): enhance admin dashboard and user management with new features and translations

Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
This commit is contained in:
2026-06-03 10:07:51 +08:00
parent b15e377187
commit ce27a3ec8a
66 changed files with 1361 additions and 720 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { ChevronDown, KeyRound, Pencil, Trash2 } from "lucide-react";
import { KeyRound, Pencil, Trash2 } from "lucide-react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
@@ -19,13 +19,13 @@ import {
} from "@/api/admin-users";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
import { Badge } from "@/components/ui/badge";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -55,9 +55,9 @@ function permissionGroupLabel(key: string, fallback: string, t: (key: string) =>
return translated === `permissionGroups.${key}` ? fallback : translated;
}
function permissionLabel(slug: string, fallback: string, t: (key: string) => string): string {
const translated = t(`permissionNames.${slug}`);
return translated === `permissionNames.${slug}` ? fallback : translated;
function permissionPackageLabel(key: string, fallback: string, t: (key: string) => string): string {
const translated = t(`permissionLevels.${key}`);
return translated === `permissionLevels.${key}` ? fallback : translated;
}
export function AdminRolesConsole(): React.ReactElement {
@@ -71,7 +71,6 @@ export function AdminRolesConsole(): React.ReactElement {
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
const [rolePermissionOpen, setRolePermissionOpen] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
@@ -94,22 +93,6 @@ export function AdminRolesConsole(): React.ReactElement {
() => roles.find((role) => role.id === selectedRoleId) ?? null,
[roles, selectedRoleId],
);
const selectedPermissionSet = useMemo(
() => new Set(draftRolePermissions),
[draftRolePermissions],
);
const directPermissionGroups = useMemo(() => {
const groups = catalog?.permission_menu_groups;
if (groups && groups.length > 0) {
return groups;
}
const flatPermissions = catalog?.permissions ?? [];
if (flatPermissions.length > 0) {
return [{ key: "all", label: t("allPermissions"), permissions: flatPermissions }];
}
return [];
}, [catalog, t]);
const load = useCallback(async () => {
setLoading(true);
@@ -134,36 +117,6 @@ export function AdminRolesConsole(): React.ReactElement {
void load();
}, []);
function isDirectGroupOpen(key: string): boolean {
return directMenuExpanded[key] === true;
}
function toggleDirectGroup(key: string): void {
setDirectMenuExpanded((prev) => {
const wasOpen = prev[key] === true;
return { ...prev, [key]: !wasOpen };
});
}
function toggleRolePermission(slug: string, checked: boolean): void {
setDraftRolePermissions((prev) => {
if (checked) {
return Array.from(new Set([...prev, slug])).sort();
}
return prev.filter((value) => value !== slug);
});
}
function toggleGroupPermissions(slugs: string[], checked: boolean): void {
setDraftRolePermissions((prev) => {
if (checked) {
return Array.from(new Set([...prev, ...slugs])).sort();
}
const remove = new Set(slugs);
return prev.filter((value) => !remove.has(value));
});
}
function openCreateRole(): void {
setRoleMode("create");
setEditingRoleId(null);
@@ -187,7 +140,6 @@ export function AdminRolesConsole(): React.ReactElement {
function openRolePermissionEditor(role: AdminRoleRow): void {
setSelectedRoleId(role.id);
setDraftRolePermissions([...role.permission_slugs].sort());
setDirectMenuExpanded({});
setRolePermissionOpen(true);
}
@@ -205,20 +157,6 @@ export function AdminRolesConsole(): React.ReactElement {
}
}
function getGroupSelectionState(slugs: string[]): boolean | "indeterminate" {
if (slugs.length === 0) {
return false;
}
const selectedCount = slugs.filter((slug) => selectedPermissionSet.has(slug)).length;
if (selectedCount === 0) {
return false;
}
if (selectedCount === slugs.length) {
return true;
}
return "indeterminate";
}
async function saveRolePermissions(): Promise<void> {
if (!selectedRole) {
return;
@@ -342,7 +280,7 @@ export function AdminRolesConsole(): React.ReactElement {
<TableHead>{t("roleTable.status")}</TableHead>
<TableHead>{t("roleTable.users")}</TableHead>
<TableHead>{t("roleTable.permissions")}</TableHead>
<TableHead className="w-14 text-center">{t("roleTable.actions")}</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("roleTable.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -379,7 +317,7 @@ export function AdminRolesConsole(): React.ReactElement {
</TableCell>
<TableCell className="tabular-nums">{role.user_count}</TableCell>
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageRoles ? (
<AdminRowActionsMenu
actions={[
@@ -432,86 +370,15 @@ export function AdminRolesConsole(): React.ReactElement {
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
{directPermissionGroups.map((group) => {
const isOpen = isDirectGroupOpen(group.key);
const groupSlugs = group.permissions.map((permission) => permission.slug);
const selectedCount = group.permissions.filter((permission) =>
selectedPermissionSet.has(permission.slug),
).length;
const checkedState = getGroupSelectionState(groupSlugs);
return (
<div key={group.key} className={cn("border-b border-border/60 last:border-b-0", isOpen && "bg-muted/10")}>
<div className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/20">
<button
type="button"
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
onClick={() => toggleDirectGroup(group.key)}
aria-label={isOpen ? t("aria.collapse", { ns: "common" }) : t("aria.expand", { ns: "common" })}
>
<ChevronDown
aria-hidden
className={cn("size-4 transition-transform", isOpen && "rotate-180")}
/>
</button>
<Checkbox
checked={checkedState === true}
indeterminate={checkedState === "indeterminate"}
onCheckedChange={(value) => toggleGroupPermissions(groupSlugs, value === true)}
/>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => toggleDirectGroup(group.key)}
>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
{permissionGroupLabel(group.key, group.label, t)}
</span>
{group.permissions.length > 0 ? (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{group.permissions.length}
</span>
) : null}
</div>
</button>
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{selectedCount}/{group.permissions.length}
</span>
</div>
{isOpen ? (
<div className="pb-2">
{group.permissions.map((permission, index) => (
<label
key={permission.slug}
className={cn(
"flex cursor-pointer items-start gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-muted/20",
index === 0 && "border-t border-border/50",
selectedPermissionSet.has(permission.slug) && "bg-muted/20",
)}
>
<span className="mt-1 flex h-4 w-8 shrink-0 items-center">
<span className="h-px w-full bg-border/70" />
</span>
<Checkbox
className="mt-0.5"
checked={selectedPermissionSet.has(permission.slug)}
onCheckedChange={(value) =>
toggleRolePermission(permission.slug, value === true)
}
/>
<span className="min-w-0 whitespace-normal break-words leading-6 text-foreground">
{permissionLabel(permission.slug, permission.name, t)}
</span>
</label>
))}
</div>
) : null}
</div>
);
})}
</div>
<AdminPermissionPackageSelector
catalog={catalog}
selectedSlugs={draftRolePermissions}
onChange={setDraftRolePermissions}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
emptyText={t("states.noData", { ns: "common" })}
heightClassName="h-[min(56vh,520px)]"
/>
</div>
<div className="flex shrink-0 justify-end gap-2 border-t bg-background px-5 py-4">
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>

View File

@@ -320,6 +320,12 @@ export function AdminUsersConsole(): React.ReactElement {
</Button>
) : null}
</div>
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{t("modelGuide", {
defaultValue:
"账号层只绑定角色,不直接分配功能权限;具体权限请到“角色管理”维护。",
})}
</div>
<div className="admin-list-toolbar">
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
@@ -372,7 +378,7 @@ export function AdminUsersConsole(): React.ReactElement {
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
<TableHead>{t("table.roles")}</TableHead>
<TableHead>{t("table.effective")}</TableHead>
<TableHead className="w-14 whitespace-nowrap text-center">{t("table.actions")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -414,7 +420,7 @@ export function AdminUsersConsole(): React.ReactElement {
</div>
</TableCell>
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageUsers ? (
<AdminRowActionsMenu
actions={[

View File

@@ -1,6 +1,6 @@
"use client";
import { ChevronRight, KeyRound, Pencil, Plus, Search, Shield, Trash2, Users } from "lucide-react";
import { ChevronRight, KeyRound, Pencil, Plus, Search, Trash2, Users } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -9,7 +9,6 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { toast } from "sonner";
import {
deleteAgentNode,
deleteAgentRole,
getAgentNodeAdminUsers,
getAgentNodeRoles,
@@ -17,9 +16,7 @@ import {
postAgentAdminUser,
postAgentNode,
postAgentRole,
putAgentAdminUserRoles,
putAgentNode,
putAgentRole,
putAgentRolePermissions,
getAgentDelegationGrants,
putAgentDelegationGrants,
@@ -27,6 +24,7 @@ import {
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { getAdminUserPermissionCatalog } from "@/api/admin-users";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
@@ -75,6 +73,20 @@ import type { AgentDelegationGrantRow, AgentNodeRow } from "@/types/api/admin-ag
import type { AdminPermissionCatalogData, AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-user";
import { LotteryApiBizError } from "@/types/api/errors";
function permissionGroupLabel(key: string, fallback: string, t: (key: string, options?: Record<string, unknown>) => string): string {
const translated = t(`adminUsers:permissionGroups.${key}`);
return translated === `adminUsers:permissionGroups.${key}` ? fallback : translated;
}
function permissionPackageLabel(
key: string,
fallback: string,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
const translated = t(`adminUsers:permissionLevels.${key}`);
return translated === `adminUsers:permissionLevels.${key}` ? fallback : translated;
}
function flattenTree(nodes: AgentNodeRow[]): AgentNodeRow[] {
const out: AgentNodeRow[] = [];
const walk = (list: AgentNodeRow[]) => {
@@ -257,6 +269,7 @@ export function AgentsConsole(): React.ReactElement {
selected !== null &&
!selected.is_root &&
(isSuperAdmin || profile?.agent?.id === selected.parent_id);
const defaultDetailTab = canViewRoles ? "roles" : canViewUsers ? "users" : canManageDelegation ? "delegation" : "roles";
const assignablePermissionSlugs = useMemo(() => {
const mine = new Set(profile?.permissions ?? []);
@@ -279,6 +292,24 @@ export function AgentsConsole(): React.ReactElement {
return slugs;
}, [catalog, profile?.permissions]);
const selectedRoleCountText = useMemo(
() => t("roles.selectedCount", {
defaultValue: "已选 {{selected}} / {{total}} 项",
selected: rolePerms.length,
total: assignablePermissionSlugs.length,
}),
[assignablePermissionSlugs.length, rolePerms.length, t],
);
const selectedDraftCountText = useMemo(
() => t("roles.selectedCount", {
defaultValue: "已选 {{selected}} / {{total}} 项",
selected: draftPerms.length,
total: assignablePermissionSlugs.length,
}),
[assignablePermissionSlugs.length, draftPerms.length, t],
);
const loadTree = useCallback(async (siteId?: number | null) => {
setLoading(true);
setErr(null);
@@ -514,7 +545,11 @@ export function AgentsConsole(): React.ReactElement {
onValueChange={(v) => setAdminSiteId(Number(v))}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder={t("siteLabel")} />
<SelectValue placeholder={t("siteLabel")}>
{adminSiteId !== null
? siteOptions.find((opt) => opt.id === adminSiteId)?.label ?? adminSiteId
: undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((opt) => (
@@ -526,6 +561,12 @@ export function AgentsConsole(): React.ReactElement {
</Select>
) : null}
</div>
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{t("modelGuide", {
defaultValue:
"代理层负责数据范围Scope与授权上限Ceiling账号权限请通过角色分配。",
})}
</div>
{err ? <p className="text-sm text-destructive">{err}</p> : null}
@@ -588,7 +629,7 @@ export function AgentsConsole(): React.ReactElement {
{!selected ? (
<p className="text-sm text-muted-foreground">{t("selectNode")}</p>
) : (
<Tabs defaultValue="overview">
<Tabs defaultValue={defaultDetailTab}>
<div className="mb-4 grid gap-3 rounded-xl border bg-muted/20 p-3 md:grid-cols-4">
<div className="space-y-1 rounded-lg bg-background/80 p-3">
<p className="text-xs text-muted-foreground">{t("status")}</p>
@@ -619,7 +660,6 @@ export function AgentsConsole(): React.ReactElement {
<div className="mb-4 flex flex-wrap items-center gap-2">
<TabsList>
<TabsTrigger value="overview">{t("tabs.overview")}</TabsTrigger>
{canViewRoles ? <TabsTrigger value="roles">{t("tabs.roles")}</TabsTrigger> : null}
{canViewUsers ? <TabsTrigger value="users">{t("tabs.users")}</TabsTrigger> : null}
{canManageDelegation ? (
@@ -640,97 +680,6 @@ export function AgentsConsole(): React.ReactElement {
) : null}
</div>
<TabsContent value="overview" className="space-y-2 text-sm">
<div className="grid gap-3 xl:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-2 rounded-xl border bg-background p-4">
<p>
<span className="text-muted-foreground">{t("code")}:</span> {selected.code}
</p>
<p>
<span className="text-muted-foreground">{t("depth")}:</span> {selected.depth}
</p>
<p>
<span className="text-muted-foreground">{t("path")}:</span>{" "}
<code className="text-xs">{selected.path}</code>
</p>
</div>
<div className="space-y-3 rounded-xl border bg-background p-4">
<p className="text-sm font-medium">{t("quickActions", { defaultValue: "常用操作" })}</p>
{canManageNode ? (
<Button type="button" className="w-full justify-start" onClick={openCreateChild}>
<Plus className="mr-1 size-3.5" />
{t("createChild")}
</Button>
) : null}
{canManageNode && !selected.is_root ? (
<Button type="button" variant="outline" className="w-full justify-start" onClick={openEditNode}>
<Pencil className="mr-1 size-3.5" />
{t("editNode")}
</Button>
) : null}
{canManageNode && !selected.is_root ? (
<Button
type="button"
variant="destructive"
className="w-full justify-start"
onClick={() => {
requestConfirm({
title: selected.name,
description: t("deleteNodeConfirm", {
defaultValue: "删除后不可恢复,请确认该节点无下级、无账号、无角色绑定。",
}),
onConfirm: async () => {
await deleteAgentNode(selected.id);
toast.success(t("deleteSuccess", { name: selected.name }));
setSelectedId(selected.parent_id ?? null);
await loadTree(adminSiteId);
},
});
}}
>
<Trash2 className="mr-1 size-3.5" />
{t("deleteNode", { defaultValue: "删除节点" })}
</Button>
) : null}
{canViewRoles ? (
<Button
type="button"
variant="outline"
className="w-full justify-start"
onClick={() => {
setRoleSlug("");
setRoleName("");
setRolePerms([]);
setRoleDialogOpen(true);
}}
disabled={!canManageRoles}
>
<Shield className="mr-1 size-3.5" />
{t("roles.create")}
</Button>
) : null}
{canViewUsers ? (
<Button
type="button"
variant="outline"
className="w-full justify-start"
onClick={() => {
setUserUsername("");
setUserNickname("");
setUserPassword("");
setUserRoleIds([]);
setUserDialogOpen(true);
}}
disabled={!canManageUsers}
>
<Users className="mr-1 size-3.5" />
{t("users.create")}
</Button>
) : null}
</div>
</div>
</TabsContent>
{canViewRoles ? (
<TabsContent value="roles">
<div className="mb-3 flex justify-end">
@@ -750,68 +699,70 @@ export function AgentsConsole(): React.ReactElement {
</Button>
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("roles.slug")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("roles.userCount")}</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.id}>
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
<TableCell>{role.name}</TableCell>
<TableCell>{role.user_count}</TableCell>
<TableCell>
{canManageRoles && !role.is_read_only_template ? (
<AdminRowActionsMenu
actions={[
{
key: "permissions",
label: t("roles.permissions"),
icon: KeyRound,
onClick: () => {
setPermRoleId(role.id);
setDraftPerms([...role.permission_slugs]);
setPermDialogOpen(true);
},
},
{
key: "delete",
label: t("common:actions.delete", { defaultValue: "Delete" }),
icon: Trash2,
destructive: true,
onClick: () => {
requestConfirm({
title: role.name,
description: t("common:confirm.deleteDescription", {
defaultValue: "This cannot be undone.",
}),
onConfirm: async () => {
await deleteAgentRole(role.id);
toast.success(t("roles.deleteSuccess", { name: role.name }));
if (selectedId !== null) {
await loadDetail(selectedId);
}
},
});
},
},
]}
/>
) : role.is_read_only_template ? (
<span className="text-xs text-muted-foreground">
{t("roles.readOnlyTemplate")}
</span>
) : null}
</TableCell>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("roles.slug")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("roles.userCount")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-[80px] shadow-[-1px_0_0_rgba(203,213,225,0.7)]" />
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.id}>
<TableCell className="font-mono text-xs">{role.slug}</TableCell>
<TableCell>{role.name}</TableCell>
<TableCell>{role.user_count}</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageRoles && !role.is_read_only_template ? (
<AdminRowActionsMenu
actions={[
{
key: "permissions",
label: t("roles.permissions"),
icon: KeyRound,
onClick: () => {
setPermRoleId(role.id);
setDraftPerms([...role.permission_slugs]);
setPermDialogOpen(true);
},
},
{
key: "delete",
label: t("common:actions.delete", { defaultValue: "Delete" }),
icon: Trash2,
destructive: true,
onClick: () => {
requestConfirm({
title: role.name,
description: t("common:confirm.deleteDescription", {
defaultValue: "This cannot be undone.",
}),
onConfirm: async () => {
await deleteAgentRole(role.id);
toast.success(t("roles.deleteSuccess", { name: role.name }));
if (selectedId !== null) {
await loadDetail(selectedId);
}
},
});
},
},
]}
/>
) : role.is_read_only_template ? (
<span className="text-xs text-muted-foreground">
{t("roles.readOnlyTemplate")}
</span>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
) : null}
@@ -835,24 +786,26 @@ export function AgentsConsole(): React.ReactElement {
</Button>
) : null}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("users.username")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("users.roles")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.nickname}</TableCell>
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("users.username")}</TableHead>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("users.roles")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.nickname}</TableCell>
<TableCell className="text-xs">{user.roles.join(", ") || "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TabsContent>
) : null}
@@ -862,40 +815,42 @@ export function AgentsConsole(): React.ReactElement {
{delegationGrants.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("delegation.empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("delegation.permission")}</TableHead>
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{delegationGrants.map((grant) => (
<TableRow key={grant.menu_action_id}>
<TableCell>
<div className="font-medium">{grant.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{grant.permission_code}
</div>
</TableCell>
<TableCell>
<Checkbox
checked={grant.can_delegate}
onCheckedChange={(checked) => {
setDelegationGrants((prev) =>
prev.map((row) =>
row.menu_action_id === grant.menu_action_id
? { ...row, can_delegate: checked === true }
: row,
),
);
}}
/>
</TableCell>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("delegation.permission")}</TableHead>
<TableHead className="w-[140px]">{t("delegation.canDelegate")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{delegationGrants.map((grant) => (
<TableRow key={grant.menu_action_id}>
<TableCell>
<div className="font-medium">{grant.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{grant.permission_code}
</div>
</TableCell>
<TableCell>
<Checkbox
checked={grant.can_delegate}
onCheckedChange={(checked) => {
setDelegationGrants((prev) =>
prev.map((row) =>
row.menu_action_id === grant.menu_action_id
? { ...row, can_delegate: checked === true }
: row,
),
);
}}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="mt-4 flex justify-end">
<Button
@@ -956,33 +911,38 @@ export function AgentsConsole(): React.ReactElement {
</Dialog>
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("roles.create")}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>{t("roles.slug")}</Label>
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("name")}</Label>
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
</div>
<p className="text-xs text-muted-foreground">{t("roles.permissionSubsetHint")}</p>
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
{assignablePermissionSlugs.map((slug) => (
<label key={slug} className="flex items-center gap-2 text-sm">
<Checkbox
checked={rolePerms.includes(slug)}
onCheckedChange={(checked) => {
setRolePerms((prev) =>
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
);
}}
/>
<span className="font-mono text-xs">{slug}</span>
</label>
))}
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("roles.slug")}</Label>
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label>{t("name")}</Label>
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
</div>
<div className="rounded-xl border bg-muted/20 p-3 text-sm text-muted-foreground">
<p>{t("roles.permissionSubsetHint")}</p>
<p className="mt-2 font-medium text-foreground">{selectedRoleCountText}</p>
</div>
</div>
<div className="min-w-0">
<AdminPermissionPackageSelector
catalog={catalog}
selectedSlugs={rolePerms}
onChange={setRolePerms}
selectableSlugs={assignablePermissionSlugs}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
helperText={t("roles.permissionSubsetHint")}
summaryText={selectedRoleCountText}
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRoleDialogOpen(false)}>
@@ -996,25 +956,24 @@ export function AgentsConsole(): React.ReactElement {
</Dialog>
<Dialog open={permDialogOpen} onOpenChange={setPermDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("roles.permissions")}</DialogTitle>
</DialogHeader>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-md border p-2">
{assignablePermissionSlugs.map((slug) => (
<label key={slug} className="flex items-center gap-2 text-sm">
<Checkbox
checked={draftPerms.includes(slug)}
onCheckedChange={(checked) => {
setDraftPerms((prev) =>
checked ? [...prev, slug] : prev.filter((s) => s !== slug),
);
}}
/>
<span className="font-mono text-xs">{slug}</span>
</label>
))}
<div className="rounded-xl border bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
{selectedDraftCountText}
</div>
<AdminPermissionPackageSelector
catalog={catalog}
selectedSlugs={draftPerms}
onChange={setDraftPerms}
selectableSlugs={assignablePermissionSlugs}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
resolvePackageLabel={(key, fallback) => permissionPackageLabel(key, fallback, t)}
helperText={t("roles.permissionSubsetHint")}
summaryText={selectedDraftCountText}
emptyText={t("roles.noAssignablePermissions", { defaultValue: "当前没有可分配权限" })}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPermDialogOpen(false)}>
{t("common:actions.cancel", { defaultValue: "Cancel" })}

View File

@@ -230,13 +230,15 @@ export function AuditLogsConsole(): React.ReactElement {
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell className="text-xs">
{row.operator_type}:{row.operator_id}
</TableCell>
<TableCell className="font-mono text-xs">{row.module_code}</TableCell>
<TableCell className="font-mono text-xs">{row.action_code}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.target_type ?? "—"} {row.target_id ?? ""}
{t(`operatorTypes.${row.operator_type}`, {
ns: "audit",
defaultValue: row.operator_type,
})}
:{row.operator_id}
</TableCell>
<TableCell className="text-sm">{row.module_label}</TableCell>
<TableCell className="text-sm">{row.action_label}</TableCell>
<TableCell className="text-sm text-muted-foreground">{row.target_label}</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>

View File

@@ -512,7 +512,7 @@ export function RiskCapDocScreen() {
<TableRow>
<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-14 text-center">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -556,7 +556,7 @@ export function RiskCapDocScreen() {
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canEditDraft ? (
<AdminRowActionsMenu
busy={saving}

View File

@@ -467,11 +467,13 @@ export function DashboardAgentRankingCard({
export function DashboardAnalyticsPanel({
enabled,
playOptions,
scope,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
scope: { siteCode: string; agentNodeId: number | undefined };
}): ReactNode {
const analytics = useDashboardAnalytics({ enabled, playOptions });
const analytics = useDashboardAnalytics({ enabled, playOptions, scope });
return (
<section className="space-y-4">
<DashboardAnalyticsMain analytics={analytics} />

View File

@@ -18,7 +18,7 @@ import {
Scale,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -188,7 +188,7 @@ export function DashboardConsole(): ReactElement {
setAbnormalTransferTotal(null);
try {
const d = await getAdminDashboard();
const d = await getAdminDashboardByScope({});
setHall(d.hall);
if (d.resolved_draw != null) {
@@ -242,7 +242,11 @@ export function DashboardConsole(): ReactElement {
const pendingReviewTotal = resultBatchQueue?.pending_review_total ?? 0;
const analytics = useDashboardAnalytics({ enabled: canFinance, playOptions });
const analytics = useDashboardAnalytics({
enabled: canFinance,
playOptions,
scope: { siteCode: "", agentNodeId: undefined },
});
const showAnalytics = canFinance;
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [

View File

@@ -1088,7 +1088,7 @@ export function PlatformLifetimePayoutSnapshot({
const bet = coerceAdminMinor(finance.total_bet_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
let win = coerceAdminMinor(finance.total_win_minor);
let jackpot = coerceAdminMinor(finance.total_jackpot_minor);
const jackpot = coerceAdminMinor(finance.total_jackpot_minor);
if (payout > 0 && win + jackpot === 0) {
win = payout;
}

View File

@@ -61,9 +61,11 @@ export function formatDashboardSignedMoneyMinor(minor: number, currencyCode: str
export function useDashboardAnalytics({
enabled,
playOptions,
scope,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
scope: { siteCode: string; agentNodeId: number | undefined };
}) {
const { t } = useTranslation(["dashboard", "common"]);
const tRef = useTranslationRef(["dashboard", "common"]);
@@ -94,6 +96,8 @@ export function useDashboardAnalytics({
period,
metric: "overview",
play_code: playCode !== "" ? playCode : undefined,
site_code: scope.siteCode || undefined,
agent_node_id: scope.agentNodeId,
...(period === "custom" ? { date_from: customFrom, date_to: customTo } : {}),
});
setData(payload);
@@ -110,11 +114,11 @@ export function useDashboardAnalytics({
} finally {
setLoading(false);
}
}, [enabled, period, playCode, customFrom, customTo]);
}, [enabled, period, playCode, customFrom, customTo, scope.agentNodeId, scope.siteCode]);
useAsyncEffect(() => {
void load();
}, [enabled, period, playCode, customFrom, customTo]);
}, [enabled, period, playCode, customFrom, customTo, scope.agentNodeId, scope.siteCode]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;

View File

@@ -243,7 +243,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -252,7 +252,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageDraw ? (
<AdminRowActionsMenu
busy={discardingBatchId === b.id}

View File

@@ -21,7 +21,6 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -111,7 +110,6 @@ export function DrawsIndexConsole() {
const [draftStatus, setDraftStatus] = useState("");
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [appliedStatus, setAppliedStatus] = useState("");
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(10);
@@ -298,12 +296,6 @@ export function DrawsIndexConsole() {
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<AdminAgentFilter
id="draws-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
{t("drawNo")}
@@ -358,7 +350,7 @@ export function DrawsIndexConsole() {
onClick={() => {
setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus);
setAppliedAgentNodeId(agentNodeId);
setAppliedAgentNodeId(undefined);
setPage(1);
}}
>
@@ -370,7 +362,6 @@ export function DrawsIndexConsole() {
onClick={() => {
setDraftDrawNo("");
setDraftStatus("");
setAgentNodeId(undefined);
setAppliedDrawNo("");
setAppliedStatus("");
setAppliedAgentNodeId(undefined);
@@ -382,18 +373,6 @@ export function DrawsIndexConsole() {
</div>
</div>
{data?.schedule ? (
<div className="space-y-1 text-xs text-muted-foreground">
<p>
{t("scheduleTimezoneHint", {
tz: "Local",
interval: data.schedule.interval_minutes,
})}
</p>
{canManageDraw ? <p>{t("listActionsHint")}</p> : null}
</div>
) : null}
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
@@ -419,7 +398,7 @@ export function DrawsIndexConsole() {
<TableHead className="text-center">{t("betTotal")}</TableHead>
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-center">{t("profitLoss")}</TableHead>
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -472,7 +451,7 @@ export function DrawsIndexConsole() {
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{

View File

@@ -365,7 +365,7 @@ export function IntegrationSitesConsole() {
<TableHead>{t("integrationSites.columns.name")}</TableHead>
<TableHead>{t("integrationSites.columns.status")}</TableHead>
<TableHead>{t("integrationSites.columns.walletUrl")}</TableHead>
<TableHead className="w-14 text-center">{t("integrationSites.columns.actions")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("integrationSites.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -385,7 +385,7 @@ export function IntegrationSitesConsole() {
<TableCell className="max-w-[240px] truncate text-xs text-muted-foreground">
{row.wallet_api_url ?? "—"}
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
busy={exportBusyId === row.id}
actions={[

View File

@@ -2,6 +2,7 @@
import { Pencil, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useExportLabels } from "@/hooks/use-export-labels";
@@ -18,7 +19,6 @@ import {
postAdminPlayerUnfreeze,
putAdminPlayer,
} from "@/api/admin-player";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -57,7 +57,6 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
@@ -91,18 +90,17 @@ export function PlayersConsole(): React.ReactElement {
const formatDt = useAdminDateTimeFormatter();
const exportLabels = useExportLabels("players");
const profile = useAdminProfile();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const keywordFromUrl = (searchParams.get("keyword") ?? "").trim();
useAdminCurrencyCatalog();
const canManagePlayers = adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const canFreezePlayers = adminHasAnyPermission(profile?.permissions, [PRD_PLAYER_FREEZE_MANAGE]);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
const [keyword, setKeyword] = useState("");
const [query, setQuery] = useState("");
const [siteCode, setSiteCode] = useState("");
const [appliedSiteCode, setAppliedSiteCode] = useState("");
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [keyword, setKeyword] = useState(keywordFromUrl);
const [query, setQuery] = useState(keywordFromUrl);
const [items, setItems] = useState<AdminPlayerRow[]>([]);
const [total, setTotal] = useState(0);
@@ -138,8 +136,6 @@ export function PlayersConsole(): React.ReactElement {
page,
per_page: perPage,
keyword: query.trim() || undefined,
site_code: appliedSiteCode.trim() || undefined,
agent_node_id: appliedAgentNodeId,
});
setItems(data.items);
setTotal(data.meta.total);
@@ -153,11 +149,17 @@ export function PlayersConsole(): React.ReactElement {
} finally {
setLoading(false);
}
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
}, [page, perPage, query]);
useAsyncEffect(() => {
void load();
}, [page, perPage, query, appliedSiteCode, appliedAgentNodeId]);
}, [page, perPage, query]);
useAsyncEffect(() => {
setKeyword(keywordFromUrl);
setQuery(keywordFromUrl);
setPage(1);
}, [keywordFromUrl]);
function openCreateAccount(): void {
setAccountMode("create");
@@ -311,42 +313,6 @@ export function PlayersConsole(): React.ReactElement {
) : null}
</div>
<div className="admin-list-toolbar">
{canChooseSite ? (
<div className="admin-list-field">
<Label className="sm:w-20 sm:shrink-0">{t("filterSite")}</Label>
<Select
value={siteCode || "__all__"}
onValueChange={(v) => setSiteCode(v === "__all__" ? "" : v ?? "")}
>
<SelectTrigger className="w-full sm:w-[12rem]">
<SelectValue>
{(v) => {
const value = String(v ?? "__all__");
if (value === "__all__") {
return t("filterAllSites");
}
const site = siteOptions.find((item) => item.code === value);
return site ? `${site.code}${site.name}` : value;
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{siteOptions.map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.code} {site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<AdminAgentFilter
id="players-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<div className="admin-list-field xl:min-w-0">
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
{t("search")}
@@ -361,8 +327,14 @@ export function PlayersConsole(): React.ReactElement {
if (e.key === "Enter") {
setPage(1);
setQuery(keyword.trim());
setAppliedSiteCode(siteCode.trim());
setAppliedAgentNodeId(agentNodeId);
const nextParams = new URLSearchParams(searchParams.toString());
if (keyword.trim()) {
nextParams.set("keyword", keyword.trim());
} else {
nextParams.delete("keyword");
}
const queryString = nextParams.toString();
router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
}
}}
/>
@@ -378,8 +350,14 @@ export function PlayersConsole(): React.ReactElement {
onClick={() => {
setPage(1);
setQuery(keyword.trim());
setAppliedSiteCode(siteCode.trim());
setAppliedAgentNodeId(agentNodeId);
const nextParams = new URLSearchParams(searchParams.toString());
if (keyword.trim()) {
nextParams.set("keyword", keyword.trim());
} else {
nextParams.delete("keyword");
}
const queryString = nextParams.toString();
router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
}}
>
{t("search")}
@@ -407,7 +385,7 @@ export function PlayersConsole(): React.ReactElement {
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -489,7 +467,7 @@ export function PlayersConsole(): React.ReactElement {
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManagePlayers ? (
<AdminRowActionsMenu
actions={[
@@ -499,6 +477,16 @@ export function PlayersConsole(): React.ReactElement {
icon: Pencil,
onClick: () => openEditAccount(row),
},
{
key: "tickets",
label: t("viewTickets", { defaultValue: "查看注单" }),
href: `/admin/tickets?player_id=${row.id}`,
},
{
key: "wallet",
label: t("viewWallet", { defaultValue: "查看钱包流水" }),
href: `/admin/wallet/transactions?player_id=${row.id}`,
},
{
key: "delete",
label: t("delete"),

View File

@@ -434,10 +434,10 @@ export function ReconcileConsole(): React.ReactElement {
<Table id="reconcile-jobs-table">
<TableHeader>
<TableRow>
<TableHead className="sticky left-0 z-20 w-24 bg-muted/20 shadow-[1px_0_0_rgba(203,213,225,0.7)]">
<TableHead className="sticky left-0 z-20 w-24 bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
{t("table.id", { ns: "common" })}
</TableHead>
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted/20 shadow-[1px_0_0_rgba(203,213,225,0.7)]">
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted shadow-[1px_0_0_rgba(203,213,225,0.7)]">
{t("jobNo")}
</TableHead>
<TableHead>{t("type")}</TableHead>
@@ -447,7 +447,7 @@ export function ReconcileConsole(): React.ReactElement {
<TableHead>{t("period")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
<TableHead>{t("createdAt")}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 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 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("operate")}
</TableHead>
</TableRow>

View File

@@ -108,7 +108,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
<TableHead>{t("tasks.columns.format")}</TableHead>
<TableHead>{t("tasks.columns.status")}</TableHead>
<TableHead>{t("tasks.columns.createdAt")}</TableHead>
<TableHead className="w-14 text-center">{t("tasks.columns.actions")}</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("tasks.columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -134,7 +134,7 @@ export function ReportJobsPanel({ canExport, refreshToken = 0 }: ReportJobsPanel
<TableCell className="text-xs text-muted-foreground">
{formatTs(job.created_at ?? job.finished_at)}
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
busy={downloadingId === job.id}
actions={[

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import * as XLSX from "xlsx";
@@ -46,7 +47,6 @@ import { getAdminTransferOrders } from "@/api/admin-wallet";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
@@ -92,7 +92,7 @@ import type {
AdminReportRebateCommissionRow,
} from "@/types/api/admin-reports";
type ReportCategory = "profit" | "wallet" | "risk" | "audit";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
type ExportFormat = "csv" | "excel";
@@ -138,7 +138,6 @@ type ReportFilters = {
number: string;
player: string;
playerId: number | null;
agentNodeId: number | undefined;
play: string;
operator: string;
operatorId: number | null;
@@ -202,7 +201,6 @@ const emptyFilters: ReportFilters = {
number: "",
player: "",
playerId: null,
agentNodeId: undefined,
play: "",
operator: "",
operatorId: null,
@@ -323,7 +321,11 @@ function optionText(...parts: Array<string | number | null | undefined>): string
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
}
function reportListParams(filters: ReportFilters, page: number, perPage: number) {
function reportListParams(
filters: ReportFilters,
page: number,
perPage: number,
) {
return {
page,
per_page: perPage,
@@ -331,7 +333,6 @@ function reportListParams(filters: ReportFilters, page: number, perPage: number)
date_to: filters.dateTo || undefined,
player_id: filters.playerId ?? undefined,
play_code: filters.play.trim() || undefined,
agent_node_id: filters.agentNodeId,
};
}
@@ -401,7 +402,7 @@ function resultRowCount(result: ReportResult | null): number {
return result?.rows.length ?? 0;
}
export function ReportsConsole() {
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
const { t, i18n } = useTranslation(["reports", "common"]);
const profile = useAdminProfile();
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
@@ -410,7 +411,13 @@ export function ReportsConsole() {
useAdminPlayTypeCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatTs = useAdminDateTimeFormatter();
const [selectedKey, setSelectedKey] = useState<ReportKey>(REPORTS[0].key);
const filteredReports = useMemo(
() => (initialCategory ? REPORTS.filter((report) => report.category === initialCategory) : REPORTS),
[initialCategory],
);
const [selectedKey, setSelectedKey] = useState<ReportKey>(
filteredReports[0]?.key ?? REPORTS[0].key,
);
const [filters, setFilters] = useState<ReportFilters>(emptyFilters);
const [result, setResult] = useState<ReportResult | null>(null);
const [loading, setLoading] = useState(false);
@@ -422,8 +429,16 @@ export function ReportsConsole() {
const [search, setSearch] = useState<SearchState>(emptySearch);
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["reports", "common"]);
const searchParams = useSearchParams();
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0];
useEffect(() => {
if (!filteredReports.some((report) => report.key === selectedKey)) {
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
}
}, [filteredReports, selectedKey]);
const pageScopedLabel = useCallback(
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
@@ -621,7 +636,9 @@ export function ReportsConsole() {
break;
}
case "daily_profit": {
const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
const payload = await getAdminReportDailyProfit(
reportListParams(filters, page, perPage),
);
const rows = payload.items.map((item) => ({
business_date: item.business_date,
total_bet_minor: item.total_bet_minor,
@@ -650,7 +667,9 @@ export function ReportsConsole() {
break;
}
case "player_win_loss": {
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
const payload = await getAdminReportPlayerWinLoss(
reportListParams(filters, page, perPage),
);
const rows = payload.items.map((item) => ({
player_id: item.player_id,
username: item.username,
@@ -806,7 +825,9 @@ export function ReportsConsole() {
break;
}
case "play_dimension": {
const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
const payload = await getAdminReportPlayDimension(
reportListParams(filters, page, perPage),
);
const rows = payload.items.map((item) => ({
play_code: item.play_code,
dimension: item.dimension,
@@ -829,7 +850,9 @@ export function ReportsConsole() {
break;
}
case "rebate_commission": {
const payload = await getAdminReportRebateCommission(reportListParams(filters, page, perPage));
const payload = await getAdminReportRebateCommission(
reportListParams(filters, page, perPage),
);
const rows = payload.items.map((item) => ({
play_code: item.play_code,
total_rebate_minor: item.total_rebate_minor,
@@ -906,13 +929,30 @@ export function ReportsConsole() {
});
}, [selectedKey]);
useEffect(() => {
setFilters((prev) => ({
...prev,
drawNo: drawNoFromUrl || prev.drawNo,
}));
if (drawNoFromUrl) {
setSelectedKey("draw_profit");
}
}, [drawNoFromUrl]);
useEffect(() => {
queueMicrotask(() => {
setResult(null);
setError(null);
setPage(1);
});
}, []);
useEffect(() => {
if (result && result.key === selectedReport.key && selectedReport.connected) {
queueMicrotask(() => {
void queryReport();
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, perPage]);
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
@@ -1394,7 +1434,7 @@ export function ReportsConsole() {
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 pt-3">
{REPORTS.map((report) => {
{filteredReports.map((report) => {
const Icon = report.icon;
const active = report.key === selectedReport.key;
return (
@@ -1431,13 +1471,6 @@ export function ReportsConsole() {
<CardContent className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{selectedReport.fields.map(renderField)}
{selectedReport.category === "profit" || selectedReport.category === "wallet" ? (
<AdminAgentFilter
id="report-agent-filter"
value={filters.agentNodeId}
onChange={(id) => setFilters((prev) => ({ ...prev, agentNodeId: id }))}
/>
) : null}
</div>
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 gap-2">

View File

@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
const tabs = [
{ category: "profit", href: "/admin/reports/profit" },
{ category: "wallet", href: "/admin/reports/wallet" },
{ category: "risk", href: "/admin/reports/risk" },
{ category: "audit", href: "/admin/reports/audit" },
] as const;
export function ReportsSubnav(): React.ReactElement {
const { t } = useTranslation("reports");
const pathname = usePathname();
return (
<nav aria-label={t("title")} className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1">
{tabs.map((tab) => {
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
)}
>
{t(`categories.${tab.category}`)}
</Link>
);
})}
</nav>
);
}

View File

@@ -183,7 +183,7 @@ export function RiskIndexConsole() {
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("closeTime")}</TableHead>
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -205,7 +205,7 @@ export function RiskIndexConsole() {
<TableCell className="text-sm text-muted-foreground">
{row.close_time ? formatDt(row.close_time) : "—"}
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{

View File

@@ -253,7 +253,7 @@ export function RiskPoolsConsole({
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
<TableHead className="text-center">{t("usageRatio")}</TableHead>
<TableHead>{t("poolStatus")}</TableHead>
<TableHead className="w-14 text-center">{t("actions")}</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -301,7 +301,7 @@ export function RiskPoolsConsole({
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
</span>
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
busy={acting}
actions={[

View File

@@ -231,7 +231,7 @@ export function CurrencySettingsPanel() {
<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.bettable", { ns: "config" })}</TableHead>
<TableHead className="w-14 whitespace-nowrap text-center">{t("currencies.table.actions", { ns: "config" })}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-14 whitespace-nowrap text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("currencies.table.actions", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -269,7 +269,7 @@ export function CurrencySettingsPanel() {
: t("system.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{

View File

@@ -11,6 +11,8 @@ import { SETTLEMENT_KEYS } from "@/modules/settings/settings-keys";
import type { AdminSettingBatchItem } from "@/api/admin-settings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
interface SettlementDraft {
autoSettlement: boolean;
@@ -54,6 +56,8 @@ function buildDirtyItems(draft: SettlementDraft, saved: SettlementDraft): AdminS
export function SettlementSettingsPanel() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, ["prd.payout.manage"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const buildItems = useCallback(buildDirtyItems, []);
const section = useSettingsSection({
@@ -66,6 +70,10 @@ export function SettlementSettingsPanel() {
const { draft, loading, saving, dirty, updateField, discard, save } = section;
if (!canManage) {
return null;
}
return (
<>
<AdminPageCard

View File

@@ -16,7 +16,6 @@ import {
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -90,8 +89,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId] = useState<number | undefined>(undefined);
const [acting, setActing] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
const [reviewRemark, setReviewRemark] = useState("");
@@ -343,24 +341,6 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<CardContent>
{details ? (
<>
<div className="mb-4 flex flex-wrap items-end gap-3">
<AdminAgentFilter
id="settlement-details-agent-filter"
className="w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<Button
type="button"
size="sm"
onClick={() => {
setAppliedAgentNodeId(agentNodeId);
setPage(1);
}}
>
{t("search", { ns: "common", defaultValue: "Search" })}
</Button>
</div>
<Table id={`settlement-details-table-${batchId}`}>
<TableHeader>
<TableRow>

View File

@@ -14,7 +14,6 @@ import {
postAdminPayoutSettlementBatch,
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
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";
@@ -104,8 +103,6 @@ export function SettlementBatchesConsole() {
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [draftStatus, setDraftStatus] = useState(STATUS_ALL);
const [appliedStatus, setAppliedStatus] = useState(STATUS_ALL);
const [agentNodeId, setAgentNodeId] = useState<number | undefined>(undefined);
const [appliedAgentNodeId, setAppliedAgentNodeId] = useState<number | undefined>(undefined);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [actingId, setActingId] = useState<number | null>(null);
@@ -124,7 +121,6 @@ export function SettlementBatchesConsole() {
appliedStatus === STATUS_ALL || appliedStatus.trim() === ""
? undefined
: appliedStatus.trim(),
agent_node_id: appliedAgentNodeId,
});
setData(d);
} catch (e) {
@@ -133,16 +129,15 @@ export function SettlementBatchesConsole() {
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
}, [page, perPage, appliedDrawNo, appliedStatus]);
useAsyncEffect(() => {
void load();
}, [page, perPage, appliedDrawNo, appliedStatus, appliedAgentNodeId]);
}, [page, perPage, appliedDrawNo, appliedStatus]);
const applyFilters = () => {
setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus);
setAppliedAgentNodeId(agentNodeId);
setPage(1);
};
@@ -201,12 +196,6 @@ export function SettlementBatchesConsole() {
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
</div>
<div className="admin-list-toolbar">
<AdminAgentFilter
id="settlement-batches-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={agentNodeId}
onChange={setAgentNodeId}
/>
<div className="admin-list-field">
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
{t("drawNo")}
@@ -260,7 +249,7 @@ export function SettlementBatchesConsole() {
<TableHead className="text-center">{t("platformProfit")}</TableHead>
<TableHead>{t("reviewStatus")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead />
<TableHead className="sticky right-0 z-20 bg-muted w-14 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -300,7 +289,7 @@ export function SettlementBatchesConsole() {
{settlementStatusText(row.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center">
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
busy={actingId === row.id}
actions={[
@@ -310,6 +299,11 @@ export function SettlementBatchesConsole() {
icon: Eye,
href: `/admin/settlement-batches/${row.id}/details`,
},
{
key: "report",
label: t("viewReport", { defaultValue: "查看报表" }),
href: `/admin/reports?draw_no=${encodeURIComponent(row.draw_no ?? "")}`,
},
{
key: "approve",
label: t("pass"),

View File

@@ -1,19 +1,19 @@
"use client";
import { useCallback, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { getAdminTicketItems } from "@/api/admin-tickets";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -26,13 +26,6 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
@@ -62,8 +55,6 @@ const TICKET_STATUS_OPTIONS = [
] as const;
type TicketFilters = {
siteCode: string;
agentNodeId: number | undefined;
playerQuery: string;
drawNo: string;
numberKeyword: string;
@@ -73,8 +64,6 @@ type TicketFilters = {
};
const emptyTicketFilters: TicketFilters = {
siteCode: "",
agentNodeId: undefined,
playerQuery: "",
drawNo: "",
numberKeyword: "",
@@ -109,12 +98,17 @@ function ticketStatusSummary(statuses: string[], t: TicketTranslateFn): string {
export function PlayerTicketsConsole(): React.ReactElement {
const { t } = useTranslation(["tickets", "common"]);
const tRef = useTranslationRef(["tickets", "common"]);
const { sites: siteOptions, canChooseSite } = useAdminSiteCodeOptions();
const playCodeLabel = useAdminPlayCodeLabel();
const exportLabels = useExportLabels("tickets");
const formatTs = useAdminDateTimeFormatter();
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
const initialFilters: TicketFilters = {
...emptyTicketFilters,
playerQuery: playerIdFromUrl,
};
const [draft, setDraft] = useState<TicketFilters>(initialFilters);
const [applied, setApplied] = useState<TicketFilters>(initialFilters);
const [data, setData] = useState<AdminTicketItemsData | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -138,8 +132,6 @@ export function PlayerTicketsConsole(): React.ReactElement {
page,
per_page: perPage,
...query,
site_code: applied.siteCode.trim() || undefined,
agent_node_id: applied.agentNodeId,
draw_no: applied.drawNo.trim() || undefined,
status: applied.statuses.length > 0 ? applied.statuses : undefined,
number: applied.numberKeyword.trim() || undefined,
@@ -163,8 +155,6 @@ export function PlayerTicketsConsole(): React.ReactElement {
setErr(null);
setApplied({
...draft,
siteCode: draft.siteCode.trim(),
agentNodeId: draft.agentNodeId,
playerQuery: draft.playerQuery.trim(),
drawNo: draft.drawNo.trim(),
numberKeyword: draft.numberKeyword.trim(),
@@ -195,47 +185,6 @@ export function PlayerTicketsConsole(): React.ReactElement {
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
{canChooseSite ? (
<div className="admin-list-field">
<Label className="sm:shrink-0">{t("filterSite")}</Label>
<Select
value={draft.siteCode || "__all__"}
onValueChange={(v) =>
setDraft((current) => ({
...current,
siteCode: v === "__all__" ? "" : (v ?? ""),
}))
}
>
<SelectTrigger className="w-full sm:w-[12rem]">
<SelectValue>
{(v) => {
const value = String(v ?? "__all__");
if (value === "__all__") {
return t("filterAllSites");
}
const site = siteOptions.find((item) => item.code === value);
return site ? `${site.code}${site.name}` : value;
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
{siteOptions.map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.code} {site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<AdminAgentFilter
id="tickets-agent-filter"
className="admin-list-field sm:w-[14rem]"
value={draft.agentNodeId}
onChange={(id) => setDraft((current) => ({ ...current, agentNodeId: id }))}
/>
<div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-player" className="sm:shrink-0">
{t("playerId")}
@@ -378,14 +327,15 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableHead className="text-center">{t("winAmount")}</TableHead>
<TableHead>{t("placedAt")}</TableHead>
<TableHead>{t("updatedAt")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={16} />
<AdminTableLoadingRow colSpan={17} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={16} className="text-muted-foreground">
<TableCell colSpan={17} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
@@ -420,6 +370,17 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="text-center tabular-nums text-xs">{winLabel}</TableCell>
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "view-player",
label: t("viewPlayer", { defaultValue: "查看玩家" }),
href: `/admin/players?keyword=${encodeURIComponent(String(row.player_id ?? ""))}&site_code=${encodeURIComponent(String(row.site_code ?? ""))}${row.agent_node_id ? `&agent_node_id=${row.agent_node_id}` : ""}`,
},
]}
/>
</TableCell>
</TableRow>
);
})

View File

@@ -0,0 +1,67 @@
const fs = require('fs');
const path = require('path');
const files = [
"reports/report-jobs-panel.tsx",
"risk/risk-pools-console.tsx",
"risk/risk-index-console.tsx",
"admin-roles/admin-roles-console.tsx",
"admin-users/admin-users-console.tsx",
"wallet/wallet-console.tsx",
"integration/integration-sites-console.tsx",
"players/players-console.tsx",
"config/doc/risk-cap-doc-screen.tsx",
"draws/draw-review-console.tsx",
"draws/draws-index-console.tsx",
"tickets/player-tickets-console.tsx",
"settings/currency-settings-panel.tsx",
"agents/agents-console.tsx",
"settlement/settlement-batches-console.tsx"
];
const STICKY_HEAD_CLASSES = "sticky right-0 z-20 bg-muted shadow-[-1px_0_0_rgba(203,213,225,0.7)] ";
const STICKY_CELL_CLASSES = "sticky right-0 z-10 bg-card shadow-[-1px_0_0_rgba(203,213,225,0.7)] ";
files.forEach(file => {
const fullPath = path.join("/Users/kang/Work/lotterySystem/lotteryadmin/src/modules", file);
if (!fs.existsSync(fullPath)) {
console.log("Not found:", file);
return;
}
let content = fs.readFileSync(fullPath, 'utf8');
let changed = false;
// Replace TableHead
const newContent1 = content.replace(
/<TableHead className="([^"]*text-center[^"]*)"([^>]*)>(.*?t\([^)]*(?:action|操作)[^)]*\).*?)<\/TableHead>/g,
(match, p1, p2, p3) => {
if (p1.includes("sticky")) return match;
changed = true;
return `<TableHead className="${STICKY_HEAD_CLASSES}${p1}"${p2}>${p3}</TableHead>`;
}
);
content = newContent1;
// Replace TableCell wrapping AdminRowActionsMenu
const newContent2 = content.replace(
/<TableCell([^>]*)>\s*<AdminRowActionsMenu/g,
(match, p1) => {
if (match.includes("sticky")) return match;
changed = true;
if (p1.includes('className="')) {
return match.replace(/className="([^"]*)"/, `className="${STICKY_CELL_CLASSES}$1"`);
} else {
return `<TableCell className="${STICKY_CELL_CLASSES}"${p1}>\n<AdminRowActionsMenu`;
}
}
);
content = newContent2;
if (changed) {
fs.writeFileSync(fullPath, content, 'utf8');
console.log("Updated", file);
} else {
console.log("Skipped", file);
}
});

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Copy, RotateCcw, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -17,7 +18,6 @@ import {
} from "@/api/admin-wallet";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -121,7 +121,7 @@ function statusLabelT(status: string, t: (key: string) => string): string {
case "reversed":
return t("statusReversed");
case "manually_processed":
return t("statusManuallyProcessed");
return t("statusCaseClosed");
case "posted":
return t("statusPosted");
default:
@@ -130,7 +130,6 @@ function statusLabelT(status: string, t: (key: string) => string): string {
}
type TransferFilters = {
agentNodeId: number | undefined;
playerId: string;
playerAccount: string;
transferNo: string;
@@ -142,7 +141,6 @@ type TransferFilters = {
};
const emptyTransferFilters: TransferFilters = {
agentNodeId: undefined,
playerId: "",
playerAccount: "",
transferNo: "",
@@ -154,7 +152,6 @@ const emptyTransferFilters: TransferFilters = {
};
type TxnFilters = {
agentNodeId: number | undefined;
playerId: string;
playerAccount: string;
txnNo: string;
@@ -167,7 +164,6 @@ type TxnFilters = {
};
const emptyTxnFilters: TxnFilters = {
agentNodeId: undefined,
playerId: "",
playerAccount: "",
txnNo: "",
@@ -203,7 +199,7 @@ const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "failed", label: "statusFailed" },
{ value: "pending_reconcile", label: "statusPendingReconcile" },
{ value: "reversed", label: "statusReversed" },
{ value: "manually_processed", label: "statusManuallyProcessed" },
{ value: "manually_processed", label: "statusCaseClosed" },
];
/** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */
@@ -251,6 +247,7 @@ function canManuallyProcessTransferOrder(
row: {
direction?: string;
status: string;
fail_reason?: string | null;
can_manually_process?: boolean;
},
canWriteWallet: boolean,
@@ -259,7 +256,8 @@ function canManuallyProcessTransferOrder(
canWriteWallet &&
(row.can_manually_process ??
(["processing", "failed", "pending_reconcile"].includes(row.status) &&
!(row.direction === "out" && row.status === "pending_reconcile")))
!(row.direction === "out" && row.status === "pending_reconcile") &&
row.fail_reason !== "lottery_credit_failed"))
);
}
@@ -295,7 +293,7 @@ function TransferOrderRowActions({
},
{
key: "manual",
label: t("manualProcess"),
label: t("markCaseClosed"),
icon: Wrench,
hidden: !canManuallyProcessTransferOrder(row, canWriteWallet),
onClick: () => onManualProcess(row.transfer_no),
@@ -362,10 +360,10 @@ export function TransferOrdersPanel(): React.ReactElement {
const handleManuallyProcess = (transferNo: string) =>
requestConfirm({
title: t("confirm.manualProcessTitle"),
description: t("confirm.manualProcessDescription", { transferNo }),
title: t("confirm.markCaseClosedTitle"),
description: t("confirm.markCaseClosedDescription", { transferNo }),
onConfirm: () =>
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")),
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("markCaseClosedSuccess")),
});
const handleCompleteCredit = (transferNo: string) =>
@@ -396,7 +394,6 @@ export function TransferOrdersPanel(): React.ReactElement {
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
status: applied.statusCsv.trim() || undefined,
agent_node_id: applied.agentNodeId,
});
setData(d);
} catch (e) {
@@ -430,11 +427,6 @@ export function TransferOrdersPanel(): React.ReactElement {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<AdminAgentFilter
id="transfer-agent-filter"
value={draft.agentNodeId}
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
/>
<div className="grid gap-1.5">
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
<Input
@@ -561,7 +553,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableHead className="min-w-0 max-w-[14rem]">{t("failReason")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
<TableHead className="w-12 text-center">{t("actions")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -600,7 +592,7 @@ export function TransferOrdersPanel(): React.ReactElement {
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.finished_at)}
</TableCell>
<TableCell className="text-center align-middle">
<TableCell className="sticky right-0 z-10 bg-card text-center align-middle shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<div className="flex justify-center">
<TransferOrderRowActions
row={row}
@@ -655,6 +647,8 @@ export function WalletTxnsPanel(): React.ReactElement {
const [perPage, setPerPage] = useState(10);
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
const searchParams = useSearchParams();
const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim();
const load = useCallback(async () => {
setLoading(true);
@@ -677,7 +671,6 @@ export function WalletTxnsPanel(): React.ReactElement {
created_to: applied.createdTo.trim() || undefined,
biz_type: applied.bizType.trim() || undefined,
status: applied.statusCsv.trim() || undefined,
agent_node_id: applied.agentNodeId,
});
setData(d);
} catch (e) {
@@ -692,6 +685,15 @@ export function WalletTxnsPanel(): React.ReactElement {
void load();
}, [page, perPage, applied]);
useAsyncEffect(() => {
if (!playerIdFromUrl) {
return;
}
setDraft((d) => ({ ...d, playerId: playerIdFromUrl }));
setApplied((d) => ({ ...d, playerId: playerIdFromUrl }));
setPage(1);
}, [playerIdFromUrl]);
const runSearch = () => {
setApplied({ ...draft });
setPage(1);
@@ -710,11 +712,6 @@ export function WalletTxnsPanel(): React.ReactElement {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<AdminAgentFilter
id="wallet-txn-agent-filter"
value={draft.agentNodeId}
onChange={(id) => setDraft((d) => ({ ...d, agentNodeId: id }))}
/>
<div className="grid gap-1.5">
<Label htmlFor="tx-no">{t("txnNo")}</Label>
<Input

View File

@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_WALLET_PLAYER_ACCESS_ANY } from "@/lib/admin-prd";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
@@ -17,6 +18,7 @@ const RECONCILE_PERMS = [
const tabs: { href: string; label: string; requiredAny: readonly string[] }[] = [
{ href: "/admin/wallet/transactions", label: "subnavTransactions", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/transfer-orders", label: "subnavTransferOrders", requiredAny: RECONCILE_PERMS },
{ href: "/admin/wallet/player", label: "subnavPlayerWallet", requiredAny: PRD_WALLET_PLAYER_ACCESS_ANY },
];
export function WalletSubnav(): React.ReactElement {