perf(web): 优化大数据量页面切换与聚合性能

This commit is contained in:
Script Generator
2026-07-03 09:43:36 +08:00
parent 554ab520d1
commit a509bb4922
32 changed files with 1087 additions and 360 deletions

View File

@@ -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<string, number>;
const map: Record<string, number> = {};
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<string, Record<string, number>>;
@@ -524,7 +473,7 @@ export default function ProjectDetailPage() {
{sortedVersions.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--line)] p-6 text-center text-xs text-[var(--ink-muted)]"></div>
) : (
sortedVersions.map((v) => <VersionCard key={v.id} version={v} progress={versionProgressMap[v.id] ?? 0} plans={plans} devTasks={devTasks} testCases={testCases} bugs={bugs} requirements={requirements} onNavigate={(id) => router.push(`/versions/${id}`)} />)
sortedVersions.map((v) => <VersionCard key={v.id} version={v} progress={versionProgressMap[v.id] ?? 0} scope={versionScopeMap[v.id]} onNavigate={(id) => router.push(`/versions/${id}`)} />)
)}
</div>
</section>

View File

@@ -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<string, typeof devTasks>();
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<RequirementScopeSelection>({ 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() {
</div>
) : (
<>
<div className="overflow-x-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
<table className="min-w-[1120px] w-full text-left text-[13px]">
<div className={REQUIREMENT_TABLE_CONTAINER_CLASS}>
<table className={REQUIREMENT_TABLE_CLASS}>
<colgroup>
{REQUIREMENT_TABLE_COLUMN_WIDTHS.map((width, index) => (
<col key={index} style={{ width: `${width}%` }} />
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
<tr className="border-b border-[var(--line)] bg-[var(--bg-subtle)]">
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th className={REQUIREMENT_TABLE_HEADER_CELL_CLASS}></th>
<th
className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
className={`${REQUIREMENT_TABLE_HEADER_CELL_CLASS} cursor-pointer select-none transition-colors hover:text-[var(--ink-soft)]`}
onClick={() => setDateSort(dateSort === 'desc' ? 'asc' : 'desc')}
>
<span className="inline-flex items-center gap-1">
@@ -473,7 +493,7 @@ function RequirementsPageContent() {
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
</span>
</th>
<th className="px-4 py-2.5 text-right text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]"></th>
<th className={`${REQUIREMENT_TABLE_HEADER_CELL_CLASS} text-right`}></th>
</tr>
</thead>
<tbody>
@@ -545,13 +565,20 @@ function RequirementsPageContent() {
{/* 所属版本 */}
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
{resolveVersionName(req.versionId)}
{(() => {
const versionName = resolveVersionName(req.versionId);
return (
<span className={getRequirementTableTextClass('version')} title={versionName}>
{versionName}
</span>
);
})()}
</td>
{/* 开发状态 */}
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
{(() => {
const devStatus = deriveReqDevStatus(req.id, devTasks);
const devStatus = requirementDevStatusMap.get(req.id) ?? 'unscheduled';
return (
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
{REQ_DEV_STATUS_LABEL[devStatus]}
@@ -573,13 +600,13 @@ function RequirementsPageContent() {
</td>
{/* 操作 */}
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1 whitespace-nowrap">
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-0.5 whitespace-nowrap">
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
{(['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) ?? []) && (
<button
onClick={() => handleEdit(req)}
className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)]"
>
</button>
@@ -589,23 +616,23 @@ function RequirementsPageContent() {
<>
<button
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
className="h-6 px-2 rounded text-[11px] font-medium text-emerald-600 hover:bg-emerald-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-emerald-600 transition-colors hover:bg-emerald-50"
>
</button>
<button
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
className="h-6 px-2 rounded text-[11px] font-medium text-red-600 hover:bg-red-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-600 transition-colors hover:bg-red-50"
>
</button>
</>
)}
{/* 已采纳/已规划:关闭 */}
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasks) && (
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
<button
onClick={() => updateRequirement(req.id, { status: 'closed' })}
className="h-6 px-2 rounded text-[11px] font-medium text-orange-600 hover:bg-orange-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-orange-600 transition-colors hover:bg-orange-50"
>
</button>
@@ -614,7 +641,7 @@ function RequirementsPageContent() {
{(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
<button
onClick={() => deleteRequirement(req.id)}
className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50 transition-colors"
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-500 transition-colors hover:bg-red-50"
>
</button>
@@ -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); }}
/>
)}

View File

@@ -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<Record<string, number>>(
() => 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,10 +202,8 @@ 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 scopedVersionData = versionScope!;
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
product.versions.forEach((item) => {
@@ -200,7 +211,8 @@ export default function VersionDetailPage() {
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
});
});
const memberRecommendationGroups = recommendVersionMembers({
return recommendVersionMembers({
candidates: memberCandidates,
currentMembers: version.members ?? [],
devTasks,
@@ -208,9 +220,9 @@ export default function VersionDetailPage() {
bugs,
overtimeRecords: records,
versionDeadline: version.expectedReleaseDate,
scopeRequirements: recommendationVersionReqs,
scopeDevTasks: recommendationVersionDevTasks,
scopeTestCases: recommendationVersionTestCases,
scopeRequirements: scopedVersionData.requirements,
scopeDevTasks: scopedVersionData.devTasks,
scopeTestCases: scopedVersionData.testCases,
taskCategories,
historicalStats: memberCandidates.flatMap((member) => {
const projectParticipationCount = currentSystemParticipation.get(member.name);
@@ -219,6 +231,7 @@ export default function VersionDetailPage() {
: [];
}),
});
})() : [];
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 (
<PlanTab
plans={plans}
plans={scopedVersionData.plans}
versionId={version.id}
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
@@ -942,27 +953,32 @@ export default function VersionDetailPage() {
);
})()
) : activeTab === 'tasks' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return (
<DevTaskTab
versionId={version.id}
requirementIds={versionReqs.map((r) => r.id)}
requirementIds={scopedVersionData.requirementIds}
versionDeadline={version.expectedReleaseDate ?? undefined}
readOnly={versionReadonly}
versionTasks={scopedVersionData.devTasks}
versionRequirements={scopedVersionData.requirements}
/>
);
})()
) : activeTab === 'testcases' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
})()
<TestCaseTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
readOnly={versionReadonly}
versionCases={scopedVersionData.testCases}
versionBugs={scopedVersionData.bugs}
versionDevTasks={scopedVersionData.devTasks}
versionRequirements={scopedVersionData.requirements}
/>
) : activeTab === 'bugs' ? (
(() => {
const versionReqs = requirements.filter((r) => r.versionId === version.id);
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
})()
<BugTab
versionId={version.id}
requirementIds={scopedVersionData.requirementIds}
readOnly={versionReadonly}
versionBugs={scopedVersionData.bugs}
versionTestCases={scopedVersionData.testCases}
/>
) : (
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
<span className="text-[13px] text-[var(--ink-muted)]"></span>

View File

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

View File

@@ -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<string | null>(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;

View File

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

View File

@@ -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,

View File

@@ -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<string, string>();
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) => ({
}), [bugs, devTasks, plans, requirements, testCases, visibleVersions]);
const workItemsByVersion = useMemo(() => {
const map = new Map<string, ReturnType<typeof buildXiaobaoWorkItems>>();
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,
}), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]);
}],
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,

View File

@@ -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> = {}): 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);
});

