关键改动: - 增加计划日志汇总、需求覆盖草稿校验与对应测试 - 抽取工作台工作项 Hook,补充待办计数能力 - 优化版本详情中计划、任务、测试用例和 Bug 的筛选与展示 Co-Authored-By: Codex GPT-5 <codex@openai.com>
426 lines
22 KiB
TypeScript
426 lines
22 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 { useAuthStore } from '@/stores/useAuthStore';
|
||
import { FilterSelect } from '@/components/FilterSelect';
|
||
import {
|
||
calcPlanProgress,
|
||
getRequirementCoverageSummary,
|
||
PRODUCT_PLAN_KIND_LABEL,
|
||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||
} from '@/lib/version-plan';
|
||
import { formatDateTime } from '@/lib/format';
|
||
import type { PlanTask, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
|
||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
|
||
|
||
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设计' };
|
||
|
||
function getProductPlanKind(plan: VersionPlan): ProductPlanKind {
|
||
return plan.productPlanKind ?? 'design';
|
||
}
|
||
|
||
function getSubmitActionLabel(plan: VersionPlan): string {
|
||
if (plan.type === 'product') {
|
||
return getProductPlanKind(plan) === 'review' ? '提交评审结论' : '提交原型地址';
|
||
}
|
||
return '提交成果';
|
||
}
|
||
|
||
function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
||
if (!types?.length) return [];
|
||
return types
|
||
.map((type) => PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.find((option) => option.value === type)?.label)
|
||
.filter(Boolean) as string[];
|
||
}
|
||
|
||
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
||
const { requirements } = useRequirementStore();
|
||
const { members } = useMemberStore();
|
||
const user = useAuthStore((s) => s.user);
|
||
const [showTransfer, setShowTransfer] = useState(false);
|
||
const [transferTo, setTransferTo] = useState('');
|
||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||
const [resultTitle, setResultTitle] = useState('');
|
||
const [resultUrl, setResultUrl] = useState('');
|
||
const [fileName, setFileName] = useState('');
|
||
const [fileData, setFileData] = useState('');
|
||
const [prototypeReviewConfirmed, setPrototypeReviewConfirmed] = useState(false);
|
||
const [reviewResult, setReviewResult] = useState<ProductPlanReviewResult>('passed');
|
||
const [reviewFailureTypes, setReviewFailureTypes] = useState<Set<ProductPlanReviewFailureType>>(new Set());
|
||
const [reviewFailureReason, setReviewFailureReason] = useState('');
|
||
const [showComplete, setShowComplete] = useState(false);
|
||
|
||
const plan = plans.find((p) => p.id === planId);
|
||
if (!plan) return null;
|
||
|
||
const completionState = getPlanCompletionState(plan);
|
||
const isResearch = plan.type === 'research';
|
||
const progress = isResearch ? calcPlanProgress(plan.tasks) : getRequirementCoverageSummary(plan).percent;
|
||
const linkedReqs = (plan.linkedRequirementIds || []).map((id) => requirements.find((r) => r.id === id)).filter(Boolean) as { id: string; code: string; title: string }[];
|
||
const canToggle = canTogglePlanChecklist(plan);
|
||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||
const currentUserName = user?.name ?? plan.owner;
|
||
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||
const isProductDesignPlan = productPlanKind === 'design';
|
||
const isProductReviewPlan = productPlanKind === 'review';
|
||
|
||
const handleToggleTask = (task: PlanTask) => {
|
||
if (!canToggle) 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 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 toggleFailureType = (type: ProductPlanReviewFailureType) => {
|
||
const next = new Set(reviewFailureTypes);
|
||
if (next.has(type)) next.delete(type);
|
||
else next.add(type);
|
||
setReviewFailureTypes(next);
|
||
};
|
||
|
||
const handleSubmitResult = () => {
|
||
let payload: PlanResultPayload | null = null;
|
||
|
||
if (isProductReviewPlan) {
|
||
payload = {
|
||
productPlanKind: 'review',
|
||
reviewResult,
|
||
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
|
||
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
|
||
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
|
||
};
|
||
} else if (isProductDesignPlan) {
|
||
const title = resultTitle.trim();
|
||
const url = resultUrl.trim();
|
||
if (!url || !title || !prototypeReviewConfirmed) return;
|
||
payload = {
|
||
productPlanKind: 'design',
|
||
resultType: 'link',
|
||
resultTitle: title,
|
||
resultUrl: url,
|
||
prototypeReviewConfirmed,
|
||
};
|
||
} else {
|
||
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
||
const title = resultTitle.trim();
|
||
if (!url || !title) return;
|
||
payload = {
|
||
resultType,
|
||
resultTitle: title,
|
||
resultUrl: url,
|
||
resultFileName: fileName || undefined,
|
||
resultFileData: resultType === 'file' ? fileData : undefined,
|
||
};
|
||
}
|
||
|
||
const response = completePlan(plan.id, payload);
|
||
if (response && typeof response === 'object' && 'ok' in response && !response.ok) {
|
||
alert(response.message || '计划未满足完成条件');
|
||
return;
|
||
}
|
||
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>
|
||
{plan.type === 'product' && (
|
||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-[var(--bg-subtle)] text-[var(--ink-muted)]">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
|
||
)}
|
||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${STATUS_STYLE[plan.status]}`}>{STATUS_LABEL[plan.status]}</span>
|
||
{plan.type === 'product' && plan.reviewResult && (
|
||
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}</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">{formatDateTime(plan.startTime)} → {formatDateTime(plan.endTime)}</div>
|
||
</div>
|
||
{plan.actualStartAt && (
|
||
<div>
|
||
<span className="text-[var(--ink-muted)]">实际开始</span>
|
||
<div className="font-medium text-blue-600 mt-0.5">{formatDateTime(plan.actualStartAt)}</div>
|
||
</div>
|
||
)}
|
||
{plan.completedAt && (
|
||
<div>
|
||
<span className="text-[var(--ink-muted)]">完成时间</span>
|
||
<div className="font-medium text-emerald-600 mt-0.5">{formatDateTime(plan.completedAt)}</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={!canToggle}
|
||
onClick={() => handleToggleTask(task)}
|
||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? '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>
|
||
)}
|
||
|
||
{linkedReqs.length > 0 && (
|
||
<div>
|
||
<PlanRequirementCoveragePanel
|
||
plan={plan}
|
||
requirements={linkedReqs}
|
||
canEdit={canEditCoverage}
|
||
currentUserName={currentUserName}
|
||
onUpdate={updatePlan}
|
||
/>
|
||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!isResearch && (
|
||
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
|
||
)}
|
||
|
||
{/* 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.resultTitle || plan.resultFileName || '查看成果'}<ExternalLink className="h-3 w-3" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
|
||
<div className={`rounded-lg border p-3 ${plan.reviewResult === 'passed' ? 'border-emerald-200 bg-emerald-50' : 'border-red-200 bg-red-50'}`}>
|
||
<div className={`text-[12px] font-medium ${plan.reviewResult === 'passed' ? 'text-emerald-700' : 'text-red-700'}`}>
|
||
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
|
||
</div>
|
||
{plan.reviewResult === 'failed' && (
|
||
<div className="mt-2 space-y-1 text-[12px] text-red-700">
|
||
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>类型:{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
|
||
{plan.reviewFailureReason && <div>原因:{plan.reviewFailureReason}</div>}
|
||
</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>
|
||
<FilterSelect
|
||
value={transferTo || 'all'}
|
||
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
|
||
options={members.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
|
||
allLabel="选择人员"
|
||
/>
|
||
<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">{getSubmitActionLabel(plan)}</div>
|
||
{isProductReviewPlan ? (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
|
||
<button
|
||
key={result}
|
||
onClick={() => setReviewResult(result)}
|
||
className={`h-8 rounded-lg border text-[11px] font-medium ${reviewResult === result ? 'border-[var(--accent)] bg-white text-[var(--accent)]' : 'border-[var(--line)] bg-white/70 text-[var(--ink-soft)]'}`}
|
||
>
|
||
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{reviewResult === 'failed' && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS.map((option) => (
|
||
<label key={option.value} className="flex min-h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-[var(--line)] bg-white/70 px-2 py-1 text-[11px] text-[var(--ink-soft)]">
|
||
<input type="checkbox" checked={reviewFailureTypes.has(option.value)} onChange={() => toggleFailureType(option.value)} className="h-3 w-3 shrink-0 rounded" />
|
||
<span className="leading-4">{option.label}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
<textarea
|
||
value={reviewFailureReason}
|
||
onChange={(e) => setReviewFailureReason(e.target.value)}
|
||
rows={3}
|
||
placeholder="写清楚具体问题、影响范围和建议调整方向"
|
||
className="w-full rounded-lg border border-[var(--line)] bg-white px-2 py-2 text-[12px] focus:border-[var(--accent)] focus:outline-none resize-none"
|
||
/>
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{isProductDesignPlan && (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
|
||
请确认产品方案评审已通过后再提交原型地址;这里不再上传 Axure 文件。
|
||
</div>
|
||
)}
|
||
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '成果标题(如 v1.0 原型地址)' : '成果标题(必填,如 v1.0 产品方案)'} className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||
{!isProductDesignPlan && (
|
||
<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' || isProductDesignPlan ? (
|
||
<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>
|
||
)}
|
||
{isProductDesignPlan && (
|
||
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-white/70 px-3 py-2 text-[11px] text-[var(--ink-soft)]">
|
||
<input
|
||
type="checkbox"
|
||
checked={prototypeReviewConfirmed}
|
||
onChange={(e) => setPrototypeReviewConfirmed(e.target.checked)}
|
||
className="mt-0.5 h-3.5 w-3.5 rounded"
|
||
/>
|
||
<span>已确认该产品设计方案评审通过,可以作为后续开发任务和测试用例拆解依据。</span>
|
||
</label>
|
||
)}
|
||
</>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={handleSubmitResult}
|
||
disabled={
|
||
!completionState.canSubmitResult
|
||
|| (isProductReviewPlan
|
||
? reviewResult === 'failed' && (reviewFailureTypes.size === 0 || !reviewFailureReason.trim())
|
||
: isProductDesignPlan
|
||
? !resultTitle.trim() || !resultUrl.trim() || !prototypeReviewConfirmed
|
||
: !resultTitle.trim() || (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)} disabled={!completionState.canSubmitResult} className="h-8 px-3 rounded-lg text-[12px] font-medium text-emerald-600 border border-emerald-200 hover:bg-emerald-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||
{getSubmitActionLabel(plan)}
|
||
</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>
|
||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||
<div className="px-5 pb-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|