diff --git a/apps/web/app/admin/members/page.tsx b/apps/web/app/admin/members/page.tsx index e178a1a..32bc66c 100644 --- a/apps/web/app/admin/members/page.tsx +++ b/apps/web/app/admin/members/page.tsx @@ -5,8 +5,17 @@ import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'luc import { useMemberStore } from '@/stores/useMemberStore'; import { maskPhone, generatePassword } from '@/lib/members'; import type { Member, Department, PasswordRule } from '@/lib/members'; +import { RouteGuard } from '@/components/auth/Guard'; export default function MembersPage() { + return ( + + + + ); +} + +function MembersPageContent() { const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore(); const [activeDeptId, setActiveDeptId] = useState(null); const [showMemberModal, setShowMemberModal] = useState(false); diff --git a/apps/web/app/admin/roles/page.tsx b/apps/web/app/admin/roles/page.tsx index b3420f3..d36a3aa 100644 --- a/apps/web/app/admin/roles/page.tsx +++ b/apps/web/app/admin/roles/page.tsx @@ -1,14 +1,27 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { Plus, Pencil, Trash2, X, Shield } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Plus, X, Shield } from 'lucide-react'; import { useMemberStore } from '@/stores/useMemberStore'; +import { PERMISSION_GROUPS, ALL_PERMISSIONS } from '@/lib/permissions'; +import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; import type { RoleItem } from '@/lib/members'; export default function RolesPage() { + return ( + + + + ); +} + +function RolesPageContent() { const { roles, fetchMembers, createRole, updateRole, deleteRole } = useMemberStore(); const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); + const canCreate = useHasPermission('role:create'); + const canEdit = useHasPermission('role:edit'); + const canDelete = useHasPermission('role:delete'); useEffect(() => { fetchMembers(); }, [fetchMembers]); @@ -19,7 +32,7 @@ export default function RolesPage() {

角色管理

{roles.length} - @@ -39,24 +52,24 @@ export default function RolesPage() { {role.isSystem && ( 系统 )} + {role.permissions.includes('*') ? '全部权限' : `${role.permissions.length} 项权限`} {role.description && (

{role.description}

)} - {!role.isSystem && ( -
- +
+ + {!role.isSystem && canDelete && ( -
- )} + )} +
))} - {/* Modal */} {showModal && ( void; - onSubmit: (data: { name: string; description?: string }) => void; + onSubmit: (data: { name: string; description?: string; permissions: string[] }) => void; }) { const [name, setName] = useState(initial?.name ?? ''); const [description, setDescription] = useState(initial?.description ?? ''); + const [permissions, setPermissions] = useState>(new Set(initial?.permissions ?? [])); + const isReadOnly = !!initial?.isSystem; + const isAdminWildcard = permissions.has('*'); + + const togglePerm = (p: string) => { + if (isReadOnly) return; + const next = new Set(permissions); + if (next.has(p)) next.delete(p); else next.add(p); + setPermissions(next); + }; + + const handleSelectAll = () => setPermissions(new Set(ALL_PERMISSIONS)); + const handleSelectNone = () => setPermissions(new Set()); + const handleViewOnly = () => { + const viewOnly = ALL_PERMISSIONS.filter((p) => p.endsWith(':view')); + setPermissions(new Set(viewOnly)); + }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!name.trim()) return; - onSubmit({ name: name.trim(), description: description.trim() || undefined }); + if (!name.trim() || isReadOnly) return; + onSubmit({ name: name.trim(), description: description.trim() || undefined, permissions: Array.from(permissions) }); }; + const mainGroups = useMemo(() => PERMISSION_GROUPS.filter((g) => g.category === 'main'), []); + const tabGroups = useMemo(() => PERMISSION_GROUPS.filter((g) => g.category === 'version_tab'), []); + return (
-
e.stopPropagation()}> -
-

{initial ? '编辑角色' : '新建角色'}

+
e.stopPropagation()}> +
+

{initial ? (isReadOnly ? '查看角色' : '编辑角色') : '新建角色'}

