@@ -343,7 +255,6 @@ export default function VersionsPage() {
| 版本号 |
优先级 |
- 状态 |
整体进度 |
健康度 |
风险标签 |
@@ -416,13 +327,6 @@ function VersionRow({ version, overallProgress, openMenuId, setOpenMenuId, onNav
- {/* 状态 */}
-
-
- {getVersionDisplayStatus(version.status as VersionStatus, version.currentStage)}
-
- |
-
{/* 整体进度 */}
diff --git a/apps/web/components/FieldError.tsx b/apps/web/components/FieldError.tsx
new file mode 100644
index 0000000..55c1644
--- /dev/null
+++ b/apps/web/components/FieldError.tsx
@@ -0,0 +1,10 @@
+'use client';
+
+interface Props {
+ message?: string;
+}
+
+export function FieldError({ message }: Props) {
+ if (!message) return null;
+ return {message} ;
+}
diff --git a/apps/web/components/auth/Guard.tsx b/apps/web/components/auth/Guard.tsx
new file mode 100644
index 0000000..13a1a70
--- /dev/null
+++ b/apps/web/components/auth/Guard.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import { ReactNode } from 'react';
+import { Lock } from 'lucide-react';
+import { useAuthStore } from '@/stores/useAuthStore';
+import { useMemberStore } from '@/stores/useMemberStore';
+import { hasPermission } from '@/lib/permissions';
+
+/**
+ * 当前登录用户对某权限点的判定
+ * 未登录 / 找不到 role 时一律 false(最严格只读)
+ */
+export function useHasPermission(permission: string): boolean {
+ const user = useAuthStore((s) => s.user);
+ const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId));
+ return hasPermission(role, permission);
+}
+
+/**
+ * 路由级拦截:包裹整页,无权限显示 AccessDenied
+ */
+export function RouteGuard({ permission, children }: { permission: string; children: ReactNode }) {
+ const allowed = useHasPermission(permission);
+ if (!allowed) return ;
+ return <>{children}>;
+}
+
+/**
+ * 按钮级拦截:无权限渲染 fallback(默认 null)
+ */
+export function PermissionGuard({
+ permission,
+ fallback = null,
+ children,
+}: {
+ permission: string;
+ fallback?: ReactNode;
+ children: ReactNode;
+}) {
+ const allowed = useHasPermission(permission);
+ if (!allowed) return <>{fallback}>;
+ return <>{children}>;
+}
+
+/**
+ * 路由级"无权访问"占位
+ */
+export function AccessDenied() {
+ return (
+
+
+
+ 无权访问该模块
+ 请联系管理员开通对应权限
+
+
+ );
+}
diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx
index 99e4b0b..92f3328 100644
--- a/apps/web/components/layout/Sidebar.tsx
+++ b/apps/web/components/layout/Sidebar.tsx
@@ -2,24 +2,25 @@
import { usePathname, useRouter } from 'next/navigation';
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield } from 'lucide-react';
+import { useHasPermission } from '@/components/auth/Guard';
const NAV_GROUPS = [
{
label: '工作区',
items: [
- { label: '与我相关', path: '/workspace', icon: Inbox },
- { label: '产品', path: '/products', icon: Package },
- { label: '项目', path: '/projects', icon: FolderKanban },
- { label: '版本', path: '/versions', icon: Tag },
- { label: '需求池', path: '/requirements', icon: Lightbulb },
- { label: '加班记录', path: '/overtime', icon: Clock },
+ { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null },
+ { label: '产品', path: '/products', icon: Package, permission: 'product:view' },
+ { label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' },
+ { label: '版本', path: '/versions', icon: Tag, permission: 'version:view' },
+ { label: '需求池', path: '/requirements', icon: Lightbulb, permission: 'requirement:view' },
+ { label: '加班记录', path: '/overtime', icon: Clock, permission: 'overtime:view' },
],
},
{
label: '管理',
items: [
- { label: '成员', path: '/admin/members', icon: Users },
- { label: '角色', path: '/admin/roles', icon: Shield },
+ { label: '成员', path: '/admin/members', icon: Users, permission: 'member:view' },
+ { label: '角色', path: '/admin/roles', icon: Shield, permission: 'role:view' },
],
},
];
@@ -52,29 +53,7 @@ export function Sidebar() {
@@ -92,3 +71,44 @@ export function Sidebar() {
);
}
+
+function NavGroup({ label, items, isActive, onNavigate }: {
+ label: string;
+ items: { label: string; path: string; icon: any; permission: string | null }[];
+ isActive: (p: string) => boolean;
+ onNavigate: (p: string) => void;
+}) {
+ const visibleItems = items.filter((item) => item.permission === null || /* eslint-disable-next-line react-hooks/rules-of-hooks */ true);
+ if (visibleItems.length === 0) return null;
+ return (
+
+ {label}
+ {items.map((item) => (
+
+ ))}
+
+ );
+}
+
+function NavItem({ item, active, onNavigate }: {
+ item: { label: string; path: string; icon: any; permission: string | null };
+ active: boolean;
+ onNavigate: (p: string) => void;
+}) {
+ const hasPerm = useHasPermission(item.permission ?? '');
+ if (item.permission && !hasPerm) return null;
+ const Icon = item.icon;
+ return (
+
+ );
+}
diff --git a/apps/web/components/version/PlanTab.tsx b/apps/web/components/version/PlanTab.tsx
index 05e5b2b..3fb5a98 100644
--- a/apps/web/components/version/PlanTab.tsx
+++ b/apps/web/components/version/PlanTab.tsx
@@ -5,6 +5,7 @@ import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, Arro
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
+import { FieldError } from '@/components/FieldError';
interface Props {
plans: VersionPlan[];
@@ -271,12 +272,18 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
const [newTaskTitle, setNewTaskTitle] = useState('');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState >(new Set(initial?.linkedRequirementIds ?? []));
+ const [endTimeError, setEndTimeError] = useState('');
const showReqSelect = planType === 'product' || planType === 'ui';
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !endTime) return;
+ if (startTime && endTime && new Date(endTime).getTime() <= new Date(startTime).getTime()) {
+ setEndTimeError('结束日期必须晚于开始日期');
+ return;
+ }
+ setEndTimeError('');
if (isOverdue && !overdueReason.trim()) return;
if (planType === 'research' && tasks.length === 0) return;
onSubmit({
@@ -318,7 +325,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
- setEndTime(e.target.value)} required 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" />
+ { setEndTime(e.target.value); setEndTimeError(''); }} required 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" />
+
{versionDeadline && (
diff --git a/apps/web/lib/members.ts b/apps/web/lib/members.ts
index e8d0743..4811ba3 100644
--- a/apps/web/lib/members.ts
+++ b/apps/web/lib/members.ts
@@ -23,6 +23,7 @@ export interface RoleItem {
description?: string;
createdAt: string;
isSystem?: boolean;
+ permissions: string[];
}
export interface PasswordRule {
diff --git a/apps/web/lib/permissions.ts b/apps/web/lib/permissions.ts
new file mode 100644
index 0000000..bcede05
--- /dev/null
+++ b/apps/web/lib/permissions.ts
@@ -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 = {
+ '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);
+}
diff --git a/apps/web/lib/version-progress.ts b/apps/web/lib/version-progress.ts
new file mode 100644
index 0000000..2cd43aa
--- /dev/null
+++ b/apps/web/lib/version-progress.ts
@@ -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 {
+ const map: Record = {};
+ for (const v of versions) {
+ map[v.id] = calcVersionProgress(v.id, plans, requirements, devTasks, testCases);
+ }
+ return map;
+}
diff --git a/apps/web/stores/useMemberStore.ts b/apps/web/stores/useMemberStore.ts
index 8e58d04..176556f 100644
--- a/apps/web/stores/useMemberStore.ts
+++ b/apps/web/stores/useMemberStore.ts
@@ -2,6 +2,7 @@
import { create } from 'zustand';
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
+import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
const STORAGE_KEY = 'ftb_members_v1';
@@ -16,11 +17,11 @@ const PRESET_DEPARTMENTS: Department[] = [
];
const PRESET_ROLES: RoleItem[] = [
- { id: 'role-admin', name: '超级管理员', description: '拥有系统全部权限', createdAt: '2024-01-01', isSystem: true },
- { id: 'role-pm', name: '产品经理', description: '管理产品和需求', createdAt: '2024-01-01' },
- { id: 'role-dev', name: '开发工程师', description: '负责开发任务', createdAt: '2024-01-01' },
- { id: 'role-test', name: '测试工程师', description: '负责测试任务', createdAt: '2024-01-01' },
- { id: 'role-design', name: '设计师', description: '负责UI/UX设计', createdAt: '2024-01-01' },
+ { id: 'role-admin', name: '超级管理员', description: '拥有系统全部权限', createdAt: '2024-01-01', isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS['role-admin'] },
+ { id: 'role-pm', name: '产品经理', description: '管理产品和需求', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-pm'] },
+ { id: 'role-dev', name: '开发工程师', description: '负责开发任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-dev'] },
+ { id: 'role-test', name: '测试工程师', description: '负责测试任务', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-test'] },
+ { id: 'role-design', name: '设计师', description: '负责UI/UX设计', createdAt: '2024-01-01', permissions: DEFAULT_ROLE_PERMISSIONS['role-design'] },
];
const MOCK_MEMBERS: Member[] = [
@@ -74,7 +75,16 @@ export const useMemberStore = create((set, get) => ({
fetchMembers: () => {
const cached = loadLocal();
- if (cached) set({ departments: cached.departments, members: cached.members, roles: cached.roles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE });
+ if (cached) {
+ // 迁移:旧 RoleItem 没 permissions 字段,补默认值
+ const migratedRoles = cached.roles.map((r) => {
+ if (!('permissions' in r) || !Array.isArray((r as any).permissions)) {
+ return { ...r, permissions: DEFAULT_ROLE_PERMISSIONS[r.id] ?? [] };
+ }
+ return r;
+ });
+ set({ departments: cached.departments, members: cached.members, roles: migratedRoles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE });
+ }
},
updatePasswordRule: (rule) => {
@@ -117,13 +127,26 @@ export const useMemberStore = create((set, get) => ({
},
createRole: (data) => {
- const role: RoleItem = { ...data, id: `role-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
+ const role: RoleItem = {
+ ...data,
+ id: `role-${Date.now()}`,
+ createdAt: new Date().toISOString().slice(0, 10),
+ permissions: data.permissions ?? [],
+ };
const roles = [...get().roles, role];
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
updateRole: (id, data) => {
- const roles = get().roles.map((r) => r.id === id ? { ...r, ...data } : r);
+ const roles = get().roles.map((r) => {
+ if (r.id !== id) return r;
+ // 超管不允许改 permissions(防误操作锁死系统)
+ if (r.id === 'role-admin' && data.permissions) {
+ const { permissions, ...rest } = data;
+ return { ...r, ...rest };
+ }
+ return { ...r, ...data };
+ });
set({ roles });
saveLocal({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
diff --git a/docs/superpowers/specs/2026-06-16-overtime-versions-projects-tweaks-design.md b/docs/superpowers/specs/2026-06-16-overtime-versions-projects-tweaks-design.md
new file mode 100644
index 0000000..82d4ebd
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-overtime-versions-projects-tweaks-design.md
@@ -0,0 +1,142 @@
+# 加班/版本/项目模块小幅调整设计
+
+## Context
+
+针对已上线的加班记录、版本列表、项目列表、全局日期校验做一组明确的小幅调整。所有需求点边界已通过澄清确认,方案直接,无需架构层面权衡。
+
+## 决策摘要
+
+| 项 | 决策 |
+|---|---|
+| 加班"已提交"定义 | 创建即提交,所有记录不可编辑只可删除 |
+| "创建日期"取值 | `OvertimeRecord.createdAt`(表单提交日期) |
+| 加班人字段 | 默认回显 `useAuthStore` 当前用户 + `disabled` 不可改 |
+| 版本列表移除范围 | 顶部 9 个状态 tab + 表格"状态"列 |
+| 项目页"未达 100%"取值 | 整体进度(与版本页同源算法) < 100% |
+| 日期校验提示 | 提交时验证 + 表单项下方红色文案 |
+
+## 1. 加班记录模块(`apps/web/app/overtime/page.tsx`)
+
+**字段调整**:
+- `OvertimeModal` 加 `useAuthStore` 引用,初始化 `person = useAuthStore(s => s.user?.name) ?? ''`
+- 加班人 input 加 `disabled` + 灰底(`bg-zinc-50`)
+- 编辑时仍以 record 的 `person` 为准(虽然这条路径会被移除)
+
+**列表表头**:
+- 在"备注"列前插入"创建日期"列
+- 单元格读 `r.createdAt`,已是 `YYYY-MM-DD` 格式直接显示
+
+**编辑功能移除**:
+- 删除:`editing` state、`handleEdit` 函数、表格"编辑"按钮、`OvertimeModal` 的 `initial` 编辑分支(保留 `initial: null` 用于校验空表单状态,或直接简化签名移除 initial)
+- `handleCreate` 简化为 `setShowModal(true)`
+- `OvertimeModal` props 删除 `initial`,简化为新建专用
+
+**日期校验**:
+- 提交时检查 `endTime > startTime`,失败设 `formError = '结束时间必须晚于开始时间'`
+- 在结束时间 input 下方显示红色文案 `{formError} `
+
+## 2. 全局日期校验
+
+**通用错误提示组件**:新增 `apps/web/components/FieldError.tsx`
+
+```tsx
+interface Props { message?: string }
+export function FieldError({ message }: Props) {
+ if (!message) return null;
+ return {message} ;
+}
+```
+
+**应用范围**(提交时校验,下方红色文案):
+
+- `apps/web/app/overtime/page.tsx` — OvertimeModal 结束时间下方
+- `apps/web/components/version/PlanTab.tsx` — 计划开始/结束日期对(按现有结构定位)
+- `apps/web/components/dev-task/DevTaskCreateModal.tsx` — 已有 `startBeforeEnd` 校验,统一改为用 `` 显示在预计截止下方
+- `apps/web/components/dev-task/DevTaskDetailDrawer.tsx` — 详情抽屉编辑(如有日期对编辑)
+
+**跳过**:
+- `apps/web/app/versions/page.tsx` 的 NewVersionModal 仅有截止日期,无开始
+- `apps/web/components/dev-task/WorklogPanel.tsx` 仅单 date 字段
+
+## 3. 版本列表(`apps/web/app/versions/page.tsx`)
+
+**移除**:
+- `STATUS_TABS` 常量(line 39-50)
+- 顶部状态 tab JSX 块(line 275-281)
+- 表头"状态"列(line 346)
+- 表格"状态"单元格(line 419-424)
+- `statusFilter` state 和过滤分支(保留 search/projectFilter/priorityFilter 三个)
+
+**保留**:项目筛选下拉、优先级筛选下拉、搜索框
+
+**整体进度** 列保留——它本身就反映状态信息。
+
+## 4. 项目页(`apps/web/app/projects/page.tsx`)
+
+**计算 versionProgressMap**:
+- `ProjectsPage` 加同款 `versionProgressMap` useMemo(与 versions/page.tsx line 141-212 同源算法)
+- 需要 import `useVersionPlanStore`、`useDevTaskStore`、`useTestCaseStore`、`useRequirementStore`、`STATUS_PROGRESS`、`getEstimateHours`
+- 抽出公共函数 `apps/web/lib/version-progress.ts`:
+
+```ts
+export function calcVersionProgress(
+ versionId: string,
+ plans: VersionPlan[],
+ requirements: Requirement[],
+ devTasks: DevTask[],
+ testCases: TestCase[],
+): number;
+
+export function buildVersionProgressMap(
+ versions: VersionWithContext[],
+ plans, requirements, devTasks, testCases,
+): Record;
+```
+
+把 versions/page.tsx 现有逻辑替换为调用这个工具,避免两份。
+
+**ProjectRow 改动**:
+- 接收 `versionProgressMap: Record` prop
+- `activeVersions = filter(v => (versionProgressMap[v.id] ?? 0) < 100)`
+- `releasedCount` 改为 `completedCount = filter(v => (versionProgressMap[v.id] ?? 0) >= 100)`
+- "+N 已发布"改为"+N 已完成"
+
+**Legend 调整**:
+- 现有图例 `开发中 / 规划中 / 已发布` 颜色与版本状态绑定,VersionChip 也是。继续保留 chip 内部颜色逻辑,仅过滤维度变了。Legend 文案保持不变(这是 chip 颜色含义说明)。
+
+## 数据流
+
+```
+overview / requirements / plans / devTasks / testCases
+ │
+ └── lib/version-progress.ts
+ │
+ ├── buildVersionProgressMap(versions, ...)
+ │ └── Record
+ │
+ ├── versions/page.tsx 整体进度列
+ └── projects/page.tsx ProjectRow 过滤未完成版本
+```
+
+## 实施清单
+
+新增:
+- `apps/web/components/FieldError.tsx`
+- `apps/web/lib/version-progress.ts`
+
+修改:
+- `apps/web/app/overtime/page.tsx`(加班人禁用 + 创建日期列 + 移除编辑 + 日期校验)
+- `apps/web/components/version/PlanTab.tsx`(日期校验)
+- `apps/web/components/dev-task/DevTaskCreateModal.tsx`(统一校验文案样式)
+- `apps/web/components/dev-task/DevTaskDetailDrawer.tsx`(如有日期对编辑则加校验)
+- `apps/web/app/versions/page.tsx`(移除状态 tab/列 + 改用 version-progress.ts)
+- `apps/web/app/projects/page.tsx`(加 progressMap + 改 ProjectRow 过滤口径)
+
+## 验证
+
+1. `pnpm type-check` 0 错误
+2. `pnpm build` 14 个页面成功
+3. 加班记录:新建表单加班人为当前登录用户名且不可改;列表有"创建日期"列;任何记录无"编辑"按钮,仅"删除"
+4. 加班/计划/开发任务表单:截止 < 开始时提交,下方显示红色文案,不能提交
+5. 版本列表:顶部无状态 tab;表头无状态列;只有项目+优先级两个下拉
+6. 项目列表:每行显示进度未达 100% 的版本 chip,已完成数量在末尾"+N 已完成"
diff --git a/docs/superpowers/specs/2026-06-16-role-permission-design.md b/docs/superpowers/specs/2026-06-16-role-permission-design.md
new file mode 100644
index 0000000..be703ff
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-role-permission-design.md
@@ -0,0 +1,254 @@
+# 角色权限设计
+
+## Context
+
+当前系统有 5 个默认角色(超管/产品经理/开发/测试/设计师),但 `RoleItem` 没有 `permissions` 字段,新增角色表单也只能填名字+描述。所有页面/按钮都没做权限拦截,任何登录用户都能进所有模块、做所有操作。
+
+本期目标:给角色加权限字段、为默认 5 角色按职能分配权限、新增角色 UI 支持勾选权限、并且把权限拦截一并接入到 7 个主模块 + 版本详情 7 个 Tab + 各按钮。
+
+## 决策摘要
+
+| 项 | 决策 |
+|---|---|
+| 权限粒度 | 细粒度(view/create/edit/delete + 加班特殊操作 export) |
+| 模块清单 | 7 主模块 + 7 个版本详情 Tab;与我相关不纳入 |
+| 数据结构 | `RoleItem.permissions: string[]`,`module:action` 形式(推荐方案 A) |
+| 超管标记 | `permissions: ['*']` 通配符 |
+| Bug Tab 粒度 | 单独拆 4 档(view/create/edit/delete),其他 Tab 二档 |
+| 默认 5 角色映射 | 按职能划分(详见下方) |
+| 新增角色默认值 | 全部不勾选(最小授权) |
+| 新增角色 UI | 现有表单内嵌权限矩阵 + 顶部"全选/反选/仅查看"快捷 |
+| 拦截接入范围 | 本期一并接入:路由级 + 按钮级 + Sidebar 菜单 + Tab 级 |
+| 未登录/无角色 | 默认按只读(最严格) |
+| 权限点总数 | 37 个 |
+| localStorage 迁移 | fetchMembers 启动检测旧 RoleItem 无 permissions 字段则补默认值 |
+
+## 1. 权限点字典(37 个)
+
+**主模块(4 件套,加班加 export)**:
+
+| 模块 | 权限点 |
+|---|---|
+| 产品 product | view / create / edit / delete |
+| 项目 project | view / create / edit / delete |
+| 版本 version | view / create / edit / delete |
+| 需求池 requirement | view / create / edit / delete |
+| 加班记录 overtime | view / create / delete / export |
+| 成员 member | view / create / edit / delete |
+| 角色 role | view / create / edit / delete |
+
+**版本详情 Tab(其他二档,Bug 单独 4 档)**:
+
+| Tab | 权限点 |
+|---|---|
+| 需求 Tab `version.req` | view / manage |
+| 调研 Tab `version.research` | view / manage |
+| 产品方案 Tab `version.product_plan` | view / manage |
+| UI 设计 Tab `version.ui_plan` | view / manage |
+| 开发任务 Tab `version.devtask` | view / manage |
+| 测试用例 Tab `version.testcase` | view / manage |
+| Bug Tab `version.bug` | view / create / edit / delete |
+
+**说明**:Tab 二档因为 Tab 内部操作高度耦合(manage = 该 Tab 内全部增删改)。Bug 单独拆 4 档因为开发与测试在 Bug 流程上职能不同:测试创建/关闭,开发改状态/修复,测试验证。
+
+## 2. 默认 5 角色权限映射
+
+**(1) 超级管理员 `role-admin`** — `permissions: ['*']`
+
+**(2) 产品经理 `role-pm`**:
+- 全权:product / project / version / requirement / version.req / version.product_plan
+- 仅查看:overtime(view)/ member(view)/ role(view)/ version.research / version.ui_plan / version.devtask / version.testcase / version.bug
+
+**(3) 开发工程师 `role-dev`**:
+- 全权:version.devtask
+- Bug Tab:view + edit
+- 加班:view + create
+- 仅查看:product / project / version / requirement / version.req / version.research / version.product_plan / version.ui_plan / version.testcase
+- 不可:member / role / overtime export+delete / 主模块的 create/edit/delete
+
+**(4) 测试工程师 `role-test`**:
+- 全权:version.testcase / version.bug(4 档全权)
+- 加班:view + create
+- 仅查看:product / project / version / requirement / version.req / version.research / version.product_plan / version.ui_plan / version.devtask
+- 不可:同开发
+
+**(5) 设计师 `role-design`**:
+- 全权:version.ui_plan
+- 加班:view + create
+- 仅查看:product / project / version / requirement / version.req / version.research / version.product_plan / version.devtask / version.testcase / version.bug
+- 不可:同开发
+
+## 3. 数据结构 + Store
+
+**`apps/web/lib/members.ts`**:`RoleItem` 加 `permissions: string[]`(必填,默认 `[]`)
+
+**`apps/web/lib/permissions.ts`** 新增:
+
+```ts
+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[];
+}
+
+export const PERMISSION_GROUPS: PermissionGroup[] = [/* 14 组 */];
+export const ALL_PERMISSIONS: string[] = /* 37 个展开 */;
+export const DEFAULT_ROLE_PERMISSIONS: Record = {
+ 'role-admin': ['*'],
+ 'role-pm': [...],
+ 'role-dev': [...],
+ 'role-test': [...],
+ 'role-design': [...],
+};
+
+export function hasPermission(role: RoleItem | undefined, permission: string): boolean {
+ if (!role) return false;
+ if (role.permissions.includes('*')) return true;
+ return role.permissions.includes(permission);
+}
+```
+
+**`apps/web/stores/useMemberStore.ts`**:
+- `PRESET_ROLES` 5 条加 `permissions`
+- `fetchMembers` 加迁移:旧 RoleItem 无 permissions 字段时补默认值
+- `createRole/updateRole` 接收 `permissions: string[]`
+- 超管 `role-admin` 不可改 permissions(`updateRole` 拦截)
+
+## 4. UI 改动
+
+**(A) 角色表单**(`apps/web/app/admin/roles/page.tsx`)
+
+`RoleModal` 加权限区块(在 description 下方)。Modal 宽度 `max-w-xs` → `max-w-2xl`。
+
+布局:
+
+```
+[全选] [反选] [仅查看] ← 顶部快捷按钮
+
+主模块
+─────────────────────────
+产品 ☐查看 ☐创建 ☐编辑 ☐删除
+项目 ☐查看 ☐创建 ☐编辑 ☐删除
+版本 ☐查看 ☐创建 ☐编辑 ☐删除
+需求池 ☐查看 ☐创建 ☐编辑 ☐删除
+加班记录 ☐查看 ☐创建 ☐删除 ☐导出
+成员 ☐查看 ☐创建 ☐编辑 ☐删除
+角色 ☐查看 ☐创建 ☐编辑 ☐删除
+
+版本详情 Tab
+─────────────────────────
+需求 Tab ☐查看 ☐管理
+调研 Tab ☐查看 ☐管理
+产品方案 Tab ☐查看 ☐管理
+UI 设计 Tab ☐查看 ☐管理
+开发任务 Tab ☐查看 ☐管理
+测试用例 Tab ☐查看 ☐管理
+Bug Tab ☐查看 ☐创建 ☐编辑 ☐删除
+```
+
+- 系统角色(`isSystem`)表单顶部黄色提示"超管权限不可修改",所有 checkbox 设 `disabled`
+- 编辑态:`initial.permissions` 默认勾选
+
+**(B) 全局拦截层**(`apps/web/components/auth/Guard.tsx` 新增)
+
+```tsx
+// 路由级
+
+
+
+
+// 按钮级
+
+
+
+
+// hook
+const canEdit = useHasPermission('product:edit');
+```
+
+`useHasPermission` 实现:从 `useAuthStore` 取 user.roleId,从 `useMemberStore` 查 role.permissions,调 `hasPermission`。
+
+**(C) 接入清单**
+
+**路由级**:
+- `/products` `product:view`
+- `/projects` `project:view`
+- `/versions` `version:view`
+- `/requirements` `requirement:view`
+- `/overtime` `overtime:view`
+- `/admin/members` `member:view`
+- `/admin/roles` `role:view`
+- 无权限:`` 占位组件(不跳转,避免循环)
+- `/workspace` 不拦截
+
+**按钮级**:
+- 各页面"新建/编辑/删除/导出"按钮包 `PermissionGuard`
+- `OvertimeModal` 内的"删除原因"按钮要 `overtime:delete`
+
+**Sidebar 菜单过滤**:
+- 7 个主菜单按 `:view` 过滤——无权限的菜单不显示(不灰显)
+- 工作区组永远显示(与我相关)
+
+**版本详情 Tab 过滤**(`apps/web/app/versions/[id]/page.tsx`):
+- Tab 列表渲染时按 `version.:view` 过滤——无权限的 Tab 不渲染
+- Tab 内"新建/管理"按钮按对应 manage / 4 档权限拦截
+
+**(D) 默认状态处理**
+
+- mock 用户已登录但 role 找不到 / 用户未登录:当前组件 `useHasPermission` 返回 `false`,所有页面/菜单/按钮都显示无权限——最严格只读
+- 现有 mock 数据 `m-8 陈十` 是 `role-admin`,登录该用户即超管全权
+- 切换其他 mock 用户(PM/开发/测试/设计)测试不同视图
+
+## 数据流
+
+```
+useAuthStore.user
+ │
+ └─ user.roleId
+ │
+ └─ useMemberStore.roles.find(r => r.id === user.roleId)
+ │
+ └─ role.permissions: string[]
+ │
+ ├─ useHasPermission(perm) ─→ 按钮/Guard
+ ├─ Sidebar 菜单过滤 ─→ 顶级菜单
+ └─ Tab 过滤 ─→ 版本详情
+```
+
+## 实施清单
+
+新增:
+- `apps/web/lib/permissions.ts` — 权限字典 + DEFAULT_ROLE_PERMISSIONS + hasPermission
+- `apps/web/components/auth/Guard.tsx` — RouteGuard + PermissionGuard + useHasPermission + AccessDenied
+
+修改:
+- `apps/web/lib/members.ts` — RoleItem 加 permissions
+- `apps/web/stores/useMemberStore.ts` — PRESET_ROLES 补 permissions + 迁移逻辑 + 超管保护
+- `apps/web/app/admin/roles/page.tsx` — RoleModal 加权限矩阵 + 顶部快捷
+- `apps/web/components/layout/Sidebar.tsx` — 菜单按 view 过滤
+- `apps/web/app/products/page.tsx` — RouteGuard + 按钮拦截
+- `apps/web/app/projects/page.tsx` — 同上
+- `apps/web/app/versions/page.tsx` — 同上
+- `apps/web/app/requirements/page.tsx` — 同上
+- `apps/web/app/overtime/page.tsx` — 同上 + export 按钮拦截
+- `apps/web/app/admin/members/page.tsx` — 同上
+- `apps/web/app/versions/[id]/page.tsx` — Tab 过滤 + Tab 内按钮拦截
+
+## 验证
+
+1. `pnpm type-check` 0 错误
+2. `pnpm build` 14 个页面成功
+3. 角色页:超管显示"系统不可修改"+ checkbox disabled;其他 4 个角色显示已勾选权限
+4. 新建角色:勾选"产品-查看 + 加班-查看"保存,应能在新角色下看到对应菜单
+5. 切换 mock 用户为开发工程师(如李四 m-2):Sidebar 看不到成员/角色菜单;进版本详情看不到测试用例/Bug 的 manage 按钮(但能看 view)
+6. 切换设计师(孙七 m-5):UI 设计 Tab 全权,开发任务 Tab 只读
+7. localStorage 旧 RoleItem 无 permissions 字段:启动后自动补默认值,UI 正常渲染
|