feat: 版本总耗时实际数据 + 参与人员管理 + 链接聚合 + 权限控制

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) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-15 14:23:11 +08:00
parent 101a6be9b7
commit c8d2bfdd51
2 changed files with 227 additions and 40 deletions

View File

@@ -2,12 +2,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; 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 { useProductStore } from '@/stores/useProductStore';
import { useRequirementStore } from '@/stores/useRequirementStore'; import { useRequirementStore } from '@/stores/useRequirementStore';
import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useOvertimeStore } from '@/stores/useOvertimeStore';
import { getVersionDetail } from '@/lib/derive'; 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 { 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 { CapsuleStages } from '@/components/version/CapsuleStages';
import { MemberChips } from '@/components/version/MemberChips'; import { MemberChips } from '@/components/version/MemberChips';
@@ -25,6 +25,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useBugStore } from '@/stores/useBugStore'; import { useBugStore } from '@/stores/useBugStore';
import { useAuthStore } from '@/stores/useAuthStore'; import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task'; import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task';
const PRIORITY_STYLE: Record<string, string> = { const PRIORITY_STYLE: Record<string, string> = {
@@ -58,7 +59,9 @@ export default function VersionDetailPage() {
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore(); const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
const { bugs, fetchBugs, deleteBug } = useBugStore(); const { bugs, fetchBugs, deleteBug } = useBugStore();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { members: allMembers } = useMemberStore();
const [activeTab, setActiveTab] = useState('overview'); const [activeTab, setActiveTab] = useState('overview');
const [showMemberModal, setShowMemberModal] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
@@ -92,13 +95,27 @@ export default function VersionDetailPage() {
}, [plans, version, versionId, updateVersion]); }, [plans, version, versionId, updateVersion]);
const elapsedDays = useMemo(() => { const elapsedDays = useMemo(() => {
if (!version?.startDate) return 0; if (!version) return 0;
const start = new Date(version.startDate); // 从所有阶段中取最早的实际开始时间
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); start.setHours(0, 0, 0, 0);
const now = new Date(); const now = new Date();
now.setHours(0, 0, 0, 0); now.setHours(0, 0, 0, 0);
return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))); return Math.max(0, Math.floor((now.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)));
}, [version?.startDate]); }, [version, plans, requirements, devTasks, testCases]);
if (!version) { if (!version) {
return ( 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 (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-[var(--ink-muted)]">访</p>
<button onClick={() => router.push('/versions')} className="text-xs text-[var(--accent)] hover:underline"></button>
</div>
);
}
const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress); const healthScore = calcHealthScore(version.status, version.startDate, version.expectedReleaseDate, version.progress);
const healthLevel = getHealthLevel(healthScore); const healthLevel = getHealthLevel(healthScore);
const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members); const riskTags = calcRiskTags(version.status, version.startDate, version.expectedReleaseDate, version.progress, version.currentStage, version.members);
@@ -339,40 +368,78 @@ export default function VersionDetailPage() {
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-center gap-4 text-[13px]"> {(() => {
// 实际开始日期
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 (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-[13px]">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" /> <Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span> <span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink-muted)]"></span> <span className="text-[var(--ink)]">{actualStart ?? '未开始'}</span>
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
</div> </div>
<div className="flex items-center gap-1.5">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{deadline ?? '未设置'}</span>
</div>
{actualEnd && (
<div className="flex items-center gap-1.5">
<span className="text-[var(--ink-muted)]"></span>
<span className="text-[var(--ink)]">{actualEnd}</span>
</div>
)}
{overdueDays > 0 && (
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-red-50 text-red-600"> {overdueDays} </span>
)}
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]"> <div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" /> <Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
<span className="font-medium text-[var(--ink)]">{elapsedDays}</span> <span className="font-medium text-[var(--ink)]">{elapsedDays}</span>
</div> </div>
</div> </div>
);
})()}
</div> </div>
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[11px] text-[var(--ink-muted)] mb-2 font-medium"></div> <div className="flex items-center justify-between mb-2">
<div className="text-[11px] text-[var(--ink-muted)] font-medium"></div>
<button onClick={() => setShowMemberModal(true)} className="flex items-center gap-1 text-[11px] text-[var(--accent)] hover:underline">
<Settings className="h-3 w-3" />
</button>
</div>
{(() => { {(() => {
const planMembers = plans const membersList = version.members ?? [];
.filter((p) => p.versionId === version.id) const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI', frontend: '前端', backend: '后端', testing: '测试' };
.map((p) => ({ role: p.type === 'research' ? 'research' as const : p.type === 'product' ? 'product' as const : 'ui' as const, name: p.owner })); if (membersList.length === 0) return <span className="text-[12px] text-[var(--ink-muted)]"></span>;
const roleLabel: Record<string, string> = { research: '调研', product: '产品', ui: 'UI' };
const seen = new Set<string>();
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 <span className="text-[12px] text-[var(--ink-muted)]"></span>;
return ( return (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{allMembers.map((m, i) => ( {membersList.map((m, i) => (
<span key={`${m.name}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--ink-soft)]"> <span key={`${m.name}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--bg-subtle)] px-2.5 py-1 text-[11px] text-[var(--ink-soft)]">
<span className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span> <span className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span>
<span className="font-medium text-[var(--ink)]">{m.name}</span> <span className="font-medium text-[var(--ink)]">{m.name}</span>
@@ -386,12 +453,37 @@ export default function VersionDetailPage() {
<div className="col-span-1"> <div className="col-span-1">
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full"> <div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 h-full">
<div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div> <div className="text-[11px] text-[var(--ink-muted)] mb-3 font-medium"></div>
{(() => {
const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl);
const typeLabel: Record<string, string> = { 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 <span className="text-[12px] text-[var(--ink-muted)]"></span>;
}
return (
<div className="space-y-3"> <div className="space-y-3">
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} /> {grouped.map((g) => (
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} /> <div key={g.type}>
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} /> <div className="text-[10px] text-[var(--ink-muted)] mb-1">{g.label}</div>
<div className="space-y-1.5">
{g.items.map((p) => (
<a key={p.id} href={p.resultUrl} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 text-[12px] text-[var(--accent)] hover:underline">
{p.resultType === 'file' ? <FileText className="h-3 w-3" /> : <Link2 className="h-3 w-3" />}
<span className="truncate">{p.resultFileName || p.title}</span>
</a>
))}
</div> </div>
</div> </div>
))}
</div>
);
})()}
</div>
</div> </div>
</div> </div>
@@ -675,6 +767,19 @@ export default function VersionDetailPage() {
</div> </div>
)} )}
</div> </div>
{/* 参与人员设置弹窗 */}
{showMemberModal && (
<MemberSettingModal
members={version.members ?? []}
allMembers={allMembers}
onSave={(newMembers) => {
updateVersion(version.productId, version.id, { members: newMembers });
setShowMemberModal(false);
}}
onClose={() => setShowMemberModal(false)}
/>
)}
</div> </div>
); );
} }
@@ -703,3 +808,76 @@ function LinkItem({ icon, label, url }: { icon: React.ReactNode; label: string;
</div> </div>
); );
} }
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<Role>('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 (
<div className="fixed inset-0 z-[60] 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-6 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]"></h3>
<button onClick={onClose} className="rounded-md p-1 hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
{/* 已有成员 */}
<div className="space-y-1.5 mb-4 max-h-[240px] overflow-y-auto">
{list.length === 0 && <span className="text-[12px] text-[var(--ink-muted)]"></span>}
{list.map((m, i) => (
<div key={`${m.name}-${m.role}-${i}`} className="flex items-center justify-between px-3 py-1.5 rounded-lg bg-[var(--bg-subtle)]">
<div className="flex items-center gap-2 text-[12px]">
<span className="text-[var(--ink-muted)]">{roleOptions.find((r) => r.value === m.role)?.label || m.role}</span>
<span className="font-medium text-[var(--ink)]">{m.name}</span>
</div>
<button onClick={() => handleRemove(i)} className="text-red-400 hover:text-red-600"><X className="h-3.5 w-3.5" /></button>
</div>
))}
</div>
{/* 添加新成员 */}
<div className="flex items-center gap-2 mb-4">
<select value={newRole} onChange={(e) => setNewRole(e.target.value as Role)} className="h-8 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
{roleOptions.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
<select value={newName} onChange={(e) => setNewName(e.target.value)} className="h-8 flex-1 rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{allMembers.filter((m) => !list.some((l) => l.name === m.name && l.role === newRole)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<button onClick={handleAdd} disabled={!newName} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white disabled:opacity-50 flex items-center gap-1">
<Plus className="h-3 w-3" />
</button>
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-[var(--line)]">
<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 onClick={() => onSave(list)} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white"></button>
</div>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore'; import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { flattenVersions, flattenProjects } from '@/lib/derive'; import { flattenVersions, flattenProjects } from '@/lib/derive';
import type { VersionWithContext } 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'; 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(); const { testCases, fetchTestCases } = useTestCaseStore();
useEffect(() => { fetchTestCases(); }, [fetchTestCases]); 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]); const allProjects = useMemo(() => flattenProjects(overview), [overview]);
// Compute overall progress per version from actual data // Compute overall progress per version from actual data