Files
ftb-project-management/apps/web/components/dev-task/DevTaskRow.tsx
2026-06-29 18:14:22 +08:00

110 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { AlertTriangle } from 'lucide-react';
import { StatusBadge } from './StatusBadge';
import { CategoryChip } from './CategoryChip';
import { getEstimateHours, getActualHours, needsDevTaskClaim } from '@/lib/dev-task';
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
import type { DevTask } from '@/lib/dev-task';
import type { TaskCategory } from '@/lib/task-category';
interface Props {
task: DevTask;
category?: TaskCategory;
categoryLabelWidthEm?: number;
onClick?: () => void;
}
const PRIORITY_DOT: Record<string, string> = {
P0: 'bg-red-500',
P1: 'bg-orange-400',
P2: 'bg-blue-400',
P3: 'bg-zinc-300',
};
function timeRangeText(task: DevTask): { text: string; tone: string } {
if (task.status === 'submitted' && task.actualStartAt && task.actualEndAt) {
return { text: `实际 ${formatShortTime(task.actualStartAt)}${formatShortTime(task.actualEndAt)}`, tone: 'text-[var(--ink-muted)]' };
}
if (task.actualStartAt) {
return { text: `实际 ${formatShortTime(task.actualStartAt)} → 进行中`, tone: 'text-emerald-600' };
}
if (task.expectedStartAt && task.expectedEndAt) {
return { text: `预计 ${formatShortTime(task.expectedStartAt)}${formatShortTime(task.expectedEndAt)}`, tone: 'text-blue-600' };
}
return { text: '', tone: 'text-[var(--ink-muted)]' };
}
function hoursText(task: DevTask, estimate: number, actual: number): { text: string; tone: string } {
const hasExecutorEstimate = typeof task.estimateHours === 'number' && task.estimateHours > 0;
const hasAiEstimate = typeof task.aiEstimateHours === 'number' && task.aiEstimateHours > 0;
const prefix = hasExecutorEstimate ? '执行预' : hasAiEstimate ? 'AI预' : '预';
if (actual <= 0) return { text: `${prefix} ${formatWorkHours(estimate)}`, tone: 'text-[var(--ink-muted)]' };
let tone = 'text-[var(--ink-soft)]';
if (actual > estimate) tone = 'text-red-600';
else if (actual < estimate) tone = 'text-emerald-600';
return { text: `${formatWorkHours(actual)} / ${formatWorkHours(estimate)}`, tone };
}
export function DevTaskRow({ task, category, categoryLabelWidthEm, onClick }: Props) {
const estimate = getEstimateHours(task);
const actual = getActualHours(task);
const range = timeRangeText(task);
const hours = hoursText(task, estimate, actual);
const needsClaim = needsDevTaskClaim(task);
const labelWidthStyle = categoryLabelWidthEm ? { width: `${categoryLabelWidthEm}em` } : undefined;
return (
<div
onClick={onClick}
className={`px-4 py-2 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0 ${task.aiDraft ? 'border-l-2 border-l-purple-400 bg-purple-50/30' : ''}`}
>
<div className="flex min-w-0 items-start gap-2">
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${PRIORITY_DOT[task.priority] || 'bg-zinc-300'}`} title={task.priority} />
<span className="mt-0.5 w-16 shrink-0 text-[11px] font-mono text-[var(--ink-muted)]">{task.taskNo}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[13px] font-medium leading-5 text-[var(--ink)]">{task.title}</span>
<span className="ml-auto inline-flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-x-2.5 gap-y-1 text-[11px]">
{range.text && (
<span className={`tabular-nums whitespace-nowrap ${range.tone}`} title={range.text}>{range.text}</span>
)}
<span className={`tabular-nums whitespace-nowrap ${hours.tone}`}>{hours.text}</span>
</span>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center justify-end gap-x-2.5 gap-y-1 text-[11px]">
{task.isBlocked && (
<span className="inline-flex h-5 shrink-0 items-center gap-0.5 whitespace-nowrap rounded bg-red-50 px-1.5 text-[10px] font-medium text-red-500" title={task.blockReason}>
<AlertTriangle className="h-2.5 w-2.5" />
</span>
)}
{task.aiDraft && (
<span
className="inline-flex h-5 shrink-0 items-center justify-center whitespace-nowrap rounded bg-purple-100 px-1.5 text-[10px] font-medium text-purple-600"
style={labelWidthStyle}
title="AI 拆解草案,编辑后会移除标记"
>
AI
</span>
)}
{!needsClaim && (
<span className="max-w-[140px] truncate whitespace-nowrap text-[var(--ink-soft)]">{task.assigneeId}</span>
)}
{needsClaim ? (
<span
className="inline-flex h-5 shrink-0 items-center justify-center whitespace-nowrap rounded bg-orange-50 px-1.5 text-[10px] font-medium text-orange-600"
style={labelWidthStyle}
>
</span>
) : (
<StatusBadge status={task.status} widthEm={categoryLabelWidthEm} />
)}
<CategoryChip category={category} widthEm={categoryLabelWidthEm} />
</div>
</div>
</div>
</div>
);
}