275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|