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:
Script Generator
2026-06-16 19:15:28 +08:00
parent 1f5bed0c79
commit c2f2fed82c
18 changed files with 1023 additions and 231 deletions

View File

@@ -23,6 +23,7 @@ export interface RoleItem {
description?: string;
createdAt: string;
isSystem?: boolean;
permissions: string[];
}
export interface PasswordRule {

101
apps/web/lib/permissions.ts Normal file
View File

@@ -0,0 +1,101 @@
import type { RoleItem } from './members';
export type Permission = string;
export interface ActionDef {
action: 'view' | 'create' | 'edit' | 'delete' | 'manage' | 'export';
label: string;
permission: string;
}
export interface PermissionGroup {
module: string;
moduleLabel: string;
category: 'main' | 'version_tab';
actions: ActionDef[];
}
const std4 = (mod: string): ActionDef[] => [
{ action: 'view', label: '查看', permission: `${mod}:view` },
{ action: 'create', label: '创建', permission: `${mod}:create` },
{ action: 'edit', label: '编辑', permission: `${mod}:edit` },
{ action: 'delete', label: '删除', permission: `${mod}:delete` },
];
const stdTab = (mod: string): ActionDef[] => [
{ action: 'view', label: '查看', permission: `${mod}:view` },
{ action: 'manage', label: '管理', permission: `${mod}:manage` },
];
export const PERMISSION_GROUPS: PermissionGroup[] = [
{ module: 'product', moduleLabel: '产品', category: 'main', actions: std4('product') },
{ module: 'project', moduleLabel: '项目', category: 'main', actions: std4('project') },
{ module: 'version', moduleLabel: '版本', category: 'main', actions: std4('version') },
{ module: 'requirement', moduleLabel: '需求池', category: 'main', actions: std4('requirement') },
{
module: 'overtime',
moduleLabel: '加班记录',
category: 'main',
actions: [
{ action: 'view', label: '查看', permission: 'overtime:view' },
{ action: 'create', label: '创建', permission: 'overtime:create' },
{ action: 'delete', label: '删除', permission: 'overtime:delete' },
{ action: 'export', label: '导出', permission: 'overtime:export' },
],
},
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
{ module: 'version.ui_plan', moduleLabel: 'UI 设计 Tab', category: 'version_tab', actions: stdTab('version.ui_plan') },
{ module: 'version.devtask', moduleLabel: '开发任务 Tab', category: 'version_tab', actions: stdTab('version.devtask') },
{ module: 'version.testcase', moduleLabel: '测试用例 Tab', category: 'version_tab', actions: stdTab('version.testcase') },
{ module: 'version.bug', moduleLabel: 'Bug Tab', category: 'version_tab', actions: std4('version.bug') },
];
export const ALL_PERMISSIONS: string[] = PERMISSION_GROUPS.flatMap((g) => g.actions.map((a) => a.permission));
const VIEW_ONLY_BASE = ['product:view', 'project:view', 'version:view', 'requirement:view', 'version.req:view'];
export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
'role-admin': ['*'],
'role-pm': [
...std4('product').map((a) => a.permission),
...std4('project').map((a) => a.permission),
...std4('version').map((a) => a.permission),
...std4('requirement').map((a) => a.permission),
'version.req:view', 'version.req:manage',
'version.product_plan:view', 'version.product_plan:manage',
'overtime:view', 'member:view', 'role:view',
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
'version.testcase:view', 'version.bug:view',
],
'role-dev': [
...VIEW_ONLY_BASE,
'version.devtask:view', 'version.devtask:manage',
'version.bug:view', 'version.bug:edit',
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view',
'overtime:view', 'overtime:create',
],
'role-test': [
...VIEW_ONLY_BASE,
'version.testcase:view', 'version.testcase:manage',
'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete',
'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view',
'overtime:view', 'overtime:create',
],
'role-design': [
...VIEW_ONLY_BASE,
'version.ui_plan:view', 'version.ui_plan:manage',
'version.research:view', 'version.product_plan:view', 'version.devtask:view',
'version.testcase:view', 'version.bug:view',
'overtime:view', 'overtime:create',
],
};
export function hasPermission(role: RoleItem | undefined, permission: string): boolean {
if (!role) return false;
if (role.permissions.includes('*')) return true;
return role.permissions.includes(permission);
}

View File

@@ -0,0 +1,97 @@
import type { VersionPlan } from './version-plan';
import type { Requirement } from './requirement';
import type { DevTask } from './dev-task';
import type { TestCase } from './test-case';
import type { VersionWithContext } from './derive';
import { STATUS_PROGRESS, getEstimateHours } from './dev-task';
/**
* 计算单个版本的整体进度0-100
* 算法:调研/产品/UI 计划进度 + 开发任务加权进度 + 测试执行率,求平均
*/
export function calcVersionProgress(
versionId: string,
plans: VersionPlan[],
requirements: Requirement[],
devTasks: DevTask[],
testCases: TestCase[],
): number {
const vPlans = plans.filter((p) => p.versionId === versionId);
const vReqs = requirements.filter((r) => r.versionId === versionId);
const vReqIds = new Set(vReqs.map((r) => r.id));
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
const vTestCases = testCases.filter((c) => c.versionId === versionId);
const segments: number[] = [];
const researchPlans = vPlans.filter((p) => p.type === 'research');
if (researchPlans.length > 0) {
const totals = researchPlans.reduce((acc, p) => {
const tasks = p.tasks || [];
acc.total += tasks.length;
acc.done += tasks.filter((t) => t.status === 'completed').length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const productPlans = vPlans.filter((p) => p.type === 'product');
if (productPlans.length > 0) {
const totals = productPlans.reduce((acc, p) => {
const linked = p.linkedRequirementIds || [];
const completed = p.completedRequirementIds || [];
acc.total += linked.length;
acc.done += completed.filter((id) => linked.includes(id)).length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
const uiPlans = vPlans.filter((p) => p.type === 'ui');
if (uiPlans.length > 0) {
const totals = uiPlans.reduce((acc, p) => {
const linked = p.linkedRequirementIds || [];
const completed = p.completedRequirementIds || [];
acc.total += linked.length;
acc.done += completed.filter((id) => linked.includes(id)).length;
return acc;
}, { total: 0, done: 0 });
segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0);
}
if (vDevTasks.length > 0) {
const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
let devProgress: number;
if (totalEstimate === 0) {
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
} else {
const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
devProgress = weighted / totalEstimate;
}
segments.push(devProgress);
}
if (vTestCases.length > 0) {
const executed = vTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
segments.push((executed / vTestCases.length) * 100);
}
return segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
/**
* 批量为多个版本算进度,返回 versionId → 进度 (0-100) 的 Map
*/
export function buildVersionProgressMap(
versions: VersionWithContext[],
plans: VersionPlan[],
requirements: Requirement[],
devTasks: DevTask[],
testCases: TestCase[],
): Record<string, number> {
const map: Record<string, number> = {};
for (const v of versions) {
map[v.id] = calcVersionProgress(v.id, plans, requirements, devTasks, testCases);
}
return map;
}