-
-
- - setName(e.target.value)} required placeholder="如:项目经理" 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" /> + + {isReadOnly && ( +
+ 系统角色不可修改,权限为系统预设 +
+ )} +
+
+ + setName(e.target.value)} required disabled={isReadOnly} placeholder="如:项目经理" 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 disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" /> +
+
+ + setDescription(e.target.value)} disabled={isReadOnly} placeholder="可选" 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 disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" /> +
-
- - setDescription(e.target.value)} placeholder="可选" 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" /> + +
+
+ + {!isReadOnly && ( +
+ + + +
+ )} +
+ {isAdminWildcard ? ( +
+ 此角色拥有所有权限(通配符 *) +
+ ) : ( +
+ + +
+ )}
-
- - + +
+ + {!isReadOnly && ( + + )}
); } + +function PermissionSection({ title, groups, permissions, togglePerm, disabled }: { + title: string; + groups: typeof PERMISSION_GROUPS; + permissions: Set; + togglePerm: (p: string) => void; + disabled: boolean; +}) { + return ( +
+
{title}
+
+ {groups.map((g) => ( +
+ {g.moduleLabel} +
+ {g.actions.map((a) => ( + + ))} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/app/overtime/page.tsx b/apps/web/app/overtime/page.tsx index ca1db5f..9722ad7 100644 --- a/apps/web/app/overtime/page.tsx +++ b/apps/web/app/overtime/page.tsx @@ -1,10 +1,11 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { Search, Plus, ChevronDown, Clock, Pencil, Trash2, X, Download } from 'lucide-react'; +import { Search, Plus, X, Download } from 'lucide-react'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; +import { useAuthStore } from '@/stores/useAuthStore'; import { flattenProjects, flattenVersions } from '@/lib/derive'; import { calcDuration } from '@/lib/overtime'; import type { OvertimeRecord } from '@/lib/overtime'; @@ -12,11 +13,22 @@ import { Pagination, usePagination } from '@/components/Pagination'; import { DictDrawer } from '@/components/requirement/DictDrawer'; import { MonthPicker } from '@/components/MonthPicker'; import { FilterSelect } from '@/components/FilterSelect'; +import { FieldError } from '@/components/FieldError'; +import { RouteGuard } from '@/components/auth/Guard'; export default function OvertimePage() { - const { records, fetchRecords, createRecord, updateRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore(); + return ( + + + + ); +} + +function OvertimePageContent() { + const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore(); const { overview, fetchOverview } = useProductStore(); const { requirements, fetchRequirements } = useRequirementStore(); + const user = useAuthStore((s) => s.user); const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); @@ -25,7 +37,6 @@ export default function OvertimePage() { const [reasonFilter, setReasonFilter] = useState('all'); const [monthFilter, setMonthFilter] = useState(''); const [showModal, setShowModal] = useState(false); - const [editing, setEditing] = useState(null); const [showReasonDrawer, setShowReasonDrawer] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); @@ -48,8 +59,7 @@ export default function OvertimePage() { const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); - const handleCreate = () => { setEditing(null); setShowModal(true); }; - const handleEdit = (r: OvertimeRecord) => { setEditing(r); setShowModal(true); }; + const handleCreate = () => { setShowModal(true); }; const handleExport = () => { const header = ['项目', '版本', '加班人', '开始时间', '结束时间', '时长(h)', '加班原因', '备注']; @@ -139,6 +149,7 @@ export default function OvertimePage() { 时长 加班原因 备注 + 创建日期 操作 @@ -161,9 +172,9 @@ export default function OvertimePage() { {r.remark || '-'} + {r.createdAt}
-
@@ -180,7 +191,7 @@ export default function OvertimePage() { {/* Modal */} {showModal && ( ({ id: p.id, name: p.name }))} projects={allProjects.map((p) => ({ id: p.id, name: p.name, productId: p.productId }))} versions={allVersions} @@ -188,8 +199,7 @@ export default function OvertimePage() { requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))} onClose={() => setShowModal(false)} onSubmit={(data) => { - if (editing) updateRecord(editing.id, data); - else createRecord(data as any); + createRecord(data as any); setShowModal(false); }} /> @@ -203,8 +213,8 @@ export default function OvertimePage() { ); } -function OvertimeModal({ initial, products, projects, versions, reasons, requirements, onClose, onSubmit }: { - initial: OvertimeRecord | null; +function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: { + defaultPerson: string; products: { id: string; name: string }[]; projects: { id: string; name: string; productId: string }[]; versions: { id: string; name: string; projectId?: string }[]; @@ -214,22 +224,14 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require onSubmit: (data: any) => void; }) { const [productId, setProductId] = useState(''); - const [projectId, setProjectId] = useState(initial?.projectId ?? ''); - const [versionId, setVersionId] = useState(initial?.versionId ?? ''); - const [person, setPerson] = useState(initial?.person ?? ''); - const [startTime, setStartTime] = useState(initial?.startTime ?? ''); - const [endTime, setEndTime] = useState(initial?.endTime ?? ''); - const [reasonId, setReasonId] = useState(initial?.reasonId ?? ''); - const [remark, setRemark] = useState(initial?.remark ?? ''); - const [requirementId, setRequirementId] = useState(initial?.requirementId ?? ''); - - // 初始编辑时反推 productId - useEffect(() => { - if (initial?.projectId) { - const proj = projects.find((p) => p.id === initial.projectId); - if (proj) setProductId(proj.productId); - } - }, [initial, projects]); + const [projectId, setProjectId] = useState(''); + const [versionId, setVersionId] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [reasonId, setReasonId] = useState(''); + const [remark, setRemark] = useState(''); + const [requirementId, setRequirementId] = useState(''); + const [endTimeError, setEndTimeError] = useState(''); const duration = startTime && endTime ? calcDuration(startTime, endTime) : 0; const filteredProjects = productId ? projects.filter((p) => p.productId === productId) : projects; @@ -238,15 +240,20 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!projectId || !person.trim() || !startTime || !endTime || !reasonId) return; - onSubmit({ projectId, versionId: versionId || undefined, requirementId: requirementId || undefined, person: person.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined }); + if (!projectId || !defaultPerson.trim() || !startTime || !endTime || !reasonId) return; + if (new Date(endTime).getTime() <= new Date(startTime).getTime()) { + setEndTimeError('结束时间必须晚于开始时间'); + return; + } + setEndTimeError(''); + onSubmit({ projectId, versionId: versionId || undefined, requirementId: requirementId || undefined, person: defaultPerson.trim(), startTime, endTime, reasonId, remark: remark.trim() || undefined }); }; return (
e.stopPropagation()}>
-

{initial ? '编辑加班记录' : '新建加班记录'}

+

新建加班记录

@@ -274,17 +281,18 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
- - setPerson(e.target.value)} required placeholder="姓名" 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" /> + +
- setStartTime(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" /> + { setStartTime(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" />
- 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" /> +
{duration > 0 && ( @@ -314,7 +322,7 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
- +
diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index d2bcc0c..54b4a01 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { RouteGuard } from '@/components/auth/Guard'; import { useState, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import { @@ -331,6 +332,14 @@ function MigrateDialog({ /* ─── Main Page ─── */ export default function ProductsPage() { + return ( + + + + ); +} + +function ProductsPageContent() { const router = useRouter(); const { overview, diff --git a/apps/web/app/projects/page.tsx b/apps/web/app/projects/page.tsx index 7518665..7d760eb 100644 --- a/apps/web/app/projects/page.tsx +++ b/apps/web/app/projects/page.tsx @@ -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 ( + + + + ); +} + +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('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() {
{paged.map((proj) => ( - router.push(`/projects/${proj.id}`)} /> + router.push(`/projects/${proj.id}`)} /> ))}
@@ -123,13 +148,15 @@ export default function ProjectsPage() { function ProjectRow({ proj, + versionProgressMap, onClick, }: { proj: ProjectWithContext; + versionProgressMap: Record; 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 (
diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index 9a96793..00e6002 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { RouteGuard } from '@/components/auth/Guard'; import { useEffect, useMemo, useState } from 'react'; import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -37,6 +38,14 @@ const STATUS_TABS: { key: string; label: string }[] = [ ]; export default function RequirementsPage() { + return ( + + + + ); +} + +function RequirementsPageContent() { const { requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement, sourceTargets, types, platforms, diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 93cd491..2d2a6e3 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -27,6 +27,7 @@ import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { calcGroupProgress as calcDevTaskProgress, getActualHours, getEstimateHours, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task'; +import { hasPermission } from '@/lib/permissions'; import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours'; import { testCaseIntervals } from '@/lib/test-case'; import { bugIntervals } from '@/lib/bug'; @@ -42,14 +43,14 @@ const PRIORITY_STYLE: Record = { }; const TABS = [ - { key: 'overview', label: '概览' }, - { key: 'requirements', label: '关联需求' }, - { key: 'research', label: '调研' }, - { key: 'product', label: '产品方案' }, - { key: 'ui', label: 'UI设计' }, - { key: 'tasks', label: '开发任务' }, - { key: 'testcases', label: '测试用例' }, - { key: 'bugs', label: 'BUG' }, + { key: 'overview', label: '概览', permission: null as string | null }, + { key: 'requirements', label: '关联需求', permission: 'version.req:view' }, + { key: 'research', label: '调研', permission: 'version.research:view' }, + { key: 'product', label: '产品方案', permission: 'version.product_plan:view' }, + { key: 'ui', label: 'UI设计', permission: 'version.ui_plan:view' }, + { key: 'tasks', label: '开发任务', permission: 'version.devtask:view' }, + { key: 'testcases', label: '测试用例', permission: 'version.testcase:view' }, + { key: 'bugs', label: 'BUG', permission: 'version.bug:view' }, ]; export default function VersionDetailPage() { @@ -64,8 +65,18 @@ export default function VersionDetailPage() { const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs, deleteBug } = useBugStore(); const user = useAuthStore((s) => s.user); - const { members: allMembers } = useMemberStore(); + const { members: allMembers, roles } = useMemberStore(); + const currentRole = useMemo(() => roles.find((r) => r.id === user?.roleId), [roles, user?.roleId]); + const visibleTabs = useMemo( + () => TABS.filter((t) => t.permission === null || hasPermission(currentRole, t.permission)), + [currentRole], + ); const [activeTab, setActiveTab] = useState('overview'); + useEffect(() => { + if (visibleTabs.length > 0 && !visibleTabs.find((t) => t.key === activeTab)) { + setActiveTab(visibleTabs[0].key); + } + }, [visibleTabs, activeTab]); const [showMemberModal, setShowMemberModal] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); @@ -212,7 +223,7 @@ export default function VersionDetailPage() { {/* Tab bar */}
- {TABS.map((tab) => ( + {visibleTabs.map((tab) => (
-
- {STATUS_TABS.map((tab) => ( - - ))} -
{/* Project dropdown */}
@@ -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 正常渲染