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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user