perf(web): 优化大数据量页面切换与聚合性能
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 的任务)不可编辑
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
171
apps/web/lib/version-data-scope.test.ts
Normal file
171
apps/web/lib/version-data-scope.test.ts
Normal 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']);
|
||||
});
|
||||
144
apps/web/lib/version-data-scope.ts
Normal file
144
apps/web/lib/version-data-scope.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '产品方案',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user