feat(版本): 优化概览与只读状态

关键改动:

- 增加需求排序和版本只读状态规则及测试

- 完善版本概览阶段耗时、项目页和工作台展示

- 优化小宝预警请求节流、建议状态和风险过滤

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 18:18:18 +08:00
parent 3d3d56697a
commit eef3c8f000
32 changed files with 1072 additions and 289 deletions

View File

@@ -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' && (

View File

@@ -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>

View File

@@ -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}