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:
@@ -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 (
|
||||
<RouteGuard permission="member:view">
|
||||
<MembersPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersPageContent() {
|
||||
const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore();
|
||||
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
|
||||
@@ -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 (
|
||||
<RouteGuard permission="role:view">
|
||||
<RolesPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesPageContent() {
|
||||
const { roles, fetchMembers, createRole, updateRole, deleteRole } = useMemberStore();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editing, setEditing] = useState<RoleItem | null>(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() {
|
||||
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">角色管理</h1>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{roles.length}</span>
|
||||
</div>
|
||||
<button onClick={() => { setEditing(null); setShowModal(true); }} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||
<button onClick={() => { setEditing(null); setShowModal(true); }} disabled={!canCreate} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建角色
|
||||
</button>
|
||||
@@ -39,24 +52,24 @@ export default function RolesPage() {
|
||||
{role.isSystem && (
|
||||
<span className="rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 border border-amber-200">系统</span>
|
||||
)}
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">{role.permissions.includes('*') ? '全部权限' : `${role.permissions.length} 项权限`}</span>
|
||||
</div>
|
||||
{role.description && (
|
||||
<p className="text-[12px] text-[var(--ink-muted)] mt-0.5">{role.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { setEditing(role); setShowModal(true); }} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">编辑</button>
|
||||
<button onClick={() => { setEditing(role); setShowModal(true); }} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors">{role.isSystem || !canEdit ? '查看' : '编辑'}</button>
|
||||
{!role.isSystem && canDelete && (
|
||||
<button onClick={() => deleteRole(role.id)} className="h-7 px-2.5 rounded-md text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors">删除</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<RoleModal
|
||||
initial={editing}
|
||||
@@ -75,39 +88,127 @@ export default function RolesPage() {
|
||||
function RoleModal({ initial, onClose, onSubmit }: {
|
||||
initial: RoleItem | null;
|
||||
onClose: () => 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<Set<string>>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-xs rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑角色' : '新建角色'}</h3>
|
||||
<div className="w-full max-w-2xl max-h-[85vh] flex flex-col rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? (isReadOnly ? '查看角色' : '编辑角色') : '新建角色'}</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 overflow-hidden">
|
||||
{isReadOnly && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-[11px] text-amber-700">
|
||||
系统角色不可修改,权限为系统预设
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">角色名称 *</label>
|
||||
<input value={name} onChange={(e) => 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" />
|
||||
<input value={name} onChange={(e) => 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)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">描述</label>
|
||||
<input value={description} onChange={(e) => 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" />
|
||||
<input value={description} onChange={(e) => 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)]" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)]">权限</label>
|
||||
{!isReadOnly && (
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" onClick={handleSelectAll} className="h-6 px-2 rounded text-[11px] border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">全选</button>
|
||||
<button type="button" onClick={handleViewOnly} className="h-6 px-2 rounded text-[11px] border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">仅查看</button>
|
||||
<button type="button" onClick={handleSelectNone} className="h-6 px-2 rounded text-[11px] border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">反选</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdminWildcard ? (
|
||||
<div className="rounded-lg bg-emerald-50 border border-emerald-200 px-3 py-2 text-[11px] text-emerald-700">
|
||||
此角色拥有所有权限(通配符 *)
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto space-y-3 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] p-3">
|
||||
<PermissionSection title="主模块" groups={mainGroups} permissions={permissions} togglePerm={togglePerm} disabled={isReadOnly} />
|
||||
<PermissionSection title="版本详情 Tab" groups={tabGroups} permissions={permissions} togglePerm={togglePerm} disabled={isReadOnly} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 shrink-0">
|
||||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">{isReadOnly ? '关闭' : '取消'}</button>
|
||||
{!isReadOnly && (
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionSection({ title, groups, permissions, togglePerm, disabled }: {
|
||||
title: string;
|
||||
groups: typeof PERMISSION_GROUPS;
|
||||
permissions: Set<string>;
|
||||
togglePerm: (p: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)] mb-2">{title}</div>
|
||||
<div className="space-y-1.5">
|
||||
{groups.map((g) => (
|
||||
<div key={g.module} className="flex items-center gap-3 rounded-md bg-[var(--bg-card)] px-3 py-1.5">
|
||||
<span className="text-[12px] font-medium text-[var(--ink)] w-28 shrink-0">{g.moduleLabel}</span>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{g.actions.map((a) => (
|
||||
<label key={a.permission} className={`flex items-center gap-1 text-[11px] ${disabled ? 'cursor-default' : 'cursor-pointer'} ${disabled ? 'text-[var(--ink-muted)]' : 'text-[var(--ink-soft)]'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.has(a.permission)}
|
||||
onChange={() => togglePerm(a.permission)}
|
||||
disabled={disabled}
|
||||
className="h-3.5 w-3.5 rounded border-[var(--line)]"
|
||||
/>
|
||||
{a.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<RouteGuard permission="overtime:view">
|
||||
<OvertimePageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
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<OvertimeRecord | null>(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() {
|
||||
<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-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -161,9 +172,9 @@ export default function OvertimePage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-muted)] max-w-[120px] truncate">{r.remark || '-'}</td>
|
||||
<td className="px-4 py-3 tabular-nums text-[var(--ink-muted)]">{r.createdAt}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => handleEdit(r)} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">编辑</button>
|
||||
<button onClick={() => deleteRecord(r.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -180,7 +191,7 @@ export default function OvertimePage() {
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<OvertimeModal
|
||||
initial={editing}
|
||||
defaultPerson={user?.name ?? ''}
|
||||
products={overview.map((p) => ({ 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{initial ? '编辑加班记录' : '新建加班记录'}</h3>
|
||||
<h3 className="text-[13px] font-semibold text-[var(--ink)]">新建加班记录</h3>
|
||||
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
@@ -274,17 +281,18 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">加班人 *</label>
|
||||
<input value={person} onChange={(e) => 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" />
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">加班人</label>
|
||||
<input value={defaultPerson} disabled className="h-9 w-full rounded-lg border border-[var(--line)] bg-zinc-50 px-3 text-[13px] text-[var(--ink-soft)] cursor-not-allowed" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">开始时间 *</label>
|
||||
<input type="datetime-local" value={startTime} onChange={(e) => 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" />
|
||||
<input type="datetime-local" value={startTime} onChange={(e) => { 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">结束时间 *</label>
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => 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" />
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => { 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" />
|
||||
<FieldError message={endTimeError} />
|
||||
</div>
|
||||
</div>
|
||||
{duration > 0 && (
|
||||
@@ -314,7 +322,7 @@ function OvertimeModal({ initial, products, projects, versions, reasons, require
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">取消</button>
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">{initial ? '保存' : '创建'}</button>
|
||||
<button type="submit" className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)]">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<RouteGuard permission="product:view">
|
||||
<ProductsPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsPageContent() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
overview,
|
||||
|
||||
@@ -1,26 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, FolderKanban, Package, ChevronDown, Plus, X } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { flattenProjects, ProjectWithContext } from '@/lib/derive';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { flattenProjects, flattenVersions, ProjectWithContext } from '@/lib/derive';
|
||||
import { VersionChip } from '@/components/version/VersionChip';
|
||||
import { VersionStatus } from '@/lib/version-status';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
|
||||
export default function ProjectsPage() {
|
||||
return (
|
||||
<RouteGuard permission="project:view">
|
||||
<ProjectsPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectsPageContent() {
|
||||
const router = useRouter();
|
||||
const { overview, fetchOverview, createProject } = useProductStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { plans, fetchPlans } = useVersionPlanStore();
|
||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||
const [search, setSearch] = useState('');
|
||||
const [productFilter, setProductFilter] = useState<string>('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverview();
|
||||
}, [fetchOverview]);
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
|
||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
|
||||
const versionProgressMap = useMemo(
|
||||
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
|
||||
[allVersions, plans, requirements, devTasks, testCases],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return allProjects.filter((p) => {
|
||||
@@ -96,7 +121,7 @@ export default function ProjectsPage() {
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
||||
{paged.map((proj) => (
|
||||
<ProjectRow key={proj.id} proj={proj} onClick={() => router.push(`/projects/${proj.id}`)} />
|
||||
<ProjectRow key={proj.id} proj={proj} versionProgressMap={versionProgressMap} onClick={() => router.push(`/projects/${proj.id}`)} />
|
||||
))}
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
@@ -123,13 +148,15 @@ export default function ProjectsPage() {
|
||||
|
||||
function ProjectRow({
|
||||
proj,
|
||||
versionProgressMap,
|
||||
onClick,
|
||||
}: {
|
||||
proj: ProjectWithContext;
|
||||
versionProgressMap: Record<string, number>;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const activeVersions = proj.versions.filter((v) => v.status !== 'released');
|
||||
const releasedCount = proj.versions.filter((v) => v.status === 'released').length;
|
||||
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
|
||||
const completedCount = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) >= 100).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -152,9 +179,9 @@ function ProjectRow({
|
||||
{activeVersions.map((v) => (
|
||||
<VersionChip key={v.id} name={v.name} status={v.status} />
|
||||
))}
|
||||
{releasedCount > 0 && (
|
||||
{completedCount > 0 && (
|
||||
<span className="text-xs text-[var(--ink-muted)] ml-1">
|
||||
+{releasedCount} 已发布
|
||||
+{completedCount} 已完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<RouteGuard permission="requirement:view">
|
||||
<RequirementsPageContent />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementsPageContent() {
|
||||
const {
|
||||
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
|
||||
sourceTargets, types, platforms,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center gap-0 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
|
||||
{TABS.map((tab) => (
|
||||
{visibleTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
10
apps/web/components/FieldError.tsx
Normal file
10
apps/web/components/FieldError.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function FieldError({ message }: Props) {
|
||||
if (!message) return null;
|
||||
return <p className="mt-1 text-[11px] text-red-500">{message}</p>;
|
||||
}
|
||||
58
apps/web/components/auth/Guard.tsx
Normal file
58
apps/web/components/auth/Guard.tsx
Normal file
@@ -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 <AccessDenied />;
|
||||
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 (
|
||||
<div className="flex h-full items-center justify-center bg-[var(--bg)]">
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] px-12 py-16">
|
||||
<Lock className="h-8 w-8 text-[var(--ink-muted)]" strokeWidth={1.5} />
|
||||
<p className="text-[14px] font-medium text-[var(--ink-soft)]">无权访问该模块</p>
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">请联系管理员开通对应权限</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
<nav className="flex-1 px-2 pt-2">
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} className="mb-4">
|
||||
<p className="mb-1 px-2 text-[11px] font-medium text-[var(--ink-muted)]">
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isActive(item.path);
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => router.push(item.path)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent-hover)]'
|
||||
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<NavGroup key={group.label} label={group.label} items={group.items} isActive={isActive} onNavigate={(p) => router.push(p)} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -92,3 +71,44 @@ export function Sidebar() {
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-4">
|
||||
<p className="mb-1 px-2 text-[11px] font-medium text-[var(--ink-muted)]">{label}</p>
|
||||
{items.map((item) => (
|
||||
<NavItem key={item.path} item={item} active={isActive(item.path)} onNavigate={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
onClick={() => onNavigate(item.path)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent-hover)]'
|
||||
: 'text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" strokeWidth={1.75} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Set<string>>(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
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">计划截止时间</label>
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => 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" />
|
||||
<input type="datetime-local" value={endTime} onChange={(e) => { 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" />
|
||||
<FieldError message={endTimeError} />
|
||||
</div>
|
||||
</div>
|
||||
{versionDeadline && (
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface RoleItem {
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
isSystem?: boolean;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface PasswordRule {
|
||||
|
||||
101
apps/web/lib/permissions.ts
Normal file
101
apps/web/lib/permissions.ts
Normal file
@@ -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<string, string[]> = {
|
||||
'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);
|
||||
}
|
||||
97
apps/web/lib/version-progress.ts
Normal file
97
apps/web/lib/version-progress.ts
Normal file
@@ -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<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
for (const v of versions) {
|
||||
map[v.id] = calcVersionProgress(v.id, plans, requirements, devTasks, testCases);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -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<MemberState>((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<MemberState>((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 });
|
||||
},
|
||||
|
||||
@@ -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 下方显示红色文案 `<p className="text-[11px] text-red-500 mt-1">{formError}</p>`
|
||||
|
||||
## 2. 全局日期校验
|
||||
|
||||
**通用错误提示组件**:新增 `apps/web/components/FieldError.tsx`
|
||||
|
||||
```tsx
|
||||
interface Props { message?: string }
|
||||
export function FieldError({ message }: Props) {
|
||||
if (!message) return null;
|
||||
return <p className="mt-1 text-[11px] text-red-500">{message}</p>;
|
||||
}
|
||||
```
|
||||
|
||||
**应用范围**(提交时校验,下方红色文案):
|
||||
|
||||
- `apps/web/app/overtime/page.tsx` — OvertimeModal 结束时间下方
|
||||
- `apps/web/components/version/PlanTab.tsx` — 计划开始/结束日期对(按现有结构定位)
|
||||
- `apps/web/components/dev-task/DevTaskCreateModal.tsx` — 已有 `startBeforeEnd` 校验,统一改为用 `<FieldError>` 显示在预计截止下方
|
||||
- `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<string, number>;
|
||||
```
|
||||
|
||||
把 versions/page.tsx 现有逻辑替换为调用这个工具,避免两份。
|
||||
|
||||
**ProjectRow 改动**:
|
||||
- 接收 `versionProgressMap: Record<string, number>` 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<versionId, percent>
|
||||
│
|
||||
├── 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 已完成"
|
||||
254
docs/superpowers/specs/2026-06-16-role-permission-design.md
Normal file
254
docs/superpowers/specs/2026-06-16-role-permission-design.md
Normal file
@@ -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<string, string[]> = {
|
||||
'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
|
||||
// 路由级
|
||||
<RouteGuard permission="product:view">
|
||||
<ProductsPage />
|
||||
</RouteGuard>
|
||||
|
||||
// 按钮级
|
||||
<PermissionGuard permission="product:create" fallback={null}>
|
||||
<button>新建产品</button>
|
||||
</PermissionGuard>
|
||||
|
||||
// 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`
|
||||
- 无权限:`<AccessDenied />` 占位组件(不跳转,避免循环)
|
||||
- `/workspace` 不拦截
|
||||
|
||||
**按钮级**:
|
||||
- 各页面"新建/编辑/删除/导出"按钮包 `PermissionGuard`
|
||||
- `OvertimeModal` 内的"删除原因"按钮要 `overtime:delete`
|
||||
|
||||
**Sidebar 菜单过滤**:
|
||||
- 7 个主菜单按 `<module>:view` 过滤——无权限的菜单不显示(不灰显)
|
||||
- 工作区组永远显示(与我相关)
|
||||
|
||||
**版本详情 Tab 过滤**(`apps/web/app/versions/[id]/page.tsx`):
|
||||
- Tab 列表渲染时按 `version.<tab>: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 正常渲染
|
||||
Reference in New Issue
Block a user