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

@@ -1,26 +1,51 @@
'use client';
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search, FolderKanban, Package, ChevronDown, Plus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
import { flattenProjects, ProjectWithContext } from '@/lib/derive';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { flattenProjects, flattenVersions, ProjectWithContext } from '@/lib/derive';
import { VersionChip } from '@/components/version/VersionChip';
import { VersionStatus } from '@/lib/version-status';
import { Pagination, usePagination } from '@/components/Pagination';
import { buildVersionProgressMap } from '@/lib/version-progress';
export default function ProjectsPage() {
return (
<RouteGuard permission="project:view">
<ProjectsPageContent />
</RouteGuard>
);
}
function ProjectsPageContent() {
const router = useRouter();
const { overview, fetchOverview, createProject } = useProductStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { plans, fetchPlans } = useVersionPlanStore();
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const [search, setSearch] = useState('');
const [productFilter, setProductFilter] = useState<string>('all');
const [showForm, setShowForm] = useState(false);
useEffect(() => {
fetchOverview();
}, [fetchOverview]);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const versionProgressMap = useMemo(
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
[allVersions, plans, requirements, devTasks, testCases],
);
const filtered = useMemo(() => {
return allProjects.filter((p) => {
@@ -96,7 +121,7 @@ export default function ProjectsPage() {
<div>
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
{paged.map((proj) => (
<ProjectRow key={proj.id} proj={proj} onClick={() => router.push(`/projects/${proj.id}`)} />
<ProjectRow key={proj.id} proj={proj} versionProgressMap={versionProgressMap} onClick={() => router.push(`/projects/${proj.id}`)} />
))}
</div>
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
@@ -123,13 +148,15 @@ export default function ProjectsPage() {
function ProjectRow({
proj,
versionProgressMap,
onClick,
}: {
proj: ProjectWithContext;
versionProgressMap: Record<string, number>;
onClick: () => void;
}) {
const activeVersions = proj.versions.filter((v) => v.status !== 'released');
const releasedCount = proj.versions.filter((v) => v.status === 'released').length;
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
const completedCount = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) >= 100).length;
return (
<button
@@ -152,9 +179,9 @@ function ProjectRow({
{activeVersions.map((v) => (
<VersionChip key={v.id} name={v.name} status={v.status} />
))}
{releasedCount > 0 && (
{completedCount > 0 && (
<span className="text-xs text-[var(--ink-muted)] ml-1">
+{releasedCount}
+{completedCount}
</span>
)}
</div>