所有 Drawer 新增 contextLabel 可选属性: - 从工作台打开时传入"产品 / 项目 / 版本"路径 - 显示在 Drawer 顶部灰色背景条中 - 从版本详情打开时不传,不显示(无需冗余) - 统一样式:px-5 py-2 bg-[var(--bg-subtle)] text-[11px] 涉及组件: - PlanDetailDrawer - DevTaskDetailDrawer - TestCaseDetailDrawer - BugDetailDrawer Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
14 KiB
TypeScript
256 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
|
|
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
|
import { useMemberStore } from '@/stores/useMemberStore';
|
|
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
|
import type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
|
|
|
interface Props {
|
|
planId: string;
|
|
onClose: () => void;
|
|
contextLabel?: string;
|
|
}
|
|
|
|
const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-600', in_progress: 'bg-blue-50 text-blue-600', completed: 'bg-emerald-50 text-emerald-600' };
|
|
const STATUS_LABEL: Record<string, string> = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
|
|
const TYPE_LABEL: Record<string, string> = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
|
|
|
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
|
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
|
const { requirements } = useRequirementStore();
|
|
const { members } = useMemberStore();
|
|
const [showTransfer, setShowTransfer] = useState(false);
|
|
const [transferTo, setTransferTo] = useState('');
|
|
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
|
const [resultUrl, setResultUrl] = useState('');
|
|
const [fileName, setFileName] = useState('');
|
|
const [fileData, setFileData] = useState('');
|
|
const [showComplete, setShowComplete] = useState(false);
|
|
|
|
const plan = plans.find((p) => p.id === planId);
|
|
if (!plan) return null;
|
|
|
|
const isResearch = plan.type === 'research';
|
|
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
|
|
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
|
const canInteract = plan.status === 'in_progress' || (plan.status === 'pending' && plan.startTime && new Date(plan.startTime) <= new Date());
|
|
|
|
const handleToggleTask = (task: PlanTask) => {
|
|
if (!canInteract) return;
|
|
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
|
|
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
|
|
updatePlan(plan.id, { tasks: updatedTasks });
|
|
};
|
|
|
|
const handleToggleReq = (reqId: string) => {
|
|
if (!canInteract) return;
|
|
const current = plan.completedRequirementIds || [];
|
|
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
|
|
updatePlan(plan.id, { completedRequirementIds: next });
|
|
};
|
|
|
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
setFileName(file.name);
|
|
const reader = new FileReader();
|
|
reader.onload = () => setFileData(reader.result as string);
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
const handleSubmitResult = () => {
|
|
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
|
if (!url) return;
|
|
completePlan(plan.id, { resultType, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
|
setShowComplete(false);
|
|
};
|
|
|
|
const handleTransfer = () => {
|
|
if (!transferTo) return;
|
|
updatePlan(plan.id, { owner: transferTo });
|
|
setShowTransfer(false);
|
|
setTransferTo('');
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex justify-end" onClick={onClose}>
|
|
<div className="w-full max-w-md h-full bg-[var(--bg)] border-l border-[var(--line)] shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
|
{/* Context */}
|
|
{contextLabel && (
|
|
<div className="px-5 py-2 border-b border-[var(--line)] bg-[var(--bg-subtle)] shrink-0">
|
|
<span className="text-[11px] text-[var(--ink-muted)]">{contextLabel}</span>
|
|
</div>
|
|
)}
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--line)] shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</span>
|
|
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
|
|
</div>
|
|
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto p-5 space-y-4">
|
|
<h3 className="text-[15px] font-semibold text-[var(--ink)]">{plan.title}</h3>
|
|
|
|
{/* Info */}
|
|
<div className="grid grid-cols-2 gap-3 text-[12px]">
|
|
<div>
|
|
<span className="text-[var(--ink-muted)]">负责人</span>
|
|
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.owner}</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-[var(--ink-muted)]">进度</span>
|
|
<div className="font-medium text-[var(--ink)] mt-0.5">{progress}%</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-[var(--ink-muted)]">计划时间</span>
|
|
<div className="font-medium text-[var(--ink)] mt-0.5">{plan.startTime.slice(0, 16).replace('T', ' ')} → {plan.endTime.slice(0, 16).replace('T', ' ')}</div>
|
|
</div>
|
|
{plan.actualStartAt && (
|
|
<div>
|
|
<span className="text-[var(--ink-muted)]">实际开始</span>
|
|
<div className="font-medium text-blue-600 mt-0.5">{plan.actualStartAt.slice(0, 16).replace('T', ' ')}</div>
|
|
</div>
|
|
)}
|
|
{plan.completedAt && (
|
|
<div>
|
|
<span className="text-[var(--ink-muted)]">完成时间</span>
|
|
<div className="font-medium text-emerald-600 mt-0.5">{plan.completedAt.slice(0, 16).replace('T', ' ')}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="h-2 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
|
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
|
|
</div>
|
|
|
|
{/* Research Tasks */}
|
|
{isResearch && plan.tasks && plan.tasks.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-[11px] font-medium text-[var(--ink-muted)]">任务清单</div>
|
|
{plan.tasks.map((task) => (
|
|
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
|
<button
|
|
disabled={!canInteract}
|
|
onClick={() => handleToggleTask(task)}
|
|
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
|
>
|
|
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
|
</button>
|
|
<span className={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Product/UI: Linked Requirements */}
|
|
{!isResearch && linkedReqs.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<div className="text-[11px] font-medium text-[var(--ink-muted)]">关联需求</div>
|
|
{linkedReqs.map((req) => {
|
|
const isDone = (plan.completedRequirementIds || []).includes(req.id);
|
|
return (
|
|
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
|
<button
|
|
disabled={!canInteract}
|
|
onClick={() => handleToggleReq(req.id)}
|
|
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canInteract ? 'opacity-40 cursor-not-allowed' : ''} ${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={`flex-1 text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Result */}
|
|
{plan.status === 'completed' && plan.resultUrl && (
|
|
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
|
<div className="text-[11px] text-[var(--ink-muted)] mb-1">成果</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{plan.resultType === 'link' ? <Link2 className="h-3 w-3 text-[var(--accent)]" /> : <FileUp className="h-3 w-3 text-[var(--accent)]" />}
|
|
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="text-[12px] text-[var(--accent)] hover:underline flex items-center gap-1">
|
|
{plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{plan.remark && (
|
|
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
|
<div className="text-[11px] text-[var(--ink-muted)] mb-1">备注</div>
|
|
<div className="text-[12px] text-[var(--ink)]">{plan.remark}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Transfer Section */}
|
|
{showTransfer && (
|
|
<div className="rounded-lg border border-[var(--line)] p-3 space-y-2">
|
|
<div className="text-[11px] font-medium text-[var(--ink-muted)]">转交给</div>
|
|
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none">
|
|
<option value="">选择人员</option>
|
|
{members.filter((m) => m.name !== plan.owner).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
|
|
</select>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleTransfer} disabled={!transferTo} className="h-7 px-3 rounded text-[11px] font-medium bg-blue-500 text-white disabled:opacity-50">确认</button>
|
|
<button onClick={() => setShowTransfer(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Complete with result */}
|
|
{showComplete && (
|
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 space-y-2">
|
|
<div className="text-[11px] font-medium text-emerald-700">提交成果</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => setResultType('link')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'link' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><Link2 className="h-3 w-3 inline mr-1" />链接</button>
|
|
<button onClick={() => setResultType('file')} className={`h-7 px-2.5 rounded text-[11px] font-medium border ${resultType === 'file' ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}><FileUp className="h-3 w-3 inline mr-1" />文件</button>
|
|
</div>
|
|
{resultType === 'link' ? (
|
|
<input value={resultUrl} onChange={(e) => setResultUrl(e.target.value)} placeholder="https://..." className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
|
) : (
|
|
<div>
|
|
<input type="file" onChange={handleFile} className="text-[11px] text-[var(--ink-soft)]" />
|
|
{fileName && <p className="text-[10px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<button onClick={handleSubmitResult} disabled={resultType === 'link' ? !resultUrl.trim() : !fileData} className="h-7 px-3 rounded text-[11px] font-medium bg-emerald-500 text-white disabled:opacity-50">确认提交</button>
|
|
<button onClick={() => setShowComplete(false)} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer Actions */}
|
|
{plan.status !== 'completed' && (
|
|
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
|
{plan.status === 'pending' && (
|
|
<button onClick={() => updatePlan(plan.id, { status: 'in_progress' })} className="h-8 px-3 rounded-lg text-[12px] font-medium text-blue-600 border border-blue-200 hover:bg-blue-50 flex items-center gap-1">
|
|
<Play className="h-3 w-3" />开始
|
|
</button>
|
|
)}
|
|
{plan.status === 'in_progress' && (
|
|
<button onClick={() => setShowComplete(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50">
|
|
提交完成
|
|
</button>
|
|
)}
|
|
<button onClick={() => setShowTransfer(true)} className="h-8 px-3 rounded-lg text-[12px] font-medium text-[var(--ink-soft)] border border-[var(--line)] hover:bg-[var(--bg-subtle)] flex items-center gap-1">
|
|
<ArrowRightLeft className="h-3 w-3" />转交
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|