From 3d3d56697a8b55fc7f3f7fe413d1d0330c1d7937 Mon Sep 17 00:00:00 2001 From: Script Generator Date: Tue, 30 Jun 2026 16:48:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=89=88=E6=9C=AC):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=A0=94=E5=8F=91=E8=AE=A1=E5=88=92=E4=B8=8E=E9=A2=84=E8=AD=A6?= =?UTF-8?q?=E5=B7=B2=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关键改动: - 增加版本表单、发布校验和调研方向进度规则 - 扩展小宝预警已读状态、风险签名和今日证据 - 补充组长权限、加班查看范围、活动记录与相关测试 Co-Authored-By: Codex GPT-5 --- apps/server/src/modules/data/data-keys.ts | 1 + apps/web/app/overtime/page.tsx | 18 +- apps/web/app/versions/[id]/page.tsx | 116 +++++++++++- apps/web/app/versions/page.tsx | 110 ++++++++++- apps/web/app/xiaobao-warning/page.tsx | 33 +++- apps/web/components/layout/Sidebar.tsx | 30 ++- .../components/version/PlanDetailDrawer.tsx | 69 +++---- .../version/PlanRequirementCoveragePanel.tsx | 176 +++++++++++++++++- apps/web/components/version/PlanTab.tsx | 57 ++++-- .../xiaobao-warning/XiaobaoWarningCard.tsx | 15 +- apps/web/lib/entity-activity-log.ts | 1 + apps/web/lib/overtime.test.ts | 97 ++++++++++ apps/web/lib/overtime.ts | 55 ++++++ apps/web/lib/permissions.ts | 18 ++ .../web/lib/role-permission-migration.test.ts | 30 +++ apps/web/lib/role-permission-migration.ts | 13 ++ apps/web/lib/server-data.ts | 1 + apps/web/lib/version-form.test.ts | 70 +++++++ apps/web/lib/version-form.ts | 115 ++++++++++++ .../version-plan-research-direction.test.ts | 82 ++++++++ apps/web/lib/version-plan-workflow.test.ts | 9 +- apps/web/lib/version-plan-workflow.ts | 4 +- apps/web/lib/version-plan.test.ts | 13 ++ apps/web/lib/version-plan.ts | 113 ++++++++++- apps/web/lib/version-release.test.ts | 19 ++ apps/web/lib/version-release.ts | 14 ++ apps/web/lib/work-activity-factory.ts | 26 +++ .../work-activity-research-direction.test.ts | 49 +++++ apps/web/lib/work-activity.ts | 1 + apps/web/lib/workspace-daily-report.test.ts | 83 +++++++++ apps/web/lib/workspace-daily-report.ts | 70 +++++-- apps/web/lib/xiaobao-risk-ai.test.ts | 2 + apps/web/lib/xiaobao-risk-ai.ts | 13 +- apps/web/lib/xiaobao-risk-trend.test.ts | 14 ++ apps/web/lib/xiaobao-risk-trend.ts | 12 +- apps/web/lib/xiaobao-warning-view.test.ts | 106 +++++++++++ apps/web/lib/xiaobao-warning-view.ts | 54 ++++++ apps/web/stores/useMemberStore.ts | 10 +- apps/web/stores/useVersionPlanStore.ts | 7 +- apps/web/stores/useXiaobaoWarningReadStore.ts | 94 ++++++++++ docs/architecture.md | 2 + 41 files changed, 1701 insertions(+), 121 deletions(-) create mode 100644 apps/web/lib/overtime.test.ts create mode 100644 apps/web/lib/version-form.test.ts create mode 100644 apps/web/lib/version-form.ts create mode 100644 apps/web/lib/version-plan-research-direction.test.ts create mode 100644 apps/web/lib/version-release.test.ts create mode 100644 apps/web/lib/version-release.ts create mode 100644 apps/web/lib/work-activity-research-direction.test.ts create mode 100644 apps/web/stores/useXiaobaoWarningReadStore.ts diff --git a/apps/server/src/modules/data/data-keys.ts b/apps/server/src/modules/data/data-keys.ts index c5b56b9..7c92c5b 100644 --- a/apps/server/src/modules/data/data-keys.ts +++ b/apps/server/src/modules/data/data-keys.ts @@ -11,6 +11,7 @@ export const APP_DATA_KEYS = [ 'work-activities', 'xiaobao-risk-insights', 'xiaobao-risk-snapshots', + 'xiaobao-warning-views', 'overtime', ] as const; diff --git a/apps/web/app/overtime/page.tsx b/apps/web/app/overtime/page.tsx index 124ae0b..acca731 100644 --- a/apps/web/app/overtime/page.tsx +++ b/apps/web/app/overtime/page.tsx @@ -6,8 +6,9 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { useMemberStore } from '@/stores/useMemberStore'; import { flattenProjects, flattenVersions } from '@/lib/derive'; -import { calcDuration } from '@/lib/overtime'; +import { calcDuration, filterOvertimeRecordsForViewer } from '@/lib/overtime'; import type { OvertimeRecord } from '@/lib/overtime'; import { Pagination, usePagination } from '@/components/Pagination'; import { DictDrawer } from '@/components/requirement/DictDrawer'; @@ -29,7 +30,9 @@ function OvertimePageContent() { const { records, fetchRecords, createRecord, deleteRecord, reasons, addReason, updateReason, deleteReason } = useOvertimeStore(); const { overview, fetchOverview } = useProductStore(); const { requirements, fetchRequirements } = useRequirementStore(); + const { departments, members, roles, fetchMembers } = useMemberStore(); const user = useAuthStore((s) => s.user); + const viewerRole = useMemo(() => roles.find((role) => role.id === user?.roleId), [roles, user?.roleId]); const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); @@ -43,20 +46,27 @@ function OvertimePageContent() { useEffect(() => { fetchOverview(); }, [fetchOverview]); useEffect(() => { fetchRequirements(); }, [fetchRequirements]); useEffect(() => { fetchRecords(); }, [fetchRecords]); + useEffect(() => { fetchMembers(); }, [fetchMembers]); const projectName = (id: string) => allProjects.find((p) => p.id === id)?.name ?? '-'; const versionName = (id?: string) => id ? (allVersions.find((v) => v.id === id)?.name ?? '-') : '-'; const reasonName = (id: string) => reasons.find((r) => r.id === id)?.name ?? '-'; + const visibleRecords = useMemo(() => filterOvertimeRecordsForViewer(records, { + viewer: user, + viewerRole, + members, + departments, + }), [records, user, viewerRole, members, departments]); const filtered = useMemo(() => { - let list = [...records]; + let list = [...visibleRecords]; if (search) list = list.filter((r) => r.person.includes(search)); if (projectFilter !== 'all') list = list.filter((r) => r.projectId === projectFilter); if (reasonFilter !== 'all') list = list.filter((r) => r.reasonId === reasonFilter); if (monthFilter) list = list.filter((r) => r.startTime.slice(0, 7) === monthFilter); list.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()); return list; - }, [records, search, projectFilter, reasonFilter, monthFilter]); + }, [visibleRecords, search, projectFilter, reasonFilter, monthFilter]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); @@ -90,7 +100,7 @@ function OvertimePageContent() {

加班记录

- {records.length} + {visibleRecords.length}
@@ -441,10 +452,11 @@ export default function VersionDetailPage() { const isTerminalVersion = version.status === 'released' || version.status === 'closed'; const deadline = version.expectedReleaseDate; + const actualReleaseDate = version.releaseDate; const versionActualHours = calcActualElapsedHours(actualStartIso, isTerminalVersion ? actualEndIso : now.toISOString()); let overdueDays = 0; - if (deadline && isTerminalVersion && actualEndIso) { - const endDate = new Date(actualEndIso); + if (deadline && actualReleaseDate) { + const endDate = new Date(actualReleaseDate); const deadlineDate = new Date(deadline); endDate.setHours(0, 0, 0, 0); deadlineDate.setHours(0, 0, 0, 0); @@ -453,7 +465,8 @@ export default function VersionDetailPage() { const metrics = [ { label: '开始', value: actualStartIso ? formatOverviewDateTime(actualStartIso) : '未开始', icon: }, - { label: '预计截止', value: deadline ?? '未设置' }, + { label: '期望发版', value: deadline ? formatOverviewDateTime(deadline) : '未设置' }, + { label: '实际发版日期', value: actualReleaseDate ? formatOverviewDateTime(actualReleaseDate) : '未发布' }, { label: '实际截止', value: isTerminalVersion ? (actualEndIso ? formatOverviewDateTime(actualEndIso) : '未记录') : '未完成' }, { label: '实际耗时', value: formatActualDuration(versionActualHours), icon: }, { label: '人力总投入', value: formatActualDuration(effortTotals.actualHours), tone: 'accent' as const }, @@ -462,7 +475,7 @@ export default function VersionDetailPage() { return (
-
+
{metrics.map((item) => ( {overdueDays > 0 && (
- 实际截止晚于预计截止 {overdueDays} 天 + 实际发版晚于期望发版 {overdueDays} 天
)}
@@ -930,6 +943,18 @@ export default function VersionDetailPage() { /> )} + {showReleaseModal && ( + { + updateVersion(version.productId, version.id, { status: 'released', releaseDate }); + setShowReleaseModal(false); + }} + onClose={() => setShowReleaseModal(false)} + /> + )} + {/* 参与人员设置弹窗 */} {showMemberModal && ( void; + onClose: () => void; +}) { + const [releaseDate, setReleaseDate] = useState(initialDate); + const warning = getReleaseProgressWarning(progress); + const canSubmit = canSubmitReleaseForm(releaseDate); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!canSubmit) return; + onSubmit(releaseDate.trim()); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

发版

+

填写实际发版日期后,版本状态会更新为已发布。

+
+ +
+ +
+ {warning ? ( +
+ + {warning} +
+ ) : ( +
+ 当前版本进度 100%,可记录实际发版日期。 +
+ )} + + +
+ +
+ + +
+
+
+ ); +} + function OverviewMetric({ label, value, icon, sub, tone }: { label: string; value: string | number; diff --git a/apps/web/app/versions/page.tsx b/apps/web/app/versions/page.tsx index 1c91623..04144ea 100644 --- a/apps/web/app/versions/page.tsx +++ b/apps/web/app/versions/page.tsx @@ -20,6 +20,14 @@ import { buildVersionProgressMap } from '@/lib/version-progress'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import type { XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { getRiskScoreTone } from '@/lib/xiaobao-warning-view'; +import { + VERSION_DEVELOPMENT_TYPE_OPTIONS, + canSubmitNewVersionForm, + getRecommendedExpectedReleaseDate, + getRecommendedWorkDays, + getVersionDevelopmentTypeOption, + type VersionDevelopmentType, +} from '@/lib/version-form'; import { Pagination, usePagination } from '@/components/Pagination'; type Priority = 'P0' | 'P1' | 'P2' | 'P3' | 'P4'; @@ -462,7 +470,7 @@ type IterationType = 'major' | 'minor' | 'patch'; interface NewVersionModalProps { overview: any[]; onClose: () => void; - onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate?: string; members?: any[] }) => void; + onCreate: (productId: string, data: { name: string; status: VersionStatus; priority?: Priority; expectedReleaseDate: string; members?: any[] }) => void; currentUserName: string; } @@ -510,6 +518,8 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe const [projectName, setProjectName] = useState(''); const [iterationType, setIterationType] = useState(''); const [versionNumber, setVersionNumber] = useState(''); + const [developmentType, setDevelopmentType] = useState(''); + const [productDesignCompleted, setProductDesignCompleted] = useState(false); const [status] = useState('developing'); const [priority, setPriority] = useState('P2'); const [expectedReleaseDate, setExpectedReleaseDate] = useState(''); @@ -526,6 +536,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe setProjectName(''); setIterationType(''); setVersionNumber(''); + setDevelopmentType(''); + setProductDesignCompleted(false); + setExpectedReleaseDate(''); }; // When project changes @@ -535,6 +548,9 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe setProjectName(proj?.name ?? ''); setIterationType(''); setVersionNumber(''); + setDevelopmentType(''); + setProductDesignCompleted(false); + setExpectedReleaseDate(''); }; // When iteration type changes, auto-generate version number @@ -552,7 +568,33 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe setVersionNumber(cleaned); }; - const canSubmit = productId && projectId && versionNumber && !submitting; + const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => { + setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned })); + }; + + const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => { + setDevelopmentType(type); + applyRecommendedReleaseDate(type, productDesignCompleted); + }; + + const handleProductDesignCompletedChange = (completed: boolean) => { + setProductDesignCompleted(completed); + if (developmentType) applyRecommendedReleaseDate(developmentType, completed); + }; + + const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined; + const recommendedWorkDays = selectedDevelopmentType + ? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted }) + : 0; + + const canSubmit = canSubmitNewVersionForm({ + productId, + projectId, + versionNumber, + developmentType, + expectedReleaseDate, + submitting, + }); const handleSubmit = async () => { if (!canSubmit) return; @@ -562,7 +604,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe name: `${projectName}V${versionNumber}`, status, priority, - ...(expectedReleaseDate ? { expectedReleaseDate } : {}), + expectedReleaseDate, members: currentUserName ? [{ role: 'product', name: currentUserName }] : [], }); onClose(); @@ -573,7 +615,7 @@ function NewVersionModal({ overview, onClose, onCreate, currentUserName }: NewVe return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

新建版本

- {/* 截止日期 */} + {/* 项目开发类型 */}
- + +
+ {VERSION_DEVELOPMENT_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* 产品设计是否已完成 */} +
+ +
+ + +
+
+ + {/* 期望发版日期 */} +
+ setExpectedReleaseDate(e.target.value)} + required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] focus:border-[var(--accent)] focus:outline-none focus:ring-2 focus:ring-[var(--accent-ring)]" /> + {selectedDevelopmentType && ( +

+ 推荐周期 {recommendedWorkDays} 个工作日: + {selectedDevelopmentType.stages.map((stage, index) => { + const skipped = productDesignCompleted && stage.key === 'product_design'; + return ( + + {index > 0 && ' + '} + + {stage.label}{stage.workDays}天 + + + ); + })} +

+ )}
diff --git a/apps/web/app/xiaobao-warning/page.tsx b/apps/web/app/xiaobao-warning/page.tsx index 7092bd4..04e4fb0 100644 --- a/apps/web/app/xiaobao-warning/page.tsx +++ b/apps/web/app/xiaobao-warning/page.tsx @@ -6,11 +6,18 @@ import { CalendarClock, Loader2, ShieldCheck, Sparkles, TriangleAlert } from 'lu import { RouteGuard, useHasPermission } from '@/components/auth/Guard'; import { XiaobaoWarningCard } from '@/components/xiaobao-warning/XiaobaoWarningCard'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; +import { useAuthStore } from '@/stores/useAuthStore'; +import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore'; import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithCacheGate } from '@/lib/xiaobao-risk-ai'; import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend'; import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion'; -import { filterXiaobaoRiskWarnings, formatRemainingWork, sanitizeRiskInsight } from '@/lib/xiaobao-warning-view'; +import { + filterXiaobaoRiskWarnings, + formatRemainingWork, + isXiaobaoWarningUpdated, + sanitizeRiskInsight, +} from '@/lib/xiaobao-warning-view'; import { formatDateTime } from '@/lib/format'; const RISK_LEVEL_LABEL: Record = { @@ -42,6 +49,7 @@ export default function XiaobaoWarningPage() { function XiaobaoWarningContent() { const router = useRouter(); const canManage = useHasPermission('xiaobao.warning:manage'); + const user = useAuthStore((s) => s.user); const { risks, snapshots, @@ -54,12 +62,17 @@ function XiaobaoWarningContent() { finishInsightUpdate, today, } = useXiaobaoWarningRisks({ loadRiskCache: true }); + const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore(); const [selectedRiskId, setSelectedRiskId] = useState(null); const [selectedProductId, setSelectedProductId] = useState(''); const [selectedProjectId, setSelectedProjectId] = useState(''); const savedSnapshotKeysRef = useRef(new Set()); const requestedInsightKeysRef = useRef(new Set()); + useEffect(() => { + if (user?.id) fetchReadStates(); + }, [fetchReadStates, user?.id]); + useEffect(() => { risks.forEach((risk) => { const snapshot = { ...risk.currentSnapshot, createdAt: new Date().toISOString() }; @@ -139,6 +152,15 @@ function XiaobaoWarningContent() { projectId: selectedProjectId || undefined, }), [risksWithInsight, selectedProductId, selectedProjectId]); + const updatedRiskIds = useMemo(() => { + if (!user?.id || !readStateLoaded) return new Set(); + return new Set( + filteredRisks + .filter((risk) => isXiaobaoWarningUpdated(risk, readStates, user.id)) + .map((risk) => risk.versionId), + ); + }, [filteredRisks, readStateLoaded, readStates, user?.id]); + useEffect(() => { if (filteredRisks.length === 0) { setSelectedRiskId(null); @@ -156,6 +178,12 @@ function XiaobaoWarningContent() { ? Math.round(filteredRisks.reduce((sum, risk) => sum + risk.confidence, 0) / filteredRisks.length) : 0; + const selectRisk = (risk: XiaobaoVersionRisk) => { + setSelectedRiskId(risk.versionId); + if (!user?.id) return; + markRiskRead(user.id, risk).catch(() => {}); + }; + return (
@@ -224,7 +252,8 @@ function XiaobaoWarningContent() { key={risk.versionId} active={risk.versionId === selectedRiskId} risk={risk} - onClick={() => setSelectedRiskId(risk.versionId)} + updated={updatedRiskIds.has(risk.versionId)} + onClick={() => selectRisk(risk)} /> ))}
diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index d0fbb7c..6fdb07d 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; @@ -7,8 +8,15 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; +import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; +import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore'; import { getWorkspacePendingCount } from '@/lib/workspace-engine'; -import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view'; +import { buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion'; +import { + filterXiaobaoRiskWarnings, + getXiaobaoWarningRiskCount, + getXiaobaoWarningUnreadUpdateCount, +} from '@/lib/xiaobao-warning-view'; type NavItemConfig = { label: string; @@ -180,17 +188,31 @@ function NavItem({ item, active, onNavigate, workspacePendingCount }: { function XiaobaoRiskNavBadge() { const { risks } = useXiaobaoWarningRisks(); + const user = useAuthStore((s) => s.user); + const pendingInsightKeys = useXiaobaoRiskStore((s) => s.pendingInsightKeys); + const { readStates, readStateLoaded, fetchReadStates } = useXiaobaoWarningReadStore(); + useEffect(() => { + if (user?.id) fetchReadStates(); + }, [fetchReadStates, user?.id]); + const count = getXiaobaoWarningRiskCount(risks); if (count <= 0) return null; - return ; + const warningRisks = filterXiaobaoRiskWarnings(risks).map((risk) => ( + pendingInsightKeys.includes(buildXiaobaoRiskInsightPendingKey(risk)) + ? { ...risk, aiInsightUpdating: true } + : risk + )); + const unreadUpdateCount = readStateLoaded ? getXiaobaoWarningUnreadUpdateCount(warningRisks, readStates, user?.id) : 0; + return 0 ? 'update' : 'risk'} />; } -function NavCountBadge({ count }: { count: number }) { +function NavCountBadge({ count, tone = 'risk' }: { count: number; tone?: 'risk' | 'update' }) { if (count <= 0) return null; + const toneClass = tone === 'update' ? 'bg-blue-600' : 'bg-red-600'; return ( - + {count > 99 ? '99+' : count} ); diff --git a/apps/web/components/version/PlanDetailDrawer.tsx b/apps/web/components/version/PlanDetailDrawer.tsx index e95ae10..33b4135 100644 --- a/apps/web/components/version/PlanDetailDrawer.tsx +++ b/apps/web/components/version/PlanDetailDrawer.tsx @@ -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) => { const file = e.target.files?.[0]; if (!file) return; @@ -214,43 +207,35 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel }: Props) {
- {/* Research Tasks */} - {isResearch && plan.tasks && plan.tasks.length > 0 && ( -
-
任务清单
- {plan.tasks.map((task) => ( -
- - {task.title} -
- ))} -
+ {plan.type === 'research' && ( + )} {linkedReqs.length > 0 && (
- + {plan.type === 'research' ? ( + + ) : ( + + )} {plan.status === 'in_progress' && !completionState.canSubmitResult && (

还不能提交成果:{completionState.missingReasons.join('、')}

)}
)} - {!isResearch && ( - - )} + {/* Result */} {plan.status === 'completed' && plan.resultUrl && ( diff --git a/apps/web/components/version/PlanRequirementCoveragePanel.tsx b/apps/web/components/version/PlanRequirementCoveragePanel.tsx index 305674f..9c1e3de 100644 --- a/apps/web/components/version/PlanRequirementCoveragePanel.tsx +++ b/apps/web/components/version/PlanRequirementCoveragePanel.tsx @@ -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) => void; } +interface DirectionProps { + plan: VersionPlan; + canEdit: boolean; + currentUserName: string; + onUpdate: (id: string, data: Partial) => void; +} + +interface LinkedRequirementReferenceProps { + requirements: RequirementOption[]; +} + interface LogTimelineProps { logs?: Array; 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(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) => { + 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 ( +
+
+
+
调研方向
+
+ 完全完成 {summary.completed} / {summary.total} + {summary.partial > 0 && 部分完成 {summary.partial}} +
+
+ {summary.percent}% +
+
+
+
+
+
+
+ {tasks.map((task) => { + const status = getResearchDirectionStatus(task); + const isEditing = editingTaskId === task.id; + const canOpenRecord = canOpenRequirementCoverageRecord(status, canEdit); + return ( +
+
+ + {REQUIREMENT_COVERAGE_LABEL[status]} + +
+
{task.title}
+ {(task.completedContent || task.remainingContent) && ( +
+ {task.completedContent &&
已完成:{task.completedContent}
} + {task.remainingContent &&
剩余:{task.remainingContent}
} +
+ )} +
+ {canEdit && (canOpenRecord || isEditing) && ( +
+ +
+ )} +
+ {isEditing && ( +
+