From c8d2bfdd512b9b93b2b209d691d4ac9dec11049a Mon Sep 17 00:00:00 2001 From: Script Generator Date: Mon, 15 Jun 2026 14:23:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=88=E6=9C=AC=E6=80=BB=E8=80=97?= =?UTF-8?q?=E6=97=B6=E5=AE=9E=E9=99=85=E6=95=B0=E6=8D=AE=20+=20=E5=8F=82?= =?UTF-8?q?=E4=B8=8E=E4=BA=BA=E5=91=98=E7=AE=A1=E7=90=86=20+=20=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E8=81=9A=E5=90=88=20+=20=E6=9D=83=E9=99=90=E6=8E=A7?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 版本总耗时改用实际数据: - 开始日期取所有阶段最早 actualStartAt - 预计截止显示 expectedReleaseDate - 实际截止取所有阶段最晚完成时间 - 逾期天数 = 实际截止 - 预计截止(红色标签) 2. 参与人员管理: - 新增"设置"按钮打开管理弹窗 - 可选角色(产品/UI/前端/后端/测试)+ 人员 - 添加/删除成员,保存到 version.members 3. 相关链接从 plans 成果聚合: - 按调研/产品方案/UI设计分组 - 展示所有已完成 plan 的 resultUrl + resultFileName - 不再是固定3个链接 4. 权限控制: - 版本详情页校验当前用户是否在 members 中 - 版本列表页只显示当前用户参与的版本 - members 为空时所有人可见(兼容旧数据) Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/app/versions/[id]/page.tsx | 256 +++++++++++++++++++++++----- apps/web/app/versions/page.tsx | 11 +- 2 files changed, 227 insertions(+), 40 deletions(-) diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index fb97101..bf3c618 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -2,12 +2,12 @@ import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout } from 'lucide-react'; +import { ChevronLeft, Package, Calendar, Clock, ExternalLink, FileText, Palette, Layout, Link2, Settings, Plus, X } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { getVersionDetail } from '@/lib/derive'; -import { STAGES } from '@/lib/stage'; +import { STAGES, Role } from '@/lib/stage'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus, calcVersionExecutionStatus, EXECUTION_STATUS_LABEL, EXECUTION_STATUS_COLOR } from '@/lib/version-status'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; @@ -25,6 +25,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useBugStore } from '@/stores/useBugStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task'; const PRIORITY_STYLE: Record = { @@ -58,7 +59,9 @@ 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 [activeTab, setActiveTab] = useState('overview'); + const [showMemberModal, setShowMemberModal] = useState(false); useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); @@ -92,13 +95,27 @@ export default function VersionDetailPage() { }, [plans, version, versionId, updateVersion]); const elapsedDays = useMemo(() => { - if (!version?.startDate) return 0; - const start = new Date(version.startDate); + if (!version) return 0; + // 从所有阶段中取最早的实际开始时间 + const vPlans = plans.filter((p) => p.versionId === version.id); + const vReqs = requirements.filter((r) => r.versionId === version.id); + const vReqIds = new Set(vReqs.map((r) => r.id)); + const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId)); + const vTCs = testCases.filter((c) => c.versionId === version.id); + + const startDates: string[] = []; + vPlans.forEach((p) => { if (p.actualStartAt) startDates.push(p.actualStartAt); }); + vDevTasks.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); + vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); + + if (startDates.length === 0 && !version.startDate) return 0; + const earliest = startDates.length > 0 ? startDates.sort()[0] : version.startDate!; + const start = new Date(earliest); start.setHours(0, 0, 0, 0); const now = new Date(); now.setHours(0, 0, 0, 0); return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))); - }, [version?.startDate]); + }, [version, plans, requirements, devTasks, testCases]); if (!version) { return ( @@ -109,6 +126,18 @@ export default function VersionDetailPage() { ); } + // 权限校验:只有参与人员可以访问 + const currentUserName = user?.name || ''; + const isMember = (version.members ?? []).length === 0 || (version.members ?? []).some((m) => m.name === currentUserName); + if (!isMember) { + return ( +
+

您不是该版本的参与人员,无权访问

+ +
+ ); + } + const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); const healthLevel = getHealthLevel(healthScore); const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members); @@ -339,40 +368,78 @@ export default function VersionDetailPage() {
-
-
- - {version.startDate ?? '未设置'} - - {version.expectedReleaseDate ?? '未设置'} -
-
- - 已耗时 {elapsedDays} 天 -
-
+ {(() => { + // 实际开始日期 + const vPlansAll = plans.filter((p) => p.versionId === version.id); + const vReqsAll = requirements.filter((r) => r.versionId === version.id); + const vReqIdsAll = new Set(vReqsAll.map((r) => r.id)); + const vDTs = devTasks.filter((t) => vReqIdsAll.has(t.requirementId)); + const vTCsAll = testCases.filter((c) => c.versionId === version.id); + const vBugsAll = bugs.filter((b) => b.versionId === version.id); + + const startDates: string[] = []; + vPlansAll.forEach((p) => { if (p.actualStartAt) startDates.push(p.actualStartAt); }); + vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); }); + vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); }); + const actualStart = startDates.length > 0 ? startDates.sort()[0].slice(0, 10) : (version.startDate ?? null); + + // 实际截止日期 + const endDates: string[] = []; + vPlansAll.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); }); + vDTs.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); }); + vTCsAll.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); }); + vBugsAll.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); }); + const actualEnd = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null; + + // 逾期天数 + const deadline = version.expectedReleaseDate; + let overdueDays = 0; + if (deadline && actualEnd) { + overdueDays = Math.floor((new Date(actualEnd).getTime() - new Date(deadline).getTime()) / (1000 * 60 * 60 * 24)); + } + + return ( +
+
+ + 开始 + {actualStart ?? '未开始'} +
+
+ 预计截止 + {deadline ?? '未设置'} +
+ {actualEnd && ( +
+ 实际截止 + {actualEnd} +
+ )} + {overdueDays > 0 && ( + 逾期 {overdueDays} 天 + )} +
+ + 已耗时 {elapsedDays} 天 +
+
+ ); + })()}
-
参与人员
+
+
参与人员
+ +
{(() => { - const planMembers = plans - .filter((p) => p.versionId === version.id) - .map((p) => ({ role: p.type === 'research' ? 'research' as const : p.type === 'product' ? 'product' as const : 'ui' as const, name: p.owner })); - const roleLabel: Record = { research: '调研', product: '产品', ui: 'UI' }; - const seen = new Set(); - const dedupPlanMembers = planMembers.filter((m) => { - const key = `${m.role}:${m.name}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - const existingKeys = new Set((version.members ?? []).map((m) => `${m.role}:${m.name}`)); - const extraMembers = dedupPlanMembers.filter((m) => !existingKeys.has(`${m.role}:${m.name}`)); - const allMembers = [...(version.members ?? []), ...extraMembers]; - if (allMembers.length === 0) return 暂无成员; + const membersList = version.members ?? []; + const roleLabel: Record = { research: '调研', product: '产品', ui: 'UI', frontend: '前端', backend: '后端', testing: '测试' }; + if (membersList.length === 0) return 暂无成员,请点击设置添加; return (
- {allMembers.map((m, i) => ( + {membersList.map((m, i) => ( {roleLabel[m.role] ?? m.role} {m.name} @@ -386,11 +453,36 @@ export default function VersionDetailPage() {
相关链接
-
- } label="调研报告" url={version.links?.research} /> - } label="原型地址" url={version.links?.prototype} /> - } label="UI设计稿" url={version.links?.ui} /> -
+ {(() => { + const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl); + const typeLabel: Record = { research: '调研', product: '产品方案', ui: 'UI设计' }; + const grouped = ['research', 'product', 'ui'].map((type) => ({ + type, + label: typeLabel[type], + items: vPlansCompleted.filter((p) => p.type === type), + })).filter((g) => g.items.length > 0); + + if (grouped.length === 0) { + return 暂无成果链接; + } + return ( +
+ {grouped.map((g) => ( +
+
{g.label}
+ +
+ ))} +
+ ); + })()}
@@ -675,6 +767,19 @@ export default function VersionDetailPage() {
)}
+ + {/* 参与人员设置弹窗 */} + {showMemberModal && ( + { + updateVersion(version.productId, version.id, { members: newMembers }); + setShowMemberModal(false); + }} + onClose={() => setShowMemberModal(false)} + /> + )}
); } @@ -703,3 +808,76 @@ function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string; ); } + +function MemberSettingModal({ members, allMembers, onSave, onClose }: { + members: { role: Role; name: string }[]; + allMembers: { id: string; name: string }[]; + onSave: (members: { role: Role; name: string }[]) => void; + onClose: () => void; +}) { + const [list, setList] = useState<{ role: Role; name: string }[]>([...members]); + const [newName, setNewName] = useState(''); + const [newRole, setNewRole] = useState('frontend'); + const roleOptions = [ + { value: 'product', label: '产品' }, + { value: 'ui', label: 'UI' }, + { value: 'frontend', label: '前端' }, + { value: 'backend', label: '后端' }, + { value: 'testing', label: '测试' }, + ]; + + const handleAdd = () => { + if (!newName) return; + if (list.some((m) => m.name === newName && m.role === newRole)) return; + setList([...list, { role: newRole, name: newName }]); + setNewName(''); + }; + + const handleRemove = (idx: number) => { + setList(list.filter((_, i) => i !== idx)); + }; + + return ( +
+
e.stopPropagation()}> +
+

设置参与人员

+ +
+ + {/* 已有成员 */} +
+ {list.length === 0 && 暂无成员} + {list.map((m, i) => ( +
+
+ {roleOptions.find((r) => r.value === m.role)?.label || m.role} + {m.name} +
+ +
+ ))} +
+ + {/* 添加新成员 */} +
+ + + +
+ +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index b6d0c46..d4320e1 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -8,6 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore'; +import { useAuthStore } from '@/stores/useAuthStore'; import { flattenVersions, flattenProjects } from '@/lib/derive'; import type { VersionWithContext } from '@/lib/derive'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status'; @@ -125,7 +126,15 @@ export default function VersionsPage() { const { testCases, fetchTestCases } = useTestCaseStore(); useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - const allVersions = useMemo(() => flattenVersions(overview), [overview]); + const user = useAuthStore((s) => s.user); + const currentUserName = user?.name || ''; + + const allVersionsRaw = useMemo(() => flattenVersions(overview), [overview]); + // 只显示当前用户参与的版本(members为空时所有人可见) + const allVersions = useMemo(() => allVersionsRaw.filter((v) => { + if (!v.members || v.members.length === 0) return true; + return v.members.some((m) => m.name === currentUserName); + }), [allVersionsRaw, currentUserName]); const allProjects = useMemo(() => flattenProjects(overview), [overview]); // Compute overall progress per version from actual data