feat(ai): 优化拆解重跑与结果展示
This commit is contained in:
@@ -7,9 +7,14 @@ import type { VersionWithContext } from '@/lib/derive';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { api } from '@/lib/api';
|
||||
import { filterDuplicateDecomposeDrafts } from '@/lib/ai-decompose-dedupe';
|
||||
import { DecomposeReportModal } from './DecomposeReportModal';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
AgentDecomposeRequest,
|
||||
AgentDecomposeResponse,
|
||||
AgentDecomposeError,
|
||||
@@ -33,12 +38,18 @@ function formatElapsed(sec: number): string {
|
||||
export function AiDecomposeButton({ plan, version }: Props) {
|
||||
const { updatePlan } = useVersionPlanStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const devTasks = useDevTaskStore((s) => s.tasks);
|
||||
const testCases = useTestCaseStore((s) => s.testCases);
|
||||
const categories = useTaskCategoryStore((s) => s.categories);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTarget, setActiveTarget] = useState<AgentDecomposeTarget | null>(null);
|
||||
const [result, setResult] = useState<AgentDecomposeResponse | null>(null);
|
||||
const [resultTarget, setResultTarget] = useState<AgentDecomposeTarget>('all');
|
||||
const [dedupeSummary, setDedupeSummary] = useState({ removedDevTaskCount: 0, removedTestCaseCount: 0 });
|
||||
const [tick, setTick] = useState(0);
|
||||
const startedAtRef = useRef<number | null>(null);
|
||||
const loading = activeTarget !== null;
|
||||
|
||||
// plan 上的状态(持久化在 localStorage)
|
||||
const persistStatus = plan.aiDecomposeStatus;
|
||||
@@ -78,14 +89,20 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
description: r.description,
|
||||
}));
|
||||
|
||||
const handleClick = async () => {
|
||||
const targetText = (target: AgentDecomposeTarget) => {
|
||||
if (target === 'dev_tasks') return '开发任务';
|
||||
if (target === 'test_cases') return '测试用例';
|
||||
return '任务和用例';
|
||||
};
|
||||
|
||||
const handleClick = async (target: AgentDecomposeTarget) => {
|
||||
if (loading) return;
|
||||
// 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点
|
||||
if (persistStatus === 'in_progress' && !isStaleInProgress) return;
|
||||
|
||||
startedAtRef.current = Date.now();
|
||||
setTick(Date.now());
|
||||
setLoading(true);
|
||||
setActiveTarget(target);
|
||||
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'in_progress',
|
||||
@@ -105,6 +122,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
members,
|
||||
versionId: version.id,
|
||||
planId: plan.id,
|
||||
target,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -119,7 +137,19 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeError: resp.error,
|
||||
});
|
||||
} else {
|
||||
setResult(resp);
|
||||
const requirementIdSet = new Set(linkedReqs.map((req) => req.id));
|
||||
const deduped = filterDuplicateDecomposeDrafts(resp.result, {
|
||||
existingDevTasks: devTasks.filter((task) => requirementIdSet.has(task.requirementId)),
|
||||
existingTestCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
||||
categories,
|
||||
requirements: linkedReqs,
|
||||
});
|
||||
setResult({ ...resp, result: deduped.result });
|
||||
setDedupeSummary({
|
||||
removedDevTaskCount: deduped.removedDevTaskCount,
|
||||
removedTestCaseCount: deduped.removedTestCaseCount,
|
||||
});
|
||||
setResultTarget(target);
|
||||
updatePlan(plan.id, {
|
||||
aiDecomposeStatus: 'completed',
|
||||
aiDecomposeError: undefined,
|
||||
@@ -132,7 +162,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeError: msg,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setActiveTarget(null);
|
||||
startedAtRef.current = null;
|
||||
}
|
||||
};
|
||||
@@ -143,39 +173,48 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isInProgress}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
|
||||
isError
|
||||
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||
}`}
|
||||
title={
|
||||
isError
|
||||
? `上次失败:${persistError || '未知错误'}(点击重试)`
|
||||
: wasCompleted
|
||||
? '此前已拆解过,再次点击会重新拆解'
|
||||
: '使用 AI 把原型 + 关联需求拆解成开发任务和测试用例'
|
||||
}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
拆解中 {formatElapsed(elapsedSec)}
|
||||
</>
|
||||
) : isError ? (
|
||||
<>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
重试
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{wasCompleted ? '重新 AI 拆解' : 'AI 拆解'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{(['dev_tasks', 'test_cases'] as AgentDecomposeTarget[]).map((target) => {
|
||||
const showSpinner = isInProgress && (!activeTarget || activeTarget === target);
|
||||
const label = wasCompleted ? `重新拆解${targetText(target)}` : `AI 拆解${targetText(target)}`;
|
||||
return (
|
||||
<button
|
||||
key={target}
|
||||
onClick={() => handleClick(target)}
|
||||
disabled={isInProgress}
|
||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium disabled:cursor-not-allowed disabled:opacity-70 ${
|
||||
isError
|
||||
? 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
|
||||
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||
}`}
|
||||
title={
|
||||
isError
|
||||
? `上次失败:${persistError || '未知错误'}(点击重试${targetText(target)})`
|
||||
: wasCompleted
|
||||
? `此前已拆解过,再次点击会重新拆解${targetText(target)}`
|
||||
: `使用 AI 把原型 + 关联需求拆解成${targetText(target)}`
|
||||
}
|
||||
>
|
||||
{showSpinner ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
拆解中 {formatElapsed(elapsedSec)}
|
||||
</>
|
||||
) : isError ? (
|
||||
<>
|
||||
<RotateCw className="h-3 w-3" />
|
||||
重试{targetText(target)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
{/* 错误信息:在按钮旁悬浮显示 */}
|
||||
{isError && persistError && (
|
||||
@@ -198,6 +237,8 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
version={version}
|
||||
plan={plan}
|
||||
requirements={linkedReqs}
|
||||
target={resultTarget}
|
||||
dedupeSummary={dedupeSummary}
|
||||
onClose={() => setResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,9 +9,10 @@ import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { findCategoryByCode, resolveCategoryIdFromCode } from '@/lib/task-category';
|
||||
import { addWorkHours } from '@/lib/work-hours';
|
||||
import { clampDevEstimateHours, clampTestCaseEstimateHours } from '@/lib/ai-estimation-policy';
|
||||
import { clampDevAiEstimateHours, clampTestCaseAiEstimateHours } from '@/lib/ai-estimation-policy';
|
||||
import { formatReportRequirementLabel } from '@/lib/ai-decompose-report';
|
||||
import type {
|
||||
AgentDecomposeTarget,
|
||||
AgentDecomposeResponse,
|
||||
AgentDevTaskDraft,
|
||||
AgentTestCaseDraft,
|
||||
@@ -22,16 +23,22 @@ interface Props {
|
||||
version: VersionWithContext;
|
||||
plan: VersionPlan;
|
||||
requirements: Array<{ id: string; code: string; title: string; description?: string }>;
|
||||
target: AgentDecomposeTarget;
|
||||
dedupeSummary?: { removedDevTaskCount: number; removedTestCaseCount: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DecomposeReportModal({ result, version, plan, requirements, onClose }: Props) {
|
||||
export function DecomposeReportModal({ result, version, plan, requirements, target, dedupeSummary, onClose }: Props) {
|
||||
const { createTask } = useDevTaskStore();
|
||||
const { createTestCase } = useTestCaseStore();
|
||||
const { categories } = useTaskCategoryStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { result: data, meta } = result;
|
||||
const showDevDrafts = target !== 'test_cases';
|
||||
const showTestDrafts = target !== 'dev_tasks';
|
||||
const filteredDuplicateCount =
|
||||
(dedupeSummary?.removedDevTaskCount ?? 0) + (dedupeSummary?.removedTestCaseCount ?? 0);
|
||||
|
||||
const [selectedDevIdx, setSelectedDevIdx] = useState<Set<number>>(
|
||||
new Set(data.devTaskDrafts.map((_, i) => i)),
|
||||
@@ -90,9 +97,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const requirementId = reqRef?.id ?? requirements[0]?.id ?? '';
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'development');
|
||||
const estimateHours = clampDevEstimateHours(draft.categoryCode, draft.estimateHours);
|
||||
const startISO = new Date().toISOString();
|
||||
const endISO = addWorkHours(startISO, estimateHours);
|
||||
const aiEstimateHours = clampDevAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
|
||||
createTask({
|
||||
requirementId,
|
||||
@@ -102,9 +107,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
assigneeId: '',
|
||||
reviewerId: undefined,
|
||||
priority: draft.priority,
|
||||
expectedStartAt: startISO,
|
||||
expectedEndAt: endISO,
|
||||
estimateHours,
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
actualStartAt: undefined,
|
||||
actualEndAt: undefined,
|
||||
status: 'todo',
|
||||
@@ -129,7 +135,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
const refs = normalizeRefs(draft.references);
|
||||
const reqRef = refs.find((r) => r.type === 'requirement');
|
||||
const categoryId = resolveCategoryIdFromCode(categories, draft.categoryCode, 'testing');
|
||||
const estimateHours = clampTestCaseEstimateHours(draft.categoryCode, draft.estimateHours);
|
||||
const aiEstimateHours = clampTestCaseAiEstimateHours(draft.categoryCode, draft.aiEstimateHours);
|
||||
|
||||
createTestCase({
|
||||
versionId: version.id,
|
||||
@@ -138,7 +144,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
description: draft.description,
|
||||
categoryId,
|
||||
priority: draft.priority,
|
||||
estimateHours,
|
||||
estimateHours: undefined,
|
||||
aiEstimateHours,
|
||||
assigneeId: undefined,
|
||||
references: refs,
|
||||
aiDraft: true,
|
||||
@@ -155,6 +162,8 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
setTimeout(() => onClose(), 1500);
|
||||
};
|
||||
|
||||
const selectedVisibleCount = (showDevDrafts ? selectedDevIdx.size : 0) + (showTestDrafts ? selectedTcIdx.size : 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
@@ -178,6 +187,12 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{filteredDuplicateCount > 0 && (
|
||||
<div className="rounded-lg border border-purple-200 bg-purple-50 px-3 py-2 text-[12px] text-purple-700">
|
||||
已自动过滤 {dedupeSummary?.removedDevTaskCount ?? 0} 个重复开发任务、{dedupeSummary?.removedTestCaseCount ?? 0} 个重复测试用例。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对账报告 */}
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">对账报告</h4>
|
||||
@@ -192,8 +207,14 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
</div>
|
||||
<ul className="text-[12px] text-emerald-800 space-y-1 ml-5">
|
||||
{data.report.matched.map((m, i) => (
|
||||
<li key={i}>
|
||||
{m.reqId} ↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务
|
||||
<li key={i} className="flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className="max-w-[280px] truncate font-medium"
|
||||
title={formatReportRequirementLabel(m.reqId, requirements)}
|
||||
>
|
||||
{formatReportRequirementLabel(m.reqId, requirements)}
|
||||
</span>
|
||||
<span>↔ {m.noteIds.join(', ') || '(仅需求驱动)'} → 拆出 {m.taskCount} 个任务</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -210,7 +231,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
以下需求在原型上未找到对应批注,已按需求文字拆解,请人工核对:
|
||||
</p>
|
||||
<ul className="text-[12px] text-amber-800 ml-5 list-disc">
|
||||
{data.report.reqOnly.map((id, i) => <li key={i}>{id}</li>)}
|
||||
{data.report.reqOnly.map((id, i) => {
|
||||
const label = formatReportRequirementLabel(id, requirements);
|
||||
return <li key={i} className="max-w-[360px] truncate" title={label}>{label}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
@@ -248,6 +272,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
</section>
|
||||
|
||||
{/* DevTask 草案 */}
|
||||
{showDevDrafts && (
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
||||
开发任务草案({data.devTaskDrafts.length})
|
||||
@@ -277,7 +302,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
{d.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
{clampDevEstimateHours(d.categoryCode, d.estimateHours)}h
|
||||
AI预估 {clampDevAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||
</span>
|
||||
</div>
|
||||
{d.description && (
|
||||
@@ -299,8 +324,10 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* TestCase 草案 */}
|
||||
{showTestDrafts && (
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-[var(--ink-soft)] mb-2">
|
||||
测试用例草案({data.testCaseDrafts.length})
|
||||
@@ -330,7 +357,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
{d.priority}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
{clampTestCaseEstimateHours(d.categoryCode, d.estimateHours)}h
|
||||
AI预估 {clampTestCaseAiEstimateHours(d.categoryCode, d.aiEstimateHours)}h
|
||||
</span>
|
||||
</div>
|
||||
<pre className="mt-1 text-[11px] text-[var(--ink-soft)] whitespace-pre-wrap font-sans line-clamp-3">
|
||||
@@ -352,12 +379,13 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--line)]">
|
||||
<div className="text-[12px] text-[var(--ink-muted)]">
|
||||
已选 {selectedDevIdx.size} 个任务 + {selectedTcIdx.size} 个用例
|
||||
已选 {showDevDrafts ? selectedDevIdx.size : 0} 个任务 + {showTestDrafts ? selectedTcIdx.size : 0} 个用例
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -368,7 +396,7 @@ export function DecomposeReportModal({ result, version, plan, requirements, onCl
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdopt}
|
||||
disabled={submitting || submitted || (selectedDevIdx.size === 0 && selectedTcIdx.size === 0)}
|
||||
disabled={submitting || submitted || selectedVisibleCount === 0}
|
||||
className="h-8 px-4 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{submitted ? '✓ 已采纳' : submitting ? '采纳中...' : '采纳选中'}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user