feat(ai): 优化拆解重跑与结果展示
This commit is contained in:
@@ -5,10 +5,17 @@ import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'luc
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { calcPlanProgress, calcLinkedReqProgress } from '@/lib/version-plan';
|
||||
import {
|
||||
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 type { PlanTask, VersionPlan } from '@/lib/version-plan';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
@@ -20,6 +27,24 @@ const STATUS_STYLE: Record<string, string> = { pending: 'bg-zinc-100 text-zinc-6
|
||||
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();
|
||||
@@ -31,6 +56,10 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
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);
|
||||
@@ -42,6 +71,9 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
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 productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||||
const isProductDesignPlan = productPlanKind === 'design';
|
||||
const isProductReviewPlan = productPlanKind === 'review';
|
||||
|
||||
const handleToggleTask = (task: PlanTask) => {
|
||||
if (!canToggle) return;
|
||||
@@ -66,11 +98,49 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
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 = () => {
|
||||
const url = resultType === 'link' ? resultUrl.trim() : fileData;
|
||||
const title = resultTitle.trim();
|
||||
if (!url || !title) return;
|
||||
const response = completePlan(plan.id, { resultType, resultTitle: title, resultUrl: url, resultFileName: fileName || undefined, resultFileData: resultType === 'file' ? fileData : undefined });
|
||||
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;
|
||||
@@ -98,7 +168,13 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
<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>
|
||||
@@ -198,6 +274,20 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</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>
|
||||
@@ -223,22 +313,90 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
{/* 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>
|
||||
<input value={resultTitle} onChange={(e) => setResultTitle(e.target.value)} placeholder="成果标题(必填,如 v1.0 产品方案)" className="h-8 w-full rounded-lg border border-[var(--line)] px-2 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<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 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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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 && (
|
||||
<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 || !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={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>
|
||||
@@ -256,7 +414,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
)}
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user