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:
@@ -5,8 +5,17 @@ import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'luc
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { maskPhone, generatePassword } from '@/lib/members';
|
||||
import type { Member, Department, PasswordRule } from '@/lib/members';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
|
||||
export default function MembersPage() {
|
||||
return (
|
||||
<RouteGuard permission="member:view">
|
||||
<MembersPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersPageContent() {
|
||||
const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore();
|
||||
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, X, Shield } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, X, Shield } from 'lucide-react';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/lib/permissions';
|
||||
import { RouteGuard, useHasPermission } from '@/components/auth/Guard';
|
||||
import type { RoleItem } from '@/lib/members';
|
||||
|
||||
export default function RolesPage() {
|
||||
return (
|
||||
<RouteGuard permission="role:view">
|
||||
<RolesPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesPageContent() {
|
||||
const { roles, fetchMembers, createRole, updateRole, deleteRole } = useMemberStore();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<RoleItem | null>(null);
|
||||
const canCreate = useHasPermission('role:create');
|
||||
const canEdit = useHasPermission('role:edit');
|
||||
const canDelete = useHasPermission('role:delete');
|
||||
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
@@ -19,7 +32,7 @@ export default function RolesPage() {
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">角色管理</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{roles.length}</span>
|
||||
</div>
|
||||
<button onClick={() => { setEditing(null); setShowModal(true); }} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||
<button onClick={() => { setEditing(null); setShowModal(true); }} disabled={!canCreate} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建角色
|
||||
</button>
|
||||
@@ -39,24 +52,24 @@ export default function RolesPage() {
|
||||
{role.isSystem && (
|
||||
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 border border-amber-200">系统</span>
|
||||
)}
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{role.permissions.includes('*') ? '全部权限' : `${role.permissions.length} 项权限`}</span>
|
||||
</div>
|
||||
{role.description && (
|
||||
<p className="text-[12px] text-[var(--ink-muted)] mt-0.5">{role.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { setEditing(role); setShowModal(true); }} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">编辑</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { setEditing(role); setShowModal(true); }} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">{role.isSystem || !canEdit ? '查看' : '编辑'}</button>
|
||||
{!role.isSystem && canDelete && (
|
||||
<button onClick={() => deleteRole(role.id)} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors">删除</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<RoleModal
|
||||
initial={editing}
|
||||
@@ -75,39 +88,127 @@ export default function RolesPage() {
|
||||
function RoleModal({ initial, onClose, onSubmit }: {
|
||||
initial: RoleItem | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { name: string; description?: string }) => void;
|
||||
onSubmit: (data: { name: string; description?: string; permissions: string[] }) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [description, setDescription] = useState(initial?.description ?? '');
|
||||
const [permissions, setPermissions] = useState<Set<string>>(new Set(initial?.permissions ?? []));
|
||||
const isReadOnly = !!initial?.isSystem;
|
||||
const isAdminWildcard = permissions.has('*');
|
||||
|
||||
const togglePerm = (p: string) => {
|
||||
if (isReadOnly) return;
|
||||
const next = new Set(permissions);
|
||||
if (next.has(p)) next.delete(p); else next.add(p);
|
||||
setPermissions(next);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => setPermissions(new Set(ALL_PERMISSIONS));
|
||||
const handleSelectNone = () => setPermissions(new Set());
|
||||
const handleViewOnly = () => {
|
||||
const viewOnly = ALL_PERMISSIONS.filter((p) => p.endsWith(':view'));
|
||||
setPermissions(new Set(viewOnly));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
onSubmit({ name: name.trim(), description: description.trim() || undefined });
|
||||
if (!name.trim() || isReadOnly) return;
|
||||
onSubmit({ name: name.trim(), description: description.trim() || undefined, permissions: Array.from(permissions) });
|
||||
};
|
||||
|
||||
const mainGroups = useMemo(() => PERMISSION_GROUPS.filter((g) => g.category === 'main'), []);
|
||||
const tabGroups = useMemo(() => PERMISSION_GROUPS.filter((g) => g.category === 'version_tab'), []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-xs rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑角色' : '新建角色'}</h3>
|
||||
<div className="w-full max-w-2xl max-h-[85vh] flex flex-col rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? (isReadOnly ? '查看角色' : '编辑角色') : '新建角色'}</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">角色名称 *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="如:项目经理" 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" />
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 overflow-hidden">
|
||||
{isReadOnly && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-[11px] text-amber-700">
|
||||
系统角色不可修改,权限为系统预设
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">角色名称 *</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} required disabled={isReadOnly} placeholder="如:项目经理" 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 disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">描述</label>
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} disabled={isReadOnly} placeholder="可选" 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 disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">描述</label>
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" 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" />
|
||||
|
||||
<div className="flex-1 flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)]">权限</label>
|
||||
{!isReadOnly && (
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" onClick={handleSelectAll} className="h-6 px-2 rounded text-[11px] border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">全选</button>
|
||||
<button type="button" onClick={handleViewOnly} className="h-6 px-2 rounded text-[11px] border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">仅查看</button>
|
||||
<button type="button" onClick={handleSelectNone} className="h-6 px-2 rounded text-[11px] border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">反选</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdminWildcard ? (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700">
|
||||
此角色拥有所有权限(通配符 *)
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto space-y-3 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] p-3">
|
||||
<PermissionSection title="主模块" groups={mainGroups} permissions={permissions} togglePerm={togglePerm} disabled={isReadOnly} />
|
||||
<PermissionSection title="版本详情 Tab" groups={tabGroups} permissions={permissions} togglePerm={togglePerm} disabled={isReadOnly} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 shrink-0">
|
||||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">{isReadOnly ? '关闭' : '取消'}</button>
|
||||
{!isReadOnly && (
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionSection({ title, groups, permissions, togglePerm, disabled }: {
|
||||
title: string;
|
||||
groups: typeof PERMISSION_GROUPS;
|
||||
permissions: Set<string>;
|
||||
togglePerm: (p: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)] mb-2">{title}</div>
|
||||
<div className="space-y-1.5">
|
||||
{groups.map((g) => (
|
||||
<div key={g.module} className="flex items-center gap-3 rounded-md bg-[var(--bg-card)] px-3 py-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--ink)] w-28 shrink-0">{g.moduleLabel}</span>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{g.actions.map((a) => (
|
||||
<label key={a.permission} className={`flex items-center gap-1 text-[11px] ${disabled ? 'cursor-default' : 'cursor-pointer'} ${disabled ? 'text-[var(--ink-muted)]' : 'text-[var(--ink-soft)]'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.has(a.permission)}
|
||||
onChange={() => togglePerm(a.permission)}
|
||||
disabled={disabled}
|
||||
className="h-3.5 w-3.5 rounded border-[var(--line)]"
|
||||
/>
|
||||
{a.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user