feat(版本详情): 完善流程日志与需求覆盖
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Sparkles, Loader2, AlertCircle, RotateCw } from 'lucide-react';
|
||||
import { appendPlanLog } from '@/lib/version-plan';
|
||||
import type { VersionPlan } from '@/lib/version-plan';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
@@ -102,6 +103,23 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
return '任务和用例';
|
||||
};
|
||||
|
||||
const appendAiLog = (
|
||||
target: AgentDecomposeTarget,
|
||||
status: 'started' | 'completed' | 'error',
|
||||
detail?: string,
|
||||
) => {
|
||||
const currentPlan = useVersionPlanStore.getState().plans.find((item) => item.id === plan.id) ?? plan;
|
||||
const statusText = status === 'started' ? '已触发' : status === 'completed' ? '已完成' : '失败';
|
||||
return appendPlanLog(currentPlan, {
|
||||
type: 'ai_decompose',
|
||||
actor: user?.name ?? plan.owner,
|
||||
title: `AI 拆解${targetText(target)}${statusText}`,
|
||||
detail,
|
||||
aiTarget: target,
|
||||
aiStatus: status,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClick = async (target: AgentDecomposeTarget) => {
|
||||
if (loading) return;
|
||||
// 即便 persistStatus 是 in_progress,只要超过阈值就允许重新点
|
||||
@@ -117,6 +135,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeAt: new Date().toISOString(),
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
logs: appendAiLog(target, 'started'),
|
||||
});
|
||||
|
||||
const members = (version.members ?? []).map((m) => ({
|
||||
@@ -144,6 +163,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: resp.error,
|
||||
logs: appendAiLog(target, 'error', resp.error),
|
||||
});
|
||||
} else {
|
||||
const targetFilteredResult = filterDecomposeResultByTarget(resp.result, target);
|
||||
@@ -166,6 +186,11 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeStatus: 'completed',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: undefined,
|
||||
logs: appendAiLog(
|
||||
target,
|
||||
'completed',
|
||||
`生成开发任务 ${deduped.result.devTaskDrafts.length} 条,测试用例 ${deduped.result.testCaseDrafts.length} 条。已过滤重复开发任务 ${deduped.removedDevTaskCount} 条,重复测试用例 ${deduped.removedTestCaseCount} 条。`,
|
||||
),
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -174,6 +199,7 @@ export function AiDecomposeButton({ plan, version }: Props) {
|
||||
aiDecomposeStatus: 'error',
|
||||
aiDecomposeTarget: target,
|
||||
aiDecomposeError: msg,
|
||||
logs: appendAiLog(target, 'error', msg),
|
||||
});
|
||||
} finally {
|
||||
setActiveTarget(null);
|
||||
|
||||
@@ -5,9 +5,10 @@ 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 { useAuthStore } from '@/stores/useAuthStore';
|
||||
import {
|
||||
calcPlanProgress,
|
||||
calcLinkedReqProgress,
|
||||
getRequirementCoverageSummary,
|
||||
PRODUCT_PLAN_KIND_LABEL,
|
||||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||
PRODUCT_PLAN_REVIEW_RESULT_LABEL,
|
||||
@@ -16,6 +17,7 @@ import { formatDateTime } from '@/lib/format';
|
||||
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';
|
||||
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
@@ -49,6 +51,7 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
const { plans, updatePlan, completePlan } = useVersionPlanStore();
|
||||
const { requirements } = useRequirementStore();
|
||||
const { members } = useMemberStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [showTransfer, setShowTransfer] = useState(false);
|
||||
const [transferTo, setTransferTo] = useState('');
|
||||
const [resultType, setResultType] = useState<'link' | 'file'>('link');
|
||||
@@ -67,10 +70,11 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const isResearch = plan.type === 'research';
|
||||
const progress = isResearch ? calcPlanProgress(plan.tasks) : calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds);
|
||||
const progress = isResearch ? calcPlanProgress(plan.tasks) : 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 canToggle = canTogglePlanChecklist(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
const currentUserName = user?.name ?? plan.owner;
|
||||
const productPlanKind = plan.type === 'product' ? getProductPlanKind(plan) : undefined;
|
||||
const isProductDesignPlan = productPlanKind === 'design';
|
||||
const isProductReviewPlan = productPlanKind === 'review';
|
||||
@@ -82,13 +86,6 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
updatePlan(plan.id, { tasks: updatedTasks });
|
||||
};
|
||||
|
||||
const handleToggleReq = (reqId: string) => {
|
||||
if (!canEditCoverage) return;
|
||||
const current = plan.completedRequirementIds || [];
|
||||
const next = current.includes(reqId) ? current.filter((id) => id !== reqId) : [...current, reqId];
|
||||
updatePlan(plan.id, { completedRequirementIds: next });
|
||||
};
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -235,32 +232,25 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product/UI: Linked Requirements */}
|
||||
{linkedReqs.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">关联需求</div>
|
||||
{linkedReqs.map((req) => {
|
||||
const isDone = (plan.completedRequirementIds || []).includes(req.id);
|
||||
return (
|
||||
<div key={req.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
|
||||
<button
|
||||
disabled={!canEditCoverage}
|
||||
onClick={() => handleToggleReq(req.id)}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
>
|
||||
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<span className="text-[11px] font-mono text-[var(--ink-muted)]">{req.code}</span>
|
||||
<span className={`flex-1 text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{req.title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<PlanRequirementCoveragePanel
|
||||
plan={plan}
|
||||
requirements={linkedReqs}
|
||||
canEdit={canEditCoverage}
|
||||
currentUserName={currentUserName}
|
||||
onUpdate={updatePlan}
|
||||
/>
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="pt-1 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResearch && (
|
||||
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{plan.status === 'completed' && plan.resultUrl && (
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
||||
|
||||
233
apps/web/components/version/PlanRequirementCoveragePanel.tsx
Normal file
233
apps/web/components/version/PlanRequirementCoveragePanel.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Check, Clock3, Sparkles } from 'lucide-react';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog } from '@/lib/version-plan';
|
||||
import {
|
||||
getRequirementCoverage,
|
||||
getRequirementCoverageStatus,
|
||||
getRequirementCoverageSummary,
|
||||
REQUIREMENT_COVERAGE_LABEL,
|
||||
updateRequirementCoverage,
|
||||
} from '@/lib/version-plan';
|
||||
|
||||
type RequirementOption = Pick<Requirement, 'id' | 'code' | 'title'> & { isHistorical?: boolean };
|
||||
|
||||
interface CoverageProps {
|
||||
plan: VersionPlan;
|
||||
requirements: RequirementOption[];
|
||||
canEdit: boolean;
|
||||
currentUserName: string;
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
}
|
||||
|
||||
interface LogTimelineProps {
|
||||
logs?: VersionPlanLog[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COVERAGE_STATUS_OPTIONS: RequirementCoverageStatus[] = ['partial', 'completed', 'not_started'];
|
||||
|
||||
const COVERAGE_BADGE_STYLE: Record<RequirementCoverageStatus, string> = {
|
||||
not_started: 'border-zinc-200 bg-zinc-50 text-zinc-500',
|
||||
partial: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
completed: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
};
|
||||
|
||||
function getLogIcon(log: VersionPlanLog) {
|
||||
if (log.type === 'ai_decompose') return <Sparkles className="h-3.5 w-3.5" />;
|
||||
if (log.type === 'requirement_progress') return <Check className="h-3.5 w-3.5" />;
|
||||
return <Clock3 className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
function getLogTone(log: VersionPlanLog): string {
|
||||
if (log.aiStatus === 'error') return 'bg-red-50 text-red-700 ring-red-100';
|
||||
if (log.type === 'ai_decompose') return 'bg-purple-50 text-purple-700 ring-purple-100';
|
||||
if (log.coverageStatus === 'completed') return 'bg-emerald-50 text-emerald-700 ring-emerald-100';
|
||||
if (log.coverageStatus === 'partial') return 'bg-amber-50 text-amber-700 ring-amber-100';
|
||||
return 'bg-zinc-50 text-zinc-600 ring-zinc-100';
|
||||
}
|
||||
|
||||
export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, currentUserName, onUpdate }: CoverageProps) {
|
||||
const [editingRequirementId, setEditingRequirementId] = useState<string | null>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<RequirementCoverageStatus>('partial');
|
||||
const [completedContent, setCompletedContent] = useState('');
|
||||
const [remainingContent, setRemainingContent] = useState('');
|
||||
const summary = getRequirementCoverageSummary(plan);
|
||||
|
||||
if (requirements.length === 0) return null;
|
||||
|
||||
const openEditor = (req: RequirementOption) => {
|
||||
const coverage = getRequirementCoverage(plan, req.id);
|
||||
setEditingRequirementId(req.id);
|
||||
setDraftStatus(coverage?.status === 'completed' ? 'completed' : coverage?.status === 'not_started' ? 'not_started' : 'partial');
|
||||
setCompletedContent(coverage?.completedContent ?? '');
|
||||
setRemainingContent(coverage?.remainingContent ?? '');
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditingRequirementId(null);
|
||||
setCompletedContent('');
|
||||
setRemainingContent('');
|
||||
setDraftStatus('partial');
|
||||
};
|
||||
|
||||
const canSave = draftStatus === 'not_started'
|
||||
|| (draftStatus === 'completed' && completedContent.trim().length > 0)
|
||||
|| (draftStatus === 'partial' && completedContent.trim().length > 0 && remainingContent.trim().length > 0);
|
||||
|
||||
const saveCoverage = (req: RequirementOption) => {
|
||||
if (!canEdit || !canSave) return;
|
||||
const patch = updateRequirementCoverage(plan, {
|
||||
requirementId: req.id,
|
||||
status: draftStatus,
|
||||
completedContent: draftStatus === 'not_started' ? undefined : completedContent,
|
||||
remainingContent: draftStatus === 'partial' ? remainingContent : undefined,
|
||||
updatedBy: currentUserName,
|
||||
requirementCode: req.code,
|
||||
requirementTitle: req.title,
|
||||
});
|
||||
onUpdate(plan.id, patch);
|
||||
closeEditor();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">引用需求</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
|
||||
完全完成 {summary.completed} / {summary.total}
|
||||
{summary.partial > 0 && <span className="ml-2 text-amber-700">部分完成 {summary.partial}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{summary.percent}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--bg-subtle)]">
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${summary.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||||
{requirements.map((req) => {
|
||||
const status = getRequirementCoverageStatus(plan, req.id);
|
||||
const coverage = getRequirementCoverage(plan, req.id);
|
||||
const isEditing = editingRequirementId === req.id;
|
||||
return (
|
||||
<div key={req.id} className="rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span className={`mt-0.5 inline-flex shrink-0 items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${COVERAGE_BADGE_STYLE[status]}`}>
|
||||
{REQUIREMENT_COVERAGE_LABEL[status]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 font-mono text-[11px] text-[var(--ink-muted)]">{req.code}</span>
|
||||
<span className="min-w-0 truncate text-[12px] font-medium text-[var(--ink)]" title={req.title}>{req.title}</span>
|
||||
{req.isHistorical && <span className="shrink-0 rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-600">历史</span>}
|
||||
</div>
|
||||
{(coverage?.completedContent || coverage?.remainingContent) && (
|
||||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
|
||||
{coverage.completedContent && <div className="line-clamp-2">已完成:{coverage.completedContent}</div>}
|
||||
{coverage.remainingContent && <div className="line-clamp-2 text-amber-700">剩余:{coverage.remainingContent}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isEditing ? closeEditor() : openEditor(req)}
|
||||
className="shrink-0 rounded-md border border-[var(--line)] px-2 py-1 text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
{isEditing ? '收起' : '记录'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{COVERAGE_STATUS_OPTIONS.map((statusOption) => (
|
||||
<button
|
||||
key={statusOption}
|
||||
type="button"
|
||||
onClick={() => setDraftStatus(statusOption)}
|
||||
className={`h-7 rounded-md border text-[11px] font-medium transition-colors ${draftStatus === statusOption ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
{REQUIREMENT_COVERAGE_LABEL[statusOption]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{draftStatus !== 'not_started' && (
|
||||
<textarea
|
||||
value={completedContent}
|
||||
onChange={(e) => setCompletedContent(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="本次已完成的内容"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
{draftStatus === 'partial' && (
|
||||
<textarea
|
||||
value={remainingContent}
|
||||
onChange={(e) => setRemainingContent(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="剩余未完成的内容"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-2 py-1.5 text-[12px] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={closeEditor} className="h-7 px-2 text-[11px] text-[var(--ink-muted)]">取消</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveCoverage(req)}
|
||||
disabled={!canSave}
|
||||
className="h-7 rounded-md bg-[var(--accent)] px-3 text-[11px] font-medium text-white hover:bg-[var(--accent-hover)] disabled:opacity-50"
|
||||
>
|
||||
保存记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanLogTimeline({ logs, className = '' }: LogTimelineProps) {
|
||||
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
const frameClass = className || 'border-l border-[var(--line)] pl-4';
|
||||
|
||||
return (
|
||||
<aside className={frameClass}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">日志</div>
|
||||
<span className="text-[11px] tabular-nums text-[var(--ink-soft)]">{sortedLogs.length}</span>
|
||||
</div>
|
||||
{sortedLogs.length === 0 ? (
|
||||
<div className="mt-4 rounded-lg bg-[var(--bg-subtle)] px-3 py-4 text-center text-[11px] text-[var(--ink-muted)]">暂无日志</div>
|
||||
) : (
|
||||
<div className="mt-3 max-h-80 space-y-3 overflow-y-auto pr-1">
|
||||
{sortedLogs.map((log) => (
|
||||
<div key={log.id} className="relative pl-5">
|
||||
<span className={`absolute left-0 top-0 flex h-6 w-6 -translate-x-3 items-center justify-center rounded-full ring-4 ${getLogTone(log)}`}>
|
||||
{getLogIcon(log)}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 text-[12px] font-medium leading-5 text-[var(--ink)]">{log.title}</div>
|
||||
<span className="shrink-0 text-[10px] text-[var(--ink-muted)]">{formatDateTime(log.createdAt)}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--ink-muted)]">{log.actor}</div>
|
||||
{log.detail && <div className="whitespace-pre-wrap rounded-md bg-[var(--bg-subtle)] px-2 py-1.5 text-[11px] leading-4 text-[var(--ink-soft)]">{log.detail}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
formatDuration,
|
||||
calcTotalDuration,
|
||||
calcPlanProgress,
|
||||
calcLinkedReqProgress,
|
||||
sortPlansNewestFirst,
|
||||
PRODUCT_PLAN_KIND_LABEL,
|
||||
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
|
||||
@@ -18,6 +17,7 @@ import { formatDateTime } from '@/lib/format';
|
||||
import { FieldError } from '@/components/FieldError';
|
||||
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
|
||||
import { AiDecomposeButton } from './AiDecomposeButton';
|
||||
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
|
||||
import type { VersionWithContext } from '@/lib/derive';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
||||
@@ -119,6 +119,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
}
|
||||
return (
|
||||
<div key={plan.id} className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4 shadow-[var(--shadow-sm)]">
|
||||
<div className={plan.type === 'research' ? '' : 'grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]'}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -229,48 +231,14 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 关联需求 */}
|
||||
{plan.linkedRequirementIds && plan.linkedRequirementIds.length > 0 && requirementOptions.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-[var(--ink-muted)]">引用需求</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">
|
||||
已覆盖 {(plan.completedRequirementIds || []).filter((id) => plan.linkedRequirementIds?.includes(id)).length} / {plan.linkedRequirementIds.length}
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">{calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-[var(--bg-subtle)] overflow-hidden">
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${calcLinkedReqProgress(plan.linkedRequirementIds, plan.completedRequirementIds)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-44 space-y-1 overflow-y-auto rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||||
{plan.linkedRequirementIds.map((rid) => {
|
||||
const req = requirementOptions.find((r) => r.id === rid);
|
||||
const isDone = (plan.completedRequirementIds || []).includes(rid);
|
||||
return req ? (
|
||||
<div key={rid} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1 hover:bg-[var(--bg-card)]">
|
||||
<button
|
||||
disabled={!canEditCoverage}
|
||||
onClick={() => {
|
||||
if (!canEditCoverage) return;
|
||||
const current = plan.completedRequirementIds || [];
|
||||
const next = isDone ? current.filter((id) => id !== rid) : [...current, rid];
|
||||
onUpdate(plan.id, { completedRequirementIds: next });
|
||||
}}
|
||||
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canEditCoverage ? 'opacity-40 cursor-not-allowed' : ''} ${isDone ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
|
||||
>
|
||||
{isDone && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
<span className="shrink-0 text-[11px] font-mono text-[var(--ink-muted)]">{req.code}</span>
|
||||
<span className={`min-w-0 flex-1 truncate text-[12px] ${isDone ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`} title={req.title}>{req.title}</span>
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<PlanRequirementCoveragePanel
|
||||
plan={plan}
|
||||
requirements={plan.linkedRequirementIds.map((rid) => requirementOptions.find((r) => r.id === rid)).filter(Boolean) as Requirement[]}
|
||||
canEdit={canEditCoverage}
|
||||
currentUserName={currentUserName}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
|
||||
<p className="mt-2 text-[11px] text-[var(--ink-muted)]">还不能提交成果:{completionState.missingReasons.join('、')}</p>
|
||||
@@ -314,6 +282,14 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
<button onClick={() => setCompletingPlan(plan)} className="text-[11px] font-medium text-green-700 hover:text-green-900 underline">{getSubmitActionLabel(plan)}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{plan.type !== 'research' && (
|
||||
<PlanLogTimeline
|
||||
logs={plan.logs}
|
||||
className="border-t border-[var(--line)] pt-4 xl:border-l xl:border-t-0 xl:pt-0 xl:pl-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user