Files
lotteryAdmin/src/modules/admin-roles/admin-roles-console.tsx

552 lines
22 KiB
TypeScript

"use client";
import { useCallback, useMemo, useState } from "react";
import Link from "next/link";
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";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner";
import {
deleteAdminRole,
getAdminRoles,
getAdminUserPermissionCatalog,
postAdminRole,
putAdminRole,
putAdminRolePermissions,
} from "@/api/admin-users";
import { isPlatformFixedRole, isPlatformSuperAdminRole } from "@/lib/platform-system-roles";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminPermissionPackageSelector } from "@/components/admin/admin-permission-package-selector";
import { permissionProfileForGroup, resolveProfileLevel } from "@/lib/admin-permission-profiles";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_ADMIN_ROLE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
import { LotteryApiBizError } from "@/types/api/errors";
function permissionGroupLabel(key: string, fallback: string, t: (key: string) => string): string {
const translated = t(`permissionGroups.${key}`);
return translated === `permissionGroups.${key}` ? fallback : translated;
}
function countEnabledAreas(
permissionSlugs: string[],
catalog: AdminPermissionCatalogData | null,
isSuperAdmin: boolean,
): number {
if (!catalog) {
return permissionSlugs.length;
}
let count = 0;
for (const group of catalog.permission_menu_groups ?? []) {
const profile = permissionProfileForGroup(group.key);
if (profile === undefined || (profile.platformOnly && !isSuperAdmin)) {
continue;
}
if (resolveProfileLevel(profile, permissionSlugs) !== "none") {
count += 1;
}
}
return count;
}
export function AdminRolesConsole(): React.ReactElement {
const { t } = useTranslation(["adminUsers", "common"]);
const tRef = useTranslationRef(["adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
const canManageRoles = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_ROLE_MANAGE]);
const isSuperAdmin = profile?.is_super_admin === true;
const exportLabels = useExportLabels("adminRoles");
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [rolePermissionOpen, setRolePermissionOpen] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
const [draftRolePermissions, setDraftRolePermissions] = useState<string[]>([]);
const [roleSaving, setRoleSaving] = useState(false);
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [roleSlug, setRoleSlug] = useState("");
const [roleName, setRoleName] = useState("");
const [roleDescription, setRoleDescription] = useState("");
const [roleStatus, setRoleStatus] = useState(1);
const [roleFormSaving, setRoleFormSaving] = useState(false);
const [roleDeleteTarget, setRoleDeleteTarget] = useState<AdminRoleRow | null>(null);
const [roleDeleteBusy, setRoleDeleteBusy] = useState(false);
const selectedRole = useMemo(
() => roles.find((role) => role.id === selectedRoleId) ?? null,
[roles, selectedRoleId],
);
const load = useCallback(async () => {
setLoading(true);
setErr(null);
try {
const [catalogData, roleData] = await Promise.all([
getAdminUserPermissionCatalog(),
getAdminRoles(),
]);
setCatalog(catalogData);
setRoles(roleData.items);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : tRef.current("roleLoadFailed");
setErr(msg);
setRoles([]);
} finally {
setLoading(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load();
}, []);
function openCreateRole(): void {
setEditingRoleId(null);
setRoleSlug("");
setRoleName("");
setRoleDescription("");
setRoleStatus(1);
setRoleDialogOpen(true);
}
function openEditRole(role: AdminRoleRow): void {
if (isPlatformSuperAdminRole(role)) {
return;
}
setEditingRoleId(role.id);
setRoleSlug(role.slug);
setRoleName(role.name);
setRoleDescription(role.description ?? "");
setRoleStatus(role.status);
setRoleDialogOpen(true);
}
function openRolePermissionEditor(role: AdminRoleRow): void {
setSelectedRoleId(role.id);
setDraftRolePermissions([...role.permission_slugs].sort());
setRolePermissionOpen(true);
}
function handleRoleDialogOpenChange(open: boolean): void {
setRoleDialogOpen(open);
if (!open) {
setEditingRoleId(null);
}
}
function handleRolePermissionDialogOpenChange(open: boolean): void {
setRolePermissionOpen(open);
if (!open) {
setSelectedRoleId(null);
}
}
async function saveRolePermissions(): Promise<void> {
if (!selectedRole) {
return;
}
setRoleSaving(true);
try {
const result = await putAdminRolePermissions(selectedRole.id, draftRolePermissions);
setDraftRolePermissions([...result.permission_slugs].sort());
setRoles((prev) => prev.map((role) => (role.id === result.id ? result : role)));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === result.id ? result : role)) } : prev,
);
toast.success(t("rolePermissionSaveSuccess"));
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("rolePermissionSaveFailed");
toast.error(msg);
} finally {
setRoleSaving(false);
}
}
async function submitRole(): Promise<void> {
const name = roleName.trim();
const slug = roleSlug.trim().toLowerCase();
if (name === "" || slug === "") {
toast.error(t("roleFormRequired"));
return;
}
setRoleFormSaving(true);
try {
if (editingRoleId === null) {
const created = await postAdminRole({
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order || a.id - b.id));
setCatalog((prev) =>
prev ? { ...prev, roles: [...prev.roles, created].sort((a, b) => a.slug.localeCompare(b.slug)) } : prev,
);
toast.success(t("roleCreateSuccess", { name: created.name }));
handleRoleDialogOpenChange(false);
return;
}
const updated = await putAdminRole(editingRoleId, {
slug,
name,
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
status: roleStatus,
});
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
);
toast.success(t("roleUpdateSuccess", { name: updated.name }));
handleRoleDialogOpenChange(false);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleSaveFailed");
toast.error(msg);
} finally {
setRoleFormSaving(false);
}
}
async function confirmRoleDelete(): Promise<void> {
if (!roleDeleteTarget) {
return;
}
setRoleDeleteBusy(true);
try {
await deleteAdminRole(roleDeleteTarget.id);
setRoles((prev) => prev.filter((role) => role.id !== roleDeleteTarget.id));
setCatalog((prev) =>
prev ? { ...prev, roles: prev.roles.filter((role) => role.id !== roleDeleteTarget.id) } : prev,
);
toast.success(t("roleDeleteSuccess", { name: roleDeleteTarget.name }));
setRoleDeleteTarget(null);
} catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("roleDeleteFailed");
toast.error(msg);
} finally {
setRoleDeleteBusy(false);
}
}
return (
<div className="flex w-full max-w-none flex-col gap-6">
<Card>
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<CardTitle>{t("roleListTitle", { defaultValue: "平台角色管理" })}</CardTitle>
</div>
<div className="admin-list-actions">
{canManageRoles ? (
<Button type="button" size="sm" onClick={() => openCreateRole()}>
{t("createRole")}
</Button>
) : null}
<AdminTableExportButton
tableId="admin-roles-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" variant="secondary" onClick={() => void load()}>
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{err ? <p className="text-sm text-destructive">{err}</p> : null}
<div className="rounded-md border">
<Table id="admin-roles-table">
<TableHeader>
<TableRow>
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("roleTable.name")}</TableHead>
{isSuperAdmin ? <TableHead>{t("roleTable.slug")}</TableHead> : null}
<TableHead>{t("roleTable.type")}</TableHead>
<TableHead>{t("roleTable.status")}</TableHead>
<TableHead>{t("roleTable.users")}</TableHead>
<TableHead>{t("roleTable.enabledAreas")}</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("roleTable.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && roles.length === 0 ? (
<AdminTableLoadingRow colSpan={isSuperAdmin ? 8 : 7} />
) : roles.length === 0 ? (
<AdminTableNoResourceRow colSpan={isSuperAdmin ? 8 : 7} className="text-muted-foreground" />
) : (
roles.map((role) => {
const fixedRole = isPlatformFixedRole(role);
const superAdminRole = isPlatformSuperAdminRole(role);
return (
<TableRow key={role.id}>
<TableCell>{role.id}</TableCell>
<TableCell>
<span className="font-medium">{role.name}</span>
</TableCell>
{isSuperAdmin ? <TableCell className="font-mono text-xs text-muted-foreground">{role.slug}</TableCell> : null}
<TableCell>
{role.is_system ? (
<Badge variant="secondary">{t("roleType.system")}</Badge>
) : (
<Badge variant="outline">{t("roleType.custom")}</Badge>
)}
</TableCell>
<TableCell>
<AdminStatusBadge status={role.status} tone={resolveRoleStatusTone(role.status)}>
{role.status === 1 ? t("status.enabled") : t("status.disabled")}
</AdminStatusBadge>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 text-xs tabular-nums">
{role.platform_user_count > 0 ? (
<Link
className="text-primary underline-offset-4 hover:underline"
href={`/admin/admin-users?role_slug=${encodeURIComponent(role.slug)}`}
>
{t("roleTable.platformUsers", { count: role.platform_user_count })}
</Link>
) : (
<span className="text-muted-foreground">
{t("roleTable.platformUsers", { count: 0 })}
</span>
)}
{role.agent_user_count > 0 ? (
<Link
className="text-primary underline-offset-4 hover:underline"
href="/admin/agents"
>
{t("roleTable.agentUsers", { count: role.agent_user_count })}
</Link>
) : (
<span className="text-muted-foreground">
{t("roleTable.agentUsers", { count: 0 })}
</span>
)}
</div>
</TableCell>
<TableCell className="tabular-nums">
{countEnabledAreas(role.permission_slugs, catalog, isSuperAdmin)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{canManageRoles ? (
<AdminRowActionsMenu
actions={[
{
key: "permissions",
label: t("roleActions.permissions"),
icon: KeyRound,
disabled: superAdminRole,
onClick: () => openRolePermissionEditor(role),
},
{
key: "edit",
label: t("actions.edit"),
icon: Pencil,
disabled: superAdminRole,
onClick: () => openEditRole(role),
},
{
key: "delete",
label: t("actions.delete"),
icon: Trash2,
destructive: true,
disabled: fixedRole || role.user_count > 0,
onClick: () => setRoleDeleteTarget(role),
},
]}
/>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Dialog open={rolePermissionOpen} onOpenChange={handleRolePermissionDialogOpenChange}>
<DialogContent
showCloseButton
className="flex h-[min(84vh,760px)] !max-w-[min(720px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden rounded-2xl border bg-background p-0 shadow-2xl"
>
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
{selectedRole
? t("rolePermissionDialog.titleWithName", { name: selectedRole.name })
: t("rolePermissionDialog.title")}
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
<AdminPermissionPackageSelector
catalog={catalog}
selectedSlugs={draftRolePermissions}
onChange={setDraftRolePermissions}
resolveGroupLabel={(key, fallback) => permissionGroupLabel(key, fallback, t)}
isSuperAdmin={isSuperAdmin}
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)}>
{t("actions.cancel")}
</Button>
<Button
type="button"
disabled={!selectedRole || roleSaving}
onClick={() =>
selectedRole &&
requestConfirm({
title: t("confirmSaveRolePermissionsTitle"),
description: t("confirmSaveRolePermissionsDescription", { name: selectedRole.name }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => saveRolePermissions(),
})
}
>
{roleSaving ? t("saving") : t("actions.save")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
<DialogContent showCloseButton className="max-w-lg gap-4">
<DialogHeader>
<DialogTitle>
{editingRoleId === null ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.slug")}</div>
<Input
value={roleSlug}
placeholder={t("roleDialog.slugPlaceholder")}
onChange={(e) => setRoleSlug(e.target.value)}
disabled={editingRoleId !== null}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.name")}</div>
<Input
value={roleName}
placeholder={t("roleDialog.namePlaceholder")}
onChange={(e) => setRoleName(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<div className="text-sm font-medium leading-none">{t("roleDialog.descriptionLabel")}</div>
<Input
value={roleDescription}
placeholder={t("roleDialog.descriptionPlaceholder")}
onChange={(e) => setRoleDescription(e.target.value)}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
<div className="space-y-1">
<p className="text-sm font-medium">{t("roleDialog.status")}</p>
<p className="text-xs text-muted-foreground">
{roleStatus === 1 ? t("status.enabled") : t("status.disabled")}
</p>
</div>
<Switch
checked={roleStatus === 1}
disabled={roleFormSaving}
aria-label={t("roleDialog.status")}
onCheckedChange={(checked) => setRoleStatus(checked ? 1 : 0)}
/>
</div>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
{t("actions.cancel")}
</Button>
<Button
type="button"
disabled={roleFormSaving}
onClick={() =>
requestConfirm({
title: t("confirmSaveRoleTitle"),
description:
editingRoleId === null
? t("confirmSaveRoleCreateDescription", { name: roleName || "—" })
: t("confirmSaveRoleEditDescription", { name: roleName || "—" }),
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
onConfirm: () => submitRole(),
})
}
>
{roleFormSaving ? t("saving") : t("actions.save")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={roleDeleteTarget !== null} onOpenChange={(open) => !open && setRoleDeleteTarget(null)}>
<DialogContent showCloseButton className="max-w-md gap-4">
<DialogHeader>
<DialogTitle>{t("roleDelete.confirmTitle")}</DialogTitle>
<DialogDescription>
{roleDeleteTarget ? t("roleDelete.confirmDescription", { name: roleDeleteTarget.name }) : null}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="outline" disabled={roleDeleteBusy} onClick={() => setRoleDeleteTarget(null)}>
{t("actions.cancel")}
</Button>
<Button type="button" variant="destructive" disabled={roleDeleteBusy} onClick={() => void confirmRoleDelete()}>
{roleDeleteBusy ? t("deleting") : t("actions.delete")}
</Button>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog />
</div>
);
}