Files
ftb-project-management/apps/web/app/admin/roles/page.tsx
Script Generator c2f2fed82c 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>
2026-06-16 19:15:28 +08:00

215 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
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]);
return (
<div className="flex h-full flex-col">
<header className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<div className="flex items-center gap-2.5">
<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); }} 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>
</header>
<div className="flex-1 overflow-y-auto bg-[var(--bg)] px-5 py-4">
<div className="grid gap-3">
{roles.map((role) => (
<div key={role.id} className="flex items-center justify-between rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 shadow-[var(--shadow-sm)]">
<div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${role.isSystem ? 'bg-amber-50' : 'bg-[var(--bg-subtle)]'}`}>
<Shield className={`h-4 w-4 ${role.isSystem ? 'text-amber-600' : 'text-[var(--ink-muted)]'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--ink)]">{role.name}</span>
{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>
<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>
{showModal && (
<RoleModal
initial={editing}
onClose={() => setShowModal(false)}
onSubmit={(data) => {
if (editing) updateRole(editing.id, data);
else createRole(data);
setShowModal(false);
}}
/>
)}
</div>
);
}
function RoleModal({ initial, onClose, onSubmit }: {
initial: RoleItem | null;
onClose: () => 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() || 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-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="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 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 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>
);
}