Shell & page header
Shell — ProLayout with react-router
// app/components/shell.tsx — used by app/layout.tsx for signed-in routes
import { ProLayout } from "@ant-design/pro-components";
import { Dropdown, Tag, theme } from "antd";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router";
export function Shell() {
const { token } = theme.useToken();
const nav = useNavigate();
const { pathname } = useLocation();
const { user, signOut } = useSession(); // your auth context
if (!user) return <Navigate to="/login" replace />;
return (
<ProLayout
title="Duyệt mua sắm"
logo={<AuditOutlined style={{ fontSize: 24, color: token.colorPrimary }} />}
layout="mix" fixSiderbar fixedHeader siderWidth={220}
location={{ pathname: "/" + (pathname.split("/")[1] ?? "") }} // highlight parent menu on detail routes
route={{ path: "/", routes: [
{ path: "/", name: "Tổng quan", icon: <DashboardOutlined /> },
{ path: "/requests", name: "Đề xuất", icon: <FileTextOutlined /> },
] }}
menuItemRender={(item, dom) => <a onClick={() => item.path && nav(item.path)}>{dom}</a>}
breadcrumbRender={false}
avatarProps={{ size: "small", title: user.name, children: user.name[0],
render: (_, dom) => <Dropdown menu={{ items: [{ key: "out", label: "Đăng xuất", onClick: signOut }] }}>{dom}</Dropdown> }}
actionsRender={(p) => (p.isMobile ? [] : [<Tag key="role" variant="filled" color="gold">Quản lý</Tag>])}
token={{ header: { colorBgHeader: token.colorBgContainer },
sider: { colorMenuBackground: token.colorBgContainer, colorBgMenuItemSelected: token.colorPrimaryBg, colorTextMenuSelected: token.colorPrimary } }}
>
<Outlet />
</ProLayout>
);
}- Menu items come from
route(filter by role before passing).locationis required or nothing highlights. - Public pages (
/login,/register) render without the shell — decide inapp/layout.tsxbyuseLocation().pathname. - Don’t: hand-roll
Layout.Sider+Menu; override.ant-pro-*classes; shipSettingDrawer.
App launcher, mix layout, grouped menus
The Ant Design Pro look (top bar with first-level menu, left sider with group titles, 9-dot app switcher, icons + avatar on the right) is all ProLayout props — no custom CSS:
<ProLayout
layout="mix" splitMenus // level 1 in the header, levels 2–3 in the sider
siderMenuType="group" // level 2 renders as group titles, level 3 as items
menu={{ collapsedShowGroupTitle: true, locale: false }}
route={{ path: "/", children: [
{ path: "/ops", name: "Vận hành", icon: <AppstoreOutlined />, children: [
{ path: "/ops/orders", name: "Đơn hàng", children: [{ path: "/ops/orders/all", name: "Tất cả đơn" }] },
] },
] }}
appList={[ // 9-dot launcher next to the logo
{ title: "Nội bộ", desc: "Ứng dụng của công ty", children: [
{ title: "CRM", desc: "Khách hàng & cơ hội", icon: <TeamOutlined />, url: "https://crm.example.vn", target: "_blank" },
] },
]}
itemClick={(item, popoverRef) => { /* optional: handle in-app; popoverRef.current?.close() */ }}
bgLayoutImgList={[{ src: "…/bg1.png", left: 85, bottom: 100, height: "303px" }]} // soft background art
actionsRender={(p) => (p.isMobile ? [] : [<QuestionCircleFilled key="q" />])}
avatarProps={{ size: "small", title: user.name, render: (_, dom) => <Dropdown menu={{ items }}>{dom}</Dropdown> }}
menuFooterRender={(p) => (p?.collapsed ? undefined : <div>v1.0</div>)}
/>appList: items withchildrenrender as titled groups. Card mode (big tiles) is chosen when every item hasdesc; otherwise the compact icon grid.splitMenusonly makes sense withlayout="mix"; the active top item is derived fromlocation.pathname, solocationmust be set.menuItemRenderis required without umi — without it menu clicks do nothing. UseuseNavigate().breakpoint(defaultlg) auto-collapses the sider;collapsed+onCollapseto control it.- ProLayout does not enforce
access— filterrouteby role yourself. titledefaults to “Ant Design Pro” — always set it (andlogo).
Khung app ProLayout — Khung app: ProLayout mix + menu nhóm 3 cấp + app launcher 9 chấm + icon header + avatar. Mở trong Playground →
Xem code (90 dòng)
// Khung app: ProLayout mix + menu nhóm 3 cấp + app launcher 9 chấm + icon header + avatar.
// Trong app thật: menuItemRender dùng useNavigate()/<Link>, location = useLocation().
import { useState } from "react";
import { PageContainer, ProCard, ProLayout } from "@ant-design/pro-components";
import { Dropdown, Typography, theme } from "antd";
import {
AppstoreOutlined, AuditOutlined, BarChartOutlined, FileTextOutlined, GithubFilled,
InfoCircleFilled, LogoutOutlined, QuestionCircleFilled, ShoppingOutlined, TeamOutlined,
} from "@ant-design/icons";
const route = {
path: "/",
children: [
{
path: "/ops", name: "Vận hành", icon: <AppstoreOutlined />,
children: [
{ path: "/ops/orders", name: "Đơn hàng", icon: <ShoppingOutlined />, children: [
{ path: "/ops/orders/all", name: "Tất cả đơn" },
{ path: "/ops/orders/pending", name: "Chờ xử lý" },
] },
{ path: "/ops/requests", name: "Đề xuất mua sắm", icon: <FileTextOutlined />, children: [
{ path: "/ops/requests/mine", name: "Của tôi" },
{ path: "/ops/requests/inbox", name: "Cần tôi duyệt" },
] },
],
},
{
path: "/report", name: "Báo cáo", icon: <BarChartOutlined />,
children: [
{ path: "/report/sales", name: "Doanh số", icon: <BarChartOutlined />, children: [
{ path: "/report/sales/month", name: "Theo tháng" },
] },
],
},
],
};
const appList = [
{ title: "Nội bộ", desc: "Ứng dụng của công ty", children: [
{ title: "CRM", desc: "Khách hàng & cơ hội", icon: <TeamOutlined />, url: "#crm" },
{ title: "Kho", desc: "Tồn kho & phiếu xuất", icon: <ShoppingOutlined />, url: "#kho" },
] },
{ title: "Công cụ", desc: "Dùng chung", children: [
{ title: "Duyệt mua sắm", desc: "Đề xuất & phê duyệt", icon: <AuditOutlined />, url: "#duyet" },
] },
];
export default function Page() {
const { token } = theme.useToken();
const [pathname, setPathname] = useState("/ops/orders/all");
return (
<div style={{ height: "100vh" }}>
<ProLayout
title="Acme Admin"
logo={<AuditOutlined style={{ fontSize: 22, color: token.colorPrimary }} />}
layout="mix"
splitMenus
siderMenuType="group"
menu={{ collapsedShowGroupTitle: true, locale: false }}
fixSiderbar
route={route}
location={{ pathname }}
menuItemRender={(item, dom) => <a onClick={() => item.path && setPathname(item.path)}>{dom}</a>}
appList={appList}
bgLayoutImgList={[
{ src: "https://img.alicdn.com/imgextra/i2/O1CN01O4etvp1DvpFLKfuWq_!!6000000000279-2-tps-609-606.png", left: 85, bottom: 100, height: "303px" },
{ src: "https://img.alicdn.com/imgextra/i3/O1CN018NxReL1shX85Yz6Cx_!!6000000005798-2-tps-884-496.png", bottom: 0, left: 0, width: "331px" },
]}
actionsRender={(p) => (p.isMobile ? [] : [<InfoCircleFilled key="i" />, <QuestionCircleFilled key="q" />, <GithubFilled key="g" />])}
avatarProps={{
size: "small",
title: "Nguyễn Thu Hà",
children: "H",
style: { background: token.colorPrimary },
render: (_, dom) => <Dropdown menu={{ items: [{ key: "out", icon: <LogoutOutlined />, label: "Đăng xuất" }] }}>{dom}</Dropdown>,
}}
menuFooterRender={(p) => (p?.collapsed ? undefined : <div style={{ textAlign: "center", paddingBlockStart: 12, color: token.colorTextTertiary }}>v1.0 · Acme</div>)}
>
<PageContainer subTitle={pathname}>
<ProCard>
<Typography.Paragraph>
Bấm các mục menu (trên: nhóm cấp 1, trái: tiêu đề nhóm cấp 2 + mục cấp 3) và nút 9 chấm cạnh logo.
</Typography.Paragraph>
</ProCard>
</PageContainer>
</ProLayout>
</div>
);
}
Page header — PageContainer
<PageContainer
header={{ title: r.title, breadcrumb: { items: [{ title: <a onClick={() => nav("/requests")}>Đề xuất</a> }, { title: `#${r.id}` }] },
tags: <Tag color="processing">Chờ duyệt</Tag>,
extra: <Space wrap>{ownerActions}</Space> }}
content={<ProDescriptions column={{ xs: 1, sm: 2, lg: 4 }}>{/* 3–4 key facts */}</ProDescriptions>}
extraContent={<Statistic title="Số tiền đề xuất" value={vnd(r.amount)} />} // the one number that matters
footer={pending && manager ? [<RejectButton key="r" />, <ApproveButton key="a" />] : undefined} // → sticky FooterToolbar
>content= summary strip under the title;extraContent= 1–3 right-aligned numbers;footer= the decision buttons, always visible.tabList+tabActiveKey+onTabChangefor detail pages with several aspects (info / history / related).- Dashboards:
title= greeting,subTitle= the to-do count (“2 đề xuất đang chờ bạn duyệt”), one primaryextrabutton.
Last updated on