feat: 计划任务清单+进度联动+与我相关工作台

- 计划新增任务清单:调研支持任务CRUD,进度=完成数/总数
- 产品方案/UI:通过关联需求勾选进度,全部完成提示提交
- 版本胶囊条动态进度:调研/产品方案/UI 阶段独立计算
- 版本状态显示具体阶段名(调研中/产品设计中/UI设计中)
- 计划开始日期≤今天自动进入"进行中",版本状态联动
- 与我相关重写为左右布局:左侧分组导航,右侧任务/需求勾选
- 计划支持超期原因校验(结束日期超版本截止)
- 修复列表overflow裁剪问题、计划耗时当天至少1天

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Script Generator
2026-06-11 15:43:09 +08:00
parent 2acc9aeaa9
commit 8bc23fbf3d
7 changed files with 613 additions and 52 deletions

View File

@@ -1,9 +1,15 @@
import { Stage, Role, STAGES, STAGE_INDEX } from '@/lib/stage';
import type { RoleProgress } from '@/lib/derive';
export function CapsuleStages({ currentStage, progress }: {
export interface StageProgressItem {
percent: number;
daysSpent: number;
}
export function CapsuleStages({ currentStage, progress, stageProgress }: {
currentStage?: Stage;
progress?: RoleProgress[];
stageProgress?: Partial<Record<Stage, StageProgressItem>>;
}) {
const currentIdx = currentStage !== undefined
? (currentStage === 'released' ? STAGES.length : STAGE_INDEX[currentStage])
@@ -24,6 +30,11 @@ export function CapsuleStages({ currentStage, progress }: {
};
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);
@@ -50,7 +61,7 @@ export function CapsuleStages({ currentStage, progress }: {
{stage.label}
</span>
<span className={`text-[10px] leading-tight ${isCompleted ? 'text-emerald-600' : isCurrent ? 'text-blue-600 font-medium' : 'text-[var(--ink-muted)]'}`}>
{isCompleted ? (info.hasData ? `${info.days}` : '-') : isCurrent ? (info.hasData ? `${info.percent}%` : '-') : ''}
{isCompleted ? (info.hasData ? `${info.percent}% · ${info.days}` : '-') : isCurrent ? (info.hasData ? `${info.percent}% · ${info.days}` : '-') : ''}
</span>
</div>
<div className="h-[3px] w-full bg-zinc-50">

View File

@@ -2,8 +2,8 @@
import { useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2 } from 'lucide-react';
import type { VersionPlan } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration } from '@/lib/version-plan';
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
interface Props {
plans: VersionPlan[];
@@ -39,7 +39,7 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} {TYPE_LABEL[planType]}</span>
<span className="text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
<span className="text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{totalDuration}</span></span>
{versionDeadline && <span className="text-[12px] text-[var(--ink-muted)]"><span className="font-medium text-red-500">{versionDeadline}</span></span>}
</div>
<button onClick={() => setShowCreateModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
@@ -55,41 +55,35 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
) : (
<div className="space-y-3">
{typePlans.map((plan) => {
const dur = plan.completedAt
// 已耗时已完成用实际完成时间进行中用当前时间未开始为0
const now = new Date().toISOString().slice(0, 10);
const effectiveStatus = plan.status === 'pending' && plan.startTime <= now ? 'in_progress' : plan.status;
const dur = effectiveStatus === 'completed' && plan.completedAt
? calcPlanDuration(plan.startTime, plan.completedAt)
: calcPlanDuration(plan.startTime, plan.endTime);
const durText = formatDuration(dur.days, dur.hours);
: effectiveStatus === 'in_progress'
? calcPlanDuration(plan.startTime, now)
: { days: 0, hours: 0 };
const durText = dur.days > 0 || dur.hours > 0 ? formatDuration(dur.days, dur.hours) : '-';
return (
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[13px] font-medium text-[var(--ink)]">{plan.title}</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[plan.status]}`}>
{STATUS_LABEL[plan.status]}
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium border ${STATUS_STYLE[effectiveStatus]}`}>
{STATUS_LABEL[effectiveStatus]}
</span>
</div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{plan.owner}</span>
<span>{plan.startTime.slice(0, 10)} {plan.endTime.slice(0, 10)}</span>
{plan.completedAt && <span className="text-green-600">{plan.completedAt.slice(0, 10)}</span>}
<span className="font-medium text-[var(--ink)]"> {durText}</span>
<span className="font-medium text-[var(--ink)]"> {durText}</span>
</div>
{plan.overdueReason && (
<div className="mt-1.5 text-[11px] text-red-600 bg-red-50 rounded px-2 py-1 inline-block">{plan.overdueReason}</div>
)}
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
<div className="flex flex-wrap gap-1.5 mt-2">
{plan.linkedRequirementIds.map((rid) => {
const req = linkedRequirements.find((r) => r.id === rid);
return req ? (
<span key={rid} className="inline-flex items-center gap-1 rounded bg-[var(--bg-subtle)] px-2 py-0.5 text-[11px] text-[var(--ink-soft)]">
{req.code} {req.title.slice(0, 12)}{req.title.length > 12 ? '...' : ''}
{req.productOwner && <span className="text-[var(--ink-muted)]">({req.productOwner})</span>}
</span>
) : null;
})}
</div>
{(plan.type === 'product' || plan.type === 'ui') && (
<div className="mt-1.5 text-[12px] text-[var(--ink-soft)]"><span className="font-medium text-[var(--ink)]">{plan.owner}</span></div>
)}
{plan.status === 'completed' && plan.resultUrl && (
<div className="flex items-center gap-1.5 mt-2">
@@ -99,9 +93,80 @@ export function PlanTab({ plans, versionId, versionDeadline, currentUserName, pl
</a>
</div>
)}
{/* 调研:任务进度 */}
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcPlanProgress(plan.tasks)}%` }} />
</div>
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcPlanProgress(plan.tasks)}%</span>
</div>
<div className="space-y-1">
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2">
<button
onClick={() => {
const nextStatus = task.status === 'pending' ? 'in_progress' : task.status === 'in_progress' ? 'completed' : 'pending';
const updatedTasks = plan.tasks!.map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
onUpdate(plan.id, { tasks: updatedTasks });
}}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : task.status === 'in_progress' ? 'border-blue-400 bg-blue-50' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
{task.status === 'in_progress' && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
</button>
<span className={`text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
<span className={`text-[10px] ${task.status === 'completed' ? 'text-green-600' : task.status === 'in_progress' ? 'text-blue-600' : 'text-[var(--ink-muted)]'}`}>
{task.status === 'completed' ? '已完成' : task.status === 'in_progress' ? '进行中' : '未开始'}
</span>
</div>
))}
</div>
</div>
)}
{/* 产品方案/UI关联需求进度 */}
{(plan.type === 'product' || plan.type === 'ui') && plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && linkedRequirements && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%` }} />
</div>
<span className="text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%</span>
</div>
<div className="space-y-1">
{plan.linkedRequirementIds.map((rid) => {
const req = linkedRequirements.find((r) => r.id === rid);
const isDone = (plan.completedRequirementIds || []).includes(rid);
return req ? (
<div key={rid} className="flex items-center gap-2">
<button
onClick={() => {
const current = plan.completedRequirementIds || [];
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
onUpdate(plan.id, { completedRequirementIds: next });
}}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
<span className={`text-[11px] font-mono text-[var(--ink-muted)]`}>{req.code}</span>
<span className={`text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
</div>
) : null;
})}
</div>
{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds) === 100 && effectiveStatus !== 'completed' && (
<div className="mt-2 rounded-lg bg-green-50 border border-green-200 px-3 py-2 flex items-center justify-between">
<span className="text-[12px] text-green-700"></span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline"></button>
</div>
)}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-3">
{plan.status !== 'completed' && (
{effectiveStatus !== 'completed' && (
<>
<button onClick={() => setCompletingPlan(plan)} className="h-7 w-7 flex items-center justify-center rounded-md text-green-600 hover:bg-green-50" title="标记完成">
<Check className="h-3.5 w-3.5" />
@@ -166,6 +231,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 10) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 10) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
const [newTaskTitle, setNewTaskTitle] = useState('');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
const [selectedReqs, setSelectedReqs] = useState<Set<string>>(new Set(initial?.linkedRequirementIds ?? []));
const showReqSelect = planType === 'product' || planType === 'ui';
@@ -184,6 +251,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
endTime,
status: initial?.status ?? 'pending',
linkedRequirementIds: showReqSelect ? Array.from(selectedReqs) : undefined,
tasks: tasks.length > 0 ? tasks : undefined,
remark: remark.trim() || undefined,
overdueReason: isOverdue ? overdueReason.trim() : undefined,
addedBy: currentUserName,
@@ -246,6 +314,29 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
</div>
</div>
)}
{planType === 'research' && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="space-y-1.5 mb-2">
{tasks.map((task, i) => (
<div key={task.id} className="flex items-center gap-2 rounded-lg bg-[var(--bg-subtle)] px-3 py-1.5">
<span className="flex-1 text-[12px] text-[var(--ink)]">{task.title}</span>
<button type="button" onClick={() => setTasks(tasks.filter((_, idx) => idx !== i))} className="text-red-400 hover:text-red-600"><X className="h-3 w-3" /></button>
</div>
))}
</div>
<div className="flex gap-2">
<input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } } }}
placeholder="输入任务名称,回车添加"
className="flex-1 h-8 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none"
/>
<button type="button" onClick={() => { if (newTaskTitle.trim()) { setTasks([...tasks, { id: `task-${Date.now()}`, title: newTaskTitle.trim(), status: 'pending' }]); setNewTaskTitle(''); } }} className="h-8 px-3 rounded-lg text-[12px] font-medium bg-[var(--bg-subtle)] text-[var(--ink-soft)] hover:bg-[var(--line)]"></button>
</div>
</div>
)}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<textarea value={remark} onChange={(e) => setRemark(e.target.value)} rows={2} className="w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 py-2 text-[13px] focus:border-[var(--accent)] focus:outline-none resize-none" />