feat(ai): 优化拆解重跑与结果展示

This commit is contained in:
Script Generator
2026-06-26 11:01:11 +08:00
parent 55e24442ab
commit 56e0fe562d
36 changed files with 1342 additions and 186 deletions

View File

@@ -2,8 +2,17 @@
import { useMemo, useState } from 'react';
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
import type { VersionPlan, PlanTask } from '@/lib/version-plan';
import { calcPlanDuration, formatDuration, calcTotalDuration, calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
import {
calcPlanDuration,
formatDuration,
calcTotalDuration,
calcPlanProgress,
calcLinkedReqProgress,
PRODUCT_PLAN_KIND_LABEL,
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
} from '@/lib/version-plan';
import { formatDateTime } from '@/lib/format';
import { FieldError } from '@/components/FieldError';
import { AiDecomposeButton } from './AiDecomposeButton';
@@ -11,6 +20,7 @@ import type { VersionWithContext } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
interface Props {
plans: VersionPlan[];
@@ -24,7 +34,7 @@ interface Props {
allRequirements?: Requirement[];
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
onComplete: (id: string, result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => { ok: boolean; message?: string } | void;
onComplete: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string } | void;
onDelete: (id: string) => void;
}
@@ -36,6 +46,24 @@ const STATUS_STYLE = {
};
const STATUS_LABEL = { pending: '未开始', in_progress: '进行中', completed: '已完成' };
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 PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete }: Props) {
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
@@ -96,6 +124,16 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
<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>
{plan.type === 'product' && (
<span className="inline-flex items-center rounded-md border border-[var(--line)] bg-[var(--bg-subtle)] px-2 py-0.5 text-[10px] font-medium text-[var(--ink-muted)]">
{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}
</span>
)}
{plan.type === 'product' && plan.reviewResult && (
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${plan.reviewResult === 'passed' ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'}`}>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[plan.reviewResult]}
</span>
)}
</div>
<div className="flex items-center gap-4 text-[12px] text-[var(--ink-soft)]">
<span>{formatDateTime(plan.startTime)} {formatDateTime(plan.endTime)}</span>
@@ -120,6 +158,19 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
)}
</div>
)}
{plan.status === 'completed' && plan.type === 'product' && getProductPlanKind(plan) === 'review' && plan.reviewResult && (
<div className={`mt-2 rounded-lg border px-3 py-2 ${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-1 space-y-1 text-[11px] text-red-700">
{getFailureLabels(plan.reviewFailureTypes).length > 0 && <div>{getFailureLabels(plan.reviewFailureTypes).join('、')}</div>}
{plan.reviewFailureReason && <div>{plan.reviewFailureReason}</div>}
</div>
)}
</div>
)}
{/* 子任务 */}
{plan.type === 'research' && plan.tasks && plan.tasks.length > 0 && (
<div className="mt-3 space-y-2">
@@ -226,8 +277,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
)}
{plan.status !== 'completed' && completionState.canSubmitResult && (
<div className="mt-3 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>
<span className="text-[12px] text-green-700">{getSubmitActionLabel(plan)}</span>
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">{getSubmitActionLabel(plan)}</button>
</div>
)}
</div>
@@ -257,6 +308,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
{completingPlan && (
<CompleteModal
plan={completingPlan}
onClose={() => setCompletingPlan(null)}
onSubmit={(result) => {
const response = onComplete(completingPlan.id, result);
@@ -289,6 +341,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
const [remark, setRemark] = useState(initial?.remark ?? '');
const [productPlanKind, setProductPlanKind] = useState<ProductPlanKind>(initial?.productPlanKind ?? 'design');
const [tasks, setTasks] = useState<PlanTask[]>(initial?.tasks ?? []);
const [newTaskTitle, setNewTaskTitle] = useState('');
const [overdueReason, setOverdueReason] = useState(initial?.overdueReason ?? '');
@@ -318,6 +371,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
startTime,
endTime,
status: initial?.status ?? 'pending',
productPlanKind: planType === 'product' ? productPlanKind : undefined,
linkedRequirementIds: requirementOptions.length > 0 ? Array.from(selectedReqs) : undefined,
tasks: planType === 'research' && tasks.length > 0 ? tasks : undefined,
remark: remark.trim() || undefined,
@@ -342,6 +396,34 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
</div>
{planType === 'product' && (
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block"></label>
<div className="grid grid-cols-2 gap-2">
{(['design', 'review'] as ProductPlanKind[]).map((kind) => (
<label
key={kind}
className={`flex min-h-16 cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 transition-colors ${productPlanKind === kind ? 'border-[var(--accent)] bg-[var(--accent-soft)]' : 'border-[var(--line)] bg-[var(--bg-card)] hover:bg-[var(--bg-subtle)]'}`}
>
<input
type="radio"
name="productPlanKind"
value={kind}
checked={productPlanKind === kind}
onChange={() => setProductPlanKind(kind)}
className="mt-0.5 h-3.5 w-3.5"
/>
<span className="min-w-0">
<span className="block text-[12px] font-medium text-[var(--ink)]">{PRODUCT_PLAN_KIND_LABEL[kind]}</span>
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
{kind === 'design' ? '提交最终原型链接,用于后续 AI 拆解。' : '记录方案是否通过评审,不通过时沉淀原因类型。'}
</span>
</span>
</label>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"></label>
@@ -461,15 +543,24 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
);
}
function CompleteModal({ onClose, onSubmit }: {
function CompleteModal({ plan, onClose, onSubmit }: {
plan: VersionPlan;
onClose: () => void;
onSubmit: (result: { resultType: 'link' | 'file'; resultTitle: string; resultUrl?: string; resultFileName?: string; resultFileData?: string }) => void;
onSubmit: (result: PlanResultPayload) => void;
}) {
const [resultType, setResultType] = useState<'link' | 'file'>('link');
const [resultTitle, setResultTitle] = useState('');
const [url, setUrl] = 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 productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
const isProductDesignPlan = productPlanKind === 'design';
const isProductReviewPlan = productPlanKind === 'review';
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -480,35 +571,143 @@ function CompleteModal({ onClose, onSubmit }: {
reader.readAsDataURL(file);
};
const canSubmit = resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
const canSubmit = isProductReviewPlan
? reviewResult === 'passed' || (reviewFailureTypes.size > 0 && reviewFailureReason.trim().length > 0)
: isProductDesignPlan
? resultTitle.trim().length > 0 && url.trim().length > 0 && prototypeReviewConfirmed
: resultTitle.trim().length > 0 && (resultType === 'link' ? url.trim().length > 0 : fileData.length > 0);
const toggleFailureType = (type: ProductPlanReviewFailureType) => {
const next = new Set(reviewFailureTypes);
if (next.has(type)) next.delete(type);
else next.add(type);
setReviewFailureTypes(next);
};
const handleSubmit = () => {
if (isProductReviewPlan) {
onSubmit({
productPlanKind: 'review',
reviewResult,
reviewFailureTypes: reviewResult === 'failed' ? Array.from(reviewFailureTypes) : undefined,
reviewFailureReason: reviewResult === 'failed' ? reviewFailureReason.trim() : undefined,
resultTitle: PRODUCT_PLAN_REVIEW_RESULT_LABEL[reviewResult],
});
return;
}
if (isProductDesignPlan) {
onSubmit({
productPlanKind: 'design',
resultType: 'link',
resultTitle: resultTitle.trim(),
resultUrl: url.trim(),
prototypeReviewConfirmed,
});
return;
}
onSubmit({
resultType,
resultTitle: resultTitle.trim(),
resultUrl: resultType === 'link' ? url.trim() : fileData,
resultFileName: fileName || undefined,
resultFileData: resultType === 'file' ? fileData : undefined,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-sm rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-[var(--shadow-md)]" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-semibold text-[var(--ink)]"></h3>
<h3 className="text-[13px] font-semibold text-[var(--ink)]">{getSubmitActionLabel(plan)}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="如 v1.0 产品方案" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
</div>
{resultType === 'link' ? (
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
{isProductReviewPlan ? (
<>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<div className="grid grid-cols-2 gap-2">
{(['passed', 'failed'] as ProductPlanReviewResult[]).map((result) => (
<button
key={result}
type="button"
onClick={() => setReviewResult(result)}
className={`h-9 rounded-lg border text-[12px] font-medium transition-colors ${reviewResult === result ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
>
{PRODUCT_PLAN_REVIEW_RESULT_LABEL[result]}
</button>
))}
</div>
</div>
{reviewResult === 'failed' && (
<div className="space-y-2">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<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)] px-2 py-1 text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
<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>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<textarea
value={reviewFailureReason}
onChange={(e) => setReviewFailureReason(e.target.value)}
rows={3}
placeholder="写清楚具体问题、影响范围和建议调整方向"
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"
/>
</div>
</div>
)}
</>
) : (
<div>
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
<>
{isProductDesignPlan && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-800">
Axure AI 访
</div>
)}
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"><span className="text-red-500 ml-0.5">*</span></label>
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder={isProductDesignPlan ? '如 v1.0 原型地址' : '如 v1.0 产品方案'} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
</div>
{!isProductDesignPlan && (
<div className="flex gap-2">
<button type="button" onClick={() => setResultType('link')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'link' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
<button type="button" onClick={() => setResultType('file')} className={`h-8 px-3 rounded-lg text-[12px] font-medium border transition-colors ${resultType === 'file' ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)]'}`}></button>
</div>
)}
{resultType === 'link' || isProductDesignPlan ? (
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
) : (
<div>
<input type="file" onChange={handleFile} className="text-[12px] text-[var(--ink-soft)]" />
{fileName && <p className="text-[11px] text-[var(--ink-muted)] mt-1">{fileName}</p>}
</div>
)}
{isProductDesignPlan && (
<label className="flex items-start gap-2 rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 py-2 text-[12px] 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 justify-end gap-2 pt-2">
<button onClick={onClose} className="h-8 px-3 rounded-lg text-[12px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => onSubmit({ resultType, resultTitle: resultTitle.trim(), resultUrl: resultType === 'link' ? url.trim() : fileData, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined })} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
<button onClick={handleSubmit} disabled={!canSubmit} className="h-8 px-4 rounded-lg text-[12px] font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"></button>
</div>
</div>
</div>