refactor: 胶囊条改为纯进度展示,去掉"当前阶段"和"阶段耗时"
胶囊条 CapsuleStages 重构: - 去掉 currentStage/progress 旧 props - 每段只显示进度百分比 + 状态颜色(idle/active/done) - 支持并行阶段(多个可同时 active) - 数据由实际 PlanTask/DevTask/TestCase 状态驱动 进度数据来源: - 调研:任务完成数 / 总数 - 产品/UI:需求完成数 / 关联数 - 开发:DevTask 进度(STATUS_PROGRESS 加权) - 测试:已执行用例 / 总用例 版本列表页: - 去掉"当前阶段"和"阶段进度"两列 - 替换为"状态"列(显示版本状态标签) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,7 @@ function VersionCard({ version, onNavigate }: { version: VersionWithContext; onN
|
|||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-4 pb-4 pt-2 border-t border-[var(--line-soft)] space-y-3">
|
<div className="px-4 pb-4 pt-2 border-t border-[var(--line-soft)] space-y-3">
|
||||||
<CapsuleStages currentStage={version.currentStage} progress={version.progress} />
|
<CapsuleStages />
|
||||||
<MemberChips members={version.members ?? []} />
|
<MemberChips members={version.members ?? []} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +92,7 @@ function VersionCard({ version, onNavigate }: { version: VersionWithContext; onN
|
|||||||
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
|
<span onClick={() => onNavigate(version.id)} className="text-sm font-medium text-[var(--ink)] cursor-pointer hover:text-[var(--accent)]">{version.name}</span>
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
|
<span className={`text-[11px] px-2 py-0.5 rounded-full ${displayBg}`}>{displayStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3"><CapsuleStages currentStage={version.currentStage} progress={version.progress} /></div>
|
<div className="mb-3"><CapsuleStages /></div>
|
||||||
<div className="flex items-center justify-between text-[11px] text-[var(--ink-muted)] mb-3 pb-3 border-b border-[var(--line-soft)]">
|
<div className="flex items-center justify-between text-[11px] text-[var(--ink-muted)] mb-3 pb-3 border-b border-[var(--line-soft)]">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
|
|||||||
@@ -314,38 +314,38 @@ export default function VersionDetailPage() {
|
|||||||
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
return totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const getPlanStatus = (group: typeof vPlans): 'idle' | 'active' | 'done' => {
|
||||||
const calcDays = (group: typeof vPlans) => {
|
if (group.length === 0) return 'idle';
|
||||||
if (group.length === 0) return 0;
|
if (group.every((p) => p.status === 'completed')) return 'done';
|
||||||
const starts = group.map((p) => p.startTime).sort();
|
if (group.some((p) => p.status === 'in_progress')) return 'active';
|
||||||
const startDate = starts[0];
|
return 'idle';
|
||||||
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 }> = {};
|
type StageProgressItem = { percent: number; status: 'idle' | 'active' | 'done' };
|
||||||
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), daysSpent: calcDays(researchPlans) };
|
const stageProgress: Partial<Record<string, StageProgressItem>> = {};
|
||||||
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) };
|
|
||||||
|
|
||||||
// 开发阶段进度由 DevTask 驱动
|
if (researchPlans.length > 0) stageProgress['requirement'] = { percent: calcGroupProgress(researchPlans, 'research'), status: getPlanStatus(researchPlans) };
|
||||||
|
if (productPlans.length > 0) stageProgress['product_design'] = { percent: calcGroupProgress(productPlans, 'product'), status: getPlanStatus(productPlans) };
|
||||||
|
if (uiPlans.length > 0) stageProgress['ui_design'] = { percent: calcGroupProgress(uiPlans, 'ui'), status: getPlanStatus(uiPlans) };
|
||||||
|
|
||||||
|
// 开发阶段
|
||||||
if (versionDevTasks.length > 0) {
|
if (versionDevTasks.length > 0) {
|
||||||
const devProgress = calcDevTaskProgress(versionDevTasks);
|
const devProgress = calcDevTaskProgress(versionDevTasks);
|
||||||
const devStart = versionDevTasks.reduce((min, t) => t.startDate && t.startDate < min ? t.startDate : min, today);
|
const allSubmitted = versionDevTasks.every((t) => t.status === 'submitted');
|
||||||
const devDays = devStart <= today ? Math.max(1, Math.ceil((new Date(today).getTime() - new Date(devStart).getTime()) / (1000 * 60 * 60 * 24))) : 0;
|
const hasActive = versionDevTasks.some((t) => t.status === 'in_progress' || t.status === 'testing');
|
||||||
stageProgress['dev'] = { percent: devProgress, daysSpent: devDays };
|
stageProgress['dev'] = { percent: devProgress, status: allSubmitted ? 'done' : hasActive ? 'active' : 'idle' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试阶段进度由 TestCase 完成率驱动
|
// 测试阶段
|
||||||
const versionTestCases = testCases.filter((c) => c.versionId === version.id);
|
if (versionTCs.length > 0) {
|
||||||
if (versionTestCases.length > 0) {
|
const executed = versionTCs.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
|
||||||
const executed = versionTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length;
|
const testPercent = Math.round((executed / versionTCs.length) * 100);
|
||||||
const testPercent = Math.round((executed / versionTestCases.length) * 100);
|
const allPassed = versionTCs.every((c) => c.status === 'passed');
|
||||||
stageProgress['testing'] = { percent: testPercent, daysSpent: 0 };
|
const hasRunning = versionTCs.some((c) => c.status === 'running');
|
||||||
|
stageProgress['testing'] = { percent: testPercent, status: allPassed ? 'done' : (hasRunning || executed > 0) ? 'active' : 'idle' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CapsuleStages currentStage={version.currentStage} progress={version.progress} stageProgress={stageProgress} />;
|
return <CapsuleStages stageProgress={stageProgress as any} />;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 双栏:加班排名 + 原因占比 */}
|
{/* 双栏:加班排名 + 原因占比 */}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useProductStore } from '@/stores/useProductStore';
|
|||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
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, getVersionDisplayStatus } from '@/lib/version-status';
|
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||||
import { STAGES } from '@/lib/stage';
|
import { STAGES } from '@/lib/stage';
|
||||||
import { ROLE_LABEL } from '@/lib/stage';
|
import { ROLE_LABEL } from '@/lib/stage';
|
||||||
import { calcOverallProgress } from '@/lib/risk';
|
import { calcOverallProgress } from '@/lib/risk';
|
||||||
@@ -250,8 +250,7 @@ export default function VersionsPage() {
|
|||||||
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
|
<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>
|
||||||
<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>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">风险标签</th>
|
||||||
@@ -323,23 +322,11 @@ function VersionRow({ version, openMenuId, setOpenMenuId, onNavigate, onAction }
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 当前阶段 */}
|
{/* 状态 */}
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
|
||||||
{getStageLabel(version)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* 阶段进度 */}
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{version.status === 'developing' && version.currentStage ? (
|
<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'}`}>
|
||||||
<div className="flex items-center gap-2">
|
{getVersionDisplayStatus(version.status as VersionStatus, version.currentStage)}
|
||||||
<div className="h-1.5 w-16 rounded-full bg-zinc-100">
|
</span>
|
||||||
<div className="h-1.5 rounded-full bg-blue-500 transition-all" style={{ width: `${getStageProgress(version)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{getStageProgress(version)}%</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[12px] text-[var(--ink-muted)]">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 整体进度 */}
|
{/* 整体进度 */}
|
||||||
|
|||||||
@@ -1,55 +1,20 @@
|
|||||||
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
|
import type { Stage } from '@/lib/stage';
|
||||||
import type { RoleProgress } from '@/lib/derive';
|
import { STAGES } from '@/lib/stage';
|
||||||
|
|
||||||
export interface StageProgressItem {
|
export interface StageProgressItem {
|
||||||
percent: number;
|
percent: number;
|
||||||
daysSpent: number;
|
status: 'idle' | 'active' | 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CapsuleStages({ currentStage, progress, stageProgress }: {
|
export function CapsuleStages({ stageProgress }: {
|
||||||
currentStage?: Stage;
|
|
||||||
progress?: RoleProgress[];
|
|
||||||
stageProgress?: Partial<Record<Stage, StageProgressItem>>;
|
stageProgress?: Partial<Record<Stage, StageProgressItem>>;
|
||||||
}) {
|
}) {
|
||||||
const currentIdx = currentStage !== undefined
|
|
||||||
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
|
|
||||||
: -1;
|
|
||||||
const progressMap = (progress ?? []).reduce<Record<Role, { percent: number; daysSpent: number }>>((acc, p) => {
|
|
||||||
acc[p.role] = { percent: p.percent, daysSpent: p.daysSpent };
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<Role, { percent: number; daysSpent: number }>);
|
|
||||||
|
|
||||||
const stageRoleMap: Record<Stage, Role[]> = {
|
|
||||||
requirement: ['product'],
|
|
||||||
product_design: ['product'],
|
|
||||||
ui_design: ['ui'],
|
|
||||||
dev: ['frontend', 'backend'],
|
|
||||||
integration: ['frontend', 'backend'],
|
|
||||||
testing: ['testing'],
|
|
||||||
released: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function getStageInfo(stage: Stage) {
|
|
||||||
// 优先使用 stageProgress
|
|
||||||
if (stageProgress && stageProgress[stage]) {
|
|
||||||
const sp = stageProgress[stage]!;
|
|
||||||
return { percent: sp.percent, days: sp.daysSpent, hasData: true };
|
|
||||||
}
|
|
||||||
const roles = stageRoleMap[stage];
|
|
||||||
if (roles.length === 0) return { percent: 0, days: 0, hasData: false };
|
|
||||||
const items = roles.map((r) => progressMap[r]).filter(Boolean);
|
|
||||||
if (items.length === 0) return { percent: 0, days: 0, hasData: false };
|
|
||||||
const percent = Math.round(items.reduce((s, i) => s + i.percent, 0) / items.length);
|
|
||||||
const days = Math.max(...items.map((i) => i.daysSpent));
|
|
||||||
return { percent, days, hasData: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]">
|
<div className="flex rounded-lg border border-[var(--line)] overflow-hidden bg-[var(--bg-card)]">
|
||||||
{STAGES.map((stage, idx) => {
|
{STAGES.map((stage, idx) => {
|
||||||
const isCompleted = idx < currentIdx;
|
const info = stageProgress?.[stage.key];
|
||||||
const isCurrent = idx === currentIdx;
|
const status = info?.status || 'idle';
|
||||||
const info = getStageInfo(stage.key);
|
const percent = info?.percent ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -57,18 +22,20 @@ export function CapsuleStages({ currentStage, progress, stageProgress }: {
|
|||||||
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`}
|
className={`flex-1 flex flex-col ${idx < STAGES.length - 1 ? 'border-r border-[var(--line-soft)]' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]">
|
<div className="flex items-center justify-between px-2 py-1.5 min-h-[28px]">
|
||||||
<span className={`text-[10px] font-medium leading-tight ${isCurrent ? 'text-[var(--ink)]' : isCompleted ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}>
|
<span className={`text-[10px] font-medium leading-tight ${status === 'active' ? 'text-[var(--ink)]' : status === 'done' ? 'text-[var(--ink-soft)]' : 'text-[var(--ink-muted)]'}`}>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
|
{status !== 'idle' && (
|
||||||
{isCompleted ? (info.hasData ? `${info.percent}% · ${info.days}天` : '-') : isCurrent ? (info.hasData ? `${info.percent}% · ${info.days}天` : '-') : ''}
|
<span className={`text-[10px] font-medium leading-tight tabular-nums ${status === 'done' ? 'text-emerald-600' : 'text-blue-600'}`}>
|
||||||
|
{percent}%
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[3px] w-full bg-zinc-50">
|
<div className="h-[3px] w-full bg-zinc-50">
|
||||||
{isCompleted && <div className="h-full bg-zinc-700 w-full" />}
|
{status === 'done' && <div className="h-full bg-emerald-500 w-full" />}
|
||||||
{isCurrent && info.hasData && (
|
{status === 'active' && (
|
||||||
<div className="h-full bg-blue-100 w-full">
|
<div className="h-full bg-blue-100 w-full">
|
||||||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${info.percent}%` }} />
|
<div className="h-full bg-blue-500 transition-all" style={{ width: `${percent}%` }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user