feat: 加班/版本/项目调整 + 角色权限设计
加班记录: - 加班人默认回显当前用户且不可改 - 列表加创建日期列 - 移除编辑功能(创建即提交,仅可删除) - 提交时校验结束>开始 版本列表: - 移除顶部 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>
This commit is contained in:
10
apps/web/components/FieldError.tsx
Normal file
10
apps/web/components/FieldError.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function FieldError({ message }: Props) {
|
||||
if (!message) return null;
|
||||
return <p className="mt-1 text-[11px] text-red-500">{message}</p>;
|
||||
}
|
||||
58
apps/web/components/auth/Guard.tsx
Normal file
58
apps/web/components/auth/Guard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { hasPermission } from '@/lib/permissions';
|
||||
|
||||
/**
|
||||
* 当前登录用户对某权限点的判定
|
||||
* 未登录 / 找不到 role 时一律 false(最严格只读)
|
||||
*/
|
||||
export function useHasPermission(permission: string): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId));
|
||||
return hasPermission(role, permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由级拦截:包裹整页,无权限显示 AccessDenied
|
||||
*/
|
||||
export function RouteGuard({ permission, children }: { permission: string; children: ReactNode }) {
|
||||
const allowed = useHasPermission(permission);
|
||||
if (!allowed) return <AccessDenied />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮级拦截:无权限渲染 fallback(默认 null)
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
fallback = null,
|
||||
children,
|
||||
}: {
|
||||
permission: string;
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const allowed = useHasPermission(permission);
|
||||
if (!allowed) return <>{fallback}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由级"无权访问"占位
|
||||
*/
|
||||
export function AccessDenied() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-[var(--bg)]">
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] px-12 py-16">
|
||||
<Lock className="h-8 w-8 text-[var(--ink-muted)]" strokeWidth={1.5} />
|
||||
<p className="text-[14px] font-medium text-[var(--ink-soft)]">无权访问该模块</p>
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">请联系管理员开通对应权限</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,24 +2,25 @@
|
||||
|
||||
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 },
|
||||
{ label: '产品', path: '/products', icon: Package },
|
||||
{ label: '项目', path: '/projects', icon: FolderKanban },
|
||||
{ label: '版本', path: '/versions', icon: Tag },
|
||||
{ label: '需求池', path: '/requirements', icon: Lightbulb },
|
||||
{ label: '加班记录', path: '/overtime', icon: Clock },
|
||||
{ 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 },
|
||||
{ label: '角色', path: '/admin/roles', icon: Shield },
|
||||
{ label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
|
||||
{ label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -52,29 +53,7 @@ export function Sidebar() {
|
||||
|
||||
<nav className="flex-1 px-2 pt-2">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} className="mb-4">
|
||||
<p className="mb-1 px-2 text-[11px] font-medium text-[var(--ink-muted)]">
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => router.push(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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<NavGroup key={group.label} label={group.label} items={group.items} isActive={isActive} onNavigate={(p) => router.push(p)} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -92,3 +71,44 @@ export function Sidebar() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, Arro
|
||||
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
|
||||
interface Props {
|
||||
plans: VersionPlan[];
|
||||
@@ -271,12 +272,18 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||||
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
|
||||
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
|
||||
const [endTimeError, setEndTimeError] = useState('');
|
||||
const showReqSelect = planType === 'product' || planType === 'ui';
|
||||
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !endTime) return;
|
||||
if (startTime && endTime && new Date(endTime).getTime() <= new Date(startTime).getTime()) {
|
||||
setEndTimeError('结束日期必须晚于开始日期');
|
||||
return;
|
||||
}
|
||||
setEndTimeError('');
|
||||
if (isOverdue && !overdueReason.trim()) return;
|
||||
if (planType === 'research' && tasks.length === 0) return;
|
||||
onSubmit({
|
||||
@@ -318,7 +325,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">计划截止时间</label>
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => setEndTime(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => { setEndTime(e.target.value); setEndTimeError(''); }} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<FieldError message={endTimeError} />
|
||||
</div>
|
||||
</div>
|
||||
{versionDeadline && (
|
||||
|
||||
Reference in New Issue
Block a user