feat(版本): 优化概览与只读状态
关键改动: - 增加需求排序和版本只读状态规则及测试 - 完善版本概览阶段耗时、项目页和工作台展示 - 优化小宝预警请求节流、建议状态和风险过滤 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -24,6 +24,7 @@ interface Props {
|
||||
planId: string;
|
||||
onClose: () => void;
|
||||
contextLabel?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
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' };
|
||||
@@ -48,7 +49,7 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
||||
.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
export function PlanDetailDrawer({ planId, onClose, contextLabel, readOnly = false }: Props) {
|
||||
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const { members } = useMemberStore();
|
||||
@@ -74,13 +75,14 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
? getResearchDirectionProgressSummary(plan).percent
|
||||
: 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 canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
const canEditCoverage = !readOnly && 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 handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (readOnly) return;
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setFileName(file.name);
|
||||
@@ -90,6 +92,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
};
|
||||
|
||||
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
|
||||
if (readOnly) return;
|
||||
const next = new Set(reviewFailureTypes);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
@@ -97,6 +100,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
};
|
||||
|
||||
const handleSubmitResult = () => {
|
||||
if (readOnly) return;
|
||||
let payload: PlanResultPayload | null = null;
|
||||
|
||||
if (isProductReviewPlan) {
|
||||
@@ -140,6 +144,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
};
|
||||
|
||||
const handleTransfer = () => {
|
||||
if (readOnly) return;
|
||||
if (!transferTo) return;
|
||||
updatePlan(plan.id, { owner: transferTo });
|
||||
setShowTransfer(false);
|
||||
@@ -272,7 +277,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
)}
|
||||
|
||||
{/* Transfer Section */}
|
||||
{showTransfer && (
|
||||
{showTransfer && !readOnly && (
|
||||
<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
|
||||
@@ -289,7 +294,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
)}
|
||||
|
||||
{/* Complete with result */}
|
||||
{showComplete && (
|
||||
{showComplete && !readOnly && (
|
||||
<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 ? (
|
||||
@@ -382,7 +387,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{plan.status !== 'completed' && (
|
||||
{plan.status !== 'completed' && !readOnly && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-t border-[var(--line)] shrink-0">
|
||||
{plan.status === 'pending' && (
|
||||
|
||||
@@ -43,6 +43,7 @@ interface Props {
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
|
||||
onDelete: (id: string) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const TYPE_LABEL = { research: '调研', product: '产品方案', ui: 'UI设计' };
|
||||
@@ -71,7 +72,7 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
||||
.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
|
||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||
@@ -321,10 +322,11 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
onEditPlan={setEditingPlan}
|
||||
onOpenComplete={setCompletingPlan}
|
||||
onCreatePlan={() => setShowCreateModal(true)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(showCreateModal || editingPlan) && (
|
||||
{(showCreateModal || editingPlan) && !readOnly && (
|
||||
<PlanFormModal
|
||||
initial={editingPlan}
|
||||
planType={planType}
|
||||
@@ -343,7 +345,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
/>
|
||||
)}
|
||||
|
||||
{completingPlan && (
|
||||
{completingPlan && !readOnly && (
|
||||
<CompleteModal
|
||||
plan={completingPlan}
|
||||
onClose={() => setCompletingPlan(null)}
|
||||
@@ -402,6 +404,7 @@ function ProductUiPlanWorkspace({
|
||||
onEditPlan,
|
||||
onOpenComplete,
|
||||
onCreatePlan,
|
||||
readOnly,
|
||||
}: {
|
||||
typePlans: VersionPlan[];
|
||||
planType: VersionPlan['type'];
|
||||
@@ -421,6 +424,7 @@ function ProductUiPlanWorkspace({
|
||||
onEditPlan: (plan: VersionPlan) => void;
|
||||
onOpenComplete: (plan: VersionPlan) => void;
|
||||
onCreatePlan: () => void;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
||||
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
||||
@@ -428,23 +432,26 @@ function ProductUiPlanWorkspace({
|
||||
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
typePlans.forEach((plan) => {
|
||||
const { autoStarted } = getPlanRuntime(plan);
|
||||
if (autoStarted && !plan.actualStartAt) {
|
||||
onUpdate(plan.id, { status: 'in_progress' });
|
||||
}
|
||||
});
|
||||
}, [typePlans, onUpdate]);
|
||||
}, [typePlans, onUpdate, readOnly]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="flex h-full w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-[var(--line)] px-4">
|
||||
<div className="text-[14px] font-semibold text-[var(--ink)]">任务计划</div>
|
||||
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button onClick={onCreatePlan} className="flex h-7 items-center gap-1.5 rounded-md bg-[var(--accent)] px-2.5 text-[11px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
新建
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 space-y-2 border-b border-[var(--line)] p-3">
|
||||
<div className={`grid gap-2 ${versionDeadline ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
@@ -530,6 +537,7 @@ function ProductUiPlanWorkspace({
|
||||
onDelete={onDelete}
|
||||
onEditPlan={onEditPlan}
|
||||
onOpenComplete={onOpenComplete}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-[13px] text-[var(--ink-muted)]">
|
||||
@@ -563,6 +571,7 @@ function ProductUiPlanDetail({
|
||||
onDelete,
|
||||
onEditPlan,
|
||||
onOpenComplete,
|
||||
readOnly,
|
||||
}: {
|
||||
plan: VersionPlan;
|
||||
planType: VersionPlan['type'];
|
||||
@@ -579,12 +588,13 @@ function ProductUiPlanDetail({
|
||||
onDelete: (id: string) => void;
|
||||
onEditPlan: (plan: VersionPlan) => void;
|
||||
onOpenComplete: (plan: VersionPlan) => void;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
const { autoStarted, effectiveStatus, effectiveStartAt } = getPlanRuntime(plan);
|
||||
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
|
||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
||||
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
||||
@@ -612,26 +622,28 @@ function ProductUiPlanDetail({
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[plan.type]}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{plan.status === 'pending' && !autoStarted && (
|
||||
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
|
||||
<Play className="h-3 w-3" />开始
|
||||
</button>
|
||||
)}
|
||||
{plan.status !== 'completed' && (
|
||||
<>
|
||||
<button onClick={() => onSetTransferPlanId(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
|
||||
<ArrowRightLeft className="h-3.5 w-3.5" />
|
||||
{!readOnly && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{plan.status === 'pending' && !autoStarted && (
|
||||
<button onClick={() => onUpdate(plan.id, { status: 'in_progress' })} className="flex h-7 items-center gap-1 rounded-md border border-blue-200 px-2 text-[11px] font-medium text-blue-600 hover:bg-blue-50" title="提前开始">
|
||||
<Play className="h-3 w-3" />开始
|
||||
</button>
|
||||
<button onClick={() => onEditPlan(plan)} className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{plan.status !== 'completed' && (
|
||||
<>
|
||||
<button onClick={() => onSetTransferPlanId(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-blue-500 hover:bg-blue-50" title="转交">
|
||||
<ArrowRightLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onEditPlan(plan)} className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]" title="编辑">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(plan.id)} className="flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-50" title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -676,7 +688,7 @@ function ProductUiPlanDetail({
|
||||
<a href={plan.resultUrl} target="_blank" rel="noopener noreferrer" className="flex min-w-0 items-center gap-1 text-[12px] text-[var(--accent)] hover:underline">
|
||||
<span className="truncate">{plan.resultTitle || plan.resultFileName || '查看成果'}</span><ExternalLink className="h-3 w-3 shrink-0" />
|
||||
</a>
|
||||
{planType === 'product' && version && (
|
||||
{planType === 'product' && version && !readOnly && (
|
||||
<AiDecomposeButton plan={plan} version={version} />
|
||||
)}
|
||||
</div>
|
||||
@@ -720,7 +732,7 @@ function ProductUiPlanDetail({
|
||||
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
|
||||
{transferPlanId === plan.id && (
|
||||
{transferPlanId === plan.id && !readOnly && (
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-[var(--line)] pt-3">
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">转交给:</span>
|
||||
<FilterSelect
|
||||
@@ -735,7 +747,7 @@ function ProductUiPlanDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan.status !== 'completed' && completionState.canSubmitResult && (
|
||||
{plan.status !== 'completed' && completionState.canSubmitResult && !readOnly && (
|
||||
<div className="mt-3 flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-3 py-2">
|
||||
<span className="text-[12px] text-green-700">已满足{getSubmitActionLabel(plan)}条件</span>
|
||||
<button onClick={() => onOpenComplete(plan)} className="text-[11px] font-medium text-green-700 underline hover:text-green-900">{getSubmitActionLabel(plan)}</button>
|
||||
|
||||
@@ -19,9 +19,10 @@ interface Props {
|
||||
onUnlink: (reqId: string) => void;
|
||||
onCreateChange: (data: Partial<Requirement>) => void;
|
||||
currentUserName: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName }: Props) {
|
||||
export function VersionRequirementsTab({ versionId, projectId, requirements, devTasks, versionMembers, onLink, onUnlink, onCreateChange, currentUserName, readOnly = false }: Props) {
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showChangeModal, setShowChangeModal] = useState(false);
|
||||
const linkedReqs = requirements.filter((r) => r.versionId === versionId);
|
||||
@@ -31,16 +32,18 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">{linkedReqs.length} 条关联需求</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
需求变更
|
||||
</button>
|
||||
<button onClick={() => setShowAddModal(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">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
添加需求
|
||||
</button>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setShowChangeModal(true)} className="flex h-8 items-center gap-1.5 rounded-lg border border-orange-300 px-3 text-[13px] font-medium text-orange-600 hover:bg-orange-50 transition-colors">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
需求变更
|
||||
</button>
|
||||
<button onClick={() => setShowAddModal(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">
|
||||
<Plus className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
添加需求
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{linkedReqs.length === 0 ? (
|
||||
@@ -61,7 +64,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">开发状态</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">添加人</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">添加日期</th>
|
||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right">操作</th>
|
||||
{!readOnly && <th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] text-right">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -95,13 +98,15 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.addedToVersionBy || '系统添加'}</td>
|
||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">{req.createdAt?.slice(0, 10) || '-'}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isDeveloping ? (
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">开发中</span>
|
||||
) : (
|
||||
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors">移除</button>
|
||||
)}
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isDeveloping ? (
|
||||
<span className="text-[11px] text-[var(--ink-muted)]">开发中</span>
|
||||
) : (
|
||||
<button onClick={() => onUnlink(req.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors">移除</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -110,7 +115,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddModal && (
|
||||
{showAddModal && !readOnly && (
|
||||
<AddRequirementModal
|
||||
available={availableReqs}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
@@ -118,7 +123,7 @@ export function VersionRequirementsTab({ versionId, projectId, requirements, dev
|
||||
/>
|
||||
)}
|
||||
|
||||
{showChangeModal && (
|
||||
{showChangeModal && !readOnly && (
|
||||
<ChangeRequirementModal
|
||||
versionMembers={versionMembers}
|
||||
currentUserName={currentUserName}
|
||||
|
||||
Reference in New Issue
Block a user