View File

@@ -28,6 +28,31 @@ export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevSt
return 'todo';
}
export function buildRequirementDevStatusMap(devTasks: DevTask[]): Map<string, ReqDevStatus> {
const stats = new Map<string, { total: number; submitted: number; active: boolean }>();
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<string, ReqDevStatus>();
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 的任务)不可编辑

View File

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

View File

@@ -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<RequirementTableTextColumn, string> = {
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';

View File

@@ -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<void>((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<Array<{ id: string }>>('test-cases');
const second = loadServerData<Array<{ id: string }>>('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<Array<{ id: string }>>('version-plans');
const second = await loadServerData<Array<{ id: string }>>('version-plans');
assert.deepEqual(first, [{ id: 'plan-1' }]);
assert.deepEqual(second, [{ id: 'plan-1' }]);
assert.equal(dataRequests, 1);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -30,6 +30,18 @@ type ServerDataConflictBody<T> = {
};
const serverDataVersions = new Map<ServerDataKey, string | null>();
const serverDataCache = new Map<ServerDataKey, {
value: unknown | null;
version: string | null;
loadedAt: number;
}>();
const serverDataLoadPromises = new Map<ServerDataKey, Promise<unknown | null>>();
export const SERVER_DATA_CACHE_MS = 30_000;
type LoadServerDataOptions = {
force?: boolean;
maxAgeMs?: number;
};
export class ServerDataConflictError<T = unknown> extends Error {
constructor(
@@ -42,10 +54,36 @@ export class ServerDataConflictError<T = unknown> extends Error {
}
}
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
serverDataVersions.set(key, res.version ?? null);
export async function loadServerData<T>(
key: ServerDataKey,
options: LoadServerDataOptions = {},
): Promise<T | null> {
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<T | null>;
const promise = api.get<ServerDataResponse<T>>(`/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<unknown | null>);
return promise;
}
export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<void> {
@@ -56,7 +94,13 @@ export async function saveServerData<T>(key: ServerDataKey, value: T): Promise<v
try {
const res = await api.put<ServerDataResponse<T>>(`/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 &&

View File

@@ -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>): 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']);
});

View File

@@ -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<string>;
plans: VersionPlan[];
plansByType: Record<VersionPlan['type'], VersionPlan[]>;
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<VersionDataScopeInput, 'versionId'> {
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<string>();
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<VersionPlan['type'], VersionPlan[]> = {
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<string, VersionDataScope> {
const scopes: Record<string, VersionDataScope> = {};
const targetVersionIds = new Set(input.versionIds);
for (const versionId of input.versionIds) {
scopes[versionId] = createEmptyScope();
}
const requirementVersionMap = new Map<string, string>();
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<string>(),
plans: [],
plansByType: {
research: [],
product: [],
ui: [],
},
devTasks: [],
testCases: [],
bugs: [],
overtimeRecords: [],
};
}

View File

@@ -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<string, number> {
const plansByVersion = groupByVersionId(plans);
const requirementVersionMap = new Map<string, string>();
for (const requirement of requirements) {
if (!requirement.versionId) continue;
requirementVersionMap.set(requirement.id, requirement.versionId);
}
const devTasksByVersion = new Map<string, DevTask[]>();
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<string, number> = {};
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<T extends { versionId: string }>(items: T[]): Map<string, T[]> {
const map = new Map<string, T[]>();
for (const item of items) {
const group = map.get(item.versionId) ?? [];
group.push(item);
map.set(item.versionId, group);
}
return map;
}

View File

@@ -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',

View File

@@ -37,6 +37,7 @@ export function aggregateWorkItems(
requirementVersionMap: Map<string, string>,
): 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<Pick<WorkItem, 'co
return items.reduce((sum, item) => sum + (item.completed ? 0 : 1), 0);
}
export function getWorkspacePendingCountByVersion(
items: ReadonlyArray<Pick<WorkItem, 'versionId' | 'completed'>>,
): Map<string, number> {
const map = new Map<string, number>();
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<WorkItemType, string> = {
plan_research: '调研',
plan_product: '产品方案',

View File

@@ -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<Bug[] | null> {
try {
return await loadServerData<Bug[]>('bugs');
@@ -28,7 +30,8 @@ function makeLog(action: BugLog['action'], operator: string, from?: string, to?:
interface BugState {
bugs: Bug[];
fetchBugs: () => Promise<void>;
loaded: boolean;
fetchBugs: (options?: { force?: boolean }) => Promise<void>;
createBug: (data: Omit<Bug, 'id' | 'bugNo' | 'createdAt' | 'updatedAt' | 'status' | 'logs'>, operator: string) => Bug;
updateBug: (id: string, data: Partial<Bug>) => void;
deleteBug: (id: string) => void;
@@ -41,10 +44,14 @@ interface BugState {
export const useBugStore = create<BugState>((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<BugState>((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<BugState>((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);
},

View File

@@ -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<DevTask[] | null> {
try {
const parsed = await loadServerData<DevTask[]>('dev-tasks');
@@ -34,7 +36,8 @@ async function loadStored(): Promise<DevTask[] | null> {
interface DevTaskState {
tasks: DevTask[];
fetchTasks: () => Promise<void>;
loaded: boolean;
fetchTasks: (options?: { force?: boolean }) => Promise<void>;
createTask: (data: Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'isBlocked'>) => DevTask;
updateTask: (id: string, data: Partial<DevTask>) => void;
deleteTask: (id: string) => void;
@@ -47,10 +50,14 @@ interface DevTaskState {
export const useDevTaskStore = create<DevTaskState>((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<DevTaskState>((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<DevTaskState>((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);
},

View File

@@ -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<void>;
updatePasswordRule: (rule: PasswordRule) => void;
createDepartment: (data: Omit<Department, 'id' | 'createdAt'>) => void;
updateDepartment: (id: string, data: Partial<Department>) => void;
@@ -65,9 +67,13 @@ export const useMemberStore = create<MemberState>((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<MemberState>((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 });
}
},

View File

@@ -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<void>;
loaded: boolean;
fetchRecords: (options?: { force?: boolean }) => Promise<void>;
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
deleteRecord: (id: string) => void;
@@ -49,10 +51,14 @@ export const useOvertimeStore = create<OvertimeState>((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<OvertimeState>((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<OvertimeState>((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 });
},
}));

View File

@@ -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<void>;
fetchOverview: () => Promise<void>;
@@ -77,6 +79,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
overview: [],
currentProduct: null,
loading: false,
overviewLoaded: false,
error: null,
fetchProducts: async () => {
@@ -90,22 +93,24 @@ export const useProductStore = create<ProductState>((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<ProductOverview[]>('/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<ProductState>((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<ProductState>((set, get) => ({
updateProduct: async (id, data) => {
try {
await api.patch(`/products/${id}`, data);
await get().fetchOverview();
} catch {
} catch {}
const updated = get().overview.map((p) =>
p.id === id ? { ...p, ...data } : p,
);
set({ overview: updated });
set({ overview: updated, overviewLoaded: true });
saveStoredOverview(updated);
}
},
deleteProduct: async (id) => {
@@ -164,6 +167,7 @@ export const useProductStore = create<ProductState>((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<ProductState>((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<ProductState>((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<ProductState>((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<ProductState>((set, get) => ({
versions: nextVersions,
};
});
set({ overview: updated });
set({ overview: updated, overviewLoaded: true });
saveStoredOverview(updated);
},
@@ -271,7 +275,7 @@ export const useProductStore = create<ProductState>((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<ProductState>((set, get) => ({
),
};
});
set({ overview: updated });
set({ overview: updated, overviewLoaded: true });
saveStoredOverview(updated);
},
@@ -298,7 +302,7 @@ export const useProductStore = create<ProductState>((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<ProductState>((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);
},
}));

View File

@@ -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<void>;
loaded: boolean;
fetchRequirements: (options?: { force?: boolean }) => Promise<void>;
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
updateRequirement: (id: string, data: Partial<Requirement>) => 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<RequirementState>((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<RequirementState>((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<RequirementState>((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<RequirementState>((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<RequirementState>((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 });
},
};

View File

@@ -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<TaskCategory[] | null> {
try {
return await loadServerData<TaskCategory[]>('task-categories');
@@ -17,7 +19,8 @@ async function loadStored(): Promise<TaskCategory[] | null> {
interface TaskCategoryState {
categories: TaskCategory[];
fetchCategories: () => Promise<void>;
loaded: boolean;
fetchCategories: (options?: { force?: boolean }) => Promise<void>;
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
ensureCategory: (name: string, group: CategoryGroup, options?: { allowCreate?: boolean; fallbackCode?: string }) => TaskCategory;
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
@@ -26,10 +29,14 @@ interface TaskCategoryState {
export const useTaskCategoryStore = create<TaskCategoryState>((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<TaskCategoryState>((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<TaskCategoryState>((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<TaskCategoryState>((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;
},

View File

@@ -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<TaskWorklog[] | null> {
try {
return await loadServerData<TaskWorklog[]>('task-worklogs');
@@ -16,7 +18,8 @@ async function loadStored(): Promise<TaskWorklog[] | null> {
interface TaskWorklogState {
worklogs: TaskWorklog[];
fetchWorklogs: () => Promise<void>;
loaded: boolean;
fetchWorklogs: (options?: { force?: boolean }) => Promise<void>;
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
deleteWorklog: (id: string) => void;
getActualHours: (taskId: string) => number;
@@ -24,10 +27,14 @@ interface TaskWorklogState {
export const useTaskWorklogStore = create<TaskWorklogState>((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<TaskWorklogState>((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);
},

View File

@@ -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<TestCase[] | null> {
try {
return await loadServerData<TestCase[]>('test-cases');
@@ -24,7 +26,8 @@ async function loadStored(): Promise<TestCase[] | null> {
interface TestCaseState {
testCases: TestCase[];
fetchTestCases: () => Promise<void>;
loaded: boolean;
fetchTestCases: (options?: { force?: boolean }) => Promise<void>;
createTestCase: (data: CreateTestCaseInput) => TestCase;
createTestCases: (items: CreateTestCaseInput[]) => TestCase[];
updateTestCase: (id: string, data: Partial<TestCase>) => void;
@@ -37,13 +40,19 @@ interface TestCaseState {
export const useTestCaseStore = create<TestCaseState>((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<TestCaseState>((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<TestCaseState>((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<TestCaseState>((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);
},

View File

@@ -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<VersionPlan[] | null> {
interface VersionPlanState {
plans: VersionPlan[];
fetchPlans: () => Promise<void>;
loaded: boolean;
fetchPlans: (options?: { force?: boolean }) => Promise<void>;
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string };
@@ -38,16 +40,20 @@ interface VersionPlanState {
export const useVersionPlanStore = create<VersionPlanState>((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<VersionPlanState>((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<VersionPlanState>((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);
},
}));

View File

@@ -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<void>;
loaded: boolean;
fetchActivities: (options?: { force?: boolean }) => Promise<void>;
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<WorkActivity[]>('work-activities');
const remote = await loadServerData<WorkActivity[]>('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<WorkActivityState>((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<WorkActivityState>((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<WorkActivityState>((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 });
},
}));

View File

@@ -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<XiaobaoRiskInsightCacheItem[] | null> {
let snapshotSaveQueue: Promise<void> = Promise.resolve();
let insightSaveQueue: Promise<void> = Promise.resolve();
let lastRiskDataFetchAt = 0;
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
snapshots: [],
@@ -52,8 +53,11 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((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<XiaobaoRiskState>((set, get) => ({
const optimistic = upsertDailySnapshot(get().snapshots, item);
set({ snapshots: optimistic, error: undefined });
const task = snapshotSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoRiskSnapshot[]>('xiaobao-risk-snapshots');
const remote = await loadServerData<XiaobaoRiskSnapshot[]>('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<XiaobaoRiskState>((set, get) => ({
const optimistic = upsertInsight(get().insights, item);
set({ insights: optimistic, error: undefined });
const task = insightSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoRiskInsightCacheItem[]>('xiaobao-risk-insights');
const remote = await loadServerData<XiaobaoRiskInsightCacheItem[]>('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);

View File

@@ -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<void> = Promise.resolve();
let lastReadStatesFetchAt = 0;
async function loadReadStates(): Promise<XiaobaoWarningReadState[] | null> {
try {
@@ -63,7 +64,10 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
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<XiaobaoWarningReadStoreState>((
set({ readStates: optimistic, error: undefined });
const task = readStateSaveQueue.then(async () => {
const remote = await loadServerData<XiaobaoWarningReadState[]>('xiaobao-warning-views');
const remote = await loadServerData<XiaobaoWarningReadState[]>('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);