feat: 计划任务清单+进度联动+与我相关工作台
- 计划新增任务清单:调研支持任务CRUD,进度=完成数/总数 - 产品方案/UI:通过关联需求勾选进度,全部完成提示提交 - 版本胶囊条动态进度:调研/产品方案/UI 阶段独立计算 - 版本状态显示具体阶段名(调研中/产品设计中/UI设计中) - 计划开始日期≤今天自动进入"进行中",版本状态联动 - 与我相关重写为左右布局:左侧分组导航,右侧任务/需求勾选 - 计划支持超期原因校验(结束日期超版本截止) - 修复列表overflow裁剪问题、计划耗时当天至少1天 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { getVersionDetail } from '@/lib/derive';
|
||||
import { STAGES } from '@/lib/stage';
|
||||
import { VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import { HealthTrend, generateMockTrend } from '@/components/version/HealthTrend';
|
||||
@@ -57,6 +57,27 @@ export default function VersionDetailPage() {
|
||||
|
||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||
|
||||
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||||
useEffect(() => {
|
||||
if (!version || !plans.length) return;
|
||||
if (version.status !== 'planned' && version.status !== 'developing') return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||||
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
|
||||
const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today);
|
||||
if (versionPlans.length === 0) return;
|
||||
let targetStage = '';
|
||||
for (const p of versionPlans) {
|
||||
const s = stageMap[p.type];
|
||||
if (!targetStage || stageOrder.indexOf(s) > stageOrder.indexOf(targetStage)) {
|
||||
targetStage = s;
|
||||
}
|
||||
}
|
||||
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
|
||||
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
|
||||
}
|
||||
}, [plans, version, versionId, updateVersion]);
|
||||
|
||||
const elapsedDays = useMemo(() => {
|
||||
if (!version?.startDate) return 0;
|
||||
const start = new Date(version.startDate);
|
||||
@@ -171,7 +192,7 @@ export default function VersionDetailPage() {
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${VERSION_STATUS_BG[version.status]}`}>
|
||||
{VERSION_STATUS_LABEL[version.status]}
|
||||
{getVersionDisplayStatus(version.status, version.currentStage)}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1.5 text-[11px] font-semibold tabular-nums px-2 py-0.5 rounded-full ${healthLevel === 'critical' ? 'bg-red-50' : healthLevel === 'risk' ? 'bg-orange-50' : healthLevel === 'attention' ? 'bg-amber-50' : 'bg-emerald-50'} ${HEALTH_LEVEL_COLOR[healthLevel]}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${HEALTH_LEVEL_DOT[healthLevel]}`} />
|
||||
@@ -221,7 +242,50 @@ export default function VersionDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Capsule stages */}
|
||||
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
||||
{(() => {
|
||||
const vPlans = plans.filter((p) => p.versionId === version.id);
|
||||
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
||||
const productPlans = vPlans.filter((p) => p.type === 'product');
|
||||
const uiPlans = vPlans.filter((p) => p.type === 'ui');
|
||||
|
||||
const calcGroupProgress = (group: typeof vPlans, type: 'research' | 'product' | 'ui') => {
|
||||
if (group.length === 0) return 0;
|
||||
if (type === 'research') {
|
||||
const totals = group.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 });
|
||||
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||||
}
|
||||
const totals = group.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 });
|
||||
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const calcDays = (group: typeof vPlans) => {
|
||||
if (group.length === 0) return 0;
|
||||
const starts = group.map((p) => p.startTime).sort();
|
||||
const startDate = starts[0];
|
||||
if (startDate > today) return 0;
|
||||
const diff = Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24));
|
||||
return Math.max(1, diff); // 当天开始至少算1天
|
||||
};
|
||||
|
||||
const stageProgress: Record<string, { percent: number; daysSpent: number }> = {};
|
||||
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), daysSpent: calcDays(researchPlans) };
|
||||
if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), daysSpent: calcDays(productPlans) };
|
||||
if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), daysSpent: calcDays(uiPlans) };
|
||||
|
||||
return <CapsuleStages currentStage={version.currentStage} progress={version.progress} stageProgress={stageProgress} />;
|
||||
})()}
|
||||
|
||||
{/* 双栏:加班排名 + 原因占比 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -289,11 +353,34 @@ export default function VersionDetailPage() {
|
||||
</div>
|
||||
<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>
|
||||
{version.members && version.members.length > 0 ? (
|
||||
<MemberChips members={version.members} />
|
||||
) : (
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">暂无成员</span>
|
||||
)}
|
||||
{(() => {
|
||||
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<string, string> = { research: '调研', product: '产品', ui: 'UI' };
|
||||
// 按 role+name 去重(同阶段同一人只显示一次)
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allMembers.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 className="text-[var(--ink-muted)]">{roleLabel[m.role] ?? m.role}</span>
|
||||
<span className="font-medium text-[var(--ink)]">{m.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
@@ -345,6 +432,12 @@ export default function VersionDetailPage() {
|
||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||
}
|
||||
// 同步版本状态:计划开始时间<=今天,版本进入对应阶段
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (data.startTime <= today && (version.status === 'planned' || version.status === 'developing')) {
|
||||
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||||
updateVersion(version.productId, version.id, { status: 'developing', currentStage: stageMap[pt] });
|
||||
}
|
||||
}}
|
||||
onUpdate={(id, data) => {
|
||||
updatePlan(id, data);
|
||||
|
||||
Reference in New Issue
Block a user