diff --git a/apps/web/app/projects/[id]/page.tsx b/apps/web/app/projects/[id]/page.tsx index 68c8a42..f0ebf9e 100644 --- a/apps/web/app/projects/[id]/page.tsx +++ b/apps/web/app/projects/[id]/page.tsx @@ -15,11 +15,13 @@ import { useMemberStore } from '@/stores/useMemberStore'; import { getProjectDetail, VersionWithContext } from '@/lib/derive'; import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage'; import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/version-status'; -import { STATUS_PROGRESS, calcGroupProgress as calcDevTaskProgress, getEstimateHours, aggregateDevTaskHours } from '@/lib/dev-task'; +import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task'; import { CapsuleStages } from '@/components/version/CapsuleStages'; import { MemberChips } from '@/components/version/MemberChips'; import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan'; import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview'; +import { calcScopedVersionProgress } from '@/lib/version-progress'; +import { buildVersionDataScopeMap, type VersionDataScope } from '@/lib/version-data-scope'; import { formatActualDuration } from '@/lib/work-hours'; import type { DevTask } from '@/lib/dev-task'; import type { TestCase } from '@/lib/test-case'; @@ -73,14 +75,10 @@ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number } /* ─── VersionCard ─── */ -function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requirements, onNavigate }: { +function VersionCard({ version, progress, scope, onNavigate }: { version: VersionWithContext; progress: number; - plans: VersionPlan[]; - devTasks: DevTask[]; - testCases: TestCase[]; - bugs: Bug[]; - requirements: { id: string; versionId?: string }[]; + scope: VersionDataScope; onNavigate: (id: string) => void; }) { const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status)); @@ -89,15 +87,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ setExpanded(getVersionCardDefaultExpanded(version.status)); }, [version.status]); - const versionData = useMemo(() => { - const vPlans = plans.filter((p) => p.versionId === version.id); - const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id)); - const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId)); - const vTCs = testCases.filter((c) => c.versionId === version.id); - const vBugs = bugs.filter((b) => b.versionId === version.id); - - return { vPlans, vDevTasks, vTCs, vBugs }; - }, [version.id, plans, devTasks, testCases, bugs, requirements]); + const versionData = useMemo(() => ({ + vPlans: scope.plans, + vDevTasks: scope.devTasks, + vTCs: scope.testCases, + vBugs: scope.bugs, + }), [scope]); const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({ plans: versionData.vPlans, @@ -373,85 +368,39 @@ export default function ProjectDetailPage() { }, [project, statusFilter, isSuperAdmin, currentUserName]); // Compute actual overall progress per version - const versionProgressMap = useMemo(() => { - if (!project) return {} as Record; - const map: Record = {}; - for (const v of project.versions) { - const vPlans = plans.filter((p) => p.versionId === v.id); - const vReqs = requirements.filter((r) => r.versionId === v.id); - const vReqIds = new Set(vReqs.map((r) => r.id)); - const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId)); - const vTestCases = testCases.filter((c) => c.versionId === v.id); + const versionScopeMap = useMemo( + () => project ? buildVersionDataScopeMap({ + versionIds: project.versions.map((version) => version.id), + plans, + requirements, + devTasks, + testCases, + bugs, + }) : {}, + [project, plans, requirements, devTasks, testCases, bugs], + ); - const segments: number[] = []; - - const researchPlans = vPlans.filter((p) => p.type === 'research'); - if (researchPlans.length > 0) { - const totals = researchPlans.reduce((acc, p) => { - const tasks = p.tasks || []; - acc.total += tasks.length; - acc.done += tasks.filter((t) => t.status === 'completed').length; - return acc; - }, { total: 0, done: 0 }); - segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0); - } - - const productPlans = vPlans.filter((p) => p.type === 'product'); - if (productPlans.length > 0) { - const totals = productPlans.reduce((acc, p) => { - const summary = getRequirementCoverageSummary(p); - acc.total += summary.total; - acc.done += summary.completed; - return acc; - }, { total: 0, done: 0 }); - segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0); - } - - const uiPlans = vPlans.filter((p) => p.type === 'ui'); - if (uiPlans.length > 0) { - const totals = uiPlans.reduce((acc, p) => { - const summary = getRequirementCoverageSummary(p); - acc.total += summary.total; - acc.done += summary.completed; - return acc; - }, { total: 0, done: 0 }); - segments.push(totals.total > 0 ? (totals.done / totals.total) * 100 : 0); - } - - if (vDevTasks.length > 0) { - const totalEstimate = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t), 0); - let devProgress: number; - if (totalEstimate === 0) { - devProgress = vDevTasks.reduce((sum, t) => sum + STATUS_PROGRESS[t.status], 0) / vDevTasks.length; - } else { - const weighted = vDevTasks.reduce((sum, t) => sum + getEstimateHours(t) * STATUS_PROGRESS[t.status], 0); - devProgress = weighted / totalEstimate; - } - segments.push(devProgress); - } - - if (vTestCases.length > 0) { - const executed = vTestCases.filter((c) => c.status === 'passed' || c.status === 'failed' || c.status === 'blocked').length; - segments.push((executed / vTestCases.length) * 100); - } - - map[v.id] = segments.length > 0 ? Math.round(segments.reduce((s, x) => s + x, 0) / segments.length) : 0; - } - return map; - }, [project, plans, requirements, devTasks, testCases]); + const versionProgressMap = useMemo( + () => Object.fromEntries( + Object.entries(versionScopeMap).map(([id, scope]) => [ + id, + calcScopedVersionProgress(scope.plans, scope.devTasks, scope.testCases), + ]), + ), + [versionScopeMap], + ); const stats = useMemo(() => { if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 }; const total = project.versions.length; const released = project.versions.filter((v) => v.status === 'released').length; const reqCount = requirements.filter((r) => r.projectId === projectId).length; - const versionIds = new Set(project.versions.map((v) => v.id)); - const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length; - const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id)); - const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId)); + const versionScopes = Object.values(versionScopeMap); + const bugCount = versionScopes.reduce((sum, scope) => sum + scope.bugs.length, 0); + const projectDevTasks = versionScopes.flatMap((scope) => scope.devTasks); const { estimate, actual } = aggregateDevTaskHours(projectDevTasks); return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual }; - }, [project, requirements, bugs, devTasks, projectId]); + }, [project, requirements, projectId, versionScopeMap]); const teamByRole = useMemo(() => { if (!project) return {} as Record>; @@ -524,7 +473,7 @@ export default function ProjectDetailPage() { {sortedVersions.length === 0 ? (
暂无版本
) : ( - sortedVersions.map((v) => router.push(`/versions/${id}`)} />) + sortedVersions.map((v) => router.push(`/versions/${id}`)} />) )} diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index ef369aa..9014e21 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -10,11 +10,15 @@ import { useAuthStore } from '@/stores/useAuthStore'; import { flattenProjects, flattenVersions } from '@/lib/derive'; import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement'; import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement'; -import { deriveReqDevStatus, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine'; +import { buildRequirementDevStatusMap, canEditRequirement, canCloseRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine'; import { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope'; import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort'; import { REQUIREMENT_TABLE_BADGE_CLASS, + REQUIREMENT_TABLE_CLASS, + REQUIREMENT_TABLE_COLUMN_WIDTHS, + REQUIREMENT_TABLE_CONTAINER_CLASS, + REQUIREMENT_TABLE_HEADER_CELL_CLASS, REQUIREMENT_TABLE_NOWRAP_CELL_CLASS, REQUIREMENT_TABLE_SMALL_BADGE_CLASS, getRequirementTableTextClass, @@ -178,6 +182,17 @@ function RequirementsPageContent() { const currentUserName = user?.name || '系统'; const allProjects = useMemo(() => flattenProjects(overview), [overview]); const allVersions = useMemo(() => flattenVersions(overview), [overview]); + const requirementDevStatusMap = useMemo(() => buildRequirementDevStatusMap(devTasks), [devTasks]); + const devTasksByRequirement = useMemo(() => { + const map = new Map(); + for (const task of devTasks) { + if (!task.requirementId) continue; + const items = map.get(task.requirementId) ?? []; + items.push(task); + map.set(task.requirementId, items); + } + return map; + }, [devTasks]); const [selectedScope, setSelectedScope] = useState({ type: 'all' }); const [scopeSearch, setScopeSearch] = useState(''); @@ -261,7 +276,7 @@ function RequirementsPageContent() { // status filter if (statusFilter !== 'all') { list = statusFilter === 'dev_completed' - ? list.filter((r) => deriveReqDevStatus(r.id, devTasks) === 'completed') + ? list.filter((r) => requirementDevStatusMap.get(r.id) === 'completed') : list.filter((r) => r.status === statusFilter); } @@ -281,7 +296,7 @@ function RequirementsPageContent() { list = sortRequirementsByCreatedAt(list, dateSort); return list; - }, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, devTasks]); + }, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]); const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20); @@ -450,22 +465,27 @@ function RequirementsPageContent() { ) : ( <> -
- +
+
+ + {REQUIREMENT_TABLE_COLUMN_WIDTHS.map((width, index) => ( + + ))} + - - - - - - - - - - + + + + + + + + + + - + @@ -545,13 +565,20 @@ function RequirementsPageContent() { {/* 所属版本 */} {/* 开发状态 */} {/* 操作 */} -
需求编号需求概述需求来源所属项目需求类型业务状态优先级所属版本实际进度录入人员需求编号需求概述需求来源所属项目需求类型业务状态优先级所属版本实际进度录入人员 setDateSort(dateSort === 'desc' ? 'asc' : 'desc')} > @@ -473,7 +493,7 @@ function RequirementsPageContent() { {dateSort === 'desc' ? : } 操作操作
- {resolveVersionName(req.versionId)} + {(() => { + const versionName = resolveVersionName(req.versionId); + return ( + + {versionName} + + ); + })()} {(() => { - const devStatus = deriveReqDevStatus(req.id, devTasks); + const devStatus = requirementDevStatusMap.get(req.id) ?? 'unscheduled'; return ( {REQ_DEV_STATUS_LABEL[devStatus]} @@ -573,13 +600,13 @@ function RequirementsPageContent() { e.stopPropagation()}> -
+
e.stopPropagation()}> +
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */} - {(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasks) && ( + {(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && ( @@ -589,23 +616,23 @@ function RequirementsPageContent() { <> )} {/* 已采纳/已规划:关闭 */} - {(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && ( + {(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && ( @@ -614,7 +641,7 @@ function RequirementsPageContent() { {(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && ( @@ -644,10 +671,10 @@ function RequirementsPageContent() { requirements={requirements} resolveVersionName={resolveVersionName} onClose={() => setViewingReq(null)} - onEdit={canEditRequirement(viewingReq.id, devTasks) ? () => handleEdit(viewingReq) : undefined} + onEdit={canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined} onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }} onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }} - onCloseReq={canCloseRequirement(viewingReq.id, devTasks) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined} + onCloseReq={canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined} onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }} /> )} diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index ddce690..e79eb39 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -34,7 +34,8 @@ import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetri import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members'; import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation'; import { getRequirementCoverageSummary } from '@/lib/version-plan'; -import { buildVersionProgressMap } from '@/lib/version-progress'; +import { buildVersionDataScope } from '@/lib/version-data-scope'; +import { calcScopedVersionProgress } from '@/lib/version-progress'; import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release'; import { isVersionReadonly } from '@/lib/version-status'; import { @@ -139,21 +140,33 @@ export default function VersionDetailPage() { }, [fetchMembers, fetchCategories]); const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]); - const releaseProgressMap = useMemo>( - () => version ? buildVersionProgressMap([version], plans, requirements, devTasks, testCases) : {}, - [version, plans, requirements, devTasks, testCases], + const versionScope = useMemo( + () => version + ? buildVersionDataScope({ + versionId: version.id, + requirements, + plans, + devTasks, + testCases, + bugs, + overtimeRecords: records, + }) + : null, + [version, requirements, plans, devTasks, testCases, bugs, records], ); - const releaseProgress = version ? (releaseProgressMap[version.id] ?? 0) : 0; + const releaseProgress = versionScope + ? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases) + : 0; const versionReadonly = version ? isVersionReadonly(version.status) : false; // 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段 useEffect(() => { - if (!version || !plans.length) return; + if (!version || !versionScope || !versionScope.plans.length) return; if (version.status !== 'planned' && version.status !== 'developing') return; const today = new Date().toISOString().slice(0, 10); const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const; const stageOrder: string[] = ['requirement', 'product_design', 'ui_design']; - const versionPlans = plans.filter((p) => p.versionId === versionId && p.startTime <= today); + const versionPlans = versionScope.plans.filter((p) => p.startTime <= today); if (versionPlans.length === 0) return; let targetStage = ''; for (const p of versionPlans) { @@ -165,7 +178,7 @@ export default function VersionDetailPage() { if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) { updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any }); } - }, [plans, version, versionId, updateVersion]); + }, [versionScope, version, updateVersion]); if (!version) { return ( @@ -189,36 +202,36 @@ export default function VersionDetailPage() { ); } - const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id); - const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id)); - const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId)); - const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id); - const currentSystemParticipation = new Map(); - overview.forEach((product) => { - product.versions.forEach((item) => { - const uniqueNames = new Set((item.members ?? []).map((member) => member.name)); - uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1)); + const scopedVersionData = versionScope!; + const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => { + const currentSystemParticipation = new Map(); + overview.forEach((product) => { + product.versions.forEach((item) => { + const uniqueNames = new Set((item.members ?? []).map((member) => member.name)); + uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1)); + }); }); - }); - const memberRecommendationGroups = recommendVersionMembers({ - candidates: memberCandidates, - currentMembers: version.members ?? [], - devTasks, - testCases, - bugs, - overtimeRecords: records, - versionDeadline: version.expectedReleaseDate, - scopeRequirements: recommendationVersionReqs, - scopeDevTasks: recommendationVersionDevTasks, - scopeTestCases: recommendationVersionTestCases, - taskCategories, - historicalStats: memberCandidates.flatMap((member) => { - const projectParticipationCount = currentSystemParticipation.get(member.name); - return typeof projectParticipationCount === 'number' - ? [{ name: member.name, projectParticipationCount }] - : []; - }), - }); + + return recommendVersionMembers({ + candidates: memberCandidates, + currentMembers: version.members ?? [], + devTasks, + testCases, + bugs, + overtimeRecords: records, + versionDeadline: version.expectedReleaseDate, + scopeRequirements: scopedVersionData.requirements, + scopeDevTasks: scopedVersionData.devTasks, + scopeTestCases: scopedVersionData.testCases, + taskCategories, + historicalStats: memberCandidates.flatMap((member) => { + const projectParticipationCount = currentSystemParticipation.get(member.name); + return typeof projectParticipationCount === 'number' + ? [{ name: member.name, projectParticipationCount }] + : []; + }), + }); + })() : []; const renderActions = () => { const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = []; @@ -231,15 +244,14 @@ export default function VersionDetailPage() { buttons.push({ label: '删除', action: () => { if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) { // 释放关联需求 - requirements.filter((r) => r.versionId === version.id).forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r))); + scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r))); // 清理计划任务 - plans.filter((p) => p.versionId === version.id).forEach((p) => deletePlan(p.id)); + scopedVersionData.plans.forEach((p) => deletePlan(p.id)); // 清理开发任务 - const versionReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id)); - devTasks.filter((t) => versionReqIds.has(t.requirementId)).forEach((t) => deleteDevTask(t.id)); + scopedVersionData.devTasks.forEach((t) => deleteDevTask(t.id)); // 清理测试用例和Bug - testCases.filter((c) => c.versionId === version.id).forEach((c) => deleteTestCase(c.id)); - bugs.filter((b) => b.versionId === version.id).forEach((b) => deleteBug(b.id)); + scopedVersionData.testCases.forEach((c) => deleteTestCase(c.id)); + scopedVersionData.bugs.forEach((b) => deleteBug(b.id)); deleteVersion(version.productId, version.id); router.push('/versions'); } @@ -304,13 +316,12 @@ export default function VersionDetailPage() { {activeTab === 'overview' ? ( (() => { const now = new Date(); - const versionReqs = requirements.filter((r) => r.versionId === version.id); - const versionReqIds = new Set(versionReqs.map((r) => r.id)); - const versionPlans = plans.filter((p) => p.versionId === version.id); - const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId)); - const versionTCs = testCases.filter((c) => c.versionId === version.id); - const versionBugs = bugs.filter((b) => b.versionId === version.id); - const versionOT = records.filter((r) => r.versionId === version.id); + const versionReqs = scopedVersionData.requirements; + const versionPlans = scopedVersionData.plans; + const versionDevTasks = scopedVersionData.devTasks; + const versionTCs = scopedVersionData.testCases; + const versionBugs = scopedVersionData.bugs; + const versionOT = scopedVersionData.overtimeRecords; const stageEffortMetrics = calcStageEffortMetrics({ plans: versionPlans, devTasks: versionDevTasks, @@ -663,7 +674,7 @@ export default function VersionDetailPage() { {/* 6. 需求变更统计 */} {(() => { - const changeReqs = requirements.filter((r) => r.versionId === version.id && r.reqType === 'change'); + const changeReqs = versionReqs.filter((r) => r.reqType === 'change'); if (changeReqs.length === 0) return null; // 变更人员排名 @@ -900,7 +911,7 @@ export default function VersionDetailPage() { const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId); return ( { - const versionReqs = requirements.filter((r) => r.versionId === version.id); - return ( - r.id)} - versionDeadline={version.expectedReleaseDate ?? undefined} - readOnly={versionReadonly} - /> - ); - })() + ) : activeTab === 'testcases' ? ( - (() => { - const versionReqs = requirements.filter((r) => r.versionId === version.id); - return r.id)} readOnly={versionReadonly} />; - })() + ) : activeTab === 'bugs' ? ( - (() => { - const versionReqs = requirements.filter((r) => r.versionId === version.id); - return r.id)} readOnly={versionReadonly} />; - })() + ) : (
功能开发中,敬请期待 diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index be6863e..672fc4a 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -13,7 +13,7 @@ import { useAuthStore } from '@/stores/useAuthStore'; import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { flattenVersions } from '@/lib/derive'; -import { aggregateWorkItems, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine'; +import { aggregateWorkItems, getWorkspacePendingCountByVersion, WORK_ITEM_TYPE_LABEL } from '@/lib/workspace-engine'; import type { WorkItem, WorkItemType } from '@/lib/workspace-engine'; import { DailyReportPanel } from '@/components/workspace/DailyReportPanel'; import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer'; @@ -96,6 +96,7 @@ export default function WorkspacePage() { aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap), [userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap] ); + const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]); // 构建树:只显示跟自己有关的产品/项目/版本 const tree = useMemo(() => { @@ -108,14 +109,14 @@ export default function WorkspacePage() { const prod = productMap.get(v.productName)!; if (!prod.projects.has(v.projectName)) prod.projects.set(v.projectName, { name: v.projectName, versions: [] }); const proj = prod.projects.get(v.projectName)!; - const pending = workItems.filter((i) => i.versionId === v.id && !i.completed).length; + const pending = pendingCountByVersion.get(v.id) ?? 0; if (!proj.versions.find((ver) => ver.id === v.id)) { proj.versions.push({ id: v.id, name: v.name, status: v.status, pendingCount: pending }); } }); return productMap; - }, [allVersions, workItems]); + }, [allVersions, pendingCountByVersion, workItems]); const filteredItems = useMemo(() => { let items = workItems; diff --git a/apps/web/components/bug/BugTab.tsx b/apps/web/components/bug/BugTab.tsx index 5a2b032..66b6780 100644 --- a/apps/web/components/bug/BugTab.tsx +++ b/apps/web/components/bug/BugTab.tsx @@ -17,28 +17,36 @@ import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugInter import { calcTwoMetrics } from '@/lib/work-hours'; import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system'; import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary'; -import type { BugStatus, BugSeverity } from '@/lib/bug'; +import type { Bug, BugStatus, BugSeverity } from '@/lib/bug'; +import type { TestCase } from '@/lib/test-case'; interface Props { versionId: string; requirementIds: string[]; readOnly?: boolean; + versionBugs?: Bug[]; + versionTestCases?: TestCase[]; } -export function BugTab({ versionId, requirementIds, readOnly = false }: Props) { +export function BugTab({ versionId, requirementIds, readOnly = false, versionBugs: scopedVersionBugs, versionTestCases }: Props) { const { bugs, fetchBugs } = useBugStore(); const { testCases, fetchTestCases } = useTestCaseStore(); const { requirements } = useRequirementStore(); const user = useAuthStore((s) => s.user); const members = useMemberStore((s) => s.members); - useEffect(() => { fetchBugs(); }, [fetchBugs]); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); + useEffect(() => { + if (!scopedVersionBugs) fetchBugs(); + }, [fetchBugs, scopedVersionBugs]); + useEffect(() => { + if (!versionTestCases) fetchTestCases(); + }, [fetchTestCases, versionTestCases]); - const versionBugs = useMemo( + const fallbackVersionBugs = useMemo( () => bugs.filter((b) => b.versionId === versionId), [bugs, versionId], ); + const versionBugs = scopedVersionBugs ?? fallbackVersionBugs; const openCount = versionBugs.filter((b) => b.status === 'open').length; const fixingCount = versionBugs.filter((b) => b.status === 'fixing').length; @@ -66,7 +74,8 @@ export function BugTab({ versionId, requirementIds, readOnly = false }: Props) { const [selectedBugId, setSelectedBugId] = useState(null); - const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]); + const visibleTestCases = versionTestCases ?? testCases; + const testCaseMap = useMemo(() => new Map(visibleTestCases.map((c) => [c.id, c])), [visibleTestCases]); const currentMember = useMemo(() => members.find((m) => m.id === user?.id), [members, user?.id]); const currentUserName = currentMember?.name ?? user?.name; diff --git a/apps/web/components/dev-task/DevTaskTab.tsx b/apps/web/components/dev-task/DevTaskTab.tsx index e3a068f..8cfb5b9 100644 --- a/apps/web/components/dev-task/DevTaskTab.tsx +++ b/apps/web/components/dev-task/DevTaskTab.tsx @@ -4,7 +4,6 @@ import { useState, useMemo, useEffect } from 'react'; import { Plus, Code2, Trash2 } from 'lucide-react'; import { useDevTaskStore } from '@/stores/useDevTaskStore'; import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; -import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; @@ -20,12 +19,15 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { orderByRequirementForGrouping } from '@/lib/requirement-grouping'; import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary'; import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; +import type { Requirement } from '@/lib/requirement'; interface Props { versionId: string; requirementIds: string[]; versionDeadline?: string; readOnly?: boolean; + versionTasks?: DevTask[]; + versionRequirements?: Requirement[]; } const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__'; @@ -38,23 +40,24 @@ function getNoRequirementGroupName(groupKey: string): string { return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求'; } -export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false }: Props) { +export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnly = false, versionTasks: scopedVersionTasks, versionRequirements }: Props) { const { tasks, fetchTasks, deleteTask } = useDevTaskStore(); const { categories, fetchCategories } = useTaskCategoryStore(); - const { fetchWorklogs } = useTaskWorklogStore(); const { requirements } = useRequirementStore(); const { members } = useMemberStore(); const user = useAuthStore((s) => s.user); - useEffect(() => { fetchTasks(); }, [fetchTasks]); + useEffect(() => { + if (!scopedVersionTasks) fetchTasks(); + }, [fetchTasks, scopedVersionTasks]); useEffect(() => { fetchCategories(); }, [fetchCategories]); - useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]); const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); - const versionTasks = useMemo( + const fallbackVersionTasks = useMemo( () => tasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))), [tasks, reqIdSet, versionId], ); + const versionTasks = scopedVersionTasks ?? fallbackVersionTasks; const [filterAssignee, setFilterAssignee] = useState(''); const [filterStatus, setFilterStatus] = useState(''); @@ -130,7 +133,8 @@ export function DevTaskTab({ versionId, requirementIds, versionDeadline, readOnl () => Math.max(4, ...categories.map((category) => category.name.length)) + 1, [categories], ); - const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); + const visibleRequirements = versionRequirements ?? requirements; + const requirementMap = useMemo(() => new Map(visibleRequirements.map((r) => [r.id, r])), [visibleRequirements]); const allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]); const assignees = useMemo(() => Array.from(new Set(versionTasks.map((t) => t.assigneeId))), [versionTasks]); diff --git a/apps/web/components/test-case/TestCaseTab.tsx b/apps/web/components/test-case/TestCaseTab.tsx index b327716..150a6cf 100644 --- a/apps/web/components/test-case/TestCaseTab.tsx +++ b/apps/web/components/test-case/TestCaseTab.tsx @@ -20,12 +20,20 @@ import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { orderByRequirementForGrouping } from '@/lib/requirement-grouping'; import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary'; +import type { Bug } from '@/lib/bug'; +import type { DevTask } from '@/lib/dev-task'; +import type { Requirement } from '@/lib/requirement'; +import type { TestCase } from '@/lib/test-case'; import type { TestCaseStatus } from '@/lib/test-case'; interface Props { versionId: string; requirementIds: string[]; readOnly?: boolean; + versionCases?: TestCase[]; + versionBugs?: Bug[]; + versionDevTasks?: DevTask[]; + versionRequirements?: Requirement[]; } const NO_REQUIREMENT_GROUP_PREFIX = '__no_requirement__'; @@ -38,7 +46,15 @@ function getNoRequirementGroupName(groupKey: string): string { return groupKey.slice(NO_REQUIREMENT_GROUP_PREFIX.length) || '未命名需求'; } -export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Props) { +export function TestCaseTab({ + versionId, + requirementIds, + readOnly = false, + versionCases: scopedVersionCases, + versionBugs: scopedVersionBugs, + versionDevTasks: scopedVersionDevTasks, + versionRequirements, +}: Props) { const { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore(); const { bugs, fetchBugs } = useBugStore(); const { tasks: devTasks } = useDevTaskStore(); @@ -46,15 +62,20 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro const user = useAuthStore((s) => s.user); const { categories, fetchCategories } = useTaskCategoryStore(); - useEffect(() => { fetchTestCases(); }, [fetchTestCases]); - useEffect(() => { fetchBugs(); }, [fetchBugs]); + useEffect(() => { + if (!scopedVersionCases) fetchTestCases(); + }, [fetchTestCases, scopedVersionCases]); + useEffect(() => { + if (!scopedVersionBugs) fetchBugs(); + }, [fetchBugs, scopedVersionBugs]); useEffect(() => { fetchCategories(); }, [fetchCategories]); const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]); - const versionCases = useMemo( + const fallbackVersionCases = useMemo( () => testCases.filter((c) => c.versionId === versionId), [testCases, versionId], ); + const versionCases = scopedVersionCases ?? fallbackVersionCases; const rounds = useMemo( () => Array.from(new Set(versionCases.map((testCase) => getTestCaseRoundNo(testCase)))).sort((a, b) => a - b), [versionCases], @@ -75,15 +96,17 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro () => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === 1), [versionCases], ); - const versionBugs = useMemo( + const fallbackVersionBugs = useMemo( () => bugs.filter((bug) => bug.versionId === versionId), [bugs, versionId], ); + const versionBugs = scopedVersionBugs ?? fallbackVersionBugs; - const versionDevTasks = useMemo( + const fallbackVersionDevTasks = useMemo( () => devTasks.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))), [devTasks, reqIdSet, versionId], ); + const versionDevTasks = scopedVersionDevTasks ?? fallbackVersionDevTasks; const stats = calcTestProgress(activeRoundCases, versionBugs); const { actual: tcActualHours } = aggregateTestCaseHours(versionCases); @@ -168,7 +191,8 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro return map; }, [paged]); - const requirementMap = useMemo(() => new Map(requirements.map((r) => [r.id, r])), [requirements]); + const visibleRequirements = versionRequirements ?? requirements; + const requirementMap = useMemo(() => new Map(visibleRequirements.map((r) => [r.id, r])), [visibleRequirements]); const categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]); const categoryLabelWidthEm = useMemo( () => Math.max(4, ...categories.map((category) => category.name.length)) + 1, diff --git a/apps/web/hooks/useXiaobaoWarningRisks.ts b/apps/web/hooks/useXiaobaoWarningRisks.ts index d9cb0af..9ea62d8 100644 --- a/apps/web/hooks/useXiaobaoWarningRisks.ts +++ b/apps/web/hooks/useXiaobaoWarningRisks.ts @@ -13,6 +13,7 @@ import { useVersionPlanStore } from '@/stores/useVersionPlanStore'; import { useWorkActivityStore } from '@/stores/useWorkActivityStore'; import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore'; import { flattenVersions } from '@/lib/derive'; +import { buildVersionDataScopeMap } from '@/lib/version-data-scope'; import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk'; import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence'; import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view'; @@ -61,31 +62,40 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach [allVersions, canManage, user?.name], ); - const requirementVersionMap = useMemo(() => { - const map = new Map(); - requirements.forEach((requirement) => { - if (requirement.versionId) map.set(requirement.id, requirement.versionId); - }); - return map; - }, [requirements]); - - const workItems = useMemo(() => buildXiaobaoWorkItems({ + const visibleVersionScopeMap = useMemo(() => buildVersionDataScopeMap({ + versionIds: visibleVersions.map((version) => version.id), plans, + requirements, devTasks, testCases, bugs, - versions: allVersions.map((version) => ({ - id: version.id, - name: version.name, - productName: version.productName, - projectName: version.projectName, - })), - requirementVersionMap, - }), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]); + }), [bugs, devTasks, plans, requirements, testCases, visibleVersions]); + + const workItemsByVersion = useMemo(() => { + const map = new Map>(); + for (const version of visibleVersions) { + const scope = visibleVersionScopeMap[version.id]; + if (!scope) continue; + map.set(version.id, buildXiaobaoWorkItems({ + plans: scope.plans, + devTasks: scope.devTasks, + testCases: scope.testCases, + bugs: scope.bugs, + versions: [{ + id: version.id, + name: version.name, + productName: version.productName, + projectName: version.projectName, + }], + requirementVersionMap: new Map(scope.requirements.map((requirement) => [requirement.id, version.id])), + })); + } + return map; + }, [visibleVersionScopeMap, visibleVersions]); const risks = useMemo(() => visibleVersions.map((version) => { - const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id); - const requirementIds = new Set(versionRequirements.map((requirement) => requirement.id)); + const scope = visibleVersionScopeMap[version.id]; + const workItems = workItemsByVersion.get(version.id) ?? []; const dailyEvidence = buildVersionDailyEvidence({ versionId: version.id, workItems, @@ -95,9 +105,9 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach return calcXiaobaoVersionRisk({ version, - devTasks: devTasks.filter((task) => requirementIds.has(task.requirementId)), - testCases: testCases.filter((testCase) => testCase.versionId === version.id), - bugs: bugs.filter((bug) => bug.versionId === version.id), + devTasks: scope?.devTasks ?? [], + testCases: scope?.testCases ?? [], + bugs: scope?.bugs ?? [], dailyEvidence, recentActivityCount: dailyEvidence.recentActivityCount, lastActivityAt: dailyEvidence.lastActivityAt, @@ -106,12 +116,9 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach }); }).sort((a, b) => b.riskScore - a.riskScore), [ activities, - bugs, - devTasks, - requirements, - testCases, + visibleVersionScopeMap, visibleVersions, - workItems, + workItemsByVersion, worklogs, snapshots, today, diff --git a/apps/web/lib/linkage-engine.test.ts b/apps/web/lib/linkage-engine.test.ts index 10c0bdc..c10a2f7 100644 --- a/apps/web/lib/linkage-engine.test.ts +++ b/apps/web/lib/linkage-engine.test.ts @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import type { DevTask } from './dev-task'; -import { canEditRequirement } from './linkage-engine'; +import { buildRequirementDevStatusMap, canEditRequirement } from './linkage-engine'; import * as linkageEngine from './linkage-engine'; function task(patch: Partial = {}): DevTask { @@ -37,3 +37,19 @@ test('active or completed requirement development status cannot be closed', () = assert.equal(canCloseRequirement('req-1', [task({ status: 'testing' })]), false); assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), false); }); + +test('buildRequirementDevStatusMap derives every requirement status in one pass', () => { + const statusMap = buildRequirementDevStatusMap([ + task({ id: 'task-todo', requirementId: 'req-todo', status: 'todo' }), + task({ id: 'task-active', requirementId: 'req-active', status: 'in_progress' }), + task({ id: 'task-testing', requirementId: 'req-active', status: 'testing' }), + task({ id: 'task-done-1', requirementId: 'req-done', status: 'submitted' }), + task({ id: 'task-done-2', requirementId: 'req-done', status: 'submitted' }), + task({ id: 'task-no-req', requirementId: '', status: 'submitted' }), + ]); + + assert.equal(statusMap.get('req-todo'), 'todo'); + assert.equal(statusMap.get('req-active'), 'developing'); + assert.equal(statusMap.get('req-done'), 'completed'); + assert.equal(statusMap.has(''), false); +}); diff --git a/apps/web/lib/linkage-engine.ts b/apps/web/lib/linkage-engine.ts index 5dbd8d8..5dd7a32 100644 --- a/apps/web/lib/linkage-engine.ts +++ b/apps/web/lib/linkage-engine.ts @@ -28,6 +28,31 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt return 'todo'; } +export function buildRequirementDevStatusMap(devTasks: DevTask[]): Map { + const stats = new Map(); + + for (const task of devTasks) { + if (!task.requirementId) continue; + const row = stats.get(task.requirementId) ?? { total: 0, submitted: 0, active: false }; + row.total += 1; + if (task.status === 'submitted') row.submitted += 1; + if (task.status === 'in_progress' || task.status === 'testing') row.active = true; + stats.set(task.requirementId, row); + } + + const statusMap = new Map(); + for (const [requirementId, row] of stats) { + if (row.total > 0 && row.submitted === row.total) { + statusMap.set(requirementId, 'completed'); + } else if (row.active) { + statusMap.set(requirementId, 'developing'); + } else { + statusMap.set(requirementId, 'todo'); + } + } + return statusMap; +} + /** * 需求是否可编辑 * 规则:开发中(有 in_progress 或 testing 的任务)不可编辑 diff --git a/apps/web/lib/requirement-table-layout.test.ts b/apps/web/lib/requirement-table-layout.test.ts index a8b251a..8a18d52 100644 --- a/apps/web/lib/requirement-table-layout.test.ts +++ b/apps/web/lib/requirement-table-layout.test.ts @@ -3,16 +3,33 @@ import assert from 'node:assert/strict'; import { REQUIREMENT_TABLE_BADGE_CLASS, + REQUIREMENT_TABLE_CLASS, + REQUIREMENT_TABLE_COLUMN_WIDTHS, + REQUIREMENT_TABLE_CONTAINER_CLASS, + REQUIREMENT_TABLE_HEADER_CELL_CLASS, getRequirementTableTextClass, } from './requirement-table-layout'; +test('uses fixed table layout without horizontal scrolling for requirement list', () => { + assert.match(REQUIREMENT_TABLE_CLASS, /\btable-fixed\b/); + assert.doesNotMatch(REQUIREMENT_TABLE_CLASS, /\bmin-w-\[/); + assert.doesNotMatch(REQUIREMENT_TABLE_CONTAINER_CLASS, /\boverflow-x-auto\b/); + + const totalWidth = REQUIREMENT_TABLE_COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0); + assert.equal(totalWidth, 100); +}); + +test('keeps requirement table headers on one line', () => { + assert.match(REQUIREMENT_TABLE_HEADER_CELL_CLASS, /\bwhitespace-nowrap\b/); +}); + test('keeps requirement table badges on one line', () => { assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bwhitespace-nowrap\b/); assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bshrink-0\b/); }); -test('clips long requirement source project type and creator cells', () => { - for (const column of ['source', 'project', 'type', 'creator'] as const) { +test('clips long requirement source project type version and creator cells', () => { + for (const column of ['source', 'project', 'type', 'version', 'creator'] as const) { const className = getRequirementTableTextClass(column); assert.match(className, /\btruncate\b/); diff --git a/apps/web/lib/requirement-table-layout.ts b/apps/web/lib/requirement-table-layout.ts index c42b03d..4396c82 100644 --- a/apps/web/lib/requirement-table-layout.ts +++ b/apps/web/lib/requirement-table-layout.ts @@ -1,13 +1,24 @@ -export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'creator'; +export type RequirementTableTextColumn = 'source' | 'project' | 'type' | 'version' | 'creator'; const TEXT_COLUMN_MAX_WIDTH: Record = { source: 'max-w-[170px]', project: 'max-w-[120px]', type: 'max-w-[96px]', + version: 'max-w-[92px]', creator: 'max-w-[96px]', }; -export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-4 py-3 whitespace-nowrap'; +export const REQUIREMENT_TABLE_CONTAINER_CLASS = + 'overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]'; + +export const REQUIREMENT_TABLE_CLASS = 'w-full table-fixed text-left text-[13px]'; + +export const REQUIREMENT_TABLE_COLUMN_WIDTHS = [6, 15, 14, 7, 6, 6, 5, 6, 7, 6, 8, 14] as const; + +export const REQUIREMENT_TABLE_HEADER_CELL_CLASS = + 'px-3 py-2.5 whitespace-nowrap text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]'; + +export const REQUIREMENT_TABLE_NOWRAP_CELL_CLASS = 'px-3 py-3 whitespace-nowrap'; export const REQUIREMENT_TABLE_BADGE_CLASS = 'inline-flex h-6 min-w-[3.25rem] shrink-0 items-center justify-center whitespace-nowrap rounded-md px-2 py-0.5 text-[11px] font-medium'; diff --git a/apps/web/lib/server-data.test.ts b/apps/web/lib/server-data.test.ts index ab5f1b4..ba206a4 100644 --- a/apps/web/lib/server-data.test.ts +++ b/apps/web/lib/server-data.test.ts @@ -90,3 +90,85 @@ test('server data saves include the latest loaded AppData version', async () => globalThis.fetch = originalFetch; } }); + +test('server data shares an in-flight load for the same AppData key', async () => { + const originalFetch = globalThis.fetch; + let dataRequests = 0; + let releaseDataResponse!: () => void; + const dataResponseReady = new Promise((resolve) => { + releaseDataResponse = resolve; + }); + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/config/ai')) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.endsWith('/data/test-cases')) { + dataRequests += 1; + await dataResponseReady; + return new Response(JSON.stringify({ + key: 'test-cases', + value: [{ id: 'tc-1' }], + version: 'test-cases-version-1', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); + }) as typeof fetch; + + try { + const first = loadServerData>('test-cases'); + const second = loadServerData>('test-cases'); + releaseDataResponse(); + + assert.deepEqual(await first, [{ id: 'tc-1' }]); + assert.deepEqual(await second, [{ id: 'tc-1' }]); + assert.equal(dataRequests, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('server data reuses a fresh cached load for repeated AppData reads', async () => { + const originalFetch = globalThis.fetch; + let dataRequests = 0; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/config/ai')) { + return new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.endsWith('/data/version-plans')) { + dataRequests += 1; + return new Response(JSON.stringify({ + key: 'version-plans', + value: [{ id: 'plan-1' }], + version: 'version-plans-version-1', + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`Unexpected fetch ${init?.method ?? 'GET'} ${url}`); + }) as typeof fetch; + + try { + const first = await loadServerData>('version-plans'); + const second = await loadServerData>('version-plans'); + + assert.deepEqual(first, [{ id: 'plan-1' }]); + assert.deepEqual(second, [{ id: 'plan-1' }]); + assert.equal(dataRequests, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/apps/web/lib/server-data.ts b/apps/web/lib/server-data.ts index 810ebdf..d7e0aec 100644 --- a/apps/web/lib/server-data.ts +++ b/apps/web/lib/server-data.ts @@ -30,6 +30,18 @@ type ServerDataConflictBody = { }; const serverDataVersions = new Map(); +const serverDataCache = new Map(); +const serverDataLoadPromises = new Map>(); +export const SERVER_DATA_CACHE_MS = 30_000; + +type LoadServerDataOptions = { + force?: boolean; + maxAgeMs?: number; +}; export class ServerDataConflictError extends Error { constructor( @@ -42,10 +54,36 @@ export class ServerDataConflictError extends Error { } } -export async function loadServerData(key: ServerDataKey): Promise { - const res = await api.get>(`/data/${key}`); - serverDataVersions.set(key, res.version ?? null); - return res.value; +export async function loadServerData( + key: ServerDataKey, + options: LoadServerDataOptions = {}, +): Promise { + const maxAgeMs = options.maxAgeMs ?? SERVER_DATA_CACHE_MS; + const cached = serverDataCache.get(key); + if (!options.force && cached && maxAgeMs > 0 && Date.now() - cached.loadedAt <= maxAgeMs) { + return cached.value as T | null; + } + + const inFlight = serverDataLoadPromises.get(key); + if (inFlight) return inFlight as Promise; + + const promise = api.get>(`/data/${key}`) + .then((res) => { + const version = res.version ?? null; + serverDataVersions.set(key, version); + serverDataCache.set(key, { + value: res.value, + version, + loadedAt: Date.now(), + }); + return res.value; + }) + .finally(() => { + serverDataLoadPromises.delete(key); + }); + + serverDataLoadPromises.set(key, promise as Promise); + return promise; } export async function saveServerData(key: ServerDataKey, value: T): Promise { @@ -56,7 +94,13 @@ export async function saveServerData(key: ServerDataKey, value: T): Promise>(`/data/${key}`, payload); - serverDataVersions.set(key, res.version ?? null); + const version = res.version ?? null; + serverDataVersions.set(key, version); + serverDataCache.set(key, { + value, + version, + loadedAt: Date.now(), + }); } catch (error) { if ( error instanceof ApiRequestError && diff --git a/apps/web/lib/version-data-scope.test.ts b/apps/web/lib/version-data-scope.test.ts new file mode 100644 index 0000000..f74ba0a --- /dev/null +++ b/apps/web/lib/version-data-scope.test.ts @@ -0,0 +1,171 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope'; +import type { Bug } from './bug'; +import type { DevTask } from './dev-task'; +import type { OvertimeRecord } from './overtime'; +import type { Requirement } from './requirement'; +import type { TestCase } from './test-case'; +import type { VersionPlan } from './version-plan'; + +function requirement(id: string, versionId: string | undefined): Requirement { + return { + id, + code: id.toUpperCase(), + title: `Requirement ${id}`, + description: '', + productId: 'product-1', + projectId: 'project-1', + versionId, + sourceType: 'internal', + sourceTarget: 'product', + platforms: ['web'], + typeId: 'type-1', + status: 'planned', + priority: 'P1', + effort: 'M', + creator: 'PM', + createdAt: '2026-07-02T09:00:00.000Z', + }; +} + +function plan(id: string, versionId: string, type: VersionPlan['type']): VersionPlan { + return { + id, + versionId, + type, + title: `Plan ${id}`, + owner: 'PM', + startTime: '2026-07-02T09:00:00.000Z', + endTime: '2026-07-02T18:00:00.000Z', + status: 'pending', + createdAt: '2026-07-02T09:00:00.000Z', + addedBy: 'PM', + }; +} + +function devTask(id: string, patch: Partial): DevTask { + return { + id, + taskNo: id.toUpperCase(), + versionId: undefined, + requirementId: '', + title: `Task ${id}`, + categoryId: 'frontend', + assigneeId: 'Alice', + priority: 'P1', + expectedStartAt: '', + expectedEndAt: '', + status: 'todo', + isBlocked: false, + createdBy: 'PM', + createdAt: '2026-07-02T09:00:00.000Z', + updatedAt: '2026-07-02T09:00:00.000Z', + ...patch, + }; +} + +function testCase(id: string, versionId: string): TestCase { + return { + id, + caseNo: id.toUpperCase(), + versionId, + title: `Case ${id}`, + categoryId: 'testing', + priority: 'P1', + status: 'pending', + createdBy: 'QA', + createdAt: '2026-07-02T09:00:00.000Z', + updatedAt: '2026-07-02T09:00:00.000Z', + }; +} + +function bug(id: string, versionId: string): Bug { + return { + id, + bugNo: id.toUpperCase(), + versionId, + testCaseId: 'tc-1', + title: `Bug ${id}`, + description: '', + severity: 'major', + priority: 'P1', + reportedBy: 'QA', + assigneeId: 'Bob', + status: 'open', + createdAt: '2026-07-02T09:00:00.000Z', + updatedAt: '2026-07-02T09:00:00.000Z', + }; +} + +function overtime(id: string, versionId: string): OvertimeRecord { + return { + id, + projectId: 'project-1', + versionId, + person: 'Alice', + startTime: '2026-07-02T19:00:00.000Z', + endTime: '2026-07-02T21:00:00.000Z', + duration: 2, + reasonId: 'reason-4', + createdAt: '2026-07-02T21:00:00.000Z', + }; +} + +test('buildVersionDataScope scopes version detail data once', () => { + const scope = buildVersionDataScope({ + versionId: 'version-1', + requirements: [requirement('req-1', 'version-1'), requirement('req-2', 'version-2')], + plans: [plan('plan-1', 'version-1', 'research'), plan('plan-2', 'version-1', 'product'), plan('plan-3', 'version-2', 'ui')], + devTasks: [ + devTask('dev-direct', { versionId: 'version-1', requirementId: '' }), + devTask('dev-legacy', { requirementId: 'req-1' }), + devTask('dev-other', { versionId: 'version-2', requirementId: 'req-2' }), + ], + testCases: [testCase('tc-1', 'version-1'), testCase('tc-2', 'version-2')], + bugs: [bug('bug-1', 'version-1'), bug('bug-2', 'version-2')], + overtimeRecords: [overtime('ot-1', 'version-1'), overtime('ot-2', 'version-2')], + }); + + assert.deepEqual(scope.requirementIds, ['req-1']); + assert.deepEqual(scope.plans.map((item) => item.id), ['plan-1', 'plan-2']); + assert.deepEqual(scope.plansByType.research.map((item) => item.id), ['plan-1']); + assert.deepEqual(scope.plansByType.product.map((item) => item.id), ['plan-2']); + assert.deepEqual(scope.devTasks.map((item) => item.id), ['dev-direct', 'dev-legacy']); + assert.deepEqual(scope.testCases.map((item) => item.id), ['tc-1']); + assert.deepEqual(scope.bugs.map((item) => item.id), ['bug-1']); + assert.deepEqual(scope.overtimeRecords.map((item) => item.id), ['ot-1']); +}); + +test('buildVersionDataScopeMap indexes many versions in one pass', () => { + const scopeMap = buildVersionDataScopeMap({ + versionIds: ['version-1', 'version-2', 'version-empty'], + requirements: [ + requirement('req-1', 'version-1'), + requirement('req-2', 'version-2'), + requirement('req-outside', 'version-3'), + ], + plans: [ + plan('plan-1', 'version-1', 'research'), + plan('plan-2', 'version-2', 'ui'), + plan('plan-outside', 'version-3', 'product'), + ], + devTasks: [ + devTask('dev-direct', { versionId: 'version-2', requirementId: 'req-1' }), + devTask('dev-legacy', { requirementId: 'req-1' }), + devTask('dev-outside', { requirementId: 'req-outside' }), + ], + testCases: [testCase('tc-1', 'version-1'), testCase('tc-2', 'version-2'), testCase('tc-outside', 'version-3')], + bugs: [bug('bug-1', 'version-1'), bug('bug-2', 'version-2'), bug('bug-outside', 'version-3')], + overtimeRecords: [overtime('ot-1', 'version-1'), overtime('ot-2', 'version-2'), overtime('ot-outside', 'version-3')], + }); + + assert.deepEqual(scopeMap['version-1'].requirements.map((item) => item.id), ['req-1']); + assert.deepEqual(scopeMap['version-1'].devTasks.map((item) => item.id), ['dev-legacy']); + assert.deepEqual(scopeMap['version-2'].requirements.map((item) => item.id), ['req-2']); + assert.deepEqual(scopeMap['version-2'].devTasks.map((item) => item.id), ['dev-direct']); + assert.deepEqual(scopeMap['version-2'].plansByType.ui.map((item) => item.id), ['plan-2']); + assert.deepEqual(scopeMap['version-empty'].requirements, []); + assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']); +}); diff --git a/apps/web/lib/version-data-scope.ts b/apps/web/lib/version-data-scope.ts new file mode 100644 index 0000000..bac12c8 --- /dev/null +++ b/apps/web/lib/version-data-scope.ts @@ -0,0 +1,144 @@ +import type { Bug } from './bug'; +import type { DevTask } from './dev-task'; +import type { OvertimeRecord } from './overtime'; +import type { Requirement } from './requirement'; +import type { TestCase } from './test-case'; +import type { VersionPlan } from './version-plan'; + +export interface VersionDataScope { + requirements: Requirement[]; + requirementIds: string[]; + requirementIdSet: Set; + plans: VersionPlan[]; + plansByType: Record; + devTasks: DevTask[]; + testCases: TestCase[]; + bugs: Bug[]; + overtimeRecords: OvertimeRecord[]; +} + +interface VersionDataScopeInput { + versionId: string; + requirements: Requirement[]; + plans: VersionPlan[]; + devTasks: DevTask[]; + testCases: TestCase[]; + bugs: Bug[]; + overtimeRecords?: OvertimeRecord[]; +} + +interface VersionDataScopeMapInput extends Omit { + versionIds: string[]; +} + +export function buildVersionDataScope(input: VersionDataScopeInput): VersionDataScope { + const { + versionId, + requirements, + plans, + devTasks, + testCases, + bugs, + overtimeRecords = [], + } = input; + + const scopedRequirements: Requirement[] = []; + const requirementIds: string[] = []; + const requirementIdSet = new Set(); + for (const requirement of requirements) { + if (requirement.versionId !== versionId) continue; + scopedRequirements.push(requirement); + requirementIds.push(requirement.id); + requirementIdSet.add(requirement.id); + } + + const scopedPlans: VersionPlan[] = []; + const plansByType: Record = { + research: [], + product: [], + ui: [], + }; + for (const plan of plans) { + if (plan.versionId !== versionId) continue; + scopedPlans.push(plan); + plansByType[plan.type].push(plan); + } + + return { + requirements: scopedRequirements, + requirementIds, + requirementIdSet, + plans: scopedPlans, + plansByType, + devTasks: devTasks.filter((task) => task.versionId === versionId || (!task.versionId && requirementIdSet.has(task.requirementId))), + testCases: testCases.filter((testCase) => testCase.versionId === versionId), + bugs: bugs.filter((bug) => bug.versionId === versionId), + overtimeRecords: overtimeRecords.filter((record) => record.versionId === versionId), + }; +} + +export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record { + const scopes: Record = {}; + const targetVersionIds = new Set(input.versionIds); + for (const versionId of input.versionIds) { + scopes[versionId] = createEmptyScope(); + } + + const requirementVersionMap = new Map(); + for (const requirement of input.requirements) { + const versionId = requirement.versionId; + if (!versionId) continue; + requirementVersionMap.set(requirement.id, versionId); + const scope = scopes[versionId]; + if (!scope) continue; + scope.requirements.push(requirement); + scope.requirementIds.push(requirement.id); + scope.requirementIdSet.add(requirement.id); + } + + for (const plan of input.plans) { + const scope = scopes[plan.versionId]; + if (!scope) continue; + scope.plans.push(plan); + scope.plansByType[plan.type].push(plan); + } + + for (const task of input.devTasks) { + const versionId = task.versionId || requirementVersionMap.get(task.requirementId); + if (!versionId || !targetVersionIds.has(versionId)) continue; + scopes[versionId]?.devTasks.push(task); + } + + for (const testCase of input.testCases) { + scopes[testCase.versionId]?.testCases.push(testCase); + } + + for (const bug of input.bugs) { + scopes[bug.versionId]?.bugs.push(bug); + } + + for (const record of input.overtimeRecords ?? []) { + if (!record.versionId) continue; + scopes[record.versionId]?.overtimeRecords.push(record); + } + + return scopes; +} + +function createEmptyScope(): VersionDataScope { + return { + requirements: [], + requirementIds: [], + requirementIdSet: new Set(), + plans: [], + plansByType: { + research: [], + product: [], + ui: [], + }, + devTasks: [], + testCases: [], + bugs: [], + overtimeRecords: [], + }; +} diff --git a/apps/web/lib/version-progress.ts b/apps/web/lib/version-progress.ts index 6da908b..4b3204b 100644 --- a/apps/web/lib/version-progress.ts +++ b/apps/web/lib/version-progress.ts @@ -23,6 +23,14 @@ export function calcVersionProgress( const vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds)); const vTestCases = testCases.filter((c) => c.versionId === versionId); + return calcScopedVersionProgress(vPlans, vDevTasks, vTestCases); +} + +export function calcScopedVersionProgress( + vPlans: VersionPlan[], + vDevTasks: DevTask[], + vTestCases: TestCase[], +): number { const segments: number[] = []; const researchPlans = vPlans.filter((p) => p.type === 'research'); @@ -93,9 +101,40 @@ export function buildVersionProgressMap( devTasks: DevTask[], testCases: TestCase[], ): Record { + const plansByVersion = groupByVersionId(plans); + const requirementVersionMap = new Map(); + for (const requirement of requirements) { + if (!requirement.versionId) continue; + requirementVersionMap.set(requirement.id, requirement.versionId); + } + + const devTasksByVersion = new Map(); + for (const task of devTasks) { + const versionId = task.versionId || requirementVersionMap.get(task.requirementId); + if (!versionId) continue; + const items = devTasksByVersion.get(versionId) ?? []; + items.push(task); + devTasksByVersion.set(versionId, items); + } + + const testCasesByVersion = groupByVersionId(testCases); const map: Record = {}; for (const v of versions) { - map[v.id] = calcVersionProgress(v.id, plans, requirements, devTasks, testCases); + map[v.id] = calcScopedVersionProgress( + plansByVersion.get(v.id) ?? [], + devTasksByVersion.get(v.id) ?? [], + testCasesByVersion.get(v.id) ?? [], + ); + } + return map; +} + +function groupByVersionId(items: T[]): Map { + const map = new Map(); + for (const item of items) { + const group = map.get(item.versionId) ?? []; + group.push(item); + map.set(item.versionId, group); } return map; } diff --git a/apps/web/lib/workspace-engine.test.ts b/apps/web/lib/workspace-engine.test.ts index 0617a54..37df64b 100644 --- a/apps/web/lib/workspace-engine.test.ts +++ b/apps/web/lib/workspace-engine.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { aggregateWorkItems, getWorkspacePendingCount } from './workspace-engine'; +import { aggregateWorkItems, getWorkspacePendingCount, getWorkspacePendingCountByVersion } from './workspace-engine'; import type { DevTask } from './dev-task'; test('getWorkspacePendingCount counts unfinished work items only', () => { @@ -13,6 +13,51 @@ test('getWorkspacePendingCount counts unfinished work items only', () => { assert.equal(count, 2); }); +test('getWorkspacePendingCountByVersion counts unfinished work items in one pass', () => { + const counts = getWorkspacePendingCountByVersion([ + { versionId: 'ver-1', completed: false }, + { versionId: 'ver-1', completed: true }, + { versionId: 'ver-2', completed: false }, + { versionId: '', completed: false }, + ]); + + assert.equal(counts.get('ver-1'), 1); + assert.equal(counts.get('ver-2'), 1); + assert.equal(counts.has(''), false); +}); + +test('aggregateWorkItems returns no items before the current user is known', () => { + const unassignedTask = { + id: 'dev-unassigned', + taskNo: 'DEV-002', + versionId: 'ver-1', + requirementId: '', + title: 'Unassigned task', + categoryId: 'frontend', + assigneeId: '', + priority: 'P1', + expectedStartAt: '', + expectedEndAt: '', + status: 'todo', + isBlocked: false, + createdBy: 'AI', + createdAt: '2026-06-29T09:00:00.000Z', + updatedAt: '2026-06-29T09:00:00.000Z', + } as DevTask; + + const items = aggregateWorkItems( + '', + [], + [unassignedTask], + [], + [], + new Map([['ver-1', { id: 'ver-1', name: 'V1.0', productName: 'FTB', projectName: 'PM' }]]), + new Map(), + ); + + assert.deepEqual(items, []); +}); + test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => { const task = { id: 'dev-no-req', diff --git a/apps/web/lib/workspace-engine.ts b/apps/web/lib/workspace-engine.ts index 49b128b..7c41b58 100644 --- a/apps/web/lib/workspace-engine.ts +++ b/apps/web/lib/workspace-engine.ts @@ -37,6 +37,7 @@ export function aggregateWorkItems( requirementVersionMap: Map, ): WorkItem[] { const items: WorkItem[] = []; + if (!userName.trim()) return items; // Plans: owner === userName plans.filter((p) => p.owner === userName).forEach((p) => { @@ -127,6 +128,17 @@ export function getWorkspacePendingCount(items: ReadonlyArray sum + (item.completed ? 0 : 1), 0); } +export function getWorkspacePendingCountByVersion( + items: ReadonlyArray>, +): Map { + const map = new Map(); + for (const item of items) { + if (!item.versionId || item.completed) continue; + map.set(item.versionId, (map.get(item.versionId) ?? 0) + 1); + } + return map; +} + export const WORK_ITEM_TYPE_LABEL: Record = { plan_research: '调研', plan_product: '产品方案', diff --git a/apps/web/stores/useBugStore.ts b/apps/web/stores/useBugStore.ts index 38ea781..aff2630 100644 --- a/apps/web/stores/useBugStore.ts +++ b/apps/web/stores/useBugStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import type { Bug, BugStatus, BugLog } from '@/lib/bug'; import { generateBugNo } from '@/lib/bug'; import { applyBugTransition } from '@/lib/bug-workflow'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { makeBugCreatedActivity, makeBugStatusActivity, @@ -15,6 +15,8 @@ function saveStored(items: Bug[]) { saveServerData('bugs', items).catch(() => {}); } +let lastBugsFetchAt = 0; + async function loadStored(): Promise { try { return await loadServerData('bugs'); @@ -28,7 +30,8 @@ function makeLog(action: BugLog['action'], operator: string, from?: string, to?: interface BugState { bugs: Bug[]; - fetchBugs: () => Promise; + loaded: boolean; + fetchBugs: (options?: { force?: boolean }) => Promise; createBug: (data: Omit, operator: string) => Bug; updateBug: (id: string, data: Partial) => void; deleteBug: (id: string) => void; @@ -41,10 +44,14 @@ interface BugState { export const useBugStore = create((set, get) => ({ bugs: [], + loaded: false, - fetchBugs: async () => { + fetchBugs: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - if (cached) set({ bugs: cached }); + if (!options?.force && get().loaded && Date.now() - lastBugsFetchAt < SERVER_DATA_CACHE_MS) return; + lastBugsFetchAt = Date.now(); + set({ bugs: cached ?? [], loaded: true }); }, createBug: (data, operator) => { @@ -61,7 +68,7 @@ export const useBugStore = create((set, get) => ({ updatedAt: now, }; const updated = [...list, bug]; - set({ bugs: updated }); + set({ bugs: updated, loaded: true }); saveStored(updated); useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator)); return bug; @@ -71,13 +78,13 @@ export const useBugStore = create((set, get) => ({ const updated = get().bugs.map((b) => b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b, ); - set({ bugs: updated }); + set({ bugs: updated, loaded: true }); saveStored(updated); }, deleteBug: (id) => { const updated = get().bugs.filter((b) => b.id !== id); - set({ bugs: updated }); + set({ bugs: updated, loaded: true }); saveStored(updated); }, diff --git a/apps/web/stores/useDevTaskStore.ts b/apps/web/stores/useDevTaskStore.ts index b1c9d37..543e57b 100644 --- a/apps/web/stores/useDevTaskStore.ts +++ b/apps/web/stores/useDevTaskStore.ts @@ -4,7 +4,7 @@ import type { DevTask, DevTaskStatus } from '@/lib/dev-task'; import { generateTaskNo, isLegacyTask } from '@/lib/dev-task'; import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { makeDevTaskBlockedActivity, makeDevTaskCreatedActivity, @@ -17,6 +17,8 @@ function saveStored(items: DevTask[]) { saveServerData('dev-tasks', items).catch(() => {}); } +let lastTasksFetchAt = 0; + async function loadStored(): Promise { try { const parsed = await loadServerData('dev-tasks'); @@ -34,7 +36,8 @@ async function loadStored(): Promise { interface DevTaskState { tasks: DevTask[]; - fetchTasks: () => Promise; + loaded: boolean; + fetchTasks: (options?: { force?: boolean }) => Promise; createTask: (data: Omit) => DevTask; updateTask: (id: string, data: Partial) => void; deleteTask: (id: string) => void; @@ -47,10 +50,14 @@ interface DevTaskState { export const useDevTaskStore = create((set, get) => ({ tasks: [], + loaded: false, - fetchTasks: async () => { + fetchTasks: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - set({ tasks: cached ?? [] }); + if (!options?.force && get().loaded && Date.now() - lastTasksFetchAt < SERVER_DATA_CACHE_MS) return; + lastTasksFetchAt = Date.now(); + set({ tasks: cached ?? [], loaded: true }); }, createTask: (data) => { @@ -65,7 +72,7 @@ export const useDevTaskStore = create((set, get) => ({ updatedAt: now, } as DevTask); const updated = [...list, task]; - set({ tasks: updated }); + set({ tasks: updated, loaded: true }); saveStored(updated); useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId)); return task; @@ -75,13 +82,13 @@ export const useDevTaskStore = create((set, get) => ({ const updated = get().tasks.map((t) => t.id === id ? { ...t, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : t, ); - set({ tasks: updated }); + set({ tasks: updated, loaded: true }); saveStored(updated); }, deleteTask: (id) => { const updated = get().tasks.filter((t) => t.id !== id); - set({ tasks: updated }); + set({ tasks: updated, loaded: true }); saveStored(updated); }, diff --git a/apps/web/stores/useMemberStore.ts b/apps/web/stores/useMemberStore.ts index 97144ab..84f2a0e 100644 --- a/apps/web/stores/useMemberStore.ts +++ b/apps/web/stores/useMemberStore.ts @@ -11,7 +11,7 @@ import { } from '@/lib/member-system'; import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions'; import { mergePresetRolePermissions } from '@/lib/role-permission-migration'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; const PRESET_DEPARTMENTS: Department[] = [ { id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' }, @@ -28,6 +28,7 @@ const PRESET_ROLES: RoleItem[] = DEFAULT_ROLE_PRESETS; const MOCK_MEMBERS: Member[] = [ SYSTEM_ADMIN_MEMBER, ]; +let lastMembersFetchAt = 0; interface MemberState { departments: Department[]; @@ -35,7 +36,8 @@ interface MemberState { roles: RoleItem[]; passwordRule: PasswordRule; loading: boolean; - fetchMembers: () => void; + loaded: boolean; + fetchMembers: (options?: { force?: boolean }) => Promise; updatePasswordRule: (rule: PasswordRule) => void; createDepartment: (data: Omit) => void; updateDepartment: (id: string, data: Partial) => void; @@ -65,9 +67,13 @@ export const useMemberStore = create((set, get) => ({ roles: PRESET_ROLES, passwordRule: DEFAULT_PASSWORD_RULE, loading: false, + loaded: false, - fetchMembers: async () => { + fetchMembers: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); + if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return; + lastMembersFetchAt = Date.now(); if (cached) { // 迁移:旧 RoleItem 没 permissions 字段,补默认值 const migratedRoles = cached.roles.map((r) => { @@ -94,8 +100,10 @@ export const useMemberStore = create((set, get) => ({ roles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE, }; - set(next); + set({ ...next, loaded: true }); if (ensured.changed || usernameEnsured.changed || rolesChanged) saveStored(next); + } else { + set({ loaded: true }); } }, diff --git a/apps/web/stores/useOvertimeStore.ts b/apps/web/stores/useOvertimeStore.ts index 2b37f2b..929f540 100644 --- a/apps/web/stores/useOvertimeStore.ts +++ b/apps/web/stores/useOvertimeStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import type { OvertimeRecord } from '@/lib/overtime'; import type { DictItem } from '@/lib/requirement'; import { calcDuration } from '@/lib/overtime'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; const PRESET_REASONS: DictItem[] = [ { id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' }, @@ -20,12 +20,14 @@ const PRESET_REASONS: DictItem[] = [ ]; const MOCK_RECORDS: OvertimeRecord[] = []; +let lastOvertimeFetchAt = 0; interface OvertimeState { records: OvertimeRecord[]; reasons: DictItem[]; loading: boolean; - fetchRecords: () => Promise; + loaded: boolean; + fetchRecords: (options?: { force?: boolean }) => Promise; createRecord: (data: Omit) => void; updateRecord: (id: string, data: Partial) => void; deleteRecord: (id: string) => void; @@ -49,10 +51,14 @@ export const useOvertimeStore = create((set, get) => ({ records: MOCK_RECORDS, reasons: PRESET_REASONS, loading: false, + loaded: false, - fetchRecords: async () => { + fetchRecords: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - if (cached) set({ records: cached.records, reasons: cached.reasons }); + if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return; + lastOvertimeFetchAt = Date.now(); + set({ records: cached?.records ?? MOCK_RECORDS, reasons: cached?.reasons ?? PRESET_REASONS, loaded: true }); }, createRecord: (data) => { @@ -64,7 +70,7 @@ export const useOvertimeStore = create((set, get) => ({ createdAt: new Date().toISOString().slice(0, 10), }; const records = [...get().records, record]; - set({ records }); + set({ records, loaded: true }); saveStored({ records, reasons: get().reasons }); }, @@ -77,32 +83,32 @@ export const useOvertimeStore = create((set, get) => ({ } return updated; }); - set({ records }); + set({ records, loaded: true }); saveStored({ records, reasons: get().reasons }); }, deleteRecord: (id) => { const records = get().records.filter((r) => r.id !== id); - set({ records }); + set({ records, loaded: true }); saveStored({ records, reasons: get().reasons }); }, addReason: (name) => { const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const reasons = [...get().reasons, item]; - set({ reasons }); + set({ reasons, loaded: true }); saveStored({ records: get().records, reasons }); }, updateReason: (id, name) => { const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r); - set({ reasons }); + set({ reasons, loaded: true }); saveStored({ records: get().records, reasons }); }, deleteReason: (id) => { const reasons = get().reasons.filter((r) => r.id !== id); - set({ reasons }); + set({ reasons, loaded: true }); saveStored({ records: get().records, reasons }); }, })); diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts index 21844b9..8bf472a 100644 --- a/apps/web/stores/useProductStore.ts +++ b/apps/web/stores/useProductStore.ts @@ -4,7 +4,7 @@ import { create } from 'zustand'; import { Product } from '@ftb/shared'; import { api } from '@/lib/api'; import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import type { Stage, Role } from '@/lib/stage'; import type { Priority, VersionLinks } from '@/lib/derive'; @@ -35,6 +35,7 @@ interface VersionItem { } const MOCK_OVERVIEW: ProductOverview[] = []; +let lastOverviewFetchAt = 0; interface ProductWithCount { id: string; @@ -55,6 +56,7 @@ interface ProductState { overview: ProductOverview[]; currentProduct: (Product & { requirements?: any[]; versions?: any[] }) | null; loading: boolean; + overviewLoaded: boolean; error: string | null; fetchProducts: () => Promise; fetchOverview: () => Promise; @@ -77,6 +79,7 @@ export const useProductStore = create((set, get) => ({ overview: [], currentProduct: null, loading: false, + overviewLoaded: false, error: null, fetchProducts: async () => { @@ -90,22 +93,24 @@ export const useProductStore = create((set, get) => ({ }, fetchOverview: async () => { - if (get().overview.length > 0) return; + if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStoredOverview(); + if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return; + lastOverviewFetchAt = Date.now(); if (cached) { // 有本地缓存,直接用,不再调远端覆盖(mock 模式核心数据在本地) - set({ overview: cached, loading: false }); + set({ overview: cached, loading: false, overviewLoaded: true }); return; } set({ loading: true, error: null }); try { const overview = await api.get('/products/overview'); - set({ overview, loading: false }); + set({ overview, loading: false, overviewLoaded: true }); if (shouldPersistRemoteOverview(overview)) { saveStoredOverview(overview); } } catch { - set({ overview: MOCK_OVERVIEW, error: null, loading: false }); + set({ overview: MOCK_OVERVIEW, error: null, loading: false, overviewLoaded: true }); } }, @@ -137,7 +142,7 @@ export const useProductStore = create((set, get) => ({ _count: { requirements: 0, projects: 0, versions: 0 }, }; const updated = [newProduct, ...get().overview]; - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); // 异步尝试同步远端(失败也无所谓,本地已更新) try { await api.post('/products', data); } catch {} @@ -146,14 +151,12 @@ export const useProductStore = create((set, get) => ({ updateProduct: async (id, data) => { try { await api.patch(`/products/${id}`, data); - await get().fetchOverview(); - } catch { - const updated = get().overview.map((p) => - p.id === id ? { ...p, ...data } : p, - ); - set({ overview: updated }); - saveStoredOverview(updated); - } + } catch {} + const updated = get().overview.map((p) => + p.id === id ? { ...p, ...data } : p, + ); + set({ overview: updated, overviewLoaded: true }); + saveStoredOverview(updated); }, deleteProduct: async (id) => { @@ -164,6 +167,7 @@ export const useProductStore = create((set, get) => ({ set({ products: get().products.filter((p) => p.id !== id), overview: updated, + overviewLoaded: true, }); saveStoredOverview(updated); }, @@ -171,7 +175,7 @@ export const useProductStore = create((set, get) => ({ reorderProducts: (ids) => { const map = new Map(get().overview.map((p) => [p.id, p])); const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[]; - set({ overview: reordered }); + set({ overview: reordered, overviewLoaded: true }); saveStoredOverview(reordered); }, @@ -194,7 +198,7 @@ export const useProductStore = create((set, get) => ({ const updated = get().overview .filter((p) => p.id !== sourceId) .map((p) => (p.id === targetId ? migratedTarget : p)); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, @@ -213,7 +217,7 @@ export const useProductStore = create((set, get) => ({ _count: { ...p._count, projects: (p._count?.projects ?? 0) + 1 } as any, }; }); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, @@ -250,7 +254,7 @@ export const useProductStore = create((set, get) => ({ versions: nextVersions, }; }); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, @@ -271,7 +275,7 @@ export const useProductStore = create((set, get) => ({ _count: { ...p._count, versions: (p._count?.versions ?? 0) + 1 } as any, }; }); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, @@ -285,7 +289,7 @@ export const useProductStore = create((set, get) => ({ ), }; }); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, @@ -298,7 +302,7 @@ export const useProductStore = create((set, get) => ({ _count: { ...p._count, versions: Math.max(0, (p._count?.versions ?? 0) - 1) } as any, }; }); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, @@ -311,7 +315,7 @@ export const useProductStore = create((set, get) => ({ _count: { ...p._count, projects: Math.max(0, (p._count?.projects ?? 0) - 1) } as any, }; }); - set({ overview: updated }); + set({ overview: updated, overviewLoaded: true }); saveStoredOverview(updated); }, })); diff --git a/apps/web/stores/useRequirementStore.ts b/apps/web/stores/useRequirementStore.ts index cc73259..9ead757 100644 --- a/apps/web/stores/useRequirementStore.ts +++ b/apps/web/stores/useRequirementStore.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement'; import type { Priority } from '@/lib/derive'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; interface RequirementState { requirements: Requirement[]; @@ -11,7 +11,8 @@ interface RequirementState { types: DictItem[]; platforms: DictItem[]; loading: boolean; - fetchRequirements: () => Promise; + loaded: boolean; + fetchRequirements: (options?: { force?: boolean }) => Promise; createRequirement: (data: Omit) => void; updateRequirement: (id: string, data: Partial) => void; deleteRequirement: (id: string) => void; @@ -52,6 +53,7 @@ const PRESET_PLATFORMS: DictItem[] = [ // --- Mock requirements --- const MOCK_REQUIREMENTS: Requirement[] = []; +let lastRequirementsFetchAt = 0; // --- Server persistence helpers --- @@ -75,11 +77,17 @@ export const useRequirementStore = create((set, get) => { types: PRESET_TYPES, platforms: PRESET_PLATFORMS, loading: false, + loaded: false, - fetchRequirements: async () => { + fetchRequirements: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); + if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return; + lastRequirementsFetchAt = Date.now(); if (cached) { - set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms }); + set({ requirements: cached.requirements, sourceTargets: cached.sourceTargets, types: cached.types, platforms: cached.platforms, loaded: true }); + } else { + set({ loaded: true }); } }, @@ -94,21 +102,21 @@ export const useRequirementStore = create((set, get) => { createdAt: new Date().toISOString(), }; const updated = [...requirements, newReq]; - set({ requirements: updated }); + set({ requirements: updated, loaded: true }); saveStored({ requirements: updated, sourceTargets, types, platforms }); }, updateRequirement: (id, data) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r)); - set({ requirements: updated }); + set({ requirements: updated, loaded: true }); saveStored({ requirements: updated, sourceTargets, types, platforms }); }, deleteRequirement: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = requirements.filter((r) => r.id !== id); - set({ requirements: updated }); + set({ requirements: updated, loaded: true }); saveStored({ requirements: updated, sourceTargets, types, platforms }); }, @@ -117,21 +125,21 @@ export const useRequirementStore = create((set, get) => { const { requirements, sourceTargets, types, platforms } = get(); const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...sourceTargets, item]; - set({ sourceTargets: updated }); + set({ sourceTargets: updated, loaded: true }); saveStored({ requirements, sourceTargets: updated, types, platforms }); }, updateSourceTarget: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s)); - set({ sourceTargets: updated }); + set({ sourceTargets: updated, loaded: true }); saveStored({ requirements, sourceTargets: updated, types, platforms }); }, deleteSourceTarget: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = sourceTargets.filter((s) => s.id !== id); - set({ sourceTargets: updated }); + set({ sourceTargets: updated, loaded: true }); saveStored({ requirements, sourceTargets: updated, types, platforms }); }, @@ -140,21 +148,21 @@ export const useRequirementStore = create((set, get) => { const { requirements, sourceTargets, types, platforms } = get(); const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...types, item]; - set({ types: updated }); + set({ types: updated, loaded: true }); saveStored({ requirements, sourceTargets, types: updated, platforms }); }, updateType: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = types.map((t) => (t.id === id ? { ...t, name } : t)); - set({ types: updated }); + set({ types: updated, loaded: true }); saveStored({ requirements, sourceTargets, types: updated, platforms }); }, deleteType: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = types.filter((t) => t.id !== id); - set({ types: updated }); + set({ types: updated, loaded: true }); saveStored({ requirements, sourceTargets, types: updated, platforms }); }, @@ -163,21 +171,21 @@ export const useRequirementStore = create((set, get) => { const { requirements, sourceTargets, types, platforms } = get(); const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) }; const updated = [...platforms, item]; - set({ platforms: updated }); + set({ platforms: updated, loaded: true }); saveStored({ requirements, sourceTargets, types, platforms: updated }); }, updatePlatform: (id, name) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p)); - set({ platforms: updated }); + set({ platforms: updated, loaded: true }); saveStored({ requirements, sourceTargets, types, platforms: updated }); }, deletePlatform: (id) => { const { requirements, sourceTargets, types, platforms } = get(); const updated = platforms.filter((p) => p.id !== id); - set({ platforms: updated }); + set({ platforms: updated, loaded: true }); saveStored({ requirements, sourceTargets, types, platforms: updated }); }, }; diff --git a/apps/web/stores/useTaskCategoryStore.ts b/apps/web/stores/useTaskCategoryStore.ts index 37162d0..c792824 100644 --- a/apps/web/stores/useTaskCategoryStore.ts +++ b/apps/web/stores/useTaskCategoryStore.ts @@ -2,12 +2,14 @@ import { create } from 'zustand'; import type { TaskCategory, CategoryGroup } from '@/lib/task-category'; import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; function saveStored(items: TaskCategory[]) { saveServerData('task-categories', items).catch(() => {}); } +let lastCategoriesFetchAt = 0; + async function loadStored(): Promise { try { return await loadServerData('task-categories'); @@ -17,7 +19,8 @@ async function loadStored(): Promise { interface TaskCategoryState { categories: TaskCategory[]; - fetchCategories: () => Promise; + loaded: boolean; + fetchCategories: (options?: { force?: boolean }) => Promise; addCategory: (name: string, group: CategoryGroup, color?: string) => void; ensureCategory: (name: string, group: CategoryGroup, options?: { allowCreate?: boolean; fallbackCode?: string }) => TaskCategory; updateCategory: (id: string, data: Partial) => void; @@ -26,10 +29,14 @@ interface TaskCategoryState { export const useTaskCategoryStore = create((set, get) => ({ categories: PRESET_CATEGORIES, + loaded: false, - fetchCategories: async () => { + fetchCategories: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - if (cached) set({ categories: normalizeTaskCategories(cached) }); + if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return; + lastCategoriesFetchAt = Date.now(); + set({ categories: cached ? normalizeTaskCategories(cached) : PRESET_CATEGORIES, loaded: true }); }, addCategory: (name, group, color) => { @@ -44,14 +51,14 @@ export const useTaskCategoryStore = create((set, get) => ({ isSystem: false, }; const updated = [...list, item]; - set({ categories: updated }); + set({ categories: updated, loaded: true }); saveStored(updated); }, ensureCategory: (name, group, options) => { const result = resolveAiTaskCategoryByName(get().categories, name, group, options); if (result.created) { - set({ categories: result.categories }); + set({ categories: result.categories, loaded: true }); saveStored(result.categories); } return result.category; @@ -59,7 +66,7 @@ export const useTaskCategoryStore = create((set, get) => ({ updateCategory: (id, data) => { const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c)); - set({ categories: updated }); + set({ categories: updated, loaded: true }); saveStored(updated); }, @@ -67,7 +74,7 @@ export const useTaskCategoryStore = create((set, get) => ({ const target = get().categories.find((c) => c.id === id); if (!target || target.isSystem) return false; const updated = get().categories.filter((c) => c.id !== id); - set({ categories: updated }); + set({ categories: updated, loaded: true }); saveStored(updated); return true; }, diff --git a/apps/web/stores/useTaskWorklogStore.ts b/apps/web/stores/useTaskWorklogStore.ts index e018453..a8e915c 100644 --- a/apps/web/stores/useTaskWorklogStore.ts +++ b/apps/web/stores/useTaskWorklogStore.ts @@ -1,12 +1,14 @@ 'use client'; import { create } from 'zustand'; import type { TaskWorklog } from '@/lib/task-worklog'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; function saveStored(items: TaskWorklog[]) { saveServerData('task-worklogs', items).catch(() => {}); } +let lastWorklogsFetchAt = 0; + async function loadStored(): Promise { try { return await loadServerData('task-worklogs'); @@ -16,7 +18,8 @@ async function loadStored(): Promise { interface TaskWorklogState { worklogs: TaskWorklog[]; - fetchWorklogs: () => Promise; + loaded: boolean; + fetchWorklogs: (options?: { force?: boolean }) => Promise; addWorklog: (data: Omit) => void; deleteWorklog: (id: string) => void; getActualHours: (taskId: string) => number; @@ -24,10 +27,14 @@ interface TaskWorklogState { export const useTaskWorklogStore = create((set, get) => ({ worklogs: [], + loaded: false, - fetchWorklogs: async () => { + fetchWorklogs: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - if (cached) set({ worklogs: cached }); + if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return; + lastWorklogsFetchAt = Date.now(); + set({ worklogs: cached ?? [], loaded: true }); }, addWorklog: (data) => { @@ -37,13 +44,13 @@ export const useTaskWorklogStore = create((set, get) => ({ createdAt: new Date().toISOString(), }; const updated = [...get().worklogs, item]; - set({ worklogs: updated }); + set({ worklogs: updated, loaded: true }); saveStored(updated); }, deleteWorklog: (id) => { const updated = get().worklogs.filter((w) => w.id !== id); - set({ worklogs: updated }); + set({ worklogs: updated, loaded: true }); saveStored(updated); }, diff --git a/apps/web/stores/useTestCaseStore.ts b/apps/web/stores/useTestCaseStore.ts index 30b3ff4..054136a 100644 --- a/apps/web/stores/useTestCaseStore.ts +++ b/apps/web/stores/useTestCaseStore.ts @@ -4,7 +4,7 @@ import type { CreateTestCaseInput, TestCase, TestCaseStatus } from '@/lib/test-c import { generateCaseNo, normalizeTestCases } from '@/lib/test-case'; import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow'; import { createEntityId, dedupeEntityIds } from '@/lib/entity-id'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { makeTestCaseCreatedActivity, makeTestCaseStatusActivity, @@ -15,6 +15,8 @@ function saveStored(items: TestCase[]) { saveServerData('test-cases', items).catch(() => {}); } +let lastTestCasesFetchAt = 0; + async function loadStored(): Promise { try { return await loadServerData('test-cases'); @@ -24,7 +26,8 @@ async function loadStored(): Promise { interface TestCaseState { testCases: TestCase[]; - fetchTestCases: () => Promise; + loaded: boolean; + fetchTestCases: (options?: { force?: boolean }) => Promise; createTestCase: (data: CreateTestCaseInput) => TestCase; createTestCases: (items: CreateTestCaseInput[]) => TestCase[]; updateTestCase: (id: string, data: Partial) => void; @@ -37,13 +40,19 @@ interface TestCaseState { export const useTestCaseStore = create((set, get) => ({ testCases: [], + loaded: false, - fetchTestCases: async () => { + fetchTestCases: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); + if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return; + lastTestCasesFetchAt = Date.now(); if (cached) { const result = dedupeEntityIds(normalizeTestCases(cached), 'tc'); if (result.changed) saveStored(result.items); - set({ testCases: result.items }); + set({ testCases: result.items, loaded: true }); + } else { + set({ loaded: true }); } }, @@ -59,7 +68,7 @@ export const useTestCaseStore = create((set, get) => ({ updatedAt: now, } as TestCase); const updated = [...list, tc]; - set({ testCases: updated }); + set({ testCases: updated, loaded: true }); saveStored(updated); useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy)); return tc; @@ -82,7 +91,7 @@ export const useTestCaseStore = create((set, get) => ({ created.push(tc); list = [...list, tc]; } - set({ testCases: list }); + set({ testCases: list, loaded: true }); saveStored(list); created.forEach((tc) => { useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy)); @@ -94,13 +103,13 @@ export const useTestCaseStore = create((set, get) => ({ const updated = get().testCases.map((c) => c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c, ); - set({ testCases: updated }); + set({ testCases: updated, loaded: true }); saveStored(updated); }, deleteTestCase: (id) => { const updated = get().testCases.filter((c) => c.id !== id); - set({ testCases: updated }); + set({ testCases: updated, loaded: true }); saveStored(updated); }, diff --git a/apps/web/stores/useVersionPlanStore.ts b/apps/web/stores/useVersionPlanStore.ts index 669637b..a4cf00d 100644 --- a/apps/web/stores/useVersionPlanStore.ts +++ b/apps/web/stores/useVersionPlanStore.ts @@ -1,7 +1,7 @@ 'use client'; import { create } from 'zustand'; import type { VersionPlan, PlanType } from '@/lib/version-plan'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { getPlanCompletionState } from '@/lib/version-plan-workflow'; import type { PlanResultPayload } from '@/lib/version-plan-workflow'; import { @@ -15,6 +15,7 @@ import type { WorkActivityDraft } from '@/lib/work-activity'; import { useWorkActivityStore } from './useWorkActivityStore'; const MOCK_PLANS: VersionPlan[] = []; +let lastPlansFetchAt = 0; function saveStored(plans: VersionPlan[]) { saveServerData('version-plans', plans).catch(() => {}); @@ -29,7 +30,8 @@ async function loadStored(): Promise { interface VersionPlanState { plans: VersionPlan[]; - fetchPlans: () => Promise; + loaded: boolean; + fetchPlans: (options?: { force?: boolean }) => Promise; createPlan: (data: Omit) => void; updatePlan: (id: string, data: Partial) => void; completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string }; @@ -38,16 +40,20 @@ interface VersionPlanState { export const useVersionPlanStore = create((set, get) => ({ plans: MOCK_PLANS, + loaded: false, - fetchPlans: async () => { + fetchPlans: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - if (cached) set({ plans: cached }); + if (!options?.force && get().loaded && Date.now() - lastPlansFetchAt < SERVER_DATA_CACHE_MS) return; + lastPlansFetchAt = Date.now(); + set({ plans: cached ?? MOCK_PLANS, loaded: true }); }, createPlan: (data) => { const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) }; const plans = [...get().plans, plan]; - set({ plans }); + set({ plans, loaded: true }); saveStored(plans); useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner)); }, @@ -81,7 +87,7 @@ export const useVersionPlanStore = create((set, get) => ({ } return next; }); - set({ plans }); + set({ plans, loaded: true }); saveStored(plans); activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity)); }, @@ -100,14 +106,14 @@ export const useVersionPlanStore = create((set, get) => ({ useWorkActivityStore.getState().addActivity(makeVersionPlanCompletedActivity(next, next.owner)); return next; }); - set({ plans }); + set({ plans, loaded: true }); saveStored(plans); return response; }, deletePlan: (id) => { const plans = get().plans.filter((p) => p.id !== id); - set({ plans }); + set({ plans, loaded: true }); saveStored(plans); }, })); diff --git a/apps/web/stores/useWorkActivityStore.ts b/apps/web/stores/useWorkActivityStore.ts index f6242c0..f98deab 100644 --- a/apps/web/stores/useWorkActivityStore.ts +++ b/apps/web/stores/useWorkActivityStore.ts @@ -3,16 +3,19 @@ import { create } from 'zustand'; import { formatLocalDate } from '@/lib/format'; import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity'; import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; interface WorkActivityState { activities: WorkActivity[]; - fetchActivities: () => Promise; + loaded: boolean; + fetchActivities: (options?: { force?: boolean }) => Promise; addActivity: (data: WorkActivityDraft) => WorkActivity; addProgressNote: (data: ProgressNoteActivityInput) => WorkActivity; deleteActivity: (id: string) => void; } +let lastActivitiesFetchAt = 0; + async function saveStored( items: WorkActivity[], setActivities?: (items: WorkActivity[]) => void, @@ -23,7 +26,7 @@ async function saveStored( await saveServerData('work-activities', items); return; } - const remote = await loadServerData('work-activities'); + const remote = await loadServerData('work-activities', { force: true }); const merged = mergeWorkActivities(Array.isArray(remote) ? remote : [], items); setActivities?.(merged); await saveServerData('work-activities', merged); @@ -45,10 +48,14 @@ function createActivityId(): string { export const useWorkActivityStore = create((set, get) => ({ activities: [], + loaded: false, - fetchActivities: async () => { + fetchActivities: async (options) => { + if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return; const cached = await loadStored(); - if (cached) set({ activities: cached }); + if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return; + lastActivitiesFetchAt = Date.now(); + set({ activities: cached ?? [], loaded: true }); }, addActivity: (data) => { @@ -60,8 +67,8 @@ export const useWorkActivityStore = create((set, get) => ({ occurredAt: data.occurredAt ?? now.toISOString(), }; const updated = [...get().activities, item]; - set({ activities: updated }); - saveStored(updated, (activities) => set({ activities })); + set({ activities: updated, loaded: true }); + saveStored(updated, (activities) => set({ activities, loaded: true })); return item; }, @@ -71,7 +78,7 @@ export const useWorkActivityStore = create((set, get) => ({ deleteActivity: (id) => { const updated = get().activities.filter((activity) => activity.id !== id); - set({ activities: updated }); - saveStored(updated, (activities) => set({ activities }), { mergeRemote: false }); + set({ activities: updated, loaded: true }); + saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false }); }, })); diff --git a/apps/web/stores/useXiaobaoRiskStore.ts b/apps/web/stores/useXiaobaoRiskStore.ts index 92c4f28..4587c73 100644 --- a/apps/web/stores/useXiaobaoRiskStore.ts +++ b/apps/web/stores/useXiaobaoRiskStore.ts @@ -1,6 +1,6 @@ 'use client'; import { create } from 'zustand'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import { mergeDailySnapshotCacheForSave, mergeInsightCacheForSave, @@ -42,6 +42,7 @@ async function loadInsights(): Promise { let snapshotSaveQueue: Promise = Promise.resolve(); let insightSaveQueue: Promise = Promise.resolve(); +let lastRiskDataFetchAt = 0; export const useXiaobaoRiskStore = create((set, get) => ({ snapshots: [], @@ -52,8 +53,11 @@ export const useXiaobaoRiskStore = create((set, get) => ({ error: undefined, fetchRiskData: async () => { + if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return; set({ riskDataLoaded: false }); const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]); + if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return; + lastRiskDataFetchAt = Date.now(); set({ ...(snapshots ? { snapshots } : {}), ...(insights ? { insights } : {}), @@ -66,7 +70,7 @@ export const useXiaobaoRiskStore = create((set, get) => ({ const optimistic = upsertDailySnapshot(get().snapshots, item); set({ snapshots: optimistic, error: undefined }); const task = snapshotSaveQueue.then(async () => { - const remote = await loadServerData('xiaobao-risk-snapshots'); + const remote = await loadServerData('xiaobao-risk-snapshots', { force: true }); const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item); set({ snapshots, error: undefined }); await saveServerData('xiaobao-risk-snapshots', snapshots); @@ -84,7 +88,7 @@ export const useXiaobaoRiskStore = create((set, get) => ({ const optimistic = upsertInsight(get().insights, item); set({ insights: optimistic, error: undefined }); const task = insightSaveQueue.then(async () => { - const remote = await loadServerData('xiaobao-risk-insights'); + const remote = await loadServerData('xiaobao-risk-insights', { force: true }); const insights = mergeInsightCacheForSave(get().insights, Array.isArray(remote) ? remote : [], item); set({ insights, error: undefined }); await saveServerData('xiaobao-risk-insights', insights); diff --git a/apps/web/stores/useXiaobaoWarningReadStore.ts b/apps/web/stores/useXiaobaoWarningReadStore.ts index 76a34d6..48e8853 100644 --- a/apps/web/stores/useXiaobaoWarningReadStore.ts +++ b/apps/web/stores/useXiaobaoWarningReadStore.ts @@ -6,7 +6,7 @@ import { markXiaobaoWarningRead, type XiaobaoWarningReadState, } from '@/lib/xiaobao-warning-view'; -import { loadServerData, saveServerData } from '@/lib/server-data'; +import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; interface XiaobaoWarningReadStoreState { readStates: XiaobaoWarningReadState[]; @@ -17,6 +17,7 @@ interface XiaobaoWarningReadStoreState { } let readStateSaveQueue: Promise = Promise.resolve(); +let lastReadStatesFetchAt = 0; async function loadReadStates(): Promise { try { @@ -63,7 +64,10 @@ export const useXiaobaoWarningReadStore = create(( error: undefined, fetchReadStates: async () => { + if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return; const rows = await loadReadStates(); + if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return; + lastReadStatesFetchAt = Date.now(); set({ readStates: rows ? mergeReadStates(rows, get().readStates) : get().readStates, readStateLoaded: rows !== null, @@ -77,7 +81,7 @@ export const useXiaobaoWarningReadStore = create(( set({ readStates: optimistic, error: undefined }); const task = readStateSaveQueue.then(async () => { - const remote = await loadServerData('xiaobao-warning-views'); + const remote = await loadServerData('xiaobao-warning-views', { force: true }); const merged = mergeReadStates(normalizeReadStates(remote), get().readStates); set({ readStates: merged, readStateLoaded: true, error: undefined }); await saveServerData('xiaobao-warning-views', merged);