加班记录: - 加班人默认回显当前用户且不可改 - 列表加创建日期列 - 移除编辑功能(创建即提交,仅可删除) - 提交时校验结束>开始 版本列表: - 移除顶部 9 个状态 tab + 表格状态列 - 仅保留搜索、项目、优先级筛选 项目列表: - 行内显示进度未达 100% 的版本(与版本页同源算法) - 新增 lib/version-progress.ts 抽出公共进度计算 角色权限: - RoleItem 加 permissions 字段 - 新建 lib/permissions.ts: 14 组 37 个权限点 + 默认 5 角色映射 + hasPermission - 角色表单内嵌权限矩阵(主模块 4 件套 + Tab 二档 + Bug Tab 4 档)+ 全选/反选/仅查看快捷 - 新建 components/auth/Guard.tsx: RouteGuard + PermissionGuard + useHasPermission + AccessDenied - Sidebar 菜单按 view 权限过滤 - 7 个主路由(products/projects/versions/requirements/overtime/admin/members/roles)包 RouteGuard - 版本详情 Tab 按 view 权限过滤,自动跳转到第一个有权限的 Tab - 默认未登录/无角色按只读最严格 - 超管 role-admin: ['*'] 通配符,permissions 不可改 通用: - 新建 components/FieldError.tsx 统一表单错误文案 - PlanTab 提交时校验结束>开始 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
115 lines
4.7 KiB
TypeScript
115 lines
4.7 KiB
TypeScript
'use client';
|
|
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield } from 'lucide-react';
|
|
import { useHasPermission } from '@/components/auth/Guard';
|
|
|
|
const NAV_GROUPS = [
|
|
{
|
|
label: '工作区',
|
|
items: [
|
|
{ label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
|
|
{ label: '产品', path: '/products', icon: Package, permission: 'product:view' },
|
|
{ label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
|
|
{ label: '版本', path: '/versions', icon: Tag, permission: 'version:view' },
|
|
{ label: '需求池', path: '/requirements', icon: Lightbulb, permission: 'requirement:view' },
|
|
{ label: '加班记录', path: '/overtime', icon: Clock, permission: 'overtime:view' },
|
|
],
|
|
},
|
|
{
|
|
label: '管理',
|
|
items: [
|
|
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
|
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
|
],
|
|
},
|
|
];
|
|
|
|
export function Sidebar() {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
|
|
const isActive = (path: string) =>
|
|
pathname === path || (path !== '/' && pathname.startsWith(path));
|
|
|
|
return (
|
|
<aside className="flex h-screen w-60 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
|
<div className="flex h-14 items-center gap-2 px-4">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--accent)]">
|
|
<LayoutGrid className="h-4 w-4 text-white" strokeWidth={2.25} />
|
|
</div>
|
|
<span className="text-[13px] font-semibold tracking-tight text-[var(--ink)]">FTB</span>
|
|
</div>
|
|
|
|
<div className="px-3 pb-2">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
|
|
<input
|
|
placeholder="搜索"
|
|
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] pl-8 pr-2 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:bg-[var(--bg-card)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex-1 px-2 pt-2">
|
|
{NAV_GROUPS.map((group) => (
|
|
<NavGroup key={group.label} label={group.label} items={group.items} isActive={isActive} onNavigate={(p) => router.push(p)} />
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-[var(--line)] p-2">
|
|
<button className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-[var(--bg-subtle)]">
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent)] text-[11px] font-semibold text-white">
|
|
A
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-[13px] font-medium text-[var(--ink)]">Admin</p>
|
|
<p className="truncate text-[11px] text-[var(--ink-muted)]">管理员</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function NavGroup({ label, items, isActive, onNavigate }: {
|
|
label: string;
|
|
items: { label: string; path: string; icon: any; permission: string | null }[];
|
|
isActive: (p: string) => boolean;
|
|
onNavigate: (p: string) => void;
|
|
}) {
|
|
const visibleItems = items.filter((item) => item.permission === null || /* eslint-disable-next-line react-hooks/rules-of-hooks */ true);
|
|
if (visibleItems.length === 0) return null;
|
|
return (
|
|
<div className="mb-4">
|
|
<p className="mb-1 px-2 text-[11px] font-medium text-[var(--ink-muted)]">{label}</p>
|
|
{items.map((item) => (
|
|
<NavItem key={item.path} item={item} active={isActive(item.path)} onNavigate={onNavigate} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NavItem({ item, active, onNavigate }: {
|
|
item: { label: string; path: string; icon: any; permission: string | null };
|
|
active: boolean;
|
|
onNavigate: (p: string) => void;
|
|
}) {
|
|
const hasPerm = useHasPermission(item.permission ?? '');
|
|
if (item.permission && !hasPerm) return null;
|
|
const Icon = item.icon;
|
|
return (
|
|
<button
|
|
onClick={() => onNavigate(item.path)}
|
|
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors ${
|
|
active
|
|
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent-hover)]'
|
|
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
|
|
}`}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
|
<span className="truncate">{item.label}</span>
|
|
</button>
|
|
);
|
|
}
|