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:
@@ -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]">
|
{(() => {
|
||||||
<div className="flex items-center gap-1.5">
|
// 实际开始日期
|
||||||
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
const vPlansAll = plans.filter((p) => p.versionId === version.id);
|
||||||
<span className="text-[var(--ink)]">{version.startDate ?? '未设置'}</span>
|
const vReqsAll = requirements.filter((r) => r.versionId === version.id);
|
||||||
<span className="text-[var(--ink-muted)]">—</span>
|
const vReqIdsAll = new Set(vReqsAll.map((r) => r.id));
|
||||||
<span className="text-[var(--ink)]">{version.expectedReleaseDate ?? '未设置'}</span>
|
const vDTs = devTasks.filter((t) => vReqIdsAll.has(t.requirementId));
|
||||||
</div>
|
const vTCsAll = testCases.filter((c) => c.versionId === version.id);
|
||||||
<div className="flex items-center gap-1.5 text-[var(--ink-soft)]">
|
const vBugsAll = bugs.filter((b) => b.versionId === version.id);
|
||||||
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
|
||||||
已耗时 <span className="font-medium text-[var(--ink)]">{elapsedDays}</span> 天
|
const startDates: string[] = [];
|
||||||
</div>
|
vPlansAll.forEach((p) => { if (p.actualStartAt) startDates.push(p.actualStartAt); });
|
||||||
</div>
|
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">
|
||||||
|
<Calendar className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
<span className="text-[var(--ink-muted)]">开始</span>
|
||||||
|
<span className="text-[var(--ink)]">{actualStart ?? '未开始'}</span>
|
||||||
|
</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)]">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
|
||||||
|
已耗时 <span className="font-medium text-[var(--ink)]">{elapsedDays}</span> 天
|
||||||
|
</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,11 +453,36 @@ 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>
|
||||||
<div className="space-y-3">
|
{(() => {
|
||||||
<LinkItem icon={<FileText className="h-3.5 w-3.5" />} label="调研报告" url={version.links?.research} />
|
const vPlansCompleted = plans.filter((p) => p.versionId === version.id && p.status === 'completed' && p.resultUrl);
|
||||||
<LinkItem icon={<Layout className="h-3.5 w-3.5" />} label="原型地址" url={version.links?.prototype} />
|
const typeLabel: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
||||||
<LinkItem icon={<Palette className="h-3.5 w-3.5" />} label="UI设计稿" url={version.links?.ui} />
|
const grouped = ['research', 'product', 'ui'].map((type) => ({
|
||||||
</div>
|
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">
|
||||||
|
{grouped.map((g) => (
|
||||||
|
<div key={g.type}>
|
||||||
|
<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>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user