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:
@@ -1,10 +1,29 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
// Keep strict linting back on; only compiler-adjacent legacy rules stay relaxed for now.
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/static-components": "off",
|
||||
"react-hooks/refs": "off",
|
||||
"react-hooks/use-memo": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.cjs"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
},
|
||||
},
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
|
||||
@@ -8,12 +8,29 @@ import type {
|
||||
} from "@/types/api/admin-dashboard-analytics";
|
||||
|
||||
const A = `/admin`;
|
||||
const DASHBOARD_SCOPE_SITE_PARAM = "site_code";
|
||||
const DASHBOARD_SCOPE_AGENT_PARAM = "agent_node_id";
|
||||
|
||||
/** 首页仪表盘聚合(大厅 + 当期财务/风控/异常转账等,按账号权限填充各块) */
|
||||
export async function getAdminDashboard(): Promise<AdminDashboardData> {
|
||||
return adminRequest.get<AdminDashboardData>(`${A}/dashboard`);
|
||||
}
|
||||
|
||||
export async function getAdminDashboardByScope(scope: {
|
||||
site_code?: string;
|
||||
agent_node_id?: number;
|
||||
}): Promise<AdminDashboardData> {
|
||||
const params = new URLSearchParams();
|
||||
if (scope.site_code && scope.site_code.trim() !== "") {
|
||||
params.set(DASHBOARD_SCOPE_SITE_PARAM, scope.site_code.trim());
|
||||
}
|
||||
if (scope.agent_node_id && Number.isInteger(scope.agent_node_id) && scope.agent_node_id > 0) {
|
||||
params.set(DASHBOARD_SCOPE_AGENT_PARAM, String(scope.agent_node_id));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return adminRequest.get<AdminDashboardData>(`${A}/dashboard${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/** 仪表盘可筛选分析(区间汇总、日趋势、玩法拆解) */
|
||||
export async function getAdminDashboardAnalytics(
|
||||
query: AdminDashboardAnalyticsQuery = {},
|
||||
@@ -34,6 +51,12 @@ export async function getAdminDashboardAnalytics(
|
||||
if (query.play_code) {
|
||||
params.set("play_code", query.play_code);
|
||||
}
|
||||
if (query.site_code && query.site_code.trim() !== "") {
|
||||
params.set(DASHBOARD_SCOPE_SITE_PARAM, query.site_code.trim());
|
||||
}
|
||||
if (query.agent_node_id && Number.isInteger(query.agent_node_id) && query.agent_node_id > 0) {
|
||||
params.set(DASHBOARD_SCOPE_AGENT_PARAM, String(query.agent_node_id));
|
||||
}
|
||||
const qs = params.toString();
|
||||
|
||||
return adminRequest.get<AdminDashboardAnalyticsData>(
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
AdminUserDeleteResult,
|
||||
AdminUserPermissionListData,
|
||||
AdminUserPermissionRow,
|
||||
AdminUserPermissionSyncData,
|
||||
AdminUserRoleSyncData,
|
||||
AdminUserUpdatePayload,
|
||||
} from "@/types/api/admin-user";
|
||||
@@ -80,16 +79,6 @@ export async function putAdminRolePermissions(
|
||||
});
|
||||
}
|
||||
|
||||
export async function putAdminUserPermissions(
|
||||
adminUserId: number,
|
||||
permissionSlugs: string[],
|
||||
): Promise<AdminUserPermissionSyncData> {
|
||||
return adminRequest.put<AdminUserPermissionSyncData>(
|
||||
`${A}/admin-users/${adminUserId}/permissions`,
|
||||
{ permission_slugs: permissionSlugs },
|
||||
);
|
||||
}
|
||||
|
||||
export async function putAdminUserRoles(
|
||||
adminUserId: number,
|
||||
roleSlugs: string[],
|
||||
|
||||
26
src/app/admin/(shell)/reports/[category]/page.tsx
Normal file
26
src/app/admin/(shell)/reports/[category]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
type Category = "profit" | "wallet" | "risk" | "audit";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
||||
|
||||
export default async function AdminReportsCategoryPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ category: string }>;
|
||||
}) {
|
||||
const { category } = await params;
|
||||
if (!["profit", "wallet", "risk", "audit"].includes(category)) {
|
||||
notFound();
|
||||
}
|
||||
return (
|
||||
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
||||
<ReportsConsole initialCategory={category as Category} />
|
||||
</AdminPermissionGate>
|
||||
);
|
||||
}
|
||||
14
src/app/admin/(shell)/reports/layout.tsx
Normal file
14
src/app/admin/(shell)/reports/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { ReportsSubnav } from "@/modules/reports/reports-subnav";
|
||||
|
||||
export default function AdminReportsLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<div className="sticky top-14 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<ReportsSubnav />
|
||||
</div>
|
||||
{children}
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd";
|
||||
import { buildPageMetadata } from "@/lib/page-metadata";
|
||||
import { ReportsConsole } from "@/modules/reports/reports-console";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = buildPageMetadata("reports", "title");
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<AdminPermissionGate requiredAny={PRD_REPORTS_VIEW_ACCESS_ANY}>
|
||||
<ReportsConsole />
|
||||
</AdminPermissionGate>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
redirect("/admin/reports/profit");
|
||||
}
|
||||
|
||||
@@ -168,6 +168,20 @@
|
||||
@apply overflow-x-auto rounded-2xl border border-border/80 bg-card shadow-sm;
|
||||
}
|
||||
|
||||
/* Sticky columns need an opaque background so scrolled cells/headers do not show through */
|
||||
[data-slot="table-head"][class*="sticky"] {
|
||||
@apply bg-muted;
|
||||
}
|
||||
|
||||
/* Match table body (white card), not page background (#f7fbff) or header muted tint */
|
||||
[data-slot="table-cell"][class*="sticky"] {
|
||||
background-color: var(--card);
|
||||
}
|
||||
|
||||
[data-slot="table-row"]:hover > [data-slot="table-cell"][class*="sticky"] {
|
||||
@apply bg-muted/35;
|
||||
}
|
||||
|
||||
.admin-table-toolbar {
|
||||
@apply flex items-center justify-end border-b border-border/70 bg-muted/20 px-4 py-2.5;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function AdminAgentFilter({ id = "admin-agent-filter", value, onChange, c
|
||||
}, [profile?.agent?.admin_site_id]);
|
||||
|
||||
const selectValue = value ? String(value) : ALL;
|
||||
const selectedLabel = options.find((opt) => String(opt.id) === selectValue)?.label;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -67,7 +68,15 @@ export function AdminAgentFilter({ id = "admin-agent-filter", value, onChange, c
|
||||
disabled={loading || options.length === 0}
|
||||
>
|
||||
<SelectTrigger id={id} className="mt-1 h-9 w-full min-w-[10rem]">
|
||||
<SelectValue placeholder={t("agentColumns.filterAll")} />
|
||||
<SelectValue>
|
||||
{(raw) => {
|
||||
const current = String(raw ?? ALL);
|
||||
if (current === ALL) {
|
||||
return t("agentColumns.filterAll");
|
||||
}
|
||||
return selectedLabel ?? t("agentColumns.filterAll");
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t("agentColumns.filterAll")}</SelectItem>
|
||||
|
||||
222
src/components/admin/admin-permission-package-selector.tsx
Normal file
222
src/components/admin/admin-permission-package-selector.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ADMIN_PERMISSION_PACKAGES } from "@/lib/admin-permission-packages";
|
||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||
|
||||
type PackageSelectorProps = {
|
||||
catalog: AdminPermissionCatalogData | null;
|
||||
selectedSlugs: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
resolveGroupLabel: (key: string, fallback: string) => string;
|
||||
resolvePackageLabel: (key: string, fallback: string) => string;
|
||||
selectableSlugs?: string[] | null;
|
||||
helperText?: string;
|
||||
summaryText?: string;
|
||||
emptyText: string;
|
||||
heightClassName?: string;
|
||||
};
|
||||
|
||||
type RenderGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
packages: Array<{ key: string; label: string; slugs: string[] }>;
|
||||
};
|
||||
|
||||
const PACKAGE_LEVEL_ORDER: Record<string, number> = {
|
||||
view: 10,
|
||||
review: 20,
|
||||
export: 20,
|
||||
manage: 30,
|
||||
config: 30,
|
||||
control: 30,
|
||||
reopen: 30,
|
||||
special: 40,
|
||||
};
|
||||
|
||||
export function AdminPermissionPackageSelector({
|
||||
catalog,
|
||||
selectedSlugs,
|
||||
onChange,
|
||||
resolveGroupLabel,
|
||||
resolvePackageLabel,
|
||||
selectableSlugs = null,
|
||||
helperText,
|
||||
summaryText,
|
||||
emptyText,
|
||||
heightClassName = "h-[52vh]",
|
||||
}: PackageSelectorProps): React.ReactElement {
|
||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
||||
const catalogSlugSet = useMemo(
|
||||
() => new Set((catalog?.permissions ?? []).map((permission) => permission.slug)),
|
||||
[catalog],
|
||||
);
|
||||
const allowedSet = useMemo(
|
||||
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
||||
[selectableSlugs],
|
||||
);
|
||||
|
||||
const groups = useMemo<RenderGroup[]>(() => {
|
||||
const defs = catalog?.permission_menu_groups ?? [];
|
||||
const out: RenderGroup[] = [];
|
||||
for (const group of defs) {
|
||||
const bundles = ADMIN_PERMISSION_PACKAGES[group.key] ?? [];
|
||||
if (bundles.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const renderedPackages = bundles
|
||||
.map((bundle) => {
|
||||
const slugs = bundle.slugs.filter((slug) => {
|
||||
if (!catalogSlugSet.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
if (allowedSet && !allowedSet.has(slug)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
key: bundle.key,
|
||||
label: resolvePackageLabel(bundle.key, bundle.label),
|
||||
slugs,
|
||||
};
|
||||
})
|
||||
.filter((bundle) => bundle.slugs.length > 0);
|
||||
if (renderedPackages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
key: group.key,
|
||||
label: resolveGroupLabel(group.key, group.label),
|
||||
packages: renderedPackages,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [allowedSet, catalog, catalogSlugSet, resolveGroupLabel, resolvePackageLabel]);
|
||||
|
||||
const bundleCount = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.packages.length, 0),
|
||||
[groups],
|
||||
);
|
||||
|
||||
if (groups.length === 0 || bundleCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleBundle = (group: RenderGroup, bundleKey: string, slugs: string[], checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
const currentLevel = PACKAGE_LEVEL_ORDER[bundleKey] ?? 10;
|
||||
const relatedSlugs = group.packages
|
||||
.filter((item) => {
|
||||
const level = PACKAGE_LEVEL_ORDER[item.key] ?? 10;
|
||||
return checked ? level <= currentLevel : level >= currentLevel;
|
||||
})
|
||||
.flatMap((item) => item.slugs);
|
||||
|
||||
for (const slug of relatedSlugs.length > 0 ? relatedSlugs : slugs) {
|
||||
if (checked) next.add(slug);
|
||||
else next.delete(slug);
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleGroup = (group: RenderGroup, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
const relatedSlugs = group.packages.flatMap((item) => item.slugs);
|
||||
for (const slug of relatedSlugs) {
|
||||
if (checked) next.add(slug);
|
||||
else next.delete(slug);
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{helperText || summaryText ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>{helperText}</span>
|
||||
<span>{summaryText}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn("relative w-full overflow-auto rounded-xl border border-border/60 bg-card", heightClassName)}>
|
||||
<table className="w-full min-w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-muted/80 backdrop-blur-md border-b border-border/50">
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }} className="w-[180px] h-11 px-4 font-semibold text-foreground">模块</th>
|
||||
<th style={{ textAlign: "left" }} className="h-11 px-4 font-semibold text-foreground">具体权限</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{groups.map((group) => {
|
||||
const groupAllSlugs = group.packages.flatMap((p) => p.slugs);
|
||||
const groupSelectedCount = groupAllSlugs.filter((slug) => selectedSet.has(slug)).length;
|
||||
const groupChecked = groupSelectedCount === groupAllSlugs.length && groupAllSlugs.length > 0;
|
||||
|
||||
return (
|
||||
<tr key={group.key} className="hover:bg-muted/10 transition-colors">
|
||||
<td style={{ textAlign: "left" }} className="align-top py-4 pl-4">
|
||||
<label
|
||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
||||
className="cursor-pointer items-center gap-2 font-medium w-full"
|
||||
>
|
||||
<Checkbox
|
||||
checked={groupChecked}
|
||||
onCheckedChange={(value) =>
|
||||
toggleGroup(group, value === true)
|
||||
}
|
||||
/>
|
||||
<span>{group.label}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ textAlign: "left" }} className="py-4 pl-4">
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-start" }}
|
||||
className="flex-wrap gap-3 w-full"
|
||||
>
|
||||
{group.packages.map((bundle) => {
|
||||
const checked = bundle.slugs.every((slug) => selectedSet.has(slug));
|
||||
return (
|
||||
<label
|
||||
key={`${group.key}.${bundle.key}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-muted/50",
|
||||
checked && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) =>
|
||||
toggleBundle(group, bundle.key, bundle.slugs, value === true)
|
||||
}
|
||||
/>
|
||||
<span>{bundle.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/components/admin/admin-permission-selector.tsx
Normal file
200
src/components/admin/admin-permission-selector.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||
|
||||
type PermissionSelectorProps = {
|
||||
catalog: AdminPermissionCatalogData | null;
|
||||
selectedSlugs: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
resolveGroupLabel: (key: string, fallback: string) => string;
|
||||
resolvePermissionLabel: (slug: string, fallback: string) => string;
|
||||
selectableSlugs?: string[] | null;
|
||||
helperText?: string;
|
||||
summaryText?: string;
|
||||
emptyText: string;
|
||||
heightClassName?: string;
|
||||
};
|
||||
|
||||
export function AdminPermissionSelector({
|
||||
catalog,
|
||||
selectedSlugs,
|
||||
onChange,
|
||||
resolveGroupLabel,
|
||||
resolvePermissionLabel,
|
||||
selectableSlugs = null,
|
||||
helperText,
|
||||
summaryText,
|
||||
emptyText,
|
||||
heightClassName = "h-[52vh]",
|
||||
}: PermissionSelectorProps): React.ReactElement {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
||||
const allowedSet = useMemo(
|
||||
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
||||
[selectableSlugs],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const rawGroups = catalog?.permission_menu_groups ?? [];
|
||||
const mapped = rawGroups
|
||||
.map((group) => ({
|
||||
key: group.key,
|
||||
label: resolveGroupLabel(group.key, group.label),
|
||||
permissions: group.permissions.filter((permission) =>
|
||||
allowedSet ? allowedSet.has(permission.slug) : true,
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.permissions.length > 0);
|
||||
|
||||
if (mapped.length > 0) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
const fallbackPermissions = (catalog?.permissions ?? []).filter((permission) =>
|
||||
allowedSet ? allowedSet.has(permission.slug) : true,
|
||||
);
|
||||
|
||||
if (fallbackPermissions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "all",
|
||||
label: resolveGroupLabel("all", "全部权限"),
|
||||
permissions: fallbackPermissions,
|
||||
},
|
||||
];
|
||||
}, [allowedSet, catalog, resolveGroupLabel]);
|
||||
|
||||
const totalCount = useMemo(
|
||||
() => groups.reduce((sum, group) => sum + group.permissions.length, 0),
|
||||
[groups],
|
||||
);
|
||||
|
||||
const toggleOne = (slug: string, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (checked) {
|
||||
next.add(slug);
|
||||
} else {
|
||||
next.delete(slug);
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleGroup = (slugs: string[], checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
for (const slug of slugs) {
|
||||
if (checked) {
|
||||
next.add(slug);
|
||||
} else {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
const toggleExpanded = (key: string) => {
|
||||
setExpandedGroups((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
|
||||
};
|
||||
|
||||
const isExpanded = (key: string): boolean => expandedGroups[key] ?? true;
|
||||
|
||||
if (groups.length === 0 || totalCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{helperText || summaryText ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>{helperText}</span>
|
||||
<span>{summaryText}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className={cn("pr-3", heightClassName)}>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
||||
{groups.map((group) => {
|
||||
const expanded = isExpanded(group.key);
|
||||
const groupSlugs = group.permissions.map((permission) => permission.slug);
|
||||
const selectedCount = groupSlugs.filter((slug) => selectedSet.has(slug)).length;
|
||||
const checkedState =
|
||||
selectedCount === 0 ? false : selectedCount === groupSlugs.length ? true : "indeterminate";
|
||||
|
||||
return (
|
||||
<div key={group.key} className={cn("border-b border-border/60 last:border-b-0", expanded && "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={() => toggleExpanded(group.key)}
|
||||
aria-label={expanded ? "collapse" : "expand"}
|
||||
>
|
||||
<ChevronDown aria-hidden className={cn("size-4 transition-transform", expanded && "rotate-180")} />
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={checkedState === true}
|
||||
indeterminate={checkedState === "indeterminate"}
|
||||
onCheckedChange={(value) => toggleGroup(groupSlugs, value === true)}
|
||||
/>
|
||||
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => toggleExpanded(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">
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{group.permissions.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{selectedCount}/{group.permissions.length}
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<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",
|
||||
selectedSet.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={selectedSet.has(permission.slug)}
|
||||
onCheckedChange={(value) => toggleOne(permission.slug, value === true)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<div className="whitespace-normal break-words leading-6 text-foreground">
|
||||
{resolvePermissionLabel(permission.slug, permission.name)}
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -253,7 +253,6 @@ export function LoginForm() {
|
||||
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
|
||||
>
|
||||
{captchaSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- data URL from API
|
||||
<img
|
||||
src={captchaSrc}
|
||||
alt=""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
LogOutIcon,
|
||||
|
||||
@@ -8,7 +8,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto rounded-2xl border border-border/60 bg-card shadow-none"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("bg-muted/20 [&_tr]:border-b", className)}
|
||||
className={cn("bg-muted [&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,5 @@ export function useAsyncEffect(
|
||||
queueMicrotask(() => {
|
||||
void factoryRef.current();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- factory 经 ref 同步,deps 仅含真实查询参数
|
||||
}, deps);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"saveRoleFailed": "Failed to save roles",
|
||||
"savePermissionSuccess": "Updated permissions for {{name}}",
|
||||
"savePermissionFailed": "Failed to save permissions",
|
||||
"modelGuide": "Accounts bind roles only. Maintain functional permissions in Role Management.",
|
||||
"saving": "Saving…",
|
||||
"deleting": "Deleting…",
|
||||
"common": {
|
||||
@@ -70,6 +71,16 @@
|
||||
"roleActions": {
|
||||
"permissions": "Permissions"
|
||||
},
|
||||
"permissionLevels": {
|
||||
"view": "View",
|
||||
"manage": "Manage",
|
||||
"review": "Review",
|
||||
"export": "Export",
|
||||
"control": "Control",
|
||||
"config": "Configure",
|
||||
"reopen": "Reopen",
|
||||
"special": "Privileged"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"title": "Assign roles",
|
||||
"rolesTitle": "Roles",
|
||||
@@ -106,7 +117,7 @@
|
||||
"passwordPlaceholderCreate": "At least 8 characters",
|
||||
"passwordPlaceholderEdit": "Leave empty to keep unchanged",
|
||||
"rolesRequired": "Roles (default site, at least one)",
|
||||
"rolesDescription": "After creation, you can continue adjusting roles or grant direct permissions in Permissions.",
|
||||
"rolesDescription": "After creation, you can continue adjusting role bindings in Assign Roles.",
|
||||
"noRoles": "No roles available yet. Wait for the list to finish loading and try again."
|
||||
},
|
||||
"delete": {
|
||||
@@ -124,21 +135,37 @@
|
||||
"dashboard": "Dashboard",
|
||||
"admin_users": "Admin Users",
|
||||
"admin_roles": "Role Management",
|
||||
"agents": "Agent Management",
|
||||
"players": "Players",
|
||||
"currencies": "Currencies",
|
||||
"wallet": "Wallet",
|
||||
"draws": "Draws",
|
||||
"config": "Configuration",
|
||||
"rules_plays": "Play Rules",
|
||||
"rules_odds": "Odds & Rebate",
|
||||
"risk_cap": "Risk Cap Rules",
|
||||
"risk": "Risk",
|
||||
"settlement": "Settlement",
|
||||
"jackpot": "Jackpot",
|
||||
"reconcile": "Reconcile",
|
||||
"reports": "Reports",
|
||||
"tickets": "Tickets",
|
||||
"audit": "Audit Logs",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"integration": "Integration Sites"
|
||||
},
|
||||
"permissionNames": {
|
||||
"prd.dashboard.view": "Dashboard · View",
|
||||
"prd.agent.view": "Agent Management · View",
|
||||
"prd.agent.manage": "Agent Management · Manage",
|
||||
"prd.agent.role.view": "Agent Roles · View",
|
||||
"prd.agent.role.manage": "Agent Roles · Manage",
|
||||
"prd.agent.user.view": "Agent Accounts · View",
|
||||
"prd.agent.user.manage": "Agent Accounts · Manage",
|
||||
"prd.admin_user.manage": "Admin Users · Manage",
|
||||
"prd.admin_role.manage": "Role Management · Manage",
|
||||
"prd.integration.view": "Integration Sites · View",
|
||||
"prd.integration.manage": "Integration Sites · Manage",
|
||||
"prd.users.manage": "Players · Manage",
|
||||
"prd.currency.manage": "Currency Management · Manage",
|
||||
"prd.users.view_finance": "Players · View Finance",
|
||||
@@ -153,6 +180,7 @@
|
||||
"prd.draw_reopen.manage": "Draw Reopen · Manage",
|
||||
"prd.play_switch.manage": "Play Switches · Manage",
|
||||
"prd.odds.manage": "Odds Configuration · Manage",
|
||||
"prd.odds.view": "Odds Configuration · View",
|
||||
"prd.risk_cap.manage": "Risk Caps · Manage",
|
||||
"prd.risk_cap.view": "Risk Caps · View",
|
||||
"prd.rebate.manage": "Commission/Rebate · Manage",
|
||||
@@ -163,7 +191,11 @@
|
||||
"prd.payout.manage": "Payout Confirmation · Manage",
|
||||
"prd.payout.review": "Payout Confirmation · Review",
|
||||
"prd.payout.view": "Payout Confirmation · View",
|
||||
"prd.tickets.view": "Player Tickets · View",
|
||||
"prd.audit.view": "Audit Logs · View",
|
||||
"prd.report.view": "Reports · View"
|
||||
"prd.report.view": "Reports · View",
|
||||
"prd.report.export": "Reports · Export",
|
||||
"prd.risk.view": "Risk Center · View",
|
||||
"prd.risk.manage": "Risk Center · Manage"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"deleteSuccess": "Deleted agent {{name}}",
|
||||
"saveFailed": "Save failed",
|
||||
"codeRequired": "Code and name are required",
|
||||
"modelGuide": "Agent layer controls data scope and delegation ceiling. Account permissions are assigned through roles.",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"roles": "Roles",
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"action": "Action",
|
||||
"target": "Target",
|
||||
"time": "Time",
|
||||
"empty": "No data"
|
||||
"empty": "No data",
|
||||
"operatorTypes": {
|
||||
"admin": "Admin",
|
||||
"player": "Player",
|
||||
"system": "System"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
"deniedDescription": "Your account does not have permission to open this page. Ask an administrator to assign the required role permissions."
|
||||
},
|
||||
"table": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"playerColumns": {
|
||||
"site": "Site",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"filter": "Filter",
|
||||
"drawNo": "Draw no.",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"apply": "Apply",
|
||||
"batchList": "Settlement batches",
|
||||
"allStatuses": "All",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"betAmount": "Bet amount",
|
||||
"actualDeduct": "Actual deduct",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"failReason": "Fail reason",
|
||||
"winAmount": "Win amount",
|
||||
"placedAt": "Placed at",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"subnavLabel": "Wallet sub pages",
|
||||
"subnavTransactions": "Wallet transactions",
|
||||
"subnavTransferOrders": "Transfer orders",
|
||||
"subnavPlayerWallet": "Player wallet",
|
||||
"noPermission": "Current account has no access to this page",
|
||||
"copySuccess": "{{label}} copied to clipboard",
|
||||
"copyFailed": "Copy failed. Check browser permissions or copy manually.",
|
||||
@@ -11,7 +12,7 @@
|
||||
"statusFailed": "Failed",
|
||||
"statusPendingReconcile": "Pending reconcile",
|
||||
"statusReversed": "Reversed",
|
||||
"statusManuallyProcessed": "Manually processed",
|
||||
"statusCaseClosed": "Case closed",
|
||||
"statusPosted": "Posted",
|
||||
"filterAll": "All",
|
||||
"transferIn": "Main site transfer in",
|
||||
@@ -44,19 +45,19 @@
|
||||
"actionsMenuAriaLabel": "Transfer order actions",
|
||||
"reverse": "Reverse",
|
||||
"completeCredit": "Complete credit",
|
||||
"manualProcess": "Manual process",
|
||||
"markCaseClosed": "Close case",
|
||||
"processing": "Processing…",
|
||||
"reverseSuccess": "Reversed successfully",
|
||||
"completeCreditSuccess": "Transfer-in credited successfully",
|
||||
"manualProcessSuccess": "Manually processed successfully",
|
||||
"markCaseClosedSuccess": "Case marked closed",
|
||||
"actionFailed": "Action failed",
|
||||
"confirm": {
|
||||
"reverseTitle": "Confirm reverse transfer?",
|
||||
"reverseDescription": "Reverse order {{transferNo}}. This may affect player wallet balance.",
|
||||
"completeCreditTitle": "Confirm complete transfer-in credit?",
|
||||
"completeCreditDescription": "When the main site has already debited, credit lottery wallet for order {{transferNo}} and mark it successful.",
|
||||
"manualProcessTitle": "Confirm manual process?",
|
||||
"manualProcessDescription": "Mark order {{transferNo}} as manually processed without automatic wallet adjustment."
|
||||
"markCaseClosedTitle": "Close this case?",
|
||||
"markCaseClosedDescription": "Only marks order {{transferNo}} as closed. Wallet balances are not adjusted. Confirm it was already handled outside the system."
|
||||
},
|
||||
"txnNo": "Txn no.",
|
||||
"bizType": "Business type",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"saveRoleFailed": "भूमिका सुरक्षित गर्न असफल",
|
||||
"savePermissionSuccess": "{{name}} को अनुमति अपडेट भयो",
|
||||
"savePermissionFailed": "अनुमति सुरक्षित गर्न असफल",
|
||||
"modelGuide": "खाता तहमा भूमिका मात्र बाँधिन्छ; कार्य अनुमति भूमिका व्यवस्थापनमा मिलाउनुहोस्।",
|
||||
"saving": "सेभ हुँदैछ…",
|
||||
"deleting": "मेटिँदैछ…",
|
||||
"common": {
|
||||
@@ -70,6 +71,16 @@
|
||||
"roleActions": {
|
||||
"permissions": "अनुमति"
|
||||
},
|
||||
"permissionLevels": {
|
||||
"view": "हेर्नुहोस्",
|
||||
"manage": "व्यवस्थापन",
|
||||
"review": "समीक्षा",
|
||||
"export": "निर्यात",
|
||||
"control": "नियन्त्रण",
|
||||
"config": "कन्फिगर",
|
||||
"reopen": "पुनःखोल्ने",
|
||||
"special": "विशेष"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"title": "भूमिका तोक्नुहोस्",
|
||||
"rolesTitle": "भूमिका",
|
||||
@@ -106,7 +117,7 @@
|
||||
"passwordPlaceholderCreate": "कम्तीमा 8 वर्ण",
|
||||
"passwordPlaceholderEdit": "परिवर्तन नगर्न खाली छोड्नुहोस्",
|
||||
"rolesRequired": "भूमिका (पूर्वनिर्धारित साइट, कम्तीमा एक)",
|
||||
"rolesDescription": "सिर्जना भएपछि अनुमतिमा गएर भूमिका वा प्रत्यक्ष अनुमति थप समायोजन गर्न सकिन्छ।",
|
||||
"rolesDescription": "सिर्जना भएपछि \"भूमिका तोक्नुहोस्\" मा गएर भूमिका बाइन्डिङ थप समायोजन गर्न सकिन्छ।",
|
||||
"noRoles": "अहिले भूमिका डाटा छैन। सूची लोड भएपछि फेरि प्रयास गर्नुहोस्।"
|
||||
},
|
||||
"delete": {
|
||||
@@ -124,21 +135,37 @@
|
||||
"dashboard": "ड्यासबोर्ड",
|
||||
"admin_users": "प्रशासक सूची",
|
||||
"admin_roles": "भूमिका व्यवस्थापन",
|
||||
"agents": "एजेन्ट व्यवस्थापन",
|
||||
"players": "खेलाडी सूची",
|
||||
"currencies": "मुद्रा व्यवस्थापन",
|
||||
"wallet": "वालेट",
|
||||
"draws": "ड्रअ सूची",
|
||||
"config": "कन्फिगरेसन",
|
||||
"rules_plays": "प्ले नियम",
|
||||
"rules_odds": "ओड्स र रिबेट",
|
||||
"risk_cap": "जोखिम सीमा नियम",
|
||||
"risk": "जोखिम",
|
||||
"settlement": "सेटलमेन्ट",
|
||||
"jackpot": "ज्याकपोट",
|
||||
"reconcile": "मिलान",
|
||||
"reports": "रिपोर्टहरू",
|
||||
"tickets": "टिकटहरू",
|
||||
"audit": "अडिट लग",
|
||||
"settings": "सेटिङ"
|
||||
"settings": "सेटिङ",
|
||||
"integration": "इन्टिग्रेशन साइटहरू"
|
||||
},
|
||||
"permissionNames": {
|
||||
"prd.dashboard.view": "ड्यासबोर्ड · हेर्नुहोस्",
|
||||
"prd.agent.view": "एजेन्ट व्यवस्थापन · हेर्नुहोस्",
|
||||
"prd.agent.manage": "एजेन्ट व्यवस्थापन · व्यवस्थापन",
|
||||
"prd.agent.role.view": "एजेन्ट भूमिका · हेर्नुहोस्",
|
||||
"prd.agent.role.manage": "एजेन्ट भूमिका · व्यवस्थापन",
|
||||
"prd.agent.user.view": "एजेन्ट खाता · हेर्नुहोस्",
|
||||
"prd.agent.user.manage": "एजेन्ट खाता · व्यवस्थापन",
|
||||
"prd.admin_user.manage": "प्रशासक सूची · व्यवस्थापन",
|
||||
"prd.admin_role.manage": "भूमिका व्यवस्थापन · व्यवस्थापन",
|
||||
"prd.integration.view": "इन्टिग्रेशन साइट · हेर्नुहोस्",
|
||||
"prd.integration.manage": "इन्टिग्रेशन साइट · व्यवस्थापन",
|
||||
"prd.users.manage": "खेलाडी व्यवस्थापन · व्यवस्थापन",
|
||||
"prd.currency.manage": "मुद्रा व्यवस्थापन · व्यवस्थापन",
|
||||
"prd.users.view_finance": "खेलाडी व्यवस्थापन · वित्त हेर्नुहोस्",
|
||||
@@ -153,6 +180,7 @@
|
||||
"prd.draw_reopen.manage": "ड्रअ पुनःखोल्ने · व्यवस्थापन",
|
||||
"prd.play_switch.manage": "प्ले स्विच · व्यवस्थापन",
|
||||
"prd.odds.manage": "ओड्स कन्फिगरेसन · व्यवस्थापन",
|
||||
"prd.odds.view": "ओड्स कन्फिगरेसन · हेर्नुहोस्",
|
||||
"prd.risk_cap.manage": "जोखिम सीमा · व्यवस्थापन",
|
||||
"prd.risk_cap.view": "जोखिम सीमा · हेर्नुहोस्",
|
||||
"prd.rebate.manage": "कमिसन/रिबेट · व्यवस्थापन",
|
||||
@@ -163,7 +191,11 @@
|
||||
"prd.payout.manage": "भुक्तानी पुष्टि · व्यवस्थापन",
|
||||
"prd.payout.review": "भुक्तानी पुष्टि · समीक्षा",
|
||||
"prd.payout.view": "भुक्तानी पुष्टि · हेर्नुहोस्",
|
||||
"prd.tickets.view": "खेलाडी टिकट · हेर्नुहोस्",
|
||||
"prd.audit.view": "अडिट लग · हेर्नुहोस्",
|
||||
"prd.report.view": "रिपोर्ट · हेर्नुहोस्"
|
||||
"prd.report.view": "रिपोर्ट · हेर्नुहोस्",
|
||||
"prd.report.export": "रिपोर्ट · निर्यात",
|
||||
"prd.risk.view": "जोखिम केन्द्र · हेर्नुहोस्",
|
||||
"prd.risk.manage": "जोखिम केन्द्र · व्यवस्थापन"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"deleteSuccess": "Deleted agent {{name}}",
|
||||
"saveFailed": "Save failed",
|
||||
"codeRequired": "Code and name are required",
|
||||
"modelGuide": "एजेन्ट तहले डाटा स्कोप र delegation ceiling नियन्त्रण गर्छ; खाताको अनुमति भूमिका मार्फत बाँडिन्छ।",
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"roles": "Roles",
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"action": "कार्य",
|
||||
"target": "लक्ष्य",
|
||||
"time": "समय",
|
||||
"empty": "डाटा छैन"
|
||||
"empty": "डाटा छैन",
|
||||
"operatorTypes": {
|
||||
"admin": "प्रशासक",
|
||||
"player": "खेलाडी",
|
||||
"system": "प्रणाली"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
"deniedDescription": "यो पृष्ठ खोल्ने अनुमति तपाईंको खातामा छैन। भूमिका व्यवस्थापनबाट आवश्यक अनुमति दिन प्रशासकलाई सम्पर्क गर्नुहोस्।"
|
||||
},
|
||||
"table": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"actions": "कार्य"
|
||||
},
|
||||
"playerColumns": {
|
||||
"site": "साइट",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"filter": "फिल्टर",
|
||||
"drawNo": "ड्रअ नं.",
|
||||
"status": "स्थिति",
|
||||
"actions": "कार्य",
|
||||
"apply": "लागू गर्नुहोस्",
|
||||
"batchList": "सेटलमेन्ट ब्याच",
|
||||
"allStatuses": "सबै",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"betAmount": "बेट रकम",
|
||||
"actualDeduct": "कटौती",
|
||||
"status": "स्थिति",
|
||||
"actions": "कार्य",
|
||||
"failReason": "असफल कारण",
|
||||
"winAmount": "जित रकम",
|
||||
"placedAt": "बेट समय",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"subnavLabel": "वालेट उपपृष्ठहरू",
|
||||
"subnavTransactions": "वालेट कारोबार",
|
||||
"subnavTransferOrders": "ट्रान्सफर अर्डर",
|
||||
"subnavPlayerWallet": "खेलाडी वालेट",
|
||||
"noPermission": "हालको खातासँग यो पृष्ठमा पहुँच अनुमति छैन",
|
||||
"copySuccess": "{{label}} क्लिपबोर्डमा प्रतिलिपि भयो",
|
||||
"copyFailed": "प्रतिलिपि असफल भयो। ब्राउजर अनुमति जाँच गर्नुहोस् वा म्यानुअल रूपमा कपी गर्नुहोस्।",
|
||||
@@ -11,7 +12,7 @@
|
||||
"statusFailed": "असफल",
|
||||
"statusPendingReconcile": "मिलान बाँकी",
|
||||
"statusReversed": "रिभर्स भयो",
|
||||
"statusManuallyProcessed": "म्यानुअल रूपमा प्रक्रिया गरियो",
|
||||
"statusCaseClosed": "केस बन्द भयो",
|
||||
"statusPosted": "पोस्ट गरियो",
|
||||
"filterAll": "सबै",
|
||||
"transferIn": "मुख्य साइटबाट भित्र",
|
||||
@@ -44,19 +45,19 @@
|
||||
"actionsMenuAriaLabel": "ट्रान्सफर अर्डर कार्य मेनु",
|
||||
"reverse": "रिभर्स",
|
||||
"completeCredit": "क्रेडिट पूरा गर्नुहोस्",
|
||||
"manualProcess": "म्यानुअल प्रक्रिया",
|
||||
"markCaseClosed": "केस बन्द चिन्ह",
|
||||
"processing": "प्रक्रियामा…",
|
||||
"reverseSuccess": "रिभर्स सफल भयो",
|
||||
"completeCreditSuccess": "ट्रान्सफर-इन क्रेडिट सफल भयो",
|
||||
"manualProcessSuccess": "म्यानुअल प्रक्रिया सफल भयो",
|
||||
"markCaseClosedSuccess": "केस बन्द चिन्ह लाग्यो",
|
||||
"actionFailed": "कार्य असफल भयो",
|
||||
"confirm": {
|
||||
"reverseTitle": "ट्रान्सफर रिभर्स पुष्टि गर्ने?",
|
||||
"reverseDescription": "अर्डर {{transferNo}} रिभर्स गर्नेछ, खेलाडी वालेट प्रभावित हुन सक्छ।",
|
||||
"completeCreditTitle": "ट्रान्सफर-इन क्रेडिट पूरा गर्ने?",
|
||||
"completeCreditDescription": "मुख्य साइटले पहिले नै कटौती गरेको छ भने, अर्डर {{transferNo}} को लागि लटरी वालेटमा क्रेडिट गरी सफल चिन्ह लगाउँछ।",
|
||||
"manualProcessTitle": "म्यानुअल प्रक्रिया पुष्टि?",
|
||||
"manualProcessDescription": "अर्डर {{transferNo}} म्यानुअल प्रक्रिया भएको चिन्ह लगाउँछ, वालेट स्वचालित मिलाउँदैन।"
|
||||
"markCaseClosedTitle": "केस बन्द चिन्ह पुष्टि?",
|
||||
"markCaseClosedDescription": "अर्डर {{transferNo}} मात्र बन्द भएको चिन्ह लगाउँछ; वालेट मिलाउँदैन। बाहिरै समाधान भइसकेको पुष्टि गर्नुहोस्।"
|
||||
},
|
||||
"txnNo": "कारोबार नं.",
|
||||
"bizType": "व्यवसाय प्रकार",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"saveRoleFailed": "保存角色失败",
|
||||
"savePermissionSuccess": "已更新 {{name}} 的权限",
|
||||
"savePermissionFailed": "保存权限失败",
|
||||
"modelGuide": "账号层只绑定角色,不直接分配功能权限;具体权限请到「角色管理」维护。",
|
||||
"saving": "保存中…",
|
||||
"deleting": "删除中…",
|
||||
"common": {
|
||||
@@ -70,6 +71,16 @@
|
||||
"roleActions": {
|
||||
"permissions": "配权限"
|
||||
},
|
||||
"permissionLevels": {
|
||||
"view": "查看",
|
||||
"manage": "管理",
|
||||
"review": "审核",
|
||||
"export": "导出",
|
||||
"control": "控制",
|
||||
"config": "配置",
|
||||
"reopen": "重开",
|
||||
"special": "特权"
|
||||
},
|
||||
"permissionDialog": {
|
||||
"title": "分配角色",
|
||||
"rolesTitle": "角色",
|
||||
@@ -106,7 +117,7 @@
|
||||
"passwordPlaceholderCreate": "至少 8 位",
|
||||
"passwordPlaceholderEdit": "不修改请留空",
|
||||
"rolesRequired": "角色(默认站点,至少一项)",
|
||||
"rolesDescription": "创建后即可在「权限」中继续调整角色或直接授权。",
|
||||
"rolesDescription": "创建后可在「分配角色」中继续调整角色绑定。",
|
||||
"noRoles": "暂无角色数据,请等待列表加载完成后重试。"
|
||||
},
|
||||
"delete": {
|
||||
@@ -134,21 +145,37 @@
|
||||
"dashboard": "仪表盘",
|
||||
"admin_users": "管理列表",
|
||||
"admin_roles": "角色管理",
|
||||
"agents": "代理管理",
|
||||
"players": "玩家列表",
|
||||
"currencies": "币种管理",
|
||||
"wallet": "钱包流水",
|
||||
"draws": "期号列表",
|
||||
"config": "运营配置",
|
||||
"rules_plays": "投注规则",
|
||||
"rules_odds": "赔率与回水",
|
||||
"risk_cap": "限额版本",
|
||||
"risk": "风控",
|
||||
"settlement": "结算",
|
||||
"jackpot": "奖池",
|
||||
"reconcile": "对账",
|
||||
"reports": "报表中心",
|
||||
"tickets": "玩家注单",
|
||||
"audit": "审计日志",
|
||||
"settings": "系统设置"
|
||||
"settings": "系统设置",
|
||||
"integration": "接入站点"
|
||||
},
|
||||
"permissionNames": {
|
||||
"prd.dashboard.view": "仪表盘·查看",
|
||||
"prd.agent.view": "代理管理·查看",
|
||||
"prd.agent.manage": "代理管理·可管理",
|
||||
"prd.agent.role.view": "代理角色·查看",
|
||||
"prd.agent.role.manage": "代理角色·可管理",
|
||||
"prd.agent.user.view": "代理账号·查看",
|
||||
"prd.agent.user.manage": "代理账号·可管理",
|
||||
"prd.admin_user.manage": "管理员列表·可管理",
|
||||
"prd.admin_role.manage": "角色管理·可管理",
|
||||
"prd.integration.view": "接入站点·查看",
|
||||
"prd.integration.manage": "接入站点·可管理",
|
||||
"prd.users.manage": "用户管理·可管理",
|
||||
"prd.currency.manage": "币种管理·可管理",
|
||||
"prd.users.view_finance": "用户管理·财务查看",
|
||||
@@ -163,6 +190,7 @@
|
||||
"prd.draw_reopen.manage": "开奖结果重开·可管理",
|
||||
"prd.play_switch.manage": "玩法开关·可管理",
|
||||
"prd.odds.manage": "赔率配置·可管理",
|
||||
"prd.odds.view": "赔率配置·查看",
|
||||
"prd.risk_cap.manage": "封顶配置·可管理",
|
||||
"prd.risk_cap.view": "封顶配置·查看",
|
||||
"prd.rebate.manage": "佣金/回水·可管理",
|
||||
@@ -173,7 +201,11 @@
|
||||
"prd.payout.manage": "派彩确认·可管理",
|
||||
"prd.payout.review": "派彩确认·可审核",
|
||||
"prd.payout.view": "派彩确认·查看",
|
||||
"prd.tickets.view": "玩家注单·查看",
|
||||
"prd.audit.view": "审计日志·查看",
|
||||
"prd.report.view": "报表中心·查看"
|
||||
"prd.report.view": "报表中心·查看",
|
||||
"prd.report.export": "报表中心·导出",
|
||||
"prd.risk.view": "风控中心·查看",
|
||||
"prd.risk.manage": "风控中心·可管理"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,12 @@
|
||||
"deleteSuccess": "已删除代理 {{name}}",
|
||||
"saveFailed": "保存失败",
|
||||
"codeRequired": "请填写编码与名称",
|
||||
"modelGuide": "代理层负责数据范围(Scope)与授权上限(Ceiling),账号权限请通过角色分配。",
|
||||
"tabs": {
|
||||
"overview": "概况",
|
||||
"roles": "角色",
|
||||
"users": "账号",
|
||||
"delegation": "下放权限"
|
||||
"delegation": "授权上限"
|
||||
},
|
||||
"delegation": {
|
||||
"title": "下放权限上限",
|
||||
@@ -47,7 +48,11 @@
|
||||
"deleteSuccess": "已删除角色 {{name}}",
|
||||
"permissionSaveSuccess": "权限已更新",
|
||||
"readOnlyTemplate": "只读模板",
|
||||
"permissionSubsetHint": "只能分配您当前拥有的权限"
|
||||
"permissionSubsetHint": "只能分配您当前拥有的权限",
|
||||
"selectedCount": "已选 {{selected}} / {{total}} 项",
|
||||
"groupSelectedCount": "已选 {{selected}} / {{total}}",
|
||||
"selectGroup": "本组全选",
|
||||
"noAssignablePermissions": "当前没有可分配权限"
|
||||
},
|
||||
"users": {
|
||||
"title": "代理账号",
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"action": "动作",
|
||||
"target": "目标",
|
||||
"time": "时间",
|
||||
"empty": "无数据"
|
||||
"empty": "无数据",
|
||||
"operatorTypes": {
|
||||
"admin": "管理员",
|
||||
"player": "玩家",
|
||||
"system": "系统"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
"deniedDescription": "当前账号没有访问此页面的权限。如需开通,请联系管理员在角色管理中分配相应功能权限。"
|
||||
},
|
||||
"table": {
|
||||
"id": "ID"
|
||||
"id": "ID",
|
||||
"actions": "操作"
|
||||
},
|
||||
"playerColumns": {
|
||||
"site": "主站",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"filter": "筛选",
|
||||
"drawNo": "期号",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
"apply": "应用",
|
||||
"batchList": "结算批次",
|
||||
"allStatuses": "不限",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"betAmount": "下注",
|
||||
"actualDeduct": "实扣",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
"failReason": "失败原因",
|
||||
"winAmount": "中奖",
|
||||
"placedAt": "下单时间",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"subnavLabel": "钱包子页",
|
||||
"subnavTransactions": "钱包流水",
|
||||
"subnavTransferOrders": "转账单",
|
||||
"subnavPlayerWallet": "玩家钱包",
|
||||
"noPermission": "当前账号无访问该页的权限",
|
||||
"copySuccess": "{{label}}已复制到剪贴板",
|
||||
"copyFailed": "复制失败,请检查浏览器权限或手动选择文本",
|
||||
@@ -11,7 +12,7 @@
|
||||
"statusFailed": "失败",
|
||||
"statusPendingReconcile": "待对账",
|
||||
"statusReversed": "已冲正",
|
||||
"statusManuallyProcessed": "已人工处理",
|
||||
"statusCaseClosed": "已结案",
|
||||
"statusPosted": "已记账",
|
||||
"filterAll": "不限",
|
||||
"transferIn": "主站转入",
|
||||
@@ -44,19 +45,19 @@
|
||||
"actionsMenuAriaLabel": "转账单操作菜单",
|
||||
"reverse": "冲正",
|
||||
"completeCredit": "补完成入账",
|
||||
"manualProcess": "人工处理",
|
||||
"markCaseClosed": "标记结案",
|
||||
"processing": "处理中…",
|
||||
"reverseSuccess": "冲正成功",
|
||||
"completeCreditSuccess": "补入账成功",
|
||||
"manualProcessSuccess": "人工处理成功",
|
||||
"markCaseClosedSuccess": "已标记结案",
|
||||
"actionFailed": "操作失败",
|
||||
"confirm": {
|
||||
"reverseTitle": "确认冲正转账单?",
|
||||
"reverseDescription": "将对单号 {{transferNo}} 执行冲正,可能影响玩家钱包余额。",
|
||||
"completeCreditTitle": "确认补完成转入入账?",
|
||||
"completeCreditDescription": "主站已扣款时,将为单号 {{transferNo}} 在彩票钱包补记转入并标记成功。",
|
||||
"manualProcessTitle": "确认人工处理?",
|
||||
"manualProcessDescription": "将标记单号 {{transferNo}} 为已人工处理,不会自动调整钱包。"
|
||||
"markCaseClosedTitle": "确认标记结案?",
|
||||
"markCaseClosedDescription": "仅将单号 {{transferNo}} 标为已结案,不会调整彩票或主站余额。请确认已在系统外处理完毕。"
|
||||
},
|
||||
"txnNo": "流水号",
|
||||
"bizType": "类型(业务)",
|
||||
|
||||
111
src/lib/admin-permission-packages.ts
Normal file
111
src/lib/admin-permission-packages.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type AdminPermissionPackage = {
|
||||
key: string;
|
||||
label: string;
|
||||
slugs: string[];
|
||||
};
|
||||
|
||||
export const ADMIN_PERMISSION_PACKAGES: Record<string, AdminPermissionPackage[]> = {
|
||||
dashboard: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.dashboard.view"] },
|
||||
],
|
||||
admin_users: [
|
||||
{ key: "manage", label: "管理", slugs: ["prd.admin_user.manage"] },
|
||||
],
|
||||
admin_roles: [
|
||||
{ key: "manage", label: "管理", slugs: ["prd.admin_role.manage"] },
|
||||
],
|
||||
agents: [
|
||||
{
|
||||
key: "view",
|
||||
label: "查看",
|
||||
slugs: ["prd.agent.view", "prd.agent.role.view", "prd.agent.user.view"],
|
||||
},
|
||||
{
|
||||
key: "manage",
|
||||
label: "管理",
|
||||
slugs: ["prd.agent.manage", "prd.agent.role.manage", "prd.agent.user.manage"],
|
||||
},
|
||||
],
|
||||
players: [
|
||||
{
|
||||
key: "view",
|
||||
label: "查看",
|
||||
slugs: ["prd.users.view_finance", "prd.users.view_cs"],
|
||||
},
|
||||
{ key: "manage", label: "管理", slugs: ["prd.users.manage"] },
|
||||
{ key: "control", label: "控制", slugs: ["prd.player_freeze.manage"] },
|
||||
],
|
||||
currencies: [
|
||||
{ key: "manage", label: "管理", slugs: ["prd.currency.manage"] },
|
||||
],
|
||||
wallet: [
|
||||
{
|
||||
key: "view",
|
||||
label: "查看",
|
||||
slugs: ["prd.wallet_reconcile.view", "prd.wallet_reconcile.view_cs", "prd.users.view_finance"],
|
||||
},
|
||||
{
|
||||
key: "manage",
|
||||
label: "管理",
|
||||
slugs: ["prd.wallet_reconcile.manage", "prd.wallet_adjust.manage"],
|
||||
},
|
||||
],
|
||||
draws: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.draw_result.view"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.draw_result.manage"] },
|
||||
{ key: "reopen", label: "重开", slugs: ["prd.draw_reopen.manage"] },
|
||||
],
|
||||
rules_plays: [
|
||||
{ key: "manage", label: "管理", slugs: ["prd.play_switch.manage"] },
|
||||
{ key: "view", label: "查看", slugs: ["prd.odds.view"] },
|
||||
{ key: "config", label: "配置", slugs: ["prd.odds.manage"] },
|
||||
],
|
||||
rules_odds: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.rebate.view"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.odds.manage", "prd.rebate.manage"] },
|
||||
],
|
||||
risk_cap: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.risk_cap.view"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.risk_cap.manage"] },
|
||||
],
|
||||
risk: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.risk.view"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.risk.manage"] },
|
||||
],
|
||||
settlement: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.payout.view"] },
|
||||
{ key: "review", label: "审核", slugs: ["prd.payout.review"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.payout.manage"] },
|
||||
],
|
||||
reconcile: [
|
||||
{
|
||||
key: "view",
|
||||
label: "查看",
|
||||
slugs: ["prd.wallet_reconcile.view", "prd.wallet_reconcile.view_cs"],
|
||||
},
|
||||
{ key: "manage", label: "管理", slugs: ["prd.wallet_reconcile.manage"] },
|
||||
],
|
||||
reports: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.report.view"] },
|
||||
{ key: "export", label: "导出", slugs: ["prd.report.export"] },
|
||||
],
|
||||
tickets: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.tickets.view"] },
|
||||
],
|
||||
audit: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.audit.view"] },
|
||||
],
|
||||
settings: [
|
||||
{ key: "manage", label: "管理", slugs: ["prd.wallet_reconcile.manage", "prd.currency.manage"] },
|
||||
],
|
||||
integration: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.integration.view"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.integration.manage"] },
|
||||
],
|
||||
jackpot: [
|
||||
{ key: "view", label: "查看", slugs: ["prd.jackpot.view"] },
|
||||
{ key: "manage", label: "管理", slugs: ["prd.jackpot.manage"] },
|
||||
{ key: "special", label: "特权", slugs: ["prd.jackpot.manual_burst"] },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 }[] = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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">
|
||||
|
||||
40
src/modules/reports/reports-subnav.tsx
Normal file
40
src/modules/reports/reports-subnav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
67
src/modules/update_sticky_actions.js
Normal file
67
src/modules/update_sticky_actions.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,7 +5,10 @@ export type AdminAuditLogRow = {
|
||||
module_code: string;
|
||||
action_code: string;
|
||||
target_type: string | null;
|
||||
target_id: number | null;
|
||||
target_id: string | null;
|
||||
module_label: string;
|
||||
action_label: string;
|
||||
target_label: string;
|
||||
before_json: Record<string, unknown> | null;
|
||||
after_json: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
|
||||
@@ -29,6 +29,8 @@ export type AdminProfile = {
|
||||
/** 代理账号绑定节点;超管为 null */
|
||||
agent?: AdminAgentContext | null;
|
||||
is_super_admin?: boolean;
|
||||
/** 与 permissions 同值,语义上强调“可操作权限” */
|
||||
operational_permissions?: string[];
|
||||
/** 当前代理可下放给下级的 prd.* 上限(未配置 grants 时与操作权限一致) */
|
||||
delegation_ceiling?: string[];
|
||||
};
|
||||
|
||||
@@ -63,4 +63,6 @@ export type AdminDashboardAnalyticsQuery = {
|
||||
date_to?: string;
|
||||
metric?: DashboardAnalyticsMetric;
|
||||
play_code?: string;
|
||||
site_code?: string;
|
||||
agent_node_id?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user