feat(ui): [UI7-1] 重构主布局AppShell与导航层
这个提交包含在:
@@ -6,72 +6,85 @@ import { cn } from "../../lib/utils";
|
||||
export type NavItem = {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
anyOf?: string[];
|
||||
};
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/dashboard", label: "仪表盘" },
|
||||
{ to: "/dashboard", label: "仪表盘", icon: "dashboard" },
|
||||
{
|
||||
to: "/reimbursements",
|
||||
label: "我的报销",
|
||||
icon: "request_quote",
|
||||
anyOf: ["expense.view_own", "expense.view_all"],
|
||||
},
|
||||
{
|
||||
to: "/workflow/tasks",
|
||||
label: "待我处理",
|
||||
icon: "fact_check",
|
||||
anyOf: ["expense.approve"],
|
||||
},
|
||||
{
|
||||
to: "/invoice-pool",
|
||||
label: "票据中心",
|
||||
icon: "receipt_long",
|
||||
anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/capture",
|
||||
label: "移动拍照",
|
||||
icon: "document_scanner",
|
||||
anyOf: ["invoice.upload", "invoice.manage"],
|
||||
},
|
||||
{
|
||||
to: "/mobile/approvals",
|
||||
label: "移动审批",
|
||||
icon: "mobile_friendly",
|
||||
anyOf: ["expense.approve"],
|
||||
},
|
||||
{
|
||||
to: "/budgets",
|
||||
label: "预算管理",
|
||||
icon: "account_balance",
|
||||
anyOf: ["budget.view", "budget.manage"],
|
||||
},
|
||||
{
|
||||
to: "/audit-logs",
|
||||
label: "审计工作台",
|
||||
icon: "manage_search",
|
||||
anyOf: ["system.audit.view"],
|
||||
},
|
||||
{
|
||||
to: "/delegations",
|
||||
label: "委派管理",
|
||||
icon: "swap_horiz",
|
||||
anyOf: ["expense.proxy_manage"],
|
||||
},
|
||||
{
|
||||
to: "/system/users",
|
||||
label: "用户管理",
|
||||
icon: "group",
|
||||
anyOf: ["system.users"],
|
||||
},
|
||||
{
|
||||
to: "/system/departments",
|
||||
label: "部门管理",
|
||||
icon: "account_tree",
|
||||
anyOf: ["system.users"],
|
||||
},
|
||||
{
|
||||
to: "/system/approval-rules",
|
||||
label: "审批规则",
|
||||
icon: "rule",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{
|
||||
to: "/system/feature-flags",
|
||||
label: "功能开关",
|
||||
icon: "toggle_on",
|
||||
anyOf: ["system.settings"],
|
||||
},
|
||||
{ to: "/settings/profile", label: "个人设置" },
|
||||
{ to: "/settings/profile", label: "个人设置", icon: "manage_accounts" },
|
||||
];
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
@@ -94,14 +107,15 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"block rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
"flex items-center gap-3 px-3 py-2 my-1 rounded-lg text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary-50 text-primary"
|
||||
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
|
||||
? "bg-zinc-100 font-bold text-zinc-900"
|
||||
: "font-medium text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="hidden w-64 shrink-0 border-r border-slate-200 bg-white lg:flex lg:flex-col">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
|
||||
<h1 className="mt-1 text-lg font-bold text-slate-900">海音达工作台</h1>
|
||||
<aside className="hidden w-64 border-r border-zinc-200 bg-white lg:flex lg:flex-col fixed inset-y-0 z-20">
|
||||
<div className="border-b border-zinc-200 p-6 flex items-center gap-3">
|
||||
<div className="size-8 bg-zinc-900 rounded-lg flex items-center justify-center text-white font-bold text-lg">H</div>
|
||||
<span className="font-bold text-zinc-900 tracking-tight">海音达工作台</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<div className="flex-1 p-3">
|
||||
<NavLinks />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function Topbar() {
|
||||
const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 px-4 py-3 backdrop-blur sm:px-6">
|
||||
<header className="sticky top-0 z-10 h-16 flex flex-col justify-center border-b border-zinc-200 bg-white px-4 sm:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
@@ -94,12 +94,12 @@ export function Topbar() {
|
||||
|
||||
<DialogContent className="left-0 top-0 h-full w-72 max-w-[80vw] translate-x-0 translate-y-0 rounded-none rounded-r-xl p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-4">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
hyb-oa
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
|
||||
<span className="text-secondary capitalize">{me?.roleCodes?.[0] || 'User'}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-bold text-slate-900">海音达工作台</p>
|
||||
<p className="mt-1 text-sm font-bold text-zinc-900">海音达工作台</p>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭菜单" type="button">
|
||||
@@ -116,8 +116,8 @@ export function Topbar() {
|
||||
</Dialog>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-secondary">欢迎回来</p>
|
||||
<p className="text-base font-semibold text-slate-900">{me?.name ?? "-"}</p>
|
||||
<p className="text-sm text-zinc-500">欢迎回来</p>
|
||||
<p className="text-base font-semibold text-zinc-900">{me?.name ?? "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,8 +141,8 @@ export function Topbar() {
|
||||
</Button>
|
||||
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-xs uppercase tracking-wide text-secondary">角色</p>
|
||||
<p className="text-sm font-medium text-slate-900">{roleCodes.join(" / ") || "-"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">角色</p>
|
||||
<p className="text-sm font-medium text-zinc-900">{roleCodes.join(" / ") || "-"}</p>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ErrorBoundary } from "../components/common/ErrorBoundary";
|
||||
import { Sidebar } from "../components/navigation/Sidebar";
|
||||
import { Topbar } from "../components/navigation/Topbar";
|
||||
|
||||
export function AppShellLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background-light">
|
||||
<div className="flex min-h-screen">
|
||||
<div className="flex min-h-screen bg-zinc-50">
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<main className="flex-1 flex flex-col min-w-0 lg:ml-64">
|
||||
<Topbar />
|
||||
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<div className="flex-1 p-4 sm:p-8">
|
||||
<div className="layout-container max-w-none">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import ReactDOM from "react-dom/client";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./auth/AuthContext";
|
||||
import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { queryClient } from "./query/queryClient";
|
||||
import { AppRouter } from "./router";
|
||||
import { registerPwaIfEnabled } from "./pwa";
|
||||
@@ -12,6 +14,7 @@ registerPwaIfEnabled();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
@@ -19,5 +22,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
<Toaster />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户