mirror of
https://github.com/zuiidea/antd-admin
synced 2026-10-30 05:13:43 +00:00
feat: HTTP层增强(token刷新/重试) + ErrorBoundary全局异常 + 通知/日志/个人中心/管理员/菜单页面 + ExportButton/FileUpload通用组件
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Drawer, Empty, Flex, Input, Tag, Typography, theme } from "antd";
|
||||
import { Bot, Send, Sparkles } from "lucide-react";
|
||||
import { useAIStore } from "@/stores/ai";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ChatRole = "user" | "assistant";
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function buildAssistantReply(prompt: string): string {
|
||||
const lower = prompt.toLowerCase();
|
||||
if (lower.includes("menu")) {
|
||||
return "You can edit menu names, icons, visibility and permissions in Menus page, then save to apply globally.";
|
||||
}
|
||||
if (lower.includes("permission") || lower.includes("auth")) {
|
||||
return "Try checking user permissions first, then route mapping in app menu rules, and finally API response payload.";
|
||||
}
|
||||
if (lower.includes("dashboard")) {
|
||||
return "Dashboard now supports timeline blocks. You can add monthly milestones or deployment events to make it useful.";
|
||||
}
|
||||
return "I can help with admin, users, menus, and dashboard changes. Tell me what you want to update.";
|
||||
}
|
||||
|
||||
export function GlobalAIChat() {
|
||||
const { token } = theme.useToken();
|
||||
const enabled = useAIStore((s) => s.enabled);
|
||||
const model = useAIStore((s) => s.model);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const send = () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
const userMessage: ChatMessage = {
|
||||
id: `u-${Date.now()}`,
|
||||
role: "user",
|
||||
content: text,
|
||||
};
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `a-${Date.now()}`,
|
||||
role: "assistant",
|
||||
content: buildAssistantReply(text),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMessage, assistantMessage]);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open ? (
|
||||
<Button
|
||||
type="primary"
|
||||
shape="round"
|
||||
size="large"
|
||||
icon={<Sparkles size={token.fontSize} />}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: token.marginLG,
|
||||
bottom: token.marginLG,
|
||||
zIndex: 1001,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
}}
|
||||
>
|
||||
Ask AI
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Drawer
|
||||
open={open}
|
||||
title={
|
||||
<Flex align="center" gap={token.marginXS}>
|
||||
<Bot size={token.fontSizeLG} />
|
||||
<span>Global AI Assistant</span>
|
||||
</Flex>
|
||||
}
|
||||
size="large"
|
||||
onClose={() => setOpen(false)}
|
||||
destroyOnClose={false}
|
||||
>
|
||||
<Flex vertical gap={token.margin} style={{ height: "100%" }}>
|
||||
<Tag color="blue" variant="filled" style={{ width: "fit-content" }}>
|
||||
Context: current admin workspace
|
||||
</Tag>
|
||||
<Tag color="purple" variant="filled" style={{ width: "fit-content" }}>
|
||||
Model: {model}
|
||||
</Tag>
|
||||
|
||||
<Flex
|
||||
vertical
|
||||
gap={token.marginSM}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadius,
|
||||
padding: token.paddingSM,
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="Start a conversation about users, admins, menus, or dashboard."
|
||||
/>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<Flex
|
||||
key={msg.id}
|
||||
justify={msg.role === "user" ? "flex-end" : "flex-start"}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "85%",
|
||||
padding: `${token.paddingXS}px ${token.paddingSM}px`,
|
||||
borderRadius: token.borderRadius,
|
||||
background:
|
||||
msg.role === "user" ? token.colorPrimaryBgHover : token.colorFillQuaternary,
|
||||
}}
|
||||
>
|
||||
<Text>{msg.content}</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
))
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<Input.TextArea
|
||||
value={input}
|
||||
rows={3}
|
||||
placeholder="Ask anything about this admin app..."
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="primary" icon={<Send size={token.fontSize} />} onClick={send}>
|
||||
Send
|
||||
</Button>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Button, Result, Typography, theme, Flex, Collapse } from "antd";
|
||||
import { reportError, ErrorSeverity } from "@/utils/errorReporter";
|
||||
|
||||
export interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
const componentStack = errorInfo.componentStack ?? undefined;
|
||||
reportError(error, ErrorSeverity.ERROR, { componentStack });
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleReset);
|
||||
}
|
||||
return (
|
||||
<DefaultErrorFallback
|
||||
error={this.state.error}
|
||||
onReset={this.handleReset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultErrorFallback({
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
error: Error;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
style={{
|
||||
minHeight: "60vh",
|
||||
padding: token.paddingLG,
|
||||
}}
|
||||
>
|
||||
<Result
|
||||
status="error"
|
||||
title="Something went wrong"
|
||||
subTitle={
|
||||
<Flex vertical gap={token.marginSM} style={{ maxWidth: 560 }}>
|
||||
<Typography.Text type="secondary">{error.message}</Typography.Text>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "stack",
|
||||
label: "Error details",
|
||||
children: (
|
||||
<Typography.Paragraph
|
||||
code
|
||||
style={{
|
||||
fontSize: token.fontSizeSM,
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{error.stack ?? error.message}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
extra={[
|
||||
<Button type="primary" key="retry" onClick={onReset}>
|
||||
Try again
|
||||
</Button>,
|
||||
<Button
|
||||
key="reload"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
Reload page
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Button, App, Tooltip } from "antd";
|
||||
import { Download } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export interface ExportButtonProps<TData extends Record<string, unknown>> {
|
||||
/** Data to export */
|
||||
data: TData[];
|
||||
/** Column definitions: header label and data key or custom accessor */
|
||||
columns: {
|
||||
title: string;
|
||||
dataIndex?: keyof TData & string;
|
||||
render?: (record: TData) => string;
|
||||
}[];
|
||||
/** Filename without extension */
|
||||
filename?: string;
|
||||
/** Button text */
|
||||
children?: React.ReactNode;
|
||||
/** Disable button */
|
||||
disabled?: boolean;
|
||||
/** Click handler before export (e.g. confirm) */
|
||||
onBeforeExport?: () => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable CSV export button.
|
||||
* Works with any tabular data + column definitions.
|
||||
*/
|
||||
export function ExportButton<TData extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
filename = "export",
|
||||
children = "Export CSV",
|
||||
disabled,
|
||||
onBeforeExport,
|
||||
}: ExportButtonProps<TData>) {
|
||||
const { message } = App.useApp();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (onBeforeExport) {
|
||||
const ok = await onBeforeExport();
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setExporting(true);
|
||||
|
||||
try {
|
||||
// Build CSV content
|
||||
const header = columns.map((c) => `"${c.title}"`).join(",");
|
||||
|
||||
const rows = data.map((record) =>
|
||||
columns
|
||||
.map((c) => {
|
||||
let value: unknown;
|
||||
if (c.render) {
|
||||
value = c.render(record);
|
||||
} else if (c.dataIndex) {
|
||||
value = record[c.dataIndex];
|
||||
} else {
|
||||
value = "";
|
||||
}
|
||||
// Escape quotes and wrap
|
||||
const str = String(value ?? "");
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
})
|
||||
.join(","),
|
||||
);
|
||||
|
||||
const csv = [header, ...rows].join("\n");
|
||||
|
||||
// Add BOM for Excel UTF-8 compatibility
|
||||
const bom = "\uFEFF";
|
||||
const blob = new Blob([bom + csv], { type: "text/csv;charset=utf-8;" });
|
||||
|
||||
// Trigger download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${filename}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
message.success(`Exported ${data.length} rows`);
|
||||
} catch {
|
||||
message.error("Export failed");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [data, columns, filename, onBeforeExport, message]);
|
||||
|
||||
return (
|
||||
<Tooltip title={`Export ${data.length} rows as CSV`}>
|
||||
<Button
|
||||
icon={<Download size={14} />}
|
||||
onClick={handleExport}
|
||||
loading={exporting}
|
||||
disabled={disabled || !data.length}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Upload, App, theme } from "antd";
|
||||
import type { UploadProps, UploadFile } from "antd/es/upload/interface";
|
||||
import { Upload as UploadIcon, Inbox } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface FileUploadProps {
|
||||
/** Upload endpoint URL */
|
||||
action?: string;
|
||||
/** Accepted MIME types, e.g. "image/*,.pdf" */
|
||||
accept?: string;
|
||||
/** Maximum file size in bytes */
|
||||
maxSize?: number;
|
||||
/** Allow multiple files */
|
||||
multiple?: boolean;
|
||||
/** Max file count */
|
||||
maxCount?: number;
|
||||
/** Custom on-success callback */
|
||||
onSuccess?: (file: UploadFile, response: unknown) => void;
|
||||
/** Custom on-error callback */
|
||||
onError?: (file: UploadFile, error: unknown) => void;
|
||||
/** Button text */
|
||||
children?: React.ReactNode;
|
||||
/** Custom request override (e.g. MSW) */
|
||||
customRequest?: UploadProps["customRequest"];
|
||||
/** Control visibility of file list */
|
||||
showUploadList?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-and-drop file upload component built on Ant Design Upload.
|
||||
* Supports size/type validation and visual feedback.
|
||||
*/
|
||||
export function FileUpload({
|
||||
action = "/api/files/upload",
|
||||
accept,
|
||||
maxSize = 10 * 1024 * 1024, // 10 MB
|
||||
multiple = false,
|
||||
maxCount = 5,
|
||||
onSuccess,
|
||||
onError,
|
||||
customRequest,
|
||||
children,
|
||||
showUploadList = true,
|
||||
}: FileUploadProps) {
|
||||
const { message } = App.useApp();
|
||||
const { token } = theme.useToken();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const beforeUpload = (file: File): boolean | Promise<File> => {
|
||||
const allowed = accept?.split(",").map((s) => s.trim());
|
||||
if (allowed?.length && !allowed.some((a) => matchMime(file, a))) {
|
||||
message.error(`File type not supported: ${file.type || file.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
|
||||
message.error(`File exceeds ${maxMB} MB limit: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleChange: UploadProps["onChange"] = (info) => {
|
||||
const { file } = info;
|
||||
|
||||
if (file.status === "uploading") {
|
||||
setUploading(true);
|
||||
}
|
||||
|
||||
if (file.status === "done") {
|
||||
setUploading(false);
|
||||
onSuccess?.(file, file.response);
|
||||
}
|
||||
|
||||
if (file.status === "error") {
|
||||
setUploading(false);
|
||||
onError?.(file, file.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Upload.Dragger
|
||||
name="file"
|
||||
action={action}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
maxCount={maxCount}
|
||||
beforeUpload={beforeUpload}
|
||||
onChange={handleChange}
|
||||
customRequest={customRequest}
|
||||
showUploadList={showUploadList}
|
||||
style={{ padding: token.padding }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon" style={{ marginBottom: token.marginSM }}>
|
||||
{uploading ? (
|
||||
<UploadIcon size={32} style={{ color: token.colorPrimary, opacity: 0.7 }} />
|
||||
) : (
|
||||
<Inbox size={32} style={{ color: token.colorTextQuaternary }} />
|
||||
)}
|
||||
</p>
|
||||
<p className="ant-upload-text" style={{ fontWeight: 500 }}>
|
||||
{children || "Click or drag file to this area to upload"}
|
||||
</p>
|
||||
<p className="ant-upload-hint" style={{ color: token.colorTextSecondary }}>
|
||||
{accept
|
||||
? `Supported: ${accept}. Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`
|
||||
: `Max ${(maxSize / (1024 * 1024)).toFixed(1)} MB per file`}
|
||||
{multiple ? `. Up to ${maxCount} files` : ""}
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
);
|
||||
}
|
||||
|
||||
function matchMime(file: File, pattern: string): boolean {
|
||||
if (!pattern) return true;
|
||||
// Simple glob: "image/*"
|
||||
if (pattern.endsWith("/*")) {
|
||||
const base = pattern.slice(0, -2);
|
||||
return file.type.startsWith(base + "/");
|
||||
}
|
||||
// Extension match: ".pdf"
|
||||
if (pattern.startsWith(".")) {
|
||||
return file.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
}
|
||||
// Exact MIME
|
||||
return file.type === pattern;
|
||||
}
|
||||
@@ -1,142 +1,317 @@
|
||||
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
|
||||
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
|
||||
/**
|
||||
* Header — breadcrumb + global actions (notification bell, fullscreen, theme).
|
||||
*
|
||||
* ── Architecture ──
|
||||
* Left: mobile sidebar toggle (lg-) + breadcrumb
|
||||
* Right: NotificationBell (popover) → Fullscreen → Theme toggle
|
||||
*
|
||||
* ── Data flow ──
|
||||
* breadcrumb ← authStore.menus (flat-tree match) + current route path
|
||||
* notification badge ← useQuery poll (30s interval)
|
||||
*/
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Flex,
|
||||
Grid,
|
||||
Layout,
|
||||
Popover,
|
||||
Space,
|
||||
Typography,
|
||||
theme,
|
||||
} from "antd";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Link, useLocation, useMatches } from "@tanstack/react-router";
|
||||
import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react";
|
||||
import {
|
||||
Bell,
|
||||
Book,
|
||||
BrainCircuit,
|
||||
Briefcase,
|
||||
CheckCheck,
|
||||
CircleDashed,
|
||||
Fullscreen,
|
||||
History,
|
||||
Home,
|
||||
Minimize2,
|
||||
PanelLeft,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
User,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Theme } from "@/components/Icon";
|
||||
import type { MenuItem } from "@/api/schemas";
|
||||
import type { Notification } from "@/api/schemas-ext";
|
||||
import { httpClient } from "@/utils/http";
|
||||
import { NOTIFICATION_ENDPOINTS } from "@/api/notification";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
|
||||
const { Header: AntHeader } = Layout;
|
||||
|
||||
// ── Route → label mapping (fallback when not found in menu tree) ──
|
||||
const PATH_LABEL: Record<string, string> = {
|
||||
"/dashboard": "Dashboard",
|
||||
"/users": "Users",
|
||||
"/notifications": "Notifications",
|
||||
"/logs": "Audit Logs",
|
||||
"/profile": "Profile",
|
||||
"/403": "403",
|
||||
};
|
||||
|
||||
// ── Menu icon string → Lucide component ──
|
||||
const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
||||
IconLucideHistory: History,
|
||||
IconLucideLayoutDashboard: Home,
|
||||
IconLucideUsers: User,
|
||||
IconLucideUserList: Users,
|
||||
IconLucideStar: Star,
|
||||
IconLucideSettings: SlidersHorizontal,
|
||||
IconLucideBriefcase: Briefcase,
|
||||
IconLucideBookOpen: Book,
|
||||
IconLucideSparkles: Zap,
|
||||
IconLucideShieldCheck: ShieldCheck,
|
||||
IconLucideBrainCircuit: BrainCircuit,
|
||||
};
|
||||
|
||||
// ── Utilities ──
|
||||
|
||||
/** Normalize pathname: strip trailing slash */
|
||||
function normalizePath(pathname: string): string {
|
||||
if (pathname === "/") return pathname;
|
||||
return pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
/** Flatten a recursive menu tree into a flat list */
|
||||
function flattenMenuItems(nodes: MenuItem[]): MenuItem[] {
|
||||
const result: MenuItem[] = [];
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.kind === "group") { result.push(...flattenMenuItems(node.children)); continue; }
|
||||
if (node.children?.length) result.push(...flattenMenuItems(node.children));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Find the deepest menu item matching the current path */
|
||||
function findBestMenuMatch(nodes: MenuItem[], pathname: string): MenuItem | null {
|
||||
const p = normalizePath(pathname);
|
||||
const candidates = flattenMenuItems(nodes)
|
||||
.filter((n): n is Extract<MenuItem, { kind: "item" }> => {
|
||||
if (n.kind !== "item") return false;
|
||||
const target = normalizePath(n.path);
|
||||
return p === target || p.startsWith(`${target}/`);
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
candidates.sort((a, b) => b.path.length - a.path.length);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function resolveMenuIcon(icon: string | null): LucideIcon {
|
||||
return (icon && MENU_ICON_MAP[icon]) || CircleDashed;
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
export type HeaderProps = {
|
||||
/**
|
||||
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
|
||||
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
|
||||
*/
|
||||
/** Hide breadcrumb; routes may override via `staticData.hideBreadcrumb` */
|
||||
showBreadcrumb?: boolean;
|
||||
};
|
||||
|
||||
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
|
||||
// ── Main component ──
|
||||
export function Header({ showBreadcrumb: showProp = true }: HeaderProps) {
|
||||
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
|
||||
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
|
||||
const menus = useAuthStore((s) => s.menus);
|
||||
const location = useLocation();
|
||||
const matches = useMatches();
|
||||
const { token } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.lg;
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const iconSize = token.fontSize;
|
||||
|
||||
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
|
||||
const row = (
|
||||
<>
|
||||
<Icon size={iconSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} />
|
||||
<span>{label}</span>
|
||||
</>
|
||||
);
|
||||
const rowStyle = {
|
||||
display: "inline-flex" as const,
|
||||
alignItems: "center" as const,
|
||||
gap: token.marginXS,
|
||||
color: "inherit" as const,
|
||||
};
|
||||
if (linkTo) {
|
||||
return (
|
||||
<Link to={linkTo} style={rowStyle}>
|
||||
{row}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span style={rowStyle}>{row}</span>;
|
||||
};
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
handler();
|
||||
document.addEventListener("fullscreenchange", handler);
|
||||
return () => document.removeEventListener("fullscreenchange", handler);
|
||||
}, []);
|
||||
|
||||
// ── Breadcrumb ──
|
||||
const path = normalizePath(location.pathname);
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
|
||||
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
|
||||
const match = findBestMenuMatch(menus, path);
|
||||
const leafLabel = match?.name ?? PATH_LABEL[path] ?? segments[0] ?? "Dashboard";
|
||||
const leafIcon: LucideIcon = path === "/403" ? ShieldAlert : match ? resolveMenuIcon(match.icon) : Home;
|
||||
|
||||
const leafIcon: LucideIcon =
|
||||
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
|
||||
|
||||
const leafLabel = leafLabelKey;
|
||||
|
||||
const breadcrumbItems: ItemType[] = [];
|
||||
// Render a breadcrumb link label
|
||||
const crumb = (IconCmp: LucideIcon, label: ReactNode, link?: "/dashboard") => {
|
||||
const row = <><IconCmp size={token.fontSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} /><span>{label}</span></>;
|
||||
const rowStyle = { display: "inline-flex" as const, alignItems: "center" as const, gap: token.marginXS, color: "inherit" as const };
|
||||
return link ? <Link to={link} style={rowStyle}>{row}</Link> : <span style={rowStyle}>{row}</span>;
|
||||
};
|
||||
|
||||
const onDashboard = path === "/dashboard" || path === "/";
|
||||
const items = onDashboard
|
||||
? [{ title: crumb(Home, "Dashboard") }]
|
||||
: [{ title: crumb(Home, "Dashboard", "/dashboard") }, { title: crumb(leafIcon, leafLabel) },
|
||||
...(segments.length > 1 ? [{ title: segments.slice(1).join(" / ") }] : [])];
|
||||
|
||||
if (onDashboard) {
|
||||
breadcrumbItems.push({
|
||||
title: crumb(Home, "Dashboard"),
|
||||
});
|
||||
} else {
|
||||
breadcrumbItems.push({
|
||||
title: crumb(Home, "Dashboard", "/dashboard"),
|
||||
});
|
||||
|
||||
breadcrumbItems.push({
|
||||
title: crumb(leafIcon, leafLabel),
|
||||
});
|
||||
|
||||
if (segments.length > 1) {
|
||||
const tail = segments.slice(1).join(" / ");
|
||||
if (tail) {
|
||||
breadcrumbItems.push({ title: tail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
|
||||
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
|
||||
const showBreadcrumb =
|
||||
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
|
||||
const hideByRoute = (matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined)?.hideBreadcrumb === true;
|
||||
const showBc = showProp && !hideByRoute && items.length > 0;
|
||||
|
||||
return (
|
||||
<AntHeader
|
||||
style={{
|
||||
background: "transparent",
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: `0 ${token.padding}px`,
|
||||
gap: token.sizeLG,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
|
||||
{isMobile ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={toggleSidebar}
|
||||
icon={<PanelLeft size={token.size} />}
|
||||
aria-label="Toggle sidebar"
|
||||
/>
|
||||
) : null}
|
||||
{showBreadcrumb ? (
|
||||
<AntHeader style={{ background: "transparent", borderBottom: `1px solid ${token.colorBorderSecondary}`, padding: `0 ${token.padding}px`, gap: token.sizeLG, display: "flex" }}>
|
||||
{/* Left */}
|
||||
<Flex align="center" flex={1} style={{ minWidth: 0, overflow: "hidden" }}>
|
||||
{isMobile && <Button type="text" size="small" onClick={toggleSidebar} icon={<PanelLeft size={token.size} />} aria-label="Toggle sidebar" />}
|
||||
{showBc && (
|
||||
<>
|
||||
{isMobile ? <Divider vertical /> : null}
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
{isMobile && <Divider vertical />}
|
||||
{isMobile ? (
|
||||
<Typography.Text ellipsis style={{ fontSize: token.fontSizeLG, fontWeight: 600 }}>
|
||||
{leafLabel}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
// Desktop: full Breadcrumb component
|
||||
<Flex align="center" style={{ minWidth: 0 }}>
|
||||
{/* antd Breadcrumb expects items, but we render them inline for simplicity */}
|
||||
{items.map((it, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span style={{ margin: `0 ${token.marginXXS}px`, color: token.colorTextQuaternary }}>/</span>}
|
||||
{it.title}
|
||||
</span>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Right */}
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={toggleDarkMode}
|
||||
icon={<Theme size={token.size} />}
|
||||
aria-label="Toggle Theme"
|
||||
/>
|
||||
<NotificationTrigger />
|
||||
<Button type="text" onClick={() => document.fullscreenElement ? void document.exitFullscreen() : void document.documentElement.requestFullscreen()}
|
||||
icon={isFullscreen ? <Minimize2 size={token.size} /> : <Fullscreen size={token.size} />} aria-label="Toggle fullscreen" />
|
||||
<Button type="text" onClick={toggleDarkMode} icon={<Theme size={token.size} />} aria-label="Toggle theme" />
|
||||
</Space>
|
||||
</AntHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Notification Bell (Popover)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/** Inline notification item for the popover dropdown */
|
||||
function NotifRow({ n, onMark }: { n: Notification; onMark: () => void }) {
|
||||
const { token } = theme.useToken();
|
||||
return (
|
||||
<div
|
||||
onClick={onMark}
|
||||
style={{
|
||||
padding: `${token.paddingXS}px ${token.paddingSM}px`,
|
||||
cursor: n.read ? "default" : "pointer",
|
||||
background: n.read ? "transparent" : token.colorFillQuaternary,
|
||||
borderRadius: token.borderRadius,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="start" gap={token.marginXS}>
|
||||
<Flex vertical gap={2} style={{ minWidth: 0 }}>
|
||||
<Typography.Text strong={!n.read} ellipsis style={{ fontSize: token.fontSizeSM }}>
|
||||
{n.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ fontSize: 11 }}>
|
||||
{n.content}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
||||
{new Date(n.createdAt).toLocaleString()}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
{!n.read && <Badge status="processing" style={{ flexShrink: 0 }} />}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationTrigger() {
|
||||
const { token } = theme.useToken();
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Poll unread-only + latest 5 for popover (lightweight)
|
||||
const { data: unreadList = [] } = useQuery({
|
||||
queryKey: ["notifications", "bell-unread"],
|
||||
queryFn: () =>
|
||||
httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list, {
|
||||
params: { unreadOnly: "true" },
|
||||
}),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const unreadCount = unreadList.length;
|
||||
|
||||
// Load full list when popover opens (for any read ones)
|
||||
const { data: fullList = [] } = useQuery({
|
||||
queryKey: ["notifications", "bell-all"],
|
||||
queryFn: () => httpClient.get<Notification[]>(NOTIFICATION_ENDPOINTS.list),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const merged = open
|
||||
? [...fullList].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 5)
|
||||
: [];
|
||||
|
||||
const markAsRead = useMutation({
|
||||
mutationFn: (id: string) => httpClient.put(NOTIFICATION_ENDPOINTS.markRead(id)),
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
|
||||
});
|
||||
|
||||
const markAll = useMutation({
|
||||
mutationFn: () => httpClient.put(NOTIFICATION_ENDPOINTS.markAllRead),
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ["notifications"] }); },
|
||||
});
|
||||
|
||||
const content = (
|
||||
<div style={{ width: 360, maxHeight: 440, overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
||||
<Flex justify="space-between" align="center" style={{ padding: `${token.paddingSM}px ${token.padding}px`, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Typography.Text strong>Notifications</Typography.Text>
|
||||
{unreadCount > 0 && (
|
||||
<Button type="link" size="small" icon={<CheckCheck size={token.fontSize} />}
|
||||
loading={markAll.isPending} onClick={() => markAll.mutate()}>
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
{merged.length === 0 ? (
|
||||
<Flex justify="center" style={{ padding: token.paddingMD * 2 }}>
|
||||
<Typography.Text type="secondary">No notifications</Typography.Text>
|
||||
</Flex>
|
||||
) : (
|
||||
merged.map((n) => <NotifRow key={n.id} n={n} onMark={() => !n.read && markAsRead.mutate(n.id)} />)
|
||||
)}
|
||||
</div>
|
||||
<Flex justify="center" style={{ padding: token.paddingSM, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Link to="/notifications" onClick={() => setOpen(false)}>
|
||||
<Typography.Link>View All Notifications</Typography.Link>
|
||||
</Link>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} trigger="click" open={open} onOpenChange={setOpen} placement="bottomRight">
|
||||
<Badge count={unreadCount} size="small" offset={[-2, 4]}>
|
||||
<Button type="text" icon={<Bell size={token.size} />} aria-label="Notifications" />
|
||||
</Badge>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Layout, theme, Flex } from "antd";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { Header } from "../Header";
|
||||
import { GlobalAIChat } from "@/components/AIChat/GlobalAIChat";
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
@@ -34,6 +35,7 @@ export function MainLayout() {
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Flex>
|
||||
<GlobalAIChat />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
Folder,
|
||||
Home,
|
||||
PanelLeft,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
User,
|
||||
Users,
|
||||
Zap,
|
||||
BrainCircuit,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
|
||||
@@ -30,6 +32,13 @@ const MENU_LABELS: Record<string, string> = {
|
||||
Projects: "Projects",
|
||||
Dashboard: "Dashboard",
|
||||
Users: "Users",
|
||||
Admins: "Admins",
|
||||
Menus: "Menus",
|
||||
Settings: "Settings",
|
||||
Notifications: "Notifications",
|
||||
Logs: "Audit Logs",
|
||||
Profile: "Profile",
|
||||
"AI Settings": "AI Settings",
|
||||
"Design Engineering": "Design Engineering",
|
||||
"Sales & Marketing": "Sales & Marketing",
|
||||
};
|
||||
@@ -53,6 +62,8 @@ const MENU_ICON_MAP: Record<string, LucideIcon> = {
|
||||
IconLucideBookOpen: Book,
|
||||
IconLucideFolderKanban: Folder,
|
||||
IconLucideSparkles: Zap,
|
||||
IconLucideShieldCheck: ShieldCheck,
|
||||
IconLucideBrainCircuit: BrainCircuit,
|
||||
};
|
||||
|
||||
function renderMenuIcon(icon: string | null, size = 16) {
|
||||
|
||||
Reference in New Issue
Block a user