feat(版本): 完善研发计划与预警已读

关键改动:

- 增加版本表单、发布校验和调研方向进度规则

- 扩展小宝预警已读状态、风险签名和今日证据

- 补充组长权限、加班查看范围、活动记录与相关测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
Script Generator
2026-06-30 16:48:58 +08:00
parent 04520bd8c5
commit 3d3d56697a
41 changed files with 1701 additions and 121 deletions

View File

@@ -1,24 +1,24 @@
'use client';
import { useState } from 'react';
import { X, Check, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { X, Link2, FileUp, ExternalLink, Play, ArrowRightLeft } from 'lucide-react';
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { FilterSelect } from '@/components/FilterSelect';
import {
calcPlanProgress,
getResearchDirectionProgressSummary,
getRequirementCoverageSummary,
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, ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan } from '@/lib/version-plan';
import { canEditPlanRequirementCoverage, getPlanCompletionState } from '@/lib/version-plan-workflow';
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
interface Props {
planId: string;
@@ -70,23 +70,16 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
if (!plan) return null;
const completionState = getPlanCompletionState(plan);
const isResearch = plan.type === 'research';
const progress = isResearch ? calcPlanProgress(plan.tasks) : getRequirementCoverageSummary(plan).percent;
const progress = plan.type === 'research'
? getResearchDirectionProgressSummary(plan).percent
: 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';
const handleToggleTask = (task: PlanTask) => {
if (!canToggle) return;
const nextStatus = task.status === 'completed' ? 'pending' : 'completed';
const updatedTasks = (plan.tasks || []).map((t) => t.id === task.id ? { ...t, status: nextStatus as PlanTask['status'] } : t);
updatePlan(plan.id, { tasks: updatedTasks });
};
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -214,43 +207,35 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
</div>
{/* Research Tasks */}
{isResearch && plan.tasks && plan.tasks.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] font-medium text-[var(--ink-muted)]"></div>
{plan.tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-[var(--bg-subtle)]">
<button
disabled={!canToggle}
onClick={() => handleToggleTask(task)}
className={`h-4 w-4 rounded border flex items-center justify-center shrink-0 transition-colors ${!canToggle ? 'opacity-40 cursor-not-allowed' : ''} ${task.status === 'completed' ? 'bg-[var(--accent)] border-[var(--accent)]' : 'border-[var(--line)]'}`}
>
{task.status === 'completed' && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
</button>
<span className={`flex-1 text-[12px] ${task.status === 'completed' ? 'line-through text-[var(--ink-muted)]' : 'text-[var(--ink)]'}`}>{task.title}</span>
</div>
))}
</div>
{plan.type === 'research' && (
<PlanResearchDirectionPanel
plan={plan}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
)}
{linkedReqs.length > 0 && (
<div>
<PlanRequirementCoveragePanel
plan={plan}
requirements={linkedReqs}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={updatePlan}
/>
{plan.type === 'research' ? (
<PlanLinkedRequirementReferenceList requirements={linkedReqs} />
) : (
<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" />
)}
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
{/* Result */}
{plan.status === 'completed' && plan.resultUrl && (

View File

@@ -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());

View File

@@ -10,6 +10,8 @@ import {
calcPlanProgress,
sortPlansNewestFirst,
getPlanLogsForPlans,
getResearchDirectionPresetOptions,
getResearchDirectionProgressSummary,
getRequirementCoverageSummary,
PRODUCT_PLAN_KIND_LABEL,
PRODUCT_PLAN_REVIEW_FAILURE_OPTIONS,
@@ -20,7 +22,7 @@ import { FieldError } from '@/components/FieldError';
import { FilterSelect } from '@/components/FilterSelect';
import { WorkDateTimePicker } from '@/components/WorkDateTimePicker';
import { AiDecomposeButton } from './AiDecomposeButton';
import { PlanLogTimeline, PlanRequirementCoveragePanel } from './PlanRequirementCoveragePanel';
import { PlanLinkedRequirementReferenceList, PlanLogTimeline, PlanRequirementCoveragePanel, PlanResearchDirectionPanel } from './PlanRequirementCoveragePanel';
import type { VersionWithContext } from '@/lib/derive';
import type { Requirement } from '@/lib/requirement';
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
@@ -80,8 +82,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
const totalDuration = calcTotalDuration(typePlans);
return (
<div className={planType === 'research' ? 'space-y-4' : '-m-5 h-[calc(100vh-98px)] min-h-[520px]'}>
{planType === 'research' && (
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
{false && planType === 'research' && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[12px] text-[var(--ink-muted)]">{typePlans.length} {TYPE_LABEL[planType]}</span>
@@ -95,11 +97,11 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
</div>
)}
{typePlans.length === 0 && planType === 'research' ? (
{false && typePlans.length === 0 && planType === 'research' ? (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 text-center text-[13px] text-[var(--ink-muted)]">
{TYPE_LABEL[planType]}
</div>
) : planType === 'research' ? (
) : false && planType === 'research' ? (
<div className="space-y-3">
{typePlans.map((plan) => {
const now = new Date().toISOString();
@@ -402,7 +404,7 @@ function ProductUiPlanWorkspace({
onCreatePlan,
}: {
typePlans: VersionPlan[];
planType: 'product' | 'ui';
planType: VersionPlan['type'];
totalDuration: string;
versionDeadline?: string;
version?: VersionWithContext;
@@ -461,7 +463,9 @@ function ProductUiPlanWorkspace({
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{typePlans.map((plan) => {
const { effectiveStatus } = getPlanRuntime(plan);
const summary = getRequirementCoverageSummary(plan);
const summary = plan.type === 'research'
? getResearchDirectionProgressSummary(plan)
: getRequirementCoverageSummary(plan);
const isSelected = plan.id === selectedPlanIdOrFirst;
return (
<button
@@ -561,7 +565,7 @@ function ProductUiPlanDetail({
onOpenComplete,
}: {
plan: VersionPlan;
planType: 'product' | 'ui';
planType: VersionPlan['type'];
version?: VersionWithContext;
currentUserName: string;
versionMembers: { role: string; name: string }[];
@@ -691,15 +695,27 @@ function ProductUiPlanDetail({
</div>
)}
{selectedRequirements.length > 0 && (
<PlanRequirementCoveragePanel
{plan.type === 'research' && (
<PlanResearchDirectionPanel
plan={plan}
requirements={selectedRequirements}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)}
{selectedRequirements.length > 0 && (
plan.type === 'research' ? (
<PlanLinkedRequirementReferenceList requirements={selectedRequirements} />
) : (
<PlanRequirementCoveragePanel
plan={plan}
requirements={selectedRequirements}
canEdit={canEditCoverage}
currentUserName={currentUserName}
onUpdate={onUpdate}
/>
)
)}
{plan.status === 'in_progress' && !completionState.canSubmitResult && (
<p className="mt-3 text-[11px] text-[var(--ink-muted)]">{completionState.missingReasons.join('、')}</p>
)}
@@ -756,6 +772,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
() => mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], Array.from(selectedReqs)),
[linkedRequirements, allRequirements, selectedReqs],
);
const researchDirectionPresetOptions = useMemo(() => getResearchDirectionPresetOptions(tasks), [tasks]);
const isOverdue = !!(versionDeadline && endTime && new Date(endTime) > new Date(versionDeadline));
const handleSubmit = (e: React.FormEvent) => {
@@ -908,7 +925,23 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
<span className="text-[10px] text-[var(--ink-muted)] ml-1"></span>
</label>
{/* 预设选项 */}
{tasks.length === 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{researchDirectionPresetOptions.map((option) => (
<button
key={option.title}
type="button"
disabled={option.disabled}
onClick={() => {
if (option.disabled) return;
setTasks([...tasks, { id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, title: option.title, status: 'pending' }]);
}}
className={`h-6 px-2.5 rounded-md text-[11px] border transition-colors ${option.disabled ? 'cursor-not-allowed border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)] opacity-70' : 'border-dashed border-[var(--line)] text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)]'}`}
>
+ {option.title}
</button>
))}
</div>
{false && tasks.length === 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{['竞品分析', '用户访谈', '数据调研', '技术可行性分析', '市场调研', '需求分析'].map((preset) => (
<button