feat(版本): 完善研发计划与预警已读
关键改动: - 增加版本表单、发布校验和调研方向进度规则 - 扩展小宝预警已读状态、风险签名和今日证据 - 补充组长权限、加班查看范围、活动记录与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -4,14 +4,17 @@ import { useState } from 'react';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import type { Requirement } from '@/lib/requirement';
|
||||
import type { RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
|
||||
import type { PlanTask, RequirementCoverageStatus, VersionPlan, VersionPlanLog, VersionPlanLogView } from '@/lib/version-plan';
|
||||
import {
|
||||
canSaveRequirementCoverageDraft,
|
||||
canOpenRequirementCoverageRecord,
|
||||
getResearchDirectionProgressSummary,
|
||||
getResearchDirectionStatus,
|
||||
getRequirementCoverage,
|
||||
getRequirementCoverageStatus,
|
||||
getRequirementCoverageSummary,
|
||||
REQUIREMENT_COVERAGE_LABEL,
|
||||
updateResearchDirectionProgress,
|
||||
updateRequirementCoverage,
|
||||
} from '@/lib/version-plan';
|
||||
|
||||
@@ -25,6 +28,17 @@ interface CoverageProps {
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
}
|
||||
|
||||
interface DirectionProps {
|
||||
plan: VersionPlan;
|
||||
canEdit: boolean;
|
||||
currentUserName: string;
|
||||
onUpdate: (id: string, data: Partial<VersionPlan>) => void;
|
||||
}
|
||||
|
||||
interface LinkedRequirementReferenceProps {
|
||||
requirements: RequirementOption[];
|
||||
}
|
||||
|
||||
interface LogTimelineProps {
|
||||
logs?: Array<VersionPlanLog | VersionPlanLogView>;
|
||||
className?: string;
|
||||
@@ -65,6 +79,7 @@ function getLogTone(log: VersionPlanLog): { badge: string } {
|
||||
|
||||
function getLogTypeLabel(log: VersionPlanLog): string {
|
||||
if (log.type === 'ai_decompose') return 'AI 拆解';
|
||||
if (log.type === 'research_direction_progress') return '调研方向';
|
||||
if (log.type === 'requirement_progress') return '需求进度';
|
||||
return '系统记录';
|
||||
}
|
||||
@@ -224,6 +239,165 @@ export function PlanRequirementCoveragePanel({ plan, requirements, canEdit, curr
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanResearchDirectionPanel({ plan, canEdit, currentUserName, onUpdate }: DirectionProps) {
|
||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||
const [completedContent, setCompletedContent] = useState('');
|
||||
const [remainingContent, setRemainingContent] = useState('');
|
||||
const tasks = plan.tasks ?? [];
|
||||
const summary = getResearchDirectionProgressSummary(plan);
|
||||
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
const openEditor = (task: PlanTask) => {
|
||||
setEditingTaskId(task.id);
|
||||
setCompletedContent(task.completedContent ?? '');
|
||||
setRemainingContent(task.remainingContent ?? '');
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditingTaskId(null);
|
||||
setCompletedContent('');
|
||||
setRemainingContent('');
|
||||
};
|
||||
|
||||
const canSavePartial = canSaveRequirementCoverageDraft('partial', completedContent, remainingContent);
|
||||
|
||||
const saveDirection = (task: PlanTask, status: Extract<RequirementCoverageStatus, 'partial' | 'completed'>) => {
|
||||
if (!canEdit || !canSaveRequirementCoverageDraft(status, completedContent, remainingContent)) return;
|
||||
const patch = updateResearchDirectionProgress(plan, {
|
||||
taskId: task.id,
|
||||
status,
|
||||
completedContent: status === 'partial' ? completedContent : undefined,
|
||||
remainingContent: status === 'partial' ? remainingContent : undefined,
|
||||
updatedBy: currentUserName,
|
||||
});
|
||||
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="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2 pr-1">
|
||||
{tasks.map((task) => {
|
||||
const status = getResearchDirectionStatus(task);
|
||||
const isEditing = editingTaskId === task.id;
|
||||
const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit);
|
||||
return (
|
||||
<div key={task.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="truncate text-[12px] font-medium text-[var(--ink)]" title={task.title}>{task.title}</div>
|
||||
{(task.completedContent || task.remainingContent) && (
|
||||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-[var(--ink-soft)]">
|
||||
{task.completedContent && <div className="line-clamp-2">已完成:{task.completedContent}</div>}
|
||||
{task.remainingContent && <div className="line-clamp-2 text-amber-700">剩余:{task.remainingContent}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (canOpenRecord || isEditing) && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => isEditing ? closeEditor() : openEditor(task)}
|
||||
className="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>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="mt-2 space-y-2 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-2">
|
||||
<textarea
|
||||
value={completedContent}
|
||||
onChange={(event) => setCompletedContent(event.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"
|
||||
/>
|
||||
<textarea
|
||||
value={remainingContent}
|
||||
onChange={(event) => setRemainingContent(event.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 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveDirection(task, 'completed')}
|
||||
className="h-7 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
完全完成
|
||||
</button>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeEditor}
|
||||
className="h-7 rounded-md border border-[var(--line)] px-2 text-[11px] font-medium text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveDirection(task, 'partial')}
|
||||
disabled={!canSavePartial}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanLinkedRequirementReferenceList({ requirements }: LinkedRequirementReferenceProps) {
|
||||
if (requirements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-[var(--ink-muted)]">引用需求</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)]">用于说明调研方向覆盖哪些需求,不参与调研进度计算</div>
|
||||
</div>
|
||||
<div className="space-y-1 rounded-lg bg-[var(--bg-subtle)] p-2">
|
||||
{requirements.map((req) => (
|
||||
<div key={req.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--bg-card)]">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanLogTimeline({ logs, className = '', fillHeight = false }: LogTimelineProps) {
|
||||
const [selectedMonth, setSelectedMonth] = useState('all');
|
||||
const sortedLogs = [...(logs ?? [])].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
Reference in New Issue
Block a user