feat: 加班/版本/项目调整 + 角色权限设计

加班记录:
- 加班人默认回显当前用户且不可改
- 列表加创建日期列
- 移除编辑功能(创建即提交,仅可删除)
- 提交时校验结束>开始

版本列表:
- 移除顶部 9 个状态 tab + 表格状态列
- 仅保留搜索、项目、优先级筛选

项目列表:
- 行内显示进度未达 100% 的版本(与版本页同源算法)
- 新增 lib/version-progress.ts 抽出公共进度计算

角色权限:
- RoleItem 加 permissions 字段
- 新建 lib/permissions.ts: 14 组 37 个权限点 + 默认 5 角色映射 + hasPermission
- 角色表单内嵌权限矩阵(主模块 4 件套 + Tab 二档 + Bug Tab 4 档)+ 全选/反选/仅查看快捷
- 新建 components/auth/Guard.tsx: RouteGuard + PermissionGuard + useHasPermission + AccessDenied
- Sidebar 菜单按 view 权限过滤
- 7 个主路由(products/projects/versions/requirements/overtime/admin/members/roles)包 RouteGuard
- 版本详情 Tab 按 view 权限过滤,自动跳转到第一个有权限的 Tab
- 默认未登录/无角色按只读最严格
- 超管 role-admin: ['*'] 通配符,permissions 不可改

通用:
- 新建 components/FieldError.tsx 统一表单错误文案
- PlanTab 提交时校验结束>开始

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-16 19:15:28 +08:00
parent 1f5bed0c79
commit c2f2fed82c
18 changed files with 1023 additions and 231 deletions

View File

@@ -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);

View File

@@ -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>
<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">{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">
<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" />
<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 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)} 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>
<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" />
<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">
<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>
<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>
);
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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,

View File

@@ -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)}

View File

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