feat: 統一全系統頁面標題樣式、優化側邊欄與實作角色成員查看功能

This commit is contained in:
2026-01-13 17:00:58 +08:00
parent 6600cde3bc
commit f18fb169f3
33 changed files with 938 additions and 472 deletions

View File

@@ -11,7 +11,6 @@ import {
Warehouse,
Truck,
Contact2,
FileText,
LogOut,
User,
ChevronDown,
@@ -20,7 +19,7 @@ import {
Users
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Link, usePage } from "@inertiajs/react";
import { cn } from "@/lib/utils";
import BreadcrumbNav, { BreadcrumbItemType } from "@/Components/shared/BreadcrumbNav";
@@ -32,6 +31,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { usePermission } from "@/hooks/usePermission";
import ApplicationLogo from "@/Components/ApplicationLogo";
interface MenuItem {
id: string;
@@ -39,6 +40,7 @@ interface MenuItem {
icon?: React.ReactNode;
route?: string;
children?: MenuItem[];
permission?: string | string[]; // 所需權限(單一或多個,滿足任一即可)
}
export default function AuthenticatedLayout({
@@ -51,6 +53,7 @@ export default function AuthenticatedLayout({
const { url, props } = usePage();
// @ts-ignore
const user = props.auth?.user || { name: 'Guest', username: 'guest' };
const { can, canAny } = usePermission();
const [isCollapsed, setIsCollapsed] = useState(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("sidebar-collapsed") === "true";
@@ -59,29 +62,34 @@ export default function AuthenticatedLayout({
});
const [isMobileOpen, setIsMobileOpen] = useState(false);
const menuItems: MenuItem[] = [
// 完整的菜單定義(含權限配置)
const allMenuItems: MenuItem[] = [
{
id: "dashboard",
label: "儀表板",
icon: <LayoutDashboard className="h-5 w-5" />,
route: "/",
// 儀表板無需特定權限,所有登入使用者皆可存取
},
{
id: "inventory-management",
label: "商品與庫存管理",
icon: <Boxes className="h-5 w-5" />,
permission: ["products.view", "warehouses.view"], // 滿足任一即可看到此群組
children: [
{
id: "product-management",
label: "商品資料管理",
icon: <Package className="h-4 w-4" />,
route: "/products",
permission: "products.view",
},
{
id: "warehouse-management",
label: "倉庫管理",
icon: <Warehouse className="h-4 w-4" />,
route: "/warehouses",
permission: "warehouses.view",
},
],
},
@@ -89,12 +97,14 @@ export default function AuthenticatedLayout({
id: "vendor-management",
label: "廠商管理",
icon: <Truck className="h-5 w-5" />,
permission: "vendors.view",
children: [
{
id: "vendor-list",
label: "廠商資料管理",
icon: <Contact2 className="h-4 w-4" />,
route: "/vendors",
permission: "vendors.view",
},
],
},
@@ -102,12 +112,14 @@ export default function AuthenticatedLayout({
id: "purchase-management",
label: "採購管理",
icon: <ShoppingCart className="h-5 w-5" />,
permission: "purchase_orders.view",
children: [
{
id: "purchase-order-list",
label: "管理採購單",
icon: <FileText className="h-4 w-4" />,
label: "採購單管理",
icon: <ShoppingCart className="h-4 w-4" />,
route: "/purchase-orders",
permission: "purchase_orders.view",
},
],
},
@@ -115,23 +127,53 @@ export default function AuthenticatedLayout({
id: "system-management",
label: "系統管理",
icon: <Settings className="h-5 w-5" />,
permission: ["users.view", "roles.view"],
children: [
{
id: "user-management",
label: "使用者管理",
icon: <Users className="h-4 w-4" />,
route: "/admin/users",
permission: "users.view",
},
{
id: "role-management",
label: "角色與權限",
icon: <Shield className="h-4 w-4" />,
route: "/admin/roles",
permission: "roles.view",
},
],
},
];
// 檢查單一項目是否有權限
const hasPermissionForItem = (item: MenuItem): boolean => {
if (!item.permission) return true; // 無指定權限則預設有權限
if (Array.isArray(item.permission)) {
return canAny(item.permission);
}
return can(item.permission);
};
// 過濾菜單:移除無權限的項目,若父層所有子項目都無權限則隱藏父層
const menuItems = useMemo(() => {
return allMenuItems
.map((item) => {
// 如果有子項目,先過濾子項目
if (item.children && item.children.length > 0) {
const filteredChildren = item.children.filter(hasPermissionForItem);
// 若所有子項目都無權限,則隱藏整個群組
if (filteredChildren.length === 0) return null;
return { ...item, children: filteredChildren };
}
// 無子項目的單一選單,直接檢查權限
if (!hasPermissionForItem(item)) return null;
return item;
})
.filter((item): item is MenuItem => item !== null);
}, [can, canAny]);
// 初始化狀態:優先讀取 localStorage
const [expandedItems, setExpandedItems] = useState<string[]>(() => {
try {
@@ -296,7 +338,7 @@ export default function AuthenticatedLayout({
{isMobileOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary-main flex items-center justify-center text-white font-bold text-lg">K</div>
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
<span className="font-bold text-slate-900"> ERP</span>
</Link>
</div>
@@ -342,12 +384,14 @@ export default function AuthenticatedLayout({
<div className="hidden h-16 items-center justify-between px-6 border-b border-slate-100">
{!isCollapsed && (
<Link href="/" className="flex items-center gap-2 group">
<div className="w-8 h-8 rounded-lg bg-primary-main flex items-center justify-center text-white font-bold text-lg group-hover:scale-110 transition-transform">K</div>
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain group-hover:scale-110 transition-transform" />
<span className="font-extrabold text-[#01ab83] text-lg tracking-tight"> ERP</span>
</Link>
)}
{isCollapsed && (
<Link href="/" className="w-8 h-8 rounded-lg bg-primary-main flex items-center justify-center text-white font-bold text-lg mx-auto">K</Link>
<Link href="/" className="w-8 h-8 mx-auto">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
</Link>
)}
</div>
@@ -389,7 +433,7 @@ export default function AuthenticatedLayout({
)}>
<div className="h-16 flex items-center justify-between px-6 border-b border-slate-100">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary-main flex items-center justify-center text-white font-bold text-lg">K</div>
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
<span className="font-extrabold text-[#01ab83] text-lg"> ERP</span>
</Link>
<button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400">