feat: 开发任务时间字段重构 + 双口径耗时 + 搜索 + 性能优化
核心变更: - DevTask 字段重构:startDate/dueDate/estimateHours/actualHours 替换为 expectedStartAt/expectedEndAt/actualStartAt/actualEndAt(含时分),预计/实际工时按工作时段(9:00-12:00 + 13:00-18:00)派生计算 - 状态机自动化:到点自动切开发中、未到可手动开干、超期需填延后原因 - 新建 lib/work-hours.ts:calcWorkHours(工作时段过滤)、formatWorkHours(X h(Y 天))、calcTwoMetrics(日历/人力双口径) - 新建 lib/dev-task-transitions.ts:状态切换守卫 - 三个 Tab 顶部统计:开发任务(预/日历/人力)、测试用例 / Bug(日历 / 人力);版本概览总人天投入改双行(日历总耗时 / 人力总投入) - 三个 Tab 加搜索框:300ms debounce + 标题/编号模糊匹配 - 性能优化:requirementIds/categories/requirements/testCases 转 Map/Set 索引、Row 组件 React.memo - 全局耗时显示统一带天数换算:8h(1天)、11h(1.4天) 新增文件: SearchInput.tsx, useDebouncedValue.ts, work-hours.ts, dev-task-transitions.ts, 设计文档 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import { useBugStore } from '@/stores/useBugStore';
|
||||
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
|
||||
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status';
|
||||
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress } from '@/lib/dev-task';
|
||||
import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import type { VersionPlan } from '@/lib/version-plan';
|
||||
@@ -31,6 +31,27 @@ function StatCard({ value, label }: { value: number | string; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HoursStatCard({ estimate, actual }: { estimate: number; actual: number }) {
|
||||
const overrun = actual > estimate && estimate > 0;
|
||||
const underrun = actual > 0 && actual < estimate;
|
||||
const tone = overrun ? 'text-red-600' : underrun ? 'text-emerald-600' : 'text-[var(--ink)]';
|
||||
const dayStr = (h: number) => {
|
||||
const d = h / 8;
|
||||
return Number.isInteger(d) ? String(d) : d.toFixed(1).replace(/\.0$/, '');
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-2xl font-bold tabular-nums ${tone}`}>{actual > 0 ? `${actual}h` : '—'}</span>
|
||||
<span className="text-xs text-[var(--ink-muted)] tabular-nums">/ {estimate}h</span>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--ink-muted)] mt-1">
|
||||
实际 / 预计耗时{estimate > 0 && <span className="ml-1">({actual > 0 ? `${dayStr(actual)} / ` : ''}{dayStr(estimate)}天)</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── ProgressBar (for expanded released cards) ─── */
|
||||
function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number; daysSpent: number }) {
|
||||
return (
|
||||
@@ -73,7 +94,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
||||
});
|
||||
vDevTasks.forEach((t) => { if (t.startDate) startDates.push(t.startDate); });
|
||||
vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
|
||||
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||
|
||||
const earliestStart = startDates.length > 0 ? startDates.sort()[0] : version.startDate;
|
||||
@@ -82,7 +103,7 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
||||
// 实际截止:取所有阶段最晚完成
|
||||
const endDates: string[] = [];
|
||||
vPlans.forEach((p) => { if (p.completedAt) endDates.push(p.completedAt); });
|
||||
vDevTasks.forEach((t) => { if (t.completedAt) endDates.push(t.completedAt); });
|
||||
vDevTasks.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
|
||||
vTCs.forEach((c) => { if (c.completedAt) endDates.push(c.completedAt); });
|
||||
vBugs.forEach((b) => { if (b.closedAt) endDates.push(b.closedAt); });
|
||||
const actualEndDisplay = endDates.length > 0 ? endDates.sort().reverse()[0].slice(0, 10) : null;
|
||||
@@ -394,12 +415,12 @@ export default function ProjectDetailPage() {
|
||||
}
|
||||
|
||||
if (vDevTasks.length > 0) {
|
||||
const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0);
|
||||
const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
|
||||
let devProgress: number;
|
||||
if (totalEstimate === 0) {
|
||||
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
|
||||
} else {
|
||||
const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
|
||||
const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
|
||||
devProgress = weighted / totalEstimate;
|
||||
}
|
||||
segments.push(devProgress);
|
||||
@@ -416,14 +437,17 @@ export default function ProjectDetailPage() {
|
||||
}, [project, plans, requirements, devTasks, testCases]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0 };
|
||||
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 };
|
||||
const total = project.versions.length;
|
||||
const released = project.versions.filter((v) => v.status === 'released').length;
|
||||
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
|
||||
const versionIds = new Set(project.versions.map((v) => v.id));
|
||||
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
|
||||
return { total, released, reqCount, bugCount };
|
||||
}, [project, requirements, bugs, projectId]);
|
||||
const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id));
|
||||
const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId));
|
||||
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
|
||||
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
|
||||
}, [project, requirements, bugs, devTasks, projectId]);
|
||||
|
||||
const teamByRole = useMemo(() => {
|
||||
if (!project) return {} as Record<string, Record<string, number>>;
|
||||
@@ -464,11 +488,12 @@ export default function ProjectDetailPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 bg-[var(--bg)]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<StatCard value={stats.total} label="总版本数" />
|
||||
<StatCard value={stats.released} label="已开发" />
|
||||
<StatCard value={stats.reqCount} label="需求数" />
|
||||
<StatCard value={stats.bugCount} label="Bug 总数" />
|
||||
<HoursStatCard estimate={stats.estimateHours} actual={stats.actualHours} />
|
||||
</div>
|
||||
|
||||
<TeamSection teamByRole={teamByRole} />
|
||||
|
||||
@@ -26,7 +26,11 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { calcGroupProgress as calcDevTaskProgress, calcActualHoursByDates } from '@/lib/dev-task';
|
||||
import { calcGroupProgress as calcDevTaskProgress, getActualHours, getEstimateHours, aggregateDevTaskHours, devTaskIntervals } from '@/lib/dev-task';
|
||||
import { calcWorkHours, formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
|
||||
import { testCaseIntervals } from '@/lib/test-case';
|
||||
import { bugIntervals } from '@/lib/bug';
|
||||
import { planIntervals } from '@/lib/version-plan';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
const PRIORITY_STYLE: Record<string, string> = {
|
||||
@@ -109,7 +113,7 @@ export default function VersionDetailPage() {
|
||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
||||
});
|
||||
vDevTasks.forEach((t) => { if (t.startDate) startDates.push(t.startDate); });
|
||||
vDevTasks.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
|
||||
vTCs.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||
|
||||
if (startDates.length === 0 && !version.startDate) return 0;
|
||||
@@ -396,14 +400,14 @@ export default function VersionDetailPage() {
|
||||
if (p.actualStartAt) startDates.push(p.actualStartAt);
|
||||
else if (p.status === 'pending' && p.startTime && new Date(p.startTime) <= new Date()) startDates.push(p.startTime);
|
||||
});
|
||||
vDTs.forEach((t) => { if (t.startDate) startDates.push(t.startDate); });
|
||||
vDTs.forEach((t) => { if (t.actualStartAt) startDates.push(t.actualStartAt); });
|
||||
vTCsAll.forEach((c) => { if (c.startedAt) startDates.push(c.startedAt); });
|
||||
const actualStart = startDates.length > 0 ? formatDateTime(startDates.sort()[0]) : (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); });
|
||||
vDTs.forEach((t) => { if (t.actualEndAt) endDates.push(t.actualEndAt); });
|
||||
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 ? formatDateTime(endDates.sort().reverse()[0]) : null;
|
||||
@@ -702,8 +706,8 @@ export default function VersionDetailPage() {
|
||||
|
||||
// 开发阶段
|
||||
const devCal = calcCalendar(
|
||||
versionDevTasks.filter((t) => t.startDate).map((t) => t.startDate!),
|
||||
versionDevTasks.filter((t) => t.completedAt).map((t) => t.completedAt!),
|
||||
versionDevTasks.filter((t) => t.actualStartAt).map((t) => t.actualStartAt!),
|
||||
versionDevTasks.filter((t) => t.actualEndAt).map((t) => t.actualEndAt!),
|
||||
);
|
||||
|
||||
// 测试阶段
|
||||
@@ -732,16 +736,17 @@ export default function VersionDetailPage() {
|
||||
// 调研/产品/UI 用 startTime→endTime 计算
|
||||
versionPlans.forEach((p) => {
|
||||
if (!p.owner || !p.actualStartAt) return;
|
||||
const hours = calcActualHoursByDates(p.actualStartAt, p.completedAt);
|
||||
const hours = calcWorkHours(p.actualStartAt, p.completedAt ?? new Date().toISOString());
|
||||
addHours(p.owner, p.type === 'research' ? 'research' : p.type === 'product' ? 'product' : 'ui', hours);
|
||||
});
|
||||
versionDevTasks.forEach((t) => {
|
||||
if (!t.startDate || !t.assigneeId) return;
|
||||
addHours(t.assigneeId, 'dev', calcActualHoursByDates(t.startDate, t.completedAt));
|
||||
if (!t.actualStartAt || !t.assigneeId) return;
|
||||
const endIso = t.actualEndAt ?? new Date().toISOString();
|
||||
addHours(t.assigneeId, 'dev', calcWorkHours(t.actualStartAt, endIso));
|
||||
});
|
||||
versionTCs.forEach((c) => {
|
||||
if (!c.startedAt || !c.assigneeId) return;
|
||||
addHours(c.assigneeId, 'test', calcActualHoursByDates(c.startedAt, c.completedAt));
|
||||
addHours(c.assigneeId, 'test', calcWorkHours(c.startedAt, c.completedAt ?? new Date().toISOString()));
|
||||
});
|
||||
|
||||
const personalRanking = Array.from(personalHours.entries())
|
||||
@@ -750,6 +755,15 @@ export default function VersionDetailPage() {
|
||||
const maxHours = personalRanking[0]?.total || 1;
|
||||
const totalHours = personalRanking.reduce((s, p) => s + p.total, 0);
|
||||
|
||||
// 双口径:日历总耗时(合并区间)+ 人力总投入(即 totalHours)
|
||||
const allIntervals = [
|
||||
...planIntervals(versionPlans),
|
||||
...devTaskIntervals(versionDevTasks),
|
||||
...testCaseIntervals(versionTCs),
|
||||
...bugIntervals(versionBugs),
|
||||
];
|
||||
const { calendarHours: versionCalendarHours } = calcTwoMetrics(allIntervals);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 阶段日历耗时 */}
|
||||
@@ -770,10 +784,14 @@ export default function VersionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-[var(--line)]">
|
||||
<div className="pt-2 border-t border-[var(--line)] space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">总人天投入</span>
|
||||
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{totalHours}h({Math.round(totalHours / 8)}人天)</span>
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">日历总耗时</span>
|
||||
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{formatWorkHours(versionCalendarHours)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-[var(--ink-soft)]">人力总投入</span>
|
||||
<span className="text-[12px] font-medium tabular-nums text-[var(--ink)]">{formatWorkHours(totalHours)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -794,11 +812,11 @@ export default function VersionDetailPage() {
|
||||
<span className="text-[12px] font-medium tabular-nums text-[var(--ink-soft)]">{item.total}h</span>
|
||||
</div>
|
||||
<div className="ml-7 flex items-center gap-0.5 h-1.5">
|
||||
{item.research > 0 && <div className="h-full rounded-full bg-orange-400" style={{ width: `${(item.research / maxHours) * 100}%` }} title={`调研 ${item.research}h`} />}
|
||||
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${item.product}h`} />}
|
||||
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${item.ui}h`} />}
|
||||
{item.dev > 0 && <div className="h-full rounded-full bg-blue-400" style={{ width: `${(item.dev / maxHours) * 100}%` }} title={`开发 ${item.dev}h`} />}
|
||||
{item.test > 0 && <div className="h-full rounded-full bg-purple-400" style={{ width: `${(item.test / maxHours) * 100}%` }} title={`测试 ${item.test}h`} />}
|
||||
{item.research > 0 && <div className="h-full rounded-full bg-orange-400" style={{ width: `${(item.research / maxHours) * 100}%` }} title={`调研 ${formatWorkHours(item.research)}`} />}
|
||||
{item.product > 0 && <div className="h-full rounded-full bg-pink-400" style={{ width: `${(item.product / maxHours) * 100}%` }} title={`产品 ${formatWorkHours(item.product)}`} />}
|
||||
{item.ui > 0 && <div className="h-full rounded-full bg-indigo-400" style={{ width: `${(item.ui / maxHours) * 100}%` }} title={`UI ${formatWorkHours(item.ui)}`} />}
|
||||
{item.dev > 0 && <div className="h-full rounded-full bg-blue-400" style={{ width: `${(item.dev / maxHours) * 100}%` }} title={`开发 ${formatWorkHours(item.dev)}`} />}
|
||||
{item.test > 0 && <div className="h-full rounded-full bg-purple-400" style={{ width: `${(item.test / maxHours) * 100}%` }} title={`测试 ${formatWorkHours(item.test)}`} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { VersionWithContext } from '@/lib/derive';
|
||||
import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_DOT, VERSION_STATUS_BG, getVersionDisplayStatus } from '@/lib/version-status';
|
||||
import { STAGES } from '@/lib/stage';
|
||||
import { ROLE_LABEL } from '@/lib/stage';
|
||||
import { STATUS_PROGRESS } from '@/lib/dev-task';
|
||||
import { STATUS_PROGRESS, getEstimateHours } from '@/lib/dev-task';
|
||||
import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_COLOR, HEALTH_LEVEL_DOT, getTagStyle } from '@/lib/health';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
|
||||
@@ -189,12 +189,12 @@ export default function VersionsPage() {
|
||||
|
||||
// Dev tasks progress (weighted by STATUS_PROGRESS)
|
||||
if (vDevTasks.length > 0) {
|
||||
const totalEstimate = vDevTasks.reduce((sum, t) => sum + t.estimateHours, 0);
|
||||
const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0);
|
||||
let devProgress: number;
|
||||
if (totalEstimate === 0) {
|
||||
devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length;
|
||||
} else {
|
||||
const weighted = vDevTasks.reduce((sum, t) => sum + t.estimateHours * STATUS_PROGRESS[t.status], 0);
|
||||
const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0);
|
||||
devProgress = weighted / totalEstimate;
|
||||
}
|
||||
segments.push(devProgress);
|
||||
|
||||
@@ -18,8 +18,9 @@ import { DevTaskDetailDrawer } from '@/components/dev-task/DevTaskDetailDrawer';
|
||||
import { TestCaseDetailDrawer } from '@/components/test-case/TestCaseDetailDrawer';
|
||||
import { BugDetailDrawer } from '@/components/bug/BugDetailDrawer';
|
||||
import { BugCreateModal } from '@/components/bug/BugCreateModal';
|
||||
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR } from '@/lib/dev-task';
|
||||
import { DEV_TASK_STATUS_LABEL, DEV_TASK_STATUS_COLOR, getEstimateHours, getActualHours } from '@/lib/dev-task';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { formatShortTime, formatWorkHours } from '@/lib/work-hours';
|
||||
import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case';
|
||||
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||
|
||||
@@ -293,6 +294,18 @@ function ProjectNode({ name, versions, selectedVersionId, onSelect }: {
|
||||
|
||||
function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigate: () => void; onClick: () => void }) {
|
||||
const statusBadge = getStatusBadge(item);
|
||||
const isDevTask = item.type === 'devTask';
|
||||
const devTaskRaw = isDevTask ? (item.raw as any) : null;
|
||||
const devEstimate = devTaskRaw ? getEstimateHours(devTaskRaw) : 0;
|
||||
const devActual = devTaskRaw ? getActualHours(devTaskRaw) : 0;
|
||||
const devOverrun = devActual > devEstimate && devEstimate > 0;
|
||||
const devTimeRange = devTaskRaw ? (
|
||||
devTaskRaw.actualStartAt
|
||||
? `实际 ${formatShortTime(devTaskRaw.actualStartAt)} → ${devTaskRaw.actualEndAt ? formatShortTime(devTaskRaw.actualEndAt) : '进行中'}`
|
||||
: (devTaskRaw.expectedStartAt && devTaskRaw.expectedEndAt
|
||||
? `预计 ${formatShortTime(devTaskRaw.expectedStartAt)} → ${formatShortTime(devTaskRaw.expectedEndAt)}`
|
||||
: '')
|
||||
) : '';
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className={`rounded-xl border border-[var(--line)] bg-[var(--bg-card)] px-4 py-3 transition-colors hover:border-[var(--accent)] cursor-pointer ${item.completed ? 'opacity-60' : ''}`}>
|
||||
@@ -322,7 +335,16 @@ function WorkItemCard({ item, onNavigate, onClick }: { item: WorkItem; onNavigat
|
||||
<span>{item.projectName}</span>
|
||||
<span className="text-[var(--line)]">/</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); onNavigate(); }} className="text-[var(--accent)] hover:underline">{item.versionName}</button>
|
||||
{item.extra?.dueDate && <span className="ml-2">截止 {item.extra.dueDate}</span>}
|
||||
{isDevTask && devTimeRange && (
|
||||
<>
|
||||
<span className="ml-2 tabular-nums">{devTimeRange}</span>
|
||||
{devEstimate > 0 && (
|
||||
<span className={`ml-2 tabular-nums ${devActual > 0 ? (devOverrun ? 'text-red-600' : (devActual < devEstimate ? 'text-emerald-600' : '')) : ''}`}>
|
||||
{devActual > 0 ? `${formatWorkHours(devActual)} / ${formatWorkHours(devEstimate)}` : `预 ${formatWorkHours(devEstimate)}`}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{item.extra?.startTime && <span className="ml-2">{formatDateTime(item.extra.startTime)} → {formatDateTime(item.extra.endTime)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user