feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates
Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
@@ -43,8 +43,7 @@ const TOP_ROUTE_LABELS: Record<string, string> = {
|
||||
const AGENT_ROUTE_LABELS: Record<string, string> = {
|
||||
list: "agents.directoryTitle",
|
||||
provision: "agents.subnav.provision",
|
||||
sites: "agents.sitesTitle",
|
||||
"settlement-bills": "agents.subnav.settlementBills",
|
||||
"settlement-bills": "settlementCenter.title",
|
||||
};
|
||||
|
||||
const CONFIG_ROUTE_LABELS: Record<string, string> = {
|
||||
|
||||
83
src/components/admin/admin-no-resource-state.tsx
Normal file
83
src/components/admin/admin-no-resource-state.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NOTDATA_IMAGE = "/notdata.png";
|
||||
|
||||
export function AdminNoResourceState({
|
||||
className,
|
||||
message,
|
||||
compact = false,
|
||||
imageClassName,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
/** 默认「暂无资源」 */
|
||||
message?: string;
|
||||
compact?: boolean;
|
||||
imageClassName?: string;
|
||||
children?: ReactNode;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const label = message ?? t("states.noResource", { defaultValue: "暂无资源" });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center text-center",
|
||||
compact ? "gap-2 py-4" : "gap-3 py-8",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<Image
|
||||
src={NOTDATA_IMAGE}
|
||||
alt=""
|
||||
width={compact ? 120 : 160}
|
||||
height={compact ? 120 : 160}
|
||||
className={cn(
|
||||
"h-auto w-auto object-contain",
|
||||
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",
|
||||
imageClassName,
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
compact ? "text-[11px] leading-snug" : "text-sm",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格无数据行(图片 + 暂无资源,竖排居中) */
|
||||
export function AdminTableNoResourceRow({
|
||||
colSpan,
|
||||
className,
|
||||
cellClassName,
|
||||
message,
|
||||
compact,
|
||||
}: {
|
||||
colSpan: number;
|
||||
className?: string;
|
||||
cellClassName?: string;
|
||||
message?: string;
|
||||
compact?: boolean;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<TableRow className={className}>
|
||||
<TableCell colSpan={colSpan} className={cn("text-muted-foreground", cellClassName)}>
|
||||
<AdminNoResourceState message={message} compact={compact} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ type AdminPermissionGateProps = {
|
||||
requiredAny: readonly string[];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** 与 `isAgentLineSubnavTabVisible` 一致:绑定线路代理时额外放行 */
|
||||
allowWhenBoundLineAgent?: boolean;
|
||||
/** 绑定线路代理时一律拒绝(如开通线路) */
|
||||
denyWhenBoundLineAgent?: boolean;
|
||||
};
|
||||
|
||||
/** 深链进入无权限页面时展示拒绝说明,避免空白或反复 403。 */
|
||||
@@ -18,10 +22,29 @@ export function AdminPermissionGate({
|
||||
requiredAny,
|
||||
children,
|
||||
className,
|
||||
allowWhenBoundLineAgent = false,
|
||||
denyWhenBoundLineAgent = false,
|
||||
}: AdminPermissionGateProps): React.ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const profile = useAdminProfile();
|
||||
const allowed = adminHasAnyPermission(profile?.permissions, [...requiredAny]);
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
|
||||
if (denyWhenBoundLineAgent && boundAgent !== null) {
|
||||
return (
|
||||
<Card className={className ?? "admin-list-card"}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("permission.deniedTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("permission.deniedDescription")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const allowed =
|
||||
(allowWhenBoundLineAgent && boundAgent !== null) ||
|
||||
adminHasAnyPermission(profile?.permissions, [...requiredAny]);
|
||||
|
||||
if (allowed) {
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -119,8 +120,8 @@ export function AdminPermissionPackageSelector({
|
||||
|
||||
if (groups.length === 0 || bundleCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
<div className="rounded-xl border border-dashed p-4">
|
||||
<AdminNoResourceState compact className="py-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -128,26 +129,51 @@ export function AdminPermissionPackageSelector({
|
||||
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);
|
||||
if (checked) {
|
||||
const implied = group.packages
|
||||
.filter((item) => (PACKAGE_LEVEL_ORDER[item.key] ?? 10) <= currentLevel)
|
||||
.flatMap((item) => item.slugs);
|
||||
for (const slug of implied.length > 0 ? implied : slugs) {
|
||||
next.add(slug);
|
||||
}
|
||||
for (const item of group.packages) {
|
||||
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
|
||||
for (const slug of item.slugs) {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const slug of slugs) {
|
||||
next.delete(slug);
|
||||
}
|
||||
if (bundleKey === "manage" || bundleKey === "reopen") {
|
||||
for (const item of group.packages) {
|
||||
if ((PACKAGE_LEVEL_ORDER[item.key] ?? 10) > currentLevel) {
|
||||
for (const slug of item.slugs) {
|
||||
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);
|
||||
if (checked) {
|
||||
const viewBundle =
|
||||
group.packages.find((item) => item.key === "view") ?? group.packages[0];
|
||||
for (const slug of viewBundle?.slugs ?? []) {
|
||||
next.add(slug);
|
||||
}
|
||||
} else {
|
||||
for (const slug of group.packages.flatMap((item) => item.slugs)) {
|
||||
next.delete(slug);
|
||||
}
|
||||
}
|
||||
onChange(Array.from(next).sort());
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
||||
@@ -108,8 +109,8 @@ export function AdminPermissionSelector({
|
||||
|
||||
if (groups.length === 0 || totalCount === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
<div className="rounded-xl border border-dashed p-4">
|
||||
<AdminNoResourceState compact className="py-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/components/admin/player-funding-badges.tsx
Normal file
65
src/components/admin/player-funding-badges.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { playerFundingModeLabel } from "@/lib/admin-player-display";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
|
||||
type FundingRow = Pick<AdminPlayerRow, "funding_mode" | "uses_credit" | "auth_source">;
|
||||
|
||||
export function PlayerFundingModeBadge({
|
||||
row,
|
||||
className,
|
||||
}: {
|
||||
row: FundingRow;
|
||||
className?: string;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation("players");
|
||||
const isCredit = row.funding_mode === "credit" || row.uses_credit === true;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
isCredit
|
||||
? "border-violet-200 bg-violet-50 text-violet-900 dark:border-violet-800 dark:bg-violet-950/50 dark:text-violet-200"
|
||||
: "border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800 dark:bg-sky-950/50 dark:text-sky-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{playerFundingModeLabel(row, t)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayerLedgerSourceBadge({
|
||||
ledgerSource,
|
||||
className,
|
||||
}: {
|
||||
ledgerSource?: string | null;
|
||||
className?: string;
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation("wallet");
|
||||
if (ledgerSource !== "credit_ledger" && ledgerSource !== "wallet_txn") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCredit = ledgerSource === "credit_ledger";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
isCredit
|
||||
? "border-violet-200 bg-violet-50 text-violet-900"
|
||||
: "border-sky-200 bg-sky-50 text-sky-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isCredit
|
||||
? t("ledgerCredit", { defaultValue: "信用流水" })
|
||||
: t("ledgerWallet", { defaultValue: "钱包流水" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user