Files
Mutual_Fund/frontend/components/admin/shell.tsx
T
2026-09-14 19:00:48 +08:00

275 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import {
Bell,
ChevronDown,
Database,
FileText,
LayoutDashboard,
ListChecks,
LogOut,
PanelLeftClose,
PanelLeftOpen,
PhoneCall,
ShieldAlert,
Users,
X,
FileSignature,
} from "lucide-react";
import { useState, type ReactNode } from "react";
import { clearToken, getRoleLabel } from "@/lib/auth";
import { canUseSection, isAdmin, isStaff, type WorkspaceSection } from "@/lib/roles";
import type { UserPayload } from "@/types/api";
import { Badge, cn } from "@/components/admin/ui";
export interface ShellNotification {
id: string;
title: string;
body: string;
time: string;
tone: "alert" | "info" | "check";
section: WorkspaceSection;
}
const NAV_ITEMS: {
section: WorkspaceSection;
label: string;
icon: typeof Users;
requires: "staff" | "advisor" | "risk" | "admin";
}[] = [
{ section: "overview", label: "工作台概览", icon: LayoutDashboard, requires: "staff" },
{ section: "todos", label: "我的待办", icon: ListChecks, requires: "advisor" },
{ section: "customers", label: "客户管理", icon: Users, requires: "advisor" },
{ section: "drafts", label: "投顾草稿", icon: FileSignature, requires: "advisor" },
{ section: "visits", label: "客户回访", icon: PhoneCall, requires: "advisor" },
{ section: "risk", label: "风险预警", icon: ShieldAlert, requires: "risk" },
{ section: "workorders", label: "工单处理", icon: FileText, requires: "risk" },
{ section: "funds", label: "基金数据", icon: Database, requires: "staff" },
];
const REQUIREMENT_TEXT: Record<string, string> = {
staff: "仅员工账号可用",
advisor: "需要投顾权限",
risk: "需要风控权限",
admin: "需要管理员权限",
};
export function AdminShell({
user,
section,
onSectionChange,
title,
subtitle,
notifications = [],
children,
}: {
user: UserPayload;
section: WorkspaceSection;
onSectionChange: (section: WorkspaceSection) => void;
title: string;
subtitle: string;
notifications?: ShellNotification[];
children: ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const roleLabel = isAdmin(user) ? "管理员" : getRoleLabel(user.employee_role);
const accessible = isStaff(user);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex flex-col bg-slate-950 text-white transition-all duration-200",
collapsed ? "w-16" : "w-56"
)}
>
<div className={cn("flex items-center gap-3 px-5 py-6", collapsed && "justify-center px-3")}>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500">
<LayoutDashboard className="h-5 w-5" />
</div>
{!collapsed && (
<div className="min-w-0">
<p className="truncate text-sm font-semibold">华夏基金</p>
<p className="truncate text-xs text-slate-400">统一员工工作台</p>
</div>
)}
</div>
<nav className="flex-1 space-y-1 px-3">
{NAV_ITEMS.map((item) => {
const allowed = canUseSection(user, item.section) && accessible;
const active = section === item.section;
return (
<button
key={item.section}
onClick={() => onSectionChange(item.section)}
title={allowed ? item.label : `${item.label}(${REQUIREMENT_TEXT[item.requires]})`}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors",
collapsed && "justify-center px-0",
active
? "bg-white/10 text-white"
: allowed
? "text-slate-400 hover:bg-white/5 hover:text-white"
: "text-slate-600 hover:bg-white/5"
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
{!collapsed && !allowed && (
<span className="ml-auto rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
无权限
</span>
)}
</button>
);
})}
</nav>
<div className={cn("border-t border-white/10 p-4", collapsed && "px-2")}>
<Link
href="/"
className={cn(
"mb-3 block text-xs text-slate-400 hover:text-white",
collapsed && "text-center"
)}
>
返回官网
</Link>
<button
onClick={() => {
clearToken();
window.location.href = "/login";
}}
className={cn(
"flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white",
collapsed && "justify-center w-full"
)}
>
<LogOut className="h-4 w-4 shrink-0" />
{!collapsed && "退出登录"}
</button>
</div>
</aside>
<div
className={cn(
"min-h-screen transition-all duration-200",
collapsed ? "ml-16" : "ml-56"
)}
>
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6">
<div className="flex items-center gap-3">
<button
onClick={() => setCollapsed(!collapsed)}
aria-label={collapsed ? "展开侧边栏" : "收起侧边栏"}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100"
>
{collapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</button>
<div>
<p className="text-sm font-semibold text-slate-800">{title}</p>
<p className="text-xs text-slate-500">{subtitle}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
aria-label="通知与提醒"
className="relative rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100"
>
<Bell className="h-5 w-5" />
{notifications.length > 0 && (
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
{notifications.length > 99 ? "99+" : notifications.length}
</span>
)}
</button>
{showNotifications && (
<div className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-slate-200 bg-white shadow-lg z-50">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<span className="text-sm font-semibold text-slate-900">通知与提醒</span>
<button
onClick={() => setShowNotifications(false)}
aria-label="关闭通知"
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{notifications.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-slate-400">暂无待处理提醒</p>
) : (
<>
<ul className="max-h-80 divide-y divide-slate-50 overflow-y-auto">
{notifications.map((item) => (
<li key={item.id}>
<button
onClick={() => {
onSectionChange(item.section);
setShowNotifications(false);
}}
className="w-full px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<div className="flex items-start gap-2">
<span
className={cn(
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
item.tone === "alert" && "bg-amber-500",
item.tone === "info" && "bg-indigo-500",
item.tone === "check" && "bg-emerald-500"
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900">{item.title}</p>
<p className="mt-0.5 text-xs leading-snug text-slate-500">{item.body}</p>
<p className="mt-1 text-xs text-slate-400">{item.time}</p>
</div>
</div>
</button>
</li>
))}
</ul>
<div className="border-t border-slate-100 px-4 py-2.5">
<span className="text-xs text-slate-400">点击提醒可跳转到对应功能区</span>
</div>
</>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 border-l border-slate-200 pl-4">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-50 text-sm font-medium text-indigo-600">
{(user.real_name ?? user.username ?? "员").slice(0, 1)}
</div>
<div className="hidden sm:block">
<p className="text-sm font-medium leading-tight text-slate-700">
{user.real_name ?? user.username}
</p>
<p className="text-xs leading-tight text-slate-400">{roleLabel}</p>
</div>
<ChevronDown className="h-4 w-4 text-slate-400" />
</div>
</div>
</header>
<main className="mx-auto max-w-[1400px] p-6">
{!accessible && (
<div className="mb-6">
<Badge tone="danger">当前账号不是员工账号,部分功能不可用</Badge>
</div>
)}
{children}
</main>
</div>
</div>
);
}