feat(工作台): 细化计划进展工时证据
关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -47,6 +47,13 @@ function defaultPlanEndLocal(): string {
|
||||
return isoToLocal(d.toISOString());
|
||||
}
|
||||
|
||||
function defaultProgressNextStartLocal(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(9, 30, 0, 0);
|
||||
return isoToLocal(d.toISOString());
|
||||
}
|
||||
|
||||
export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel, readOnly = false }: Props) {
|
||||
const { tasks, changeStatus, setBlocked, deleteTask, updateTask } = useDevTaskStore();
|
||||
const addProgressNote = useWorkActivityStore((s) => s.addProgressNote);
|
||||
@@ -65,6 +72,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
const [progressBlocker, setProgressBlocker] = useState('');
|
||||
const [progressHelperId, setProgressHelperId] = useState('');
|
||||
const [progressDelayRisk, setProgressDelayRisk] = useState('');
|
||||
const [progressNextStartLocal, setProgressNextStartLocal] = useState(() => defaultProgressNextStartLocal());
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return null;
|
||||
@@ -92,6 +100,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
const planEndISO = localToISO(planEndLocal);
|
||||
const planStartBeforeEnd = Boolean(planStartISO && planEndISO && planStartISO < planEndISO);
|
||||
const planEstimateHours = planStartBeforeEnd ? calcWorkHours(planStartISO, planEndISO) : 0;
|
||||
const progressNextStartISO = localToISO(progressNextStartLocal);
|
||||
const canRecordProgress = Boolean(
|
||||
(progressNote.trim() || progressBlocker.trim() || progressDelayRisk.trim()) && progressNextStartISO,
|
||||
);
|
||||
|
||||
const openPlanInput = () => {
|
||||
if (readOnly) return;
|
||||
@@ -160,6 +172,10 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
const blocker = progressBlocker.trim();
|
||||
const delayRisk = progressDelayRisk.trim();
|
||||
if (!note && !blocker && !delayRisk) return;
|
||||
if (!progressNextStartISO) {
|
||||
alert('请填写下次开始时间');
|
||||
return;
|
||||
}
|
||||
|
||||
addProgressNote({
|
||||
actorId: task.assigneeId,
|
||||
@@ -167,6 +183,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
sourceId: task.id,
|
||||
title: task.title,
|
||||
note: note || '今日进展已更新',
|
||||
nextStartAt: progressNextStartISO,
|
||||
blocker: blocker || undefined,
|
||||
helperId: progressHelperId || undefined,
|
||||
delayRisk: delayRisk || undefined,
|
||||
@@ -175,6 +192,7 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
setProgressBlocker('');
|
||||
setProgressHelperId('');
|
||||
setProgressDelayRisk('');
|
||||
setProgressNextStartLocal(defaultProgressNextStartLocal());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -381,11 +399,25 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
placeholder="延期风险"
|
||||
className="h-8 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-[11px] text-[var(--ink-muted)]">
|
||||
下次开始时间
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<WorkDateTimePicker
|
||||
value={progressNextStartLocal}
|
||||
onChange={setProgressNextStartLocal}
|
||||
placeholder="选择下次开始时间"
|
||||
defaultHour={9}
|
||||
popoverAlign="right"
|
||||
className="bg-[var(--bg)]"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleProgressNote}
|
||||
disabled={!progressNote.trim() && !progressBlocker.trim() && !progressDelayRisk.trim()}
|
||||
className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
disabled={!canRecordProgress}
|
||||
className="h-9 px-3 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
>
|
||||
记录进展
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
getRequirementCoverageStatus,
|
||||
getRequirementCoverageSummary,
|
||||
REQUIREMENT_COVERAGE_LABEL,
|
||||
startResearchDirectionWork,
|
||||
startRequirementCoverageWork,
|
||||
updateResearchDirectionProgress,
|
||||
updateRequirementCoverage,
|
||||
} from '@/lib/version-plan';
|
||||
@@ -116,10 +118,19 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
setRemainingContent('');
|
||||
};
|
||||
|
||||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
|
||||
const startCoverage = (req: RequirementOption) => {
|
||||
if (!canEdit) return;
|
||||
const patch = startRequirementCoverageWork(plan, {
|
||||
requirementId: req.id,
|
||||
updatedBy: currentUserName,
|
||||
});
|
||||
onUpdate(plan.id, patch);
|
||||
};
|
||||
|
||||
const saveCoverage = (req: RequirementOption, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
|
||||
const coverage = getRequirementCoverage(plan, req.id);
|
||||
const workStartedAt = coverage?.currentWorkStartedAt;
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, workStartedAt)) return;
|
||||
const patch = updateRequirementCoverage(plan, {
|
||||
requirementId: req.id,
|
||||
status,
|
||||
@@ -155,7 +166,11 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
const status = getRequirementCoverageStatus(plan, req.id);
|
||||
const coverage = getRequirementCoverage(plan, req.id);
|
||||
const isEditing = editingRequirementId === req.id;
|
||||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit);
|
||||
const hasStartedWork = Boolean(coverage?.currentWorkStartedAt);
|
||||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
|
||||
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
|
||||
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, coverage?.currentWorkStartedAt);
|
||||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, coverage?.currentWorkStartedAt);
|
||||
return (
|
||||
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
@@ -168,19 +183,31 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
|
||||
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600">历史</span>}
|
||||
</div>
|
||||
{(coverage?.completedContent || coverage?.remainingContent) && (
|
||||
{(coverage?.completedContent || coverage?.remainingContent || coverage?.currentWorkStartedAt) && (
|
||||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
|
||||
{coverage.completedContent && <div className="line-clamp-2">已完成:{coverage.completedContent}</div>}
|
||||
{coverage.remainingContent && <div className="line-clamp-2 text-amber-700">剩余:{coverage.remainingContent}</div>}
|
||||
{coverage.currentWorkStartedAt && <div className="text-blue-600">已开始:{formatDateTime(coverage.currentWorkStartedAt)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (canOpenRecord || isEditing) && (
|
||||
{canEdit && status !== 'completed' && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{canStartWork && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCoverage(req)}
|
||||
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
开始任务
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isEditing ? closeEditor() : openEditor(req)}
|
||||
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
|
||||
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(req))}
|
||||
disabled={!canOpenRecord}
|
||||
title={!hasStartedWork ? '请先开始任务' : undefined}
|
||||
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEditing ? '收起' : '记录'}
|
||||
</button>
|
||||
@@ -207,7 +234,8 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCoverage(req, 'completed')}
|
||||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
|
||||
disabled={!canSaveCompleted}
|
||||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
完全完成
|
||||
</button>
|
||||
@@ -260,10 +288,17 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
setRemainingContent('');
|
||||
};
|
||||
|
||||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
|
||||
const startDirection = (task: PlanTask) => {
|
||||
if (!canEdit) return;
|
||||
const patch = startResearchDirectionWork(plan, {
|
||||
taskId: task.id,
|
||||
updatedBy: currentUserName,
|
||||
});
|
||||
onUpdate(plan.id, patch);
|
||||
};
|
||||
|
||||
const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent, task.currentWorkStartedAt)) return;
|
||||
const patch = updateResearchDirectionProgress(plan, {
|
||||
taskId: task.id,
|
||||
status,
|
||||
@@ -296,7 +331,11 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
{tasks.map((task) => {
|
||||
const status = getResearchDirectionStatus(task);
|
||||
const isEditing = editingTaskId === task.id;
|
||||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit);
|
||||
const hasStartedWork = Boolean(task.currentWorkStartedAt);
|
||||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit) && hasStartedWork;
|
||||
const canStartWork = canEdit && status !== 'completed' && !hasStartedWork;
|
||||
const canSaveCompleted = canSaveRequirementCoverageDraft('completed', undefined, undefined, task.currentWorkStartedAt);
|
||||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent, task.currentWorkStartedAt);
|
||||
return (
|
||||
<div key={task.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
@@ -305,19 +344,31 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div>
|
||||
{(task.completedContent || task.remainingContent) && (
|
||||
{(task.completedContent || task.remainingContent || task.currentWorkStartedAt) && (
|
||||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
|
||||
{task.completedContent && <div className="line-clamp-2">已完成:{task.completedContent}</div>}
|
||||
{task.remainingContent && <div className="line-clamp-2 text-amber-700">剩余:{task.remainingContent}</div>}
|
||||
{task.currentWorkStartedAt && <div className="text-blue-600">已开始:{formatDateTime(task.currentWorkStartedAt)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (canOpenRecord || isEditing) && (
|
||||
{canEdit && status !== 'completed' && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{canStartWork && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startDirection(task)}
|
||||
className="rounded-md bg-blue-600 px-2 py-1 text-[11px] font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
开始任务
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isEditing ? closeEditor() : openEditor(task)}
|
||||
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
|
||||
onClick={() => canOpenRecord && (isEditing ? closeEditor() : openEditor(task))}
|
||||
disabled={!canOpenRecord}
|
||||
title={!hasStartedWork ? '请先开始任务' : undefined}
|
||||
className="rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEditing ? '收起' : '记录'}
|
||||
</button>
|
||||
@@ -344,7 +395,8 @@ export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onU
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveDirection(task, 'completed')}
|
||||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
|
||||
disabled={!canSaveCompleted}
|
||||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
完全完成
|
||||
</button>
|
||||
@@ -460,6 +512,11 @@ export function PlanLogTimeline({ logs, className = '', fillHeight = false }: Lo
|
||||
<div className="whitespace-pre-wrap">{log.detail}</div>
|
||||
</div>
|
||||
)}
|
||||
{log.workStartedAt && (
|
||||
<div className="mt-2 inline-flex rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700">
|
||||
本次开始:{formatDateTime(log.workStartedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user