refactor: 合并多语言支持的显示名称字段,优化奖池手动爆发功能的返回数据结构,增强管理端权限控制
This commit is contained in:
44
src/components/admin/admin-page-card.tsx
Normal file
44
src/components/admin/admin-page-card.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AdminPageCardProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/** 页内锚点(如 #records) */
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** 与列表/运营台一致的 admin-list-card 外层,用于设置、奖池等多区块页。 */
|
||||
export function AdminPageCard({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
id,
|
||||
}: AdminPageCardProps) {
|
||||
return (
|
||||
<Card id={id} className={cn("admin-list-card scroll-mt-24", className)}>
|
||||
<CardHeader
|
||||
className={cn(
|
||||
"admin-list-header",
|
||||
actions != null && "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="admin-list-title">{title}</CardTitle>
|
||||
{description ? <CardDescription className="text-sm">{description}</CardDescription> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</CardHeader>
|
||||
<CardContent className={cn("admin-list-content", contentClassName)}>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
30
src/components/admin/admin-page-section.tsx
Normal file
30
src/components/admin/admin-page-section.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { AdminSectionHeader } from "@/components/admin/admin-section-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AdminPageSectionProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/** 无 Card 的页内分区,标题样式与 ConfigSection 相同。 */
|
||||
export function AdminPageSection({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
}: AdminPageSectionProps) {
|
||||
return (
|
||||
<section id={id} className={cn("scroll-mt-24 space-y-4", className)}>
|
||||
<AdminSectionHeader title={title} description={description} actions={actions} />
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
src/components/admin/admin-permission-gate.tsx
Normal file
40
src/components/admin/admin-permission-gate.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
type AdminPermissionGateProps = {
|
||||
requiredAny: readonly string[];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 深链进入无权限页面时展示拒绝说明,避免空白或反复 403。 */
|
||||
export function AdminPermissionGate({
|
||||
requiredAny,
|
||||
children,
|
||||
className,
|
||||
}: AdminPermissionGateProps): React.ReactElement {
|
||||
const { t } = useTranslation("common");
|
||||
const profile = useAdminProfile();
|
||||
const allowed = adminHasAnyPermission(profile?.permissions, [...requiredAny]);
|
||||
|
||||
if (allowed) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
33
src/components/admin/admin-section-header.tsx
Normal file
33
src/components/admin/admin-section-header.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AdminSectionHeaderProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 页内区块标题(与 ConfigSection 一致),用于 Card 内子分区或配置文档。 */
|
||||
export function AdminSectionHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: AdminSectionHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-start justify-between gap-3 border-b border-border/60 pb-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h3 className="text-base font-semibold text-foreground">{title}</h3>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { adminNavIconBySegment } from "@/modules/_config/admin-nav-icons";
|
||||
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
|
||||
import { ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
@@ -82,7 +82,7 @@ export function AdminAppSidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleNav.map((item) => {
|
||||
const Icon = adminNavIconBySegment[item.segment];
|
||||
const Icon = resolveAdminNavIcon(item.segment);
|
||||
return (
|
||||
<SidebarMenuItem key={item.segment}>
|
||||
<SidebarMenuButton
|
||||
|
||||
63
src/components/admin/confirm-action-dialog.tsx
Normal file
63
src/components/admin/confirm-action-dialog.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type ConfirmActionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
confirmVariant?: "default" | "destructive";
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function ConfirmActionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmVariant = "destructive",
|
||||
busy = false,
|
||||
onConfirm,
|
||||
}: ConfirmActionDialogProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirmVariant}
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{busy ? t("actions.submitting") : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
LogOutIcon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -67,11 +66,12 @@ export function ShellToolbar() {
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin/account" className="flex items-center gap-2 cursor-pointer">
|
||||
<UserRoundIcon className="size-4" />
|
||||
{t("toolbar.accountSettings")}
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => router.push("/admin/account")}
|
||||
>
|
||||
<UserRoundIcon className="size-4" />
|
||||
{t("toolbar.accountSettings")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
Reference in New Issue
Block a user