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,5 +1,6 @@
'use client';
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Tag, Plus, X, ChevronDown, MoreHorizontal, Pause, Play, XCircle, Trash2 } from 'lucide-react';
@@ -14,7 +15,7 @@ import type { VersionWithContext } from '@/lib/derive';
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
import { STAGES } from '@/lib/stage';
import { ROLE_LABEL } from '@/lib/stage';
import { STATUS_PROGRESS, getEstimateHours } from '@/lib/dev-task';
import { buildVersionProgressMap } from '@/lib/version-progress';
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health';
import { Pagination, usePagination } from '@/components/Pagination';
@@ -36,19 +37,6 @@ const PRIORITY_ORDER: Record<Priority, number> = {
P4: 4,
};
const STATUS_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'requirement', label: '调研' },
{ key: 'product_design', label: '产品设计' },
{ key: 'ui_design', label: 'UI设计' },
{ key: 'dev', label: '开发' },
{ key: 'testing', label: '测试' },
{ key: 'released', label: '已发布' },
{ key: 'paused', label: '已暂停' },
{ key: 'closed', label: '已关闭' },
{ key: 'planned', label: '规划中' },
];
function parseVersionNumber(name: string): number[] {
const match = name.match(/V([\d.]+)$/i);
if (!match) return [0];
@@ -101,10 +89,17 @@ function getStageProgress(version: VersionWithContext): number {
}
export default function VersionsPage() {
return (
<RouteGuard permission="version:view">
<VersionsPageContent />
</RouteGuard>
);
}
function VersionsPageContent() {
const router = useRouter();
const { overview, fetchOverview, createVersion, updateVersion, deleteVersion } = useProductStore();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [projectFilter, setProjectFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [projectDropdownOpen, setProjectDropdownOpen] = useState(false);
@@ -138,78 +133,10 @@ export default function VersionsPage() {
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
// Compute overall progress per version from actual data
const versionProgressMap = useMemo(() => {
const map: Record<string, number> = {};
for (const v of allVersions) {
const vPlans = plans.filter((p) => p.versionId === v.id);
const vReqs = requirements.filter((r) => r.versionId === v.id);
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 === v.id);
const segments: number[] = [];
// Research plans progress
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);
}
// Product plans progress
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);
}
// UI plans progress
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);
}
// Dev tasks progress (weighted by STATUS_PROGRESS)
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);
}
// Test cases progress (executed / total)
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);
}
map[v.id] = segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0;
}
return map;
}, [allVersions, plans, requirements, devTasks, testCases]);
const versionProgressMap = useMemo(
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
[allVersions, plans, requirements, devTasks, testCases],
);
const filtered = useMemo(() => {
let list = allVersions.filter((v) => {
@@ -218,20 +145,12 @@ export default function VersionsPage() {
return true;
});
if (statusFilter !== 'all') {
if (['planned', 'released', 'paused', 'closed'].includes(statusFilter)) {
list = list.filter(v => v.status === statusFilter);
} else {
list = list.filter(v => v.status === 'developing' && v.currentStage === statusFilter);
}
}
if (priorityFilter !== 'all') {
list = list.filter(v => (v.priority ?? 'P2') === priorityFilter);
}
return sortVersions(list);
}, [allVersions, search, statusFilter, projectFilter, priorityFilter]);
}, [allVersions, search, projectFilter, priorityFilter]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
@@ -272,13 +191,6 @@ export default function VersionsPage() {
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--ink-muted)]" strokeWidth={2} />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索版本号" className="h-8 w-64 rounded-lg border border-[var(--line)] bg-[var(--bg)] pl-8 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" />
</div>
<div className="flex flex-wrap gap-1">
{STATUS_TABS.map((tab) => (
<button key={tab.key} onClick={() => setStatusFilter(tab.key)} className={`h-8 rounded-lg px-3 text-[12px] transition-colors ${statusFilter === tab.key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}>
{tab.label}
</button>
))}
</div>
{/* Project dropdown */}
<div className="relative">
@@ -343,7 +255,6 @@ export default function VersionsPage() {
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
@@ -416,13 +327,6 @@ function VersionRow({ version, overallProgress, openMenuId, setOpenMenuId, onNav
</span>
</td>
{/* 状态 */}
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${VERSION_STATUS_BG[version.status as VersionStatus] || 'bg-zinc-100 text-zinc-600'}`}>
{getVersionDisplayStatus(version.status as VersionStatus, version.currentStage)}
</span>
</td>
{/* 整体进度 */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">