perf(web): 优化大数据量页面切换与聚合性能
This commit is contained in:
@@ -15,11 +15,13 @@ import { useMemberStore } from '@/stores/useMemberStore';
|
|||||||
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
|
import { getProjectDetail, VersionWithContext } from '@/lib/derive';
|
||||||
import { Stage, Role, STAGES, ROLES, STAGE_INDEX, ROLE_LABEL } from '@/lib/stage';
|
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 { 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 { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||||
import { MemberChips } from '@/components/version/MemberChips';
|
import { MemberChips } from '@/components/version/MemberChips';
|
||||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
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 { formatActualDuration } from '@/lib/work-hours';
|
||||||
import type { DevTask } from '@/lib/dev-task';
|
import type { DevTask } from '@/lib/dev-task';
|
||||||
import type { TestCase } from '@/lib/test-case';
|
import type { TestCase } from '@/lib/test-case';
|
||||||
@@ -73,14 +75,10 @@ function ProgressBar({ role, percent, daysSpent }: { role: Role; percent: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── VersionCard ─── */
|
/* ─── VersionCard ─── */
|
||||||
function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requirements, onNavigate }: {
|
function VersionCard({ version, progress, scope, onNavigate }: {
|
||||||
version: VersionWithContext;
|
version: VersionWithContext;
|
||||||
progress: number;
|
progress: number;
|
||||||
plans: VersionPlan[];
|
scope: VersionDataScope;
|
||||||
devTasks: DevTask[];
|
|
||||||
testCases: TestCase[];
|
|
||||||
bugs: Bug[];
|
|
||||||
requirements: { id: string; versionId?: string }[];
|
|
||||||
onNavigate: (id: string) => void;
|
onNavigate: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status));
|
const [expanded, setExpanded] = useState(() => getVersionCardDefaultExpanded(version.status));
|
||||||
@@ -89,15 +87,12 @@ function VersionCard({ version, progress, plans, devTasks, testCases, bugs, requ
|
|||||||
setExpanded(getVersionCardDefaultExpanded(version.status));
|
setExpanded(getVersionCardDefaultExpanded(version.status));
|
||||||
}, [version.status]);
|
}, [version.status]);
|
||||||
|
|
||||||
const versionData = useMemo(() => {
|
const versionData = useMemo(() => ({
|
||||||
const vPlans = plans.filter((p) => p.versionId === version.id);
|
vPlans: scope.plans,
|
||||||
const vReqIds = new Set(requirements.filter((r) => r.versionId === version.id).map((r) => r.id));
|
vDevTasks: scope.devTasks,
|
||||||
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
|
vTCs: scope.testCases,
|
||||||
const vTCs = testCases.filter((c) => c.versionId === version.id);
|
vBugs: scope.bugs,
|
||||||
const vBugs = bugs.filter((b) => b.versionId === version.id);
|
}), [scope]);
|
||||||
|
|
||||||
return { vPlans, vDevTasks, vTCs, vBugs };
|
|
||||||
}, [version.id, plans, devTasks, testCases, bugs, requirements]);
|
|
||||||
|
|
||||||
const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({
|
const stageEffortMetrics = useMemo(() => calcStageEffortMetrics({
|
||||||
plans: versionData.vPlans,
|
plans: versionData.vPlans,
|
||||||
@@ -373,85 +368,39 @@ export default function ProjectDetailPage() {
|
|||||||
}, [project, statusFilter, isSuperAdmin, currentUserName]);
|
}, [project, statusFilter, isSuperAdmin, currentUserName]);
|
||||||
|
|
||||||
// Compute actual overall progress per version
|
// Compute actual overall progress per version
|
||||||
const versionProgressMap = useMemo(() => {
|
const versionScopeMap = useMemo(
|
||||||
if (!project) return {} as Record<string, number>;
|
() => project ? buildVersionDataScopeMap({
|
||||||
const map: Record<string, number> = {};
|
versionIds: project.versions.map((version) => version.id),
|
||||||
for (const v of project.versions) {
|
plans,
|
||||||
const vPlans = plans.filter((p) => p.versionId === v.id);
|
requirements,
|
||||||
const vReqs = requirements.filter((r) => r.versionId === v.id);
|
devTasks,
|
||||||
const vReqIds = new Set(vReqs.map((r) => r.id));
|
testCases,
|
||||||
const vDevTasks = devTasks.filter((t) => vReqIds.has(t.requirementId));
|
bugs,
|
||||||
const vTestCases = testCases.filter((c) => c.versionId === v.id);
|
}) : {},
|
||||||
|
[project, plans, requirements, devTasks, testCases, bugs],
|
||||||
|
);
|
||||||
|
|
||||||
const segments: number[] = [];
|
const versionProgressMap = useMemo(
|
||||||
|
() => Object.fromEntries(
|
||||||
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
Object.entries(versionScopeMap).map(([id, scope]) => [
|
||||||
if (researchPlans.length > 0) {
|
id,
|
||||||
const totals = researchPlans.reduce((acc, p) => {
|
calcScopedVersionProgress(scope.plans, scope.devTasks, scope.testCases),
|
||||||
const tasks = p.tasks || [];
|
]),
|
||||||
acc.total += tasks.length;
|
),
|
||||||
acc.done += tasks.filter((t) => t.status === 'completed').length;
|
[versionScopeMap],
|
||||||
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 stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 };
|
if (!project) return { total: 0, released: 0, reqCount: 0, bugCount: 0, estimateHours: 0, actualHours: 0 };
|
||||||
const total = project.versions.length;
|
const total = project.versions.length;
|
||||||
const released = project.versions.filter((v) => v.status === 'released').length;
|
const released = project.versions.filter((v) => v.status === 'released').length;
|
||||||
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
|
const reqCount = requirements.filter((r) => r.projectId === projectId).length;
|
||||||
const versionIds = new Set(project.versions.map((v) => v.id));
|
const versionScopes = Object.values(versionScopeMap);
|
||||||
const bugCount = bugs.filter((b) => versionIds.has(b.versionId)).length;
|
const bugCount = versionScopes.reduce((sum, scope) => sum + scope.bugs.length, 0);
|
||||||
const projectReqIds = new Set(requirements.filter((r) => r.projectId === projectId).map((r) => r.id));
|
const projectDevTasks = versionScopes.flatMap((scope) => scope.devTasks);
|
||||||
const projectDevTasks = devTasks.filter((t) => projectReqIds.has(t.requirementId));
|
|
||||||
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
|
const { estimate, actual } = aggregateDevTaskHours(projectDevTasks);
|
||||||
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
|
return { total, released, reqCount, bugCount, estimateHours: estimate, actualHours: actual };
|
||||||
}, [project, requirements, bugs, devTasks, projectId]);
|
}, [project, requirements, projectId, versionScopeMap]);
|
||||||
|
|
||||||
const teamByRole = useMemo(() => {
|
const teamByRole = useMemo(() => {
|
||||||
if (!project) return {} as Record<string, Record<string, number>>;
|
if (!project) return {} as Record<string, Record<string, number>>;
|
||||||
@@ -524,7 +473,7 @@ export default function ProjectDetailPage() {
|
|||||||
{sortedVersions.length === 0 ? (
|
{sortedVersions.length === 0 ? (
|
||||||
<div className="rounded-xl border border-dashed border-[var(--line)] p-6 text-center text-xs text-[var(--ink-muted)]">暂无版本</div>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
|||||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||||
import type { Requirement, RequirementStatus, SourceType } 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 { buildRequirementScopeTree, filterRequirementScopeTreeByKeyword, filterRequirementsByScope, type RequirementProductScopeNode, type RequirementScopeSelection } from '@/lib/requirement-scope';
|
||||||
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
import { sortRequirementsByCreatedAt, type RequirementDateSort } from '@/lib/requirement-sort';
|
||||||
import {
|
import {
|
||||||
REQUIREMENT_TABLE_BADGE_CLASS,
|
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_NOWRAP_CELL_CLASS,
|
||||||
REQUIREMENT_TABLE_SMALL_BADGE_CLASS,
|
REQUIREMENT_TABLE_SMALL_BADGE_CLASS,
|
||||||
getRequirementTableTextClass,
|
getRequirementTableTextClass,
|
||||||
@@ -178,6 +182,17 @@ function RequirementsPageContent() {
|
|||||||
const currentUserName = user?.name || '系统';
|
const currentUserName = user?.name || '系统';
|
||||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||||
const allVersions = useMemo(() => flattenVersions(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 [selectedScope, setSelectedScope] = useState<RequirementScopeSelection>({ type: 'all' });
|
||||||
const [scopeSearch, setScopeSearch] = useState('');
|
const [scopeSearch, setScopeSearch] = useState('');
|
||||||
@@ -261,7 +276,7 @@ function RequirementsPageContent() {
|
|||||||
// status filter
|
// status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
list = statusFilter === 'dev_completed'
|
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);
|
: list.filter((r) => r.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +296,7 @@ function RequirementsPageContent() {
|
|||||||
list = sortRequirementsByCreatedAt(list, dateSort);
|
list = sortRequirementsByCreatedAt(list, dateSort);
|
||||||
|
|
||||||
return list;
|
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);
|
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||||
|
|
||||||
@@ -450,22 +465,27 @@ function RequirementsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="overflow-x-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-[var(--shadow-sm)]">
|
<div className={REQUIREMENT_TABLE_CONTAINER_CLASS}>
|
||||||
<table className="min-w-[1120px] w-full text-left text-[13px]">
|
<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)]">
|
<thead className="sticky top-0 z-10 bg-[var(--bg-subtle)]">
|
||||||
<tr className="border-b border-[var(--line)] 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={REQUIREMENT_TABLE_HEADER_CELL_CLASS}>需求编号</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="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="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="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="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="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="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="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="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
|
<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')}
|
onClick={() => setDateSort(dateSort === 'desc' ? 'asc' : 'desc')}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">
|
<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" />}
|
{dateSort === 'desc' ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -545,13 +565,20 @@ function RequirementsPageContent() {
|
|||||||
|
|
||||||
{/* 所属版本 */}
|
{/* 所属版本 */}
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
<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>
|
||||||
|
|
||||||
{/* 开发状态 */}
|
{/* 开发状态 */}
|
||||||
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
|
<td className={REQUIREMENT_TABLE_NOWRAP_CELL_CLASS}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const devStatus = deriveReqDevStatus(req.id, devTasks);
|
const devStatus = requirementDevStatusMap.get(req.id) ?? 'unscheduled';
|
||||||
return (
|
return (
|
||||||
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
|
<span className={`${REQUIREMENT_TABLE_SMALL_BADGE_CLASS} ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
|
||||||
{REQ_DEV_STATUS_LABEL[devStatus]}
|
{REQ_DEV_STATUS_LABEL[devStatus]}
|
||||||
@@ -573,13 +600,13 @@ function RequirementsPageContent() {
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 操作 */}
|
{/* 操作 */}
|
||||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1 whitespace-nowrap">
|
<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
|
<button
|
||||||
onClick={() => handleEdit(req)}
|
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>
|
</button>
|
||||||
@@ -589,23 +616,23 @@ function RequirementsPageContent() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setRejectingReq(req); setRejectReason(''); }}
|
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>
|
</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
|
<button
|
||||||
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
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>
|
</button>
|
||||||
@@ -614,7 +641,7 @@ function RequirementsPageContent() {
|
|||||||
{(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
{(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteRequirement(req.id)}
|
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>
|
</button>
|
||||||
@@ -644,10 +671,10 @@ function RequirementsPageContent() {
|
|||||||
requirements={requirements}
|
requirements={requirements}
|
||||||
resolveVersionName={resolveVersionName}
|
resolveVersionName={resolveVersionName}
|
||||||
onClose={() => setViewingReq(null)}
|
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); }}
|
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
||||||
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); 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); }}
|
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 { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
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 { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||||
import { isVersionReadonly } from '@/lib/version-status';
|
import { isVersionReadonly } from '@/lib/version-status';
|
||||||
import {
|
import {
|
||||||
@@ -139,21 +140,33 @@ export default function VersionDetailPage() {
|
|||||||
}, [fetchMembers, fetchCategories]);
|
}, [fetchMembers, fetchCategories]);
|
||||||
|
|
||||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||||
const releaseProgressMap = useMemo<Record<string, number>>(
|
const versionScope = useMemo(
|
||||||
() => version ? buildVersionProgressMap([version], plans, requirements, devTasks, testCases) : {},
|
() => version
|
||||||
[version, plans, requirements, devTasks, testCases],
|
? 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;
|
const versionReadonly = version ? isVersionReadonly(version.status) : false;
|
||||||
|
|
||||||
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
// 自动同步版本状态:有计划开始时间<=今天,版本应进入对应阶段
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version || !plans.length) return;
|
if (!version || !versionScope || !versionScope.plans.length) return;
|
||||||
if (version.status !== 'planned' && version.status !== 'developing') return;
|
if (version.status !== 'planned' && version.status !== 'developing') return;
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
const stageMap = { research: 'requirement', product: 'product_design', ui: 'ui_design' } as const;
|
||||||
const stageOrder: string[] = ['requirement', 'product_design', 'ui_design'];
|
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;
|
if (versionPlans.length === 0) return;
|
||||||
let targetStage = '';
|
let targetStage = '';
|
||||||
for (const p of versionPlans) {
|
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))) {
|
if (version.status === 'planned' || (version.currentStage && stageOrder.indexOf(targetStage) > stageOrder.indexOf(version.currentStage))) {
|
||||||
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
|
updateVersion(version.productId, version.id, { status: 'developing', currentStage: targetStage as any });
|
||||||
}
|
}
|
||||||
}, [plans, version, versionId, updateVersion]);
|
}, [versionScope, version, updateVersion]);
|
||||||
|
|
||||||
if (!version) {
|
if (!version) {
|
||||||
return (
|
return (
|
||||||
@@ -189,10 +202,8 @@ export default function VersionDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const recommendationVersionReqs = requirements.filter((requirement) => requirement.versionId === version.id);
|
const scopedVersionData = versionScope!;
|
||||||
const recommendationVersionReqIds = new Set(recommendationVersionReqs.map((requirement) => requirement.id));
|
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
|
||||||
const recommendationVersionDevTasks = devTasks.filter((task) => recommendationVersionReqIds.has(task.requirementId));
|
|
||||||
const recommendationVersionTestCases = testCases.filter((testCase) => testCase.versionId === version.id);
|
|
||||||
const currentSystemParticipation = new Map<string, number>();
|
const currentSystemParticipation = new Map<string, number>();
|
||||||
overview.forEach((product) => {
|
overview.forEach((product) => {
|
||||||
product.versions.forEach((item) => {
|
product.versions.forEach((item) => {
|
||||||
@@ -200,7 +211,8 @@ export default function VersionDetailPage() {
|
|||||||
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
|
uniqueNames.forEach((name) => currentSystemParticipation.set(name, (currentSystemParticipation.get(name) ?? 0) + 1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const memberRecommendationGroups = recommendVersionMembers({
|
|
||||||
|
return recommendVersionMembers({
|
||||||
candidates: memberCandidates,
|
candidates: memberCandidates,
|
||||||
currentMembers: version.members ?? [],
|
currentMembers: version.members ?? [],
|
||||||
devTasks,
|
devTasks,
|
||||||
@@ -208,9 +220,9 @@ export default function VersionDetailPage() {
|
|||||||
bugs,
|
bugs,
|
||||||
overtimeRecords: records,
|
overtimeRecords: records,
|
||||||
versionDeadline: version.expectedReleaseDate,
|
versionDeadline: version.expectedReleaseDate,
|
||||||
scopeRequirements: recommendationVersionReqs,
|
scopeRequirements: scopedVersionData.requirements,
|
||||||
scopeDevTasks: recommendationVersionDevTasks,
|
scopeDevTasks: scopedVersionData.devTasks,
|
||||||
scopeTestCases: recommendationVersionTestCases,
|
scopeTestCases: scopedVersionData.testCases,
|
||||||
taskCategories,
|
taskCategories,
|
||||||
historicalStats: memberCandidates.flatMap((member) => {
|
historicalStats: memberCandidates.flatMap((member) => {
|
||||||
const projectParticipationCount = currentSystemParticipation.get(member.name);
|
const projectParticipationCount = currentSystemParticipation.get(member.name);
|
||||||
@@ -219,6 +231,7 @@ export default function VersionDetailPage() {
|
|||||||
: [];
|
: [];
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
})() : [];
|
||||||
|
|
||||||
const renderActions = () => {
|
const renderActions = () => {
|
||||||
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = [];
|
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: () => {
|
buttons.push({ label: '删除', action: () => {
|
||||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
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));
|
scopedVersionData.devTasks.forEach((t) => deleteDevTask(t.id));
|
||||||
devTasks.filter((t) => versionReqIds.has(t.requirementId)).forEach((t) => deleteDevTask(t.id));
|
|
||||||
// 清理测试用例和Bug
|
// 清理测试用例和Bug
|
||||||
testCases.filter((c) => c.versionId === version.id).forEach((c) => deleteTestCase(c.id));
|
scopedVersionData.testCases.forEach((c) => deleteTestCase(c.id));
|
||||||
bugs.filter((b) => b.versionId === version.id).forEach((b) => deleteBug(b.id));
|
scopedVersionData.bugs.forEach((b) => deleteBug(b.id));
|
||||||
deleteVersion(version.productId, version.id);
|
deleteVersion(version.productId, version.id);
|
||||||
router.push('/versions');
|
router.push('/versions');
|
||||||
}
|
}
|
||||||
@@ -304,13 +316,12 @@ export default function VersionDetailPage() {
|
|||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
const versionReqs = scopedVersionData.requirements;
|
||||||
const versionReqIds = new Set(versionReqs.map((r) => r.id));
|
const versionPlans = scopedVersionData.plans;
|
||||||
const versionPlans = plans.filter((p) => p.versionId === version.id);
|
const versionDevTasks = scopedVersionData.devTasks;
|
||||||
const versionDevTasks = devTasks.filter((t) => versionReqIds.has(t.requirementId));
|
const versionTCs = scopedVersionData.testCases;
|
||||||
const versionTCs = testCases.filter((c) => c.versionId === version.id);
|
const versionBugs = scopedVersionData.bugs;
|
||||||
const versionBugs = bugs.filter((b) => b.versionId === version.id);
|
const versionOT = scopedVersionData.overtimeRecords;
|
||||||
const versionOT = records.filter((r) => r.versionId === version.id);
|
|
||||||
const stageEffortMetrics = calcStageEffortMetrics({
|
const stageEffortMetrics = calcStageEffortMetrics({
|
||||||
plans: versionPlans,
|
plans: versionPlans,
|
||||||
devTasks: versionDevTasks,
|
devTasks: versionDevTasks,
|
||||||
@@ -663,7 +674,7 @@ export default function VersionDetailPage() {
|
|||||||
|
|
||||||
{/* 6. 需求变更统计 */}
|
{/* 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;
|
if (changeReqs.length === 0) return null;
|
||||||
|
|
||||||
// 变更人员排名
|
// 变更人员排名
|
||||||
@@ -900,7 +911,7 @@ export default function VersionDetailPage() {
|
|||||||
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
|
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
|
||||||
return (
|
return (
|
||||||
<PlanTab
|
<PlanTab
|
||||||
plans={plans}
|
plans={scopedVersionData.plans}
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
version={version}
|
version={version}
|
||||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
@@ -942,27 +953,32 @@ export default function VersionDetailPage() {
|
|||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : activeTab === 'tasks' ? (
|
) : activeTab === 'tasks' ? (
|
||||||
(() => {
|
|
||||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
|
||||||
return (
|
|
||||||
<DevTaskTab
|
<DevTaskTab
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
requirementIds={versionReqs.map((r) => r.id)}
|
requirementIds={scopedVersionData.requirementIds}
|
||||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||||
readOnly={versionReadonly}
|
readOnly={versionReadonly}
|
||||||
|
versionTasks={scopedVersionData.devTasks}
|
||||||
|
versionRequirements={scopedVersionData.requirements}
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : activeTab === 'testcases' ? (
|
) : activeTab === 'testcases' ? (
|
||||||
(() => {
|
<TestCaseTab
|
||||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
versionId={version.id}
|
||||||
return <TestCaseTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
|
requirementIds={scopedVersionData.requirementIds}
|
||||||
})()
|
readOnly={versionReadonly}
|
||||||
|
versionCases={scopedVersionData.testCases}
|
||||||
|
versionBugs={scopedVersionData.bugs}
|
||||||
|
versionDevTasks={scopedVersionData.devTasks}
|
||||||
|
versionRequirements={scopedVersionData.requirements}
|
||||||
|
/>
|
||||||
) : activeTab === 'bugs' ? (
|
) : activeTab === 'bugs' ? (
|
||||||
(() => {
|
<BugTab
|
||||||
const versionReqs = requirements.filter((r) => r.versionId === version.id);
|
versionId={version.id}
|
||||||
return <BugTab versionId={version.id} requirementIds={versionReqs.map((r) => r.id)} readOnly={versionReadonly} />;
|
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">
|
<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>
|
<span className="text-[13px] text-[var(--ink-muted)]">功能开发中,敬请期待</span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useAuthStore } from '@/stores/useAuthStore';
|
|||||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
||||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
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 type { WorkItem, WorkItemType } from '@/lib/workspace-engine';
|
||||||
import { DailyReportPanel } from '@/components/workspace/DailyReportPanel';
|
import { DailyReportPanel } from '@/components/workspace/DailyReportPanel';
|
||||||
import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer';
|
import { PlanDetailDrawer } from '@/components/version/PlanDetailDrawer';
|
||||||
@@ -96,6 +96,7 @@ export default function WorkspacePage() {
|
|||||||
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
|
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
|
||||||
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
|
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
|
||||||
);
|
);
|
||||||
|
const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]);
|
||||||
|
|
||||||
// 构建树:只显示跟自己有关的产品/项目/版本
|
// 构建树:只显示跟自己有关的产品/项目/版本
|
||||||
const tree = useMemo(() => {
|
const tree = useMemo(() => {
|
||||||
@@ -108,14 +109,14 @@ export default function WorkspacePage() {
|
|||||||
const prod = productMap.get(v.productName)!;
|
const prod = productMap.get(v.productName)!;
|
||||||
if (!prod.projects.has(v.projectName)) prod.projects.set(v.projectName, { name: v.projectName, versions: [] });
|
if (!prod.projects.has(v.projectName)) prod.projects.set(v.projectName, { name: v.projectName, versions: [] });
|
||||||
const proj = prod.projects.get(v.projectName)!;
|
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)) {
|
if (!proj.versions.find((ver) => ver.id === v.id)) {
|
||||||
proj.versions.push({ id: v.id, name: v.name, status: v.status, pendingCount: pending });
|
proj.versions.push({ id: v.id, name: v.name, status: v.status, pendingCount: pending });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return productMap;
|
return productMap;
|
||||||
}, [allVersions, workItems]);
|
}, [allVersions, pendingCountByVersion, workItems]);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
let items = workItems;
|
let items = workItems;
|
||||||
|
|||||||
@@ -17,28 +17,36 @@ import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugInter
|
|||||||
import { calcTwoMetrics } from '@/lib/work-hours';
|
import { calcTwoMetrics } from '@/lib/work-hours';
|
||||||
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
|
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
|
||||||
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
|
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 {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
readOnly?: boolean;
|
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 { bugs, fetchBugs } = useBugStore();
|
||||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const members = useMemberStore((s) => s.members);
|
const members = useMemberStore((s) => s.members);
|
||||||
|
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
if (!scopedVersionBugs) fetchBugs();
|
||||||
|
}, [fetchBugs, scopedVersionBugs]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!versionTestCases) fetchTestCases();
|
||||||
|
}, [fetchTestCases, versionTestCases]);
|
||||||
|
|
||||||
const versionBugs = useMemo(
|
const fallbackVersionBugs = useMemo(
|
||||||
() => bugs.filter((b) => b.versionId === versionId),
|
() => bugs.filter((b) => b.versionId === versionId),
|
||||||
[bugs, versionId],
|
[bugs, versionId],
|
||||||
);
|
);
|
||||||
|
const versionBugs = scopedVersionBugs ?? fallbackVersionBugs;
|
||||||
|
|
||||||
const openCount = versionBugs.filter((b) => b.status === 'open').length;
|
const openCount = versionBugs.filter((b) => b.status === 'open').length;
|
||||||
const fixingCount = versionBugs.filter((b) => b.status === 'fixing').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 [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 currentMember = useMemo(() => members.find((m) => m.id === user?.id), [members, user?.id]);
|
||||||
const currentUserName = currentMember?.name ?? user?.name;
|
const currentUserName = currentMember?.name ?? user?.name;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState, useMemo, useEffect } from 'react';
|
|||||||
import { Plus, Code2, Trash2 } from 'lucide-react';
|
import { Plus, Code2, Trash2 } from 'lucide-react';
|
||||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||||
import { useTaskWorklogStore } from '@/stores/useTaskWorklogStore';
|
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useMemberStore } from '@/stores/useMemberStore';
|
import { useMemberStore } from '@/stores/useMemberStore';
|
||||||
import { useAuthStore } from '@/stores/useAuthStore';
|
import { useAuthStore } from '@/stores/useAuthStore';
|
||||||
@@ -20,12 +19,15 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|||||||
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
|
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
|
||||||
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
|
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
|
||||||
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
||||||
|
import type { Requirement } from '@/lib/requirement';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
versionDeadline?: string;
|
versionDeadline?: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
versionTasks?: DevTask[];
|
||||||
|
versionRequirements?: Requirement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_REQUIREMENT_GROUP_PREFIX = '__no_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) || '未命名需求';
|
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 { tasks, fetchTasks, deleteTask } = useDevTaskStore();
|
||||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
const { fetchWorklogs } = useTaskWorklogStore();
|
|
||||||
const { requirements } = useRequirementStore();
|
const { requirements } = useRequirementStore();
|
||||||
const { members } = useMemberStore();
|
const { members } = useMemberStore();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
useEffect(() => {
|
||||||
|
if (!scopedVersionTasks) fetchTasks();
|
||||||
|
}, [fetchTasks, scopedVersionTasks]);
|
||||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
|
||||||
|
|
||||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
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.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))),
|
||||||
[tasks, reqIdSet, versionId],
|
[tasks, reqIdSet, versionId],
|
||||||
);
|
);
|
||||||
|
const versionTasks = scopedVersionTasks ?? fallbackVersionTasks;
|
||||||
|
|
||||||
const [filterAssignee, setFilterAssignee] = useState('');
|
const [filterAssignee, setFilterAssignee] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = 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,
|
() => Math.max(4, ...categories.map((category) => category.name.length)) + 1,
|
||||||
[categories],
|
[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 allTaskIds = useMemo(() => versionTasks.map((t) => t.id), [versionTasks]);
|
||||||
|
|
||||||
const assignees = useMemo(() => Array.from(new Set(versionTasks.map((t) => t.assigneeId))), [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 { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
|
import { orderByRequirementForGrouping } from '@/lib/requirement-grouping';
|
||||||
import { buildEffortSummaryMetrics, sumPositiveHours } from '@/lib/effort-summary';
|
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';
|
import type { TestCaseStatus } from '@/lib/test-case';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
requirementIds: string[];
|
requirementIds: string[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
versionCases?: TestCase[];
|
||||||
|
versionBugs?: Bug[];
|
||||||
|
versionDevTasks?: DevTask[];
|
||||||
|
versionRequirements?: Requirement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_REQUIREMENT_GROUP_PREFIX = '__no_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) || '未命名需求';
|
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 { testCases, fetchTestCases, createTestCases, deleteTestCase } = useTestCaseStore();
|
||||||
const { bugs, fetchBugs } = useBugStore();
|
const { bugs, fetchBugs } = useBugStore();
|
||||||
const { tasks: devTasks } = useDevTaskStore();
|
const { tasks: devTasks } = useDevTaskStore();
|
||||||
@@ -46,15 +62,20 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const { categories, fetchCategories } = useTaskCategoryStore();
|
const { categories, fetchCategories } = useTaskCategoryStore();
|
||||||
|
|
||||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
useEffect(() => {
|
||||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
if (!scopedVersionCases) fetchTestCases();
|
||||||
|
}, [fetchTestCases, scopedVersionCases]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scopedVersionBugs) fetchBugs();
|
||||||
|
}, [fetchBugs, scopedVersionBugs]);
|
||||||
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
useEffect(() => { fetchCategories(); }, [fetchCategories]);
|
||||||
|
|
||||||
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
const reqIdSet = useMemo(() => new Set(requirementIds), [requirementIds]);
|
||||||
const versionCases = useMemo(
|
const fallbackVersionCases = useMemo(
|
||||||
() => testCases.filter((c) => c.versionId === versionId),
|
() => testCases.filter((c) => c.versionId === versionId),
|
||||||
[testCases, versionId],
|
[testCases, versionId],
|
||||||
);
|
);
|
||||||
|
const versionCases = scopedVersionCases ?? fallbackVersionCases;
|
||||||
const rounds = useMemo(
|
const rounds = useMemo(
|
||||||
() => Array.from(new Set(versionCases.map((testCase) => getTestCaseRoundNo(testCase)))).sort((a, b) => a - b),
|
() => Array.from(new Set(versionCases.map((testCase) => getTestCaseRoundNo(testCase)))).sort((a, b) => a - b),
|
||||||
[versionCases],
|
[versionCases],
|
||||||
@@ -75,15 +96,17 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
() => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === 1),
|
() => versionCases.filter((testCase) => getTestCaseRoundNo(testCase) === 1),
|
||||||
[versionCases],
|
[versionCases],
|
||||||
);
|
);
|
||||||
const versionBugs = useMemo(
|
const fallbackVersionBugs = useMemo(
|
||||||
() => bugs.filter((bug) => bug.versionId === versionId),
|
() => bugs.filter((bug) => bug.versionId === versionId),
|
||||||
[bugs, 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.filter((t) => t.versionId === versionId || (!t.versionId && reqIdSet.has(t.requirementId))),
|
||||||
[devTasks, reqIdSet, versionId],
|
[devTasks, reqIdSet, versionId],
|
||||||
);
|
);
|
||||||
|
const versionDevTasks = scopedVersionDevTasks ?? fallbackVersionDevTasks;
|
||||||
|
|
||||||
const stats = calcTestProgress(activeRoundCases, versionBugs);
|
const stats = calcTestProgress(activeRoundCases, versionBugs);
|
||||||
const { actual: tcActualHours } = aggregateTestCaseHours(versionCases);
|
const { actual: tcActualHours } = aggregateTestCaseHours(versionCases);
|
||||||
@@ -168,7 +191,8 @@ export function TestCaseTab({ versionId, requirementIds, readOnly = false }: Pro
|
|||||||
return map;
|
return map;
|
||||||
}, [paged]);
|
}, [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 categoryMap = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
|
||||||
const categoryLabelWidthEm = useMemo(
|
const categoryLabelWidthEm = useMemo(
|
||||||
() => Math.max(4, ...categories.map((category) => category.name.length)) + 1,
|
() => 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 { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||||
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
import { useXiaobaoRiskStore } from '@/stores/useXiaobaoRiskStore';
|
||||||
import { flattenVersions } from '@/lib/derive';
|
import { flattenVersions } from '@/lib/derive';
|
||||||
|
import { buildVersionDataScopeMap } from '@/lib/version-data-scope';
|
||||||
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
import { calcXiaobaoVersionRisk } from '@/lib/xiaobao-risk';
|
||||||
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
import { buildVersionDailyEvidence, buildXiaobaoWorkItems } from '@/lib/xiaobao-risk-evidence';
|
||||||
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
import { filterXiaobaoWarningVersions } from '@/lib/xiaobao-warning-view';
|
||||||
@@ -61,31 +62,40 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
[allVersions, canManage, user?.name],
|
[allVersions, canManage, user?.name],
|
||||||
);
|
);
|
||||||
|
|
||||||
const requirementVersionMap = useMemo(() => {
|
const visibleVersionScopeMap = useMemo(() => buildVersionDataScopeMap({
|
||||||
const map = new Map<string, string>();
|
versionIds: visibleVersions.map((version) => version.id),
|
||||||
requirements.forEach((requirement) => {
|
|
||||||
if (requirement.versionId) map.set(requirement.id, requirement.versionId);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [requirements]);
|
|
||||||
|
|
||||||
const workItems = useMemo(() => buildXiaobaoWorkItems({
|
|
||||||
plans,
|
plans,
|
||||||
|
requirements,
|
||||||
devTasks,
|
devTasks,
|
||||||
testCases,
|
testCases,
|
||||||
bugs,
|
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,
|
id: version.id,
|
||||||
name: version.name,
|
name: version.name,
|
||||||
productName: version.productName,
|
productName: version.productName,
|
||||||
projectName: version.projectName,
|
projectName: version.projectName,
|
||||||
})),
|
}],
|
||||||
requirementVersionMap,
|
requirementVersionMap: new Map(scope.requirements.map((requirement) => [requirement.id, version.id])),
|
||||||
}), [allVersions, bugs, devTasks, plans, requirementVersionMap, testCases]);
|
}));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [visibleVersionScopeMap, visibleVersions]);
|
||||||
|
|
||||||
const risks = useMemo(() => visibleVersions.map((version) => {
|
const risks = useMemo(() => visibleVersions.map((version) => {
|
||||||
const versionRequirements = requirements.filter((requirement) => requirement.versionId === version.id);
|
const scope = visibleVersionScopeMap[version.id];
|
||||||
const requirementIds = new Set(versionRequirements.map((requirement) => requirement.id));
|
const workItems = workItemsByVersion.get(version.id) ?? [];
|
||||||
const dailyEvidence = buildVersionDailyEvidence({
|
const dailyEvidence = buildVersionDailyEvidence({
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
workItems,
|
workItems,
|
||||||
@@ -95,9 +105,9 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
|
|
||||||
return calcXiaobaoVersionRisk({
|
return calcXiaobaoVersionRisk({
|
||||||
version,
|
version,
|
||||||
devTasks: devTasks.filter((task) => requirementIds.has(task.requirementId)),
|
devTasks: scope?.devTasks ?? [],
|
||||||
testCases: testCases.filter((testCase) => testCase.versionId === version.id),
|
testCases: scope?.testCases ?? [],
|
||||||
bugs: bugs.filter((bug) => bug.versionId === version.id),
|
bugs: scope?.bugs ?? [],
|
||||||
dailyEvidence,
|
dailyEvidence,
|
||||||
recentActivityCount: dailyEvidence.recentActivityCount,
|
recentActivityCount: dailyEvidence.recentActivityCount,
|
||||||
lastActivityAt: dailyEvidence.lastActivityAt,
|
lastActivityAt: dailyEvidence.lastActivityAt,
|
||||||
@@ -106,12 +116,9 @@ export function useXiaobaoWarningRisks({ loadRiskCache = false }: { loadRiskCach
|
|||||||
});
|
});
|
||||||
}).sort((a, b) => b.riskScore - a.riskScore), [
|
}).sort((a, b) => b.riskScore - a.riskScore), [
|
||||||
activities,
|
activities,
|
||||||
bugs,
|
visibleVersionScopeMap,
|
||||||
devTasks,
|
|
||||||
requirements,
|
|
||||||
testCases,
|
|
||||||
visibleVersions,
|
visibleVersions,
|
||||||
workItems,
|
workItemsByVersion,
|
||||||
worklogs,
|
worklogs,
|
||||||
snapshots,
|
snapshots,
|
||||||
today,
|
today,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import type { DevTask } from './dev-task';
|
import type { DevTask } from './dev-task';
|
||||||
import { canEditRequirement } from './linkage-engine';
|
import { buildRequirementDevStatusMap, canEditRequirement } from './linkage-engine';
|
||||||
import * as linkageEngine from './linkage-engine';
|
import * as linkageEngine from './linkage-engine';
|
||||||
|
|
||||||
function task(patch: Partial<DevTask> = {}): DevTask {
|
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: 'testing' })]), false);
|
||||||
assert.equal(canCloseRequirement('req-1', [task({ status: 'submitted' })]), 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';
|
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 的任务)不可编辑
|
* 规则:开发中(有 in_progress 或 testing 的任务)不可编辑
|
||||||
|
|||||||
@@ -3,16 +3,33 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
REQUIREMENT_TABLE_BADGE_CLASS,
|
REQUIREMENT_TABLE_BADGE_CLASS,
|
||||||
|
REQUIREMENT_TABLE_CLASS,
|
||||||
|
REQUIREMENT_TABLE_COLUMN_WIDTHS,
|
||||||
|
REQUIREMENT_TABLE_CONTAINER_CLASS,
|
||||||
|
REQUIREMENT_TABLE_HEADER_CELL_CLASS,
|
||||||
getRequirementTableTextClass,
|
getRequirementTableTextClass,
|
||||||
} from './requirement-table-layout';
|
} 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', () => {
|
test('keeps requirement table badges on one line', () => {
|
||||||
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bwhitespace-nowrap\b/);
|
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bwhitespace-nowrap\b/);
|
||||||
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bshrink-0\b/);
|
assert.match(REQUIREMENT_TABLE_BADGE_CLASS, /\bshrink-0\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clips long requirement source project type and creator cells', () => {
|
test('clips long requirement source project type version and creator cells', () => {
|
||||||
for (const column of ['source', 'project', 'type', 'creator'] as const) {
|
for (const column of ['source', 'project', 'type', 'version', 'creator'] as const) {
|
||||||
const className = getRequirementTableTextClass(column);
|
const className = getRequirementTableTextClass(column);
|
||||||
|
|
||||||
assert.match(className, /\btruncate\b/);
|
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> = {
|
const TEXT_COLUMN_MAX_WIDTH: Record<RequirementTableTextColumn, string> = {
|
||||||
source: 'max-w-[170px]',
|
source: 'max-w-[170px]',
|
||||||
project: 'max-w-[120px]',
|
project: 'max-w-[120px]',
|
||||||
type: 'max-w-[96px]',
|
type: 'max-w-[96px]',
|
||||||
|
version: 'max-w-[92px]',
|
||||||
creator: 'max-w-[96px]',
|
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 =
|
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';
|
'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;
|
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 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 {
|
export class ServerDataConflictError<T = unknown> extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -42,10 +54,36 @@ export class ServerDataConflictError<T = unknown> extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadServerData<T>(key: ServerDataKey): Promise<T | null> {
|
export async function loadServerData<T>(
|
||||||
const res = await api.get<ServerDataResponse<T>>(`/data/${key}`);
|
key: ServerDataKey,
|
||||||
serverDataVersions.set(key, res.version ?? null);
|
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;
|
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> {
|
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 {
|
try {
|
||||||
const res = await api.put<ServerDataResponse<T>>(`/data/${key}`, payload);
|
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) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof ApiRequestError &&
|
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 vDevTasks = devTasks.filter((t) => isDevTaskInVersion(t, versionId, vReqIds));
|
||||||
const vTestCases = testCases.filter((c) => c.versionId === versionId);
|
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 segments: number[] = [];
|
||||||
|
|
||||||
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
const researchPlans = vPlans.filter((p) => p.type === 'research');
|
||||||
@@ -93,9 +101,40 @@ export function buildVersionProgressMap(
|
|||||||
devTasks: DevTask[],
|
devTasks: DevTask[],
|
||||||
testCases: TestCase[],
|
testCases: TestCase[],
|
||||||
): Record<string, number> {
|
): 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> = {};
|
const map: Record<string, number> = {};
|
||||||
for (const v of versions) {
|
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;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import type { DevTask } from './dev-task';
|
||||||
|
|
||||||
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
test('getWorkspacePendingCount counts unfinished work items only', () => {
|
||||||
@@ -13,6 +13,51 @@ test('getWorkspacePendingCount counts unfinished work items only', () => {
|
|||||||
assert.equal(count, 2);
|
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', () => {
|
test('aggregateWorkItems uses direct version id for no-requirement dev tasks', () => {
|
||||||
const task = {
|
const task = {
|
||||||
id: 'dev-no-req',
|
id: 'dev-no-req',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function aggregateWorkItems(
|
|||||||
requirementVersionMap: Map<string, string>,
|
requirementVersionMap: Map<string, string>,
|
||||||
): WorkItem[] {
|
): WorkItem[] {
|
||||||
const items: WorkItem[] = [];
|
const items: WorkItem[] = [];
|
||||||
|
if (!userName.trim()) return items;
|
||||||
|
|
||||||
// Plans: owner === userName
|
// Plans: owner === userName
|
||||||
plans.filter((p) => p.owner === userName).forEach((p) => {
|
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);
|
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> = {
|
export const WORK_ITEM_TYPE_LABEL: Record<WorkItemType, string> = {
|
||||||
plan_research: '调研',
|
plan_research: '调研',
|
||||||
plan_product: '产品方案',
|
plan_product: '产品方案',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { create } from 'zustand';
|
|||||||
import type { Bug, BugStatus, BugLog } from '@/lib/bug';
|
import type { Bug, BugStatus, BugLog } from '@/lib/bug';
|
||||||
import { generateBugNo } from '@/lib/bug';
|
import { generateBugNo } from '@/lib/bug';
|
||||||
import { applyBugTransition } from '@/lib/bug-workflow';
|
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 {
|
import {
|
||||||
makeBugCreatedActivity,
|
makeBugCreatedActivity,
|
||||||
makeBugStatusActivity,
|
makeBugStatusActivity,
|
||||||
@@ -15,6 +15,8 @@ function saveStored(items: Bug[]) {
|
|||||||
saveServerData('bugs', items).catch(() => {});
|
saveServerData('bugs', items).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastBugsFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<Bug[] | null> {
|
async function loadStored(): Promise<Bug[] | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<Bug[]>('bugs');
|
return await loadServerData<Bug[]>('bugs');
|
||||||
@@ -28,7 +30,8 @@ function makeLog(action: BugLog['action'], operator: string, from?: string, to?:
|
|||||||
|
|
||||||
interface BugState {
|
interface BugState {
|
||||||
bugs: Bug[];
|
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;
|
createBug: (data: Omit<Bug, 'id' | 'bugNo' | 'createdAt' | 'updatedAt' | 'status' | 'logs'>, operator: string) => Bug;
|
||||||
updateBug: (id: string, data: Partial<Bug>) => void;
|
updateBug: (id: string, data: Partial<Bug>) => void;
|
||||||
deleteBug: (id: string) => void;
|
deleteBug: (id: string) => void;
|
||||||
@@ -41,10 +44,14 @@ interface BugState {
|
|||||||
|
|
||||||
export const useBugStore = create<BugState>((set, get) => ({
|
export const useBugStore = create<BugState>((set, get) => ({
|
||||||
bugs: [],
|
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();
|
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) => {
|
createBug: (data, operator) => {
|
||||||
@@ -61,7 +68,7 @@ export const useBugStore = create<BugState>((set, get) => ({
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
const updated = [...list, bug];
|
const updated = [...list, bug];
|
||||||
set({ bugs: updated });
|
set({ bugs: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
useWorkActivityStore.getState().addActivity(makeBugCreatedActivity(bug, operator));
|
||||||
return bug;
|
return bug;
|
||||||
@@ -71,13 +78,13 @@ export const useBugStore = create<BugState>((set, get) => ({
|
|||||||
const updated = get().bugs.map((b) =>
|
const updated = get().bugs.map((b) =>
|
||||||
b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b,
|
b.id === id ? { ...b, ...data, updatedAt: new Date().toISOString() } : b,
|
||||||
);
|
);
|
||||||
set({ bugs: updated });
|
set({ bugs: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBug: (id) => {
|
deleteBug: (id) => {
|
||||||
const updated = get().bugs.filter((b) => b.id !== id);
|
const updated = get().bugs.filter((b) => b.id !== id);
|
||||||
set({ bugs: updated });
|
set({ bugs: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { DevTask, DevTaskStatus } from '@/lib/dev-task';
|
|||||||
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
import { generateTaskNo, isLegacyTask } from '@/lib/dev-task';
|
||||||
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
import { applyDevTaskTransition, normalizeDevTaskOnCreate } from '@/lib/dev-task-workflow';
|
||||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
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 {
|
import {
|
||||||
makeDevTaskBlockedActivity,
|
makeDevTaskBlockedActivity,
|
||||||
makeDevTaskCreatedActivity,
|
makeDevTaskCreatedActivity,
|
||||||
@@ -17,6 +17,8 @@ function saveStored(items: DevTask[]) {
|
|||||||
saveServerData('dev-tasks', items).catch(() => {});
|
saveServerData('dev-tasks', items).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastTasksFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<DevTask[] | null> {
|
async function loadStored(): Promise<DevTask[] | null> {
|
||||||
try {
|
try {
|
||||||
const parsed = await loadServerData<DevTask[]>('dev-tasks');
|
const parsed = await loadServerData<DevTask[]>('dev-tasks');
|
||||||
@@ -34,7 +36,8 @@ async function loadStored(): Promise<DevTask[] | null> {
|
|||||||
|
|
||||||
interface DevTaskState {
|
interface DevTaskState {
|
||||||
tasks: DevTask[];
|
tasks: DevTask[];
|
||||||
fetchTasks: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchTasks: (options?: { force?: boolean }) => Promise<void>;
|
||||||
createTask: (data: Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'isBlocked'>) => DevTask;
|
createTask: (data: Omit<DevTask, 'id' | 'taskNo' | 'createdAt' | 'updatedAt' | 'isBlocked'>) => DevTask;
|
||||||
updateTask: (id: string, data: Partial<DevTask>) => void;
|
updateTask: (id: string, data: Partial<DevTask>) => void;
|
||||||
deleteTask: (id: string) => void;
|
deleteTask: (id: string) => void;
|
||||||
@@ -47,10 +50,14 @@ interface DevTaskState {
|
|||||||
|
|
||||||
export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
||||||
tasks: [],
|
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();
|
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) => {
|
createTask: (data) => {
|
||||||
@@ -65,7 +72,7 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
} as DevTask);
|
} as DevTask);
|
||||||
const updated = [...list, task];
|
const updated = [...list, task];
|
||||||
set({ tasks: updated });
|
set({ tasks: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
useWorkActivityStore.getState().addActivity(makeDevTaskCreatedActivity(task, task.createdBy || task.assigneeId));
|
||||||
return task;
|
return task;
|
||||||
@@ -75,13 +82,13 @@ export const useDevTaskStore = create<DevTaskState>((set, get) => ({
|
|||||||
const updated = get().tasks.map((t) =>
|
const updated = get().tasks.map((t) =>
|
||||||
t.id === id ? { ...t, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : t,
|
t.id === id ? { ...t, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : t,
|
||||||
);
|
);
|
||||||
set({ tasks: updated });
|
set({ tasks: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTask: (id) => {
|
deleteTask: (id) => {
|
||||||
const updated = get().tasks.filter((t) => t.id !== id);
|
const updated = get().tasks.filter((t) => t.id !== id);
|
||||||
set({ tasks: updated });
|
set({ tasks: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@/lib/member-system';
|
} from '@/lib/member-system';
|
||||||
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
|
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
|
||||||
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
|
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[] = [
|
const PRESET_DEPARTMENTS: Department[] = [
|
||||||
{ id: 'dept-1', name: '产品部', order: 1, createdAt: '2024-01-01' },
|
{ 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[] = [
|
const MOCK_MEMBERS: Member[] = [
|
||||||
SYSTEM_ADMIN_MEMBER,
|
SYSTEM_ADMIN_MEMBER,
|
||||||
];
|
];
|
||||||
|
let lastMembersFetchAt = 0;
|
||||||
|
|
||||||
interface MemberState {
|
interface MemberState {
|
||||||
departments: Department[];
|
departments: Department[];
|
||||||
@@ -35,7 +36,8 @@ interface MemberState {
|
|||||||
roles: RoleItem[];
|
roles: RoleItem[];
|
||||||
passwordRule: PasswordRule;
|
passwordRule: PasswordRule;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fetchMembers: () => void;
|
loaded: boolean;
|
||||||
|
fetchMembers: (options?: { force?: boolean }) => Promise<void>;
|
||||||
updatePasswordRule: (rule: PasswordRule) => void;
|
updatePasswordRule: (rule: PasswordRule) => void;
|
||||||
createDepartment: (data: Omit<Department, 'id' | 'createdAt'>) => void;
|
createDepartment: (data: Omit<Department, 'id' | 'createdAt'>) => void;
|
||||||
updateDepartment: (id: string, data: Partial<Department>) => void;
|
updateDepartment: (id: string, data: Partial<Department>) => void;
|
||||||
@@ -65,9 +67,13 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
roles: PRESET_ROLES,
|
roles: PRESET_ROLES,
|
||||||
passwordRule: DEFAULT_PASSWORD_RULE,
|
passwordRule: DEFAULT_PASSWORD_RULE,
|
||||||
loading: false,
|
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();
|
const cached = await loadStored();
|
||||||
|
if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
|
lastMembersFetchAt = Date.now();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
// 迁移:旧 RoleItem 没 permissions 字段,补默认值
|
// 迁移:旧 RoleItem 没 permissions 字段,补默认值
|
||||||
const migratedRoles = cached.roles.map((r) => {
|
const migratedRoles = cached.roles.map((r) => {
|
||||||
@@ -94,8 +100,10 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
|||||||
roles,
|
roles,
|
||||||
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
|
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
|
||||||
};
|
};
|
||||||
set(next);
|
set({ ...next, loaded: true });
|
||||||
if (ensured.changed || usernameEnsured.changed || rolesChanged) saveStored(next);
|
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 { OvertimeRecord } from '@/lib/overtime';
|
||||||
import type { DictItem } from '@/lib/requirement';
|
import type { DictItem } from '@/lib/requirement';
|
||||||
import { calcDuration } from '@/lib/overtime';
|
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[] = [
|
const PRESET_REASONS: DictItem[] = [
|
||||||
{ id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' },
|
{ id: 'reason-1', name: '需求变更', createdAt: '2024-01-01' },
|
||||||
@@ -20,12 +20,14 @@ const PRESET_REASONS: DictItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_RECORDS: OvertimeRecord[] = [];
|
const MOCK_RECORDS: OvertimeRecord[] = [];
|
||||||
|
let lastOvertimeFetchAt = 0;
|
||||||
|
|
||||||
interface OvertimeState {
|
interface OvertimeState {
|
||||||
records: OvertimeRecord[];
|
records: OvertimeRecord[];
|
||||||
reasons: DictItem[];
|
reasons: DictItem[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fetchRecords: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchRecords: (options?: { force?: boolean }) => Promise<void>;
|
||||||
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
|
createRecord: (data: Omit<OvertimeRecord, 'id' | 'duration' | 'createdAt'>) => void;
|
||||||
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
|
updateRecord: (id: string, data: Partial<OvertimeRecord>) => void;
|
||||||
deleteRecord: (id: string) => void;
|
deleteRecord: (id: string) => void;
|
||||||
@@ -49,10 +51,14 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
|||||||
records: MOCK_RECORDS,
|
records: MOCK_RECORDS,
|
||||||
reasons: PRESET_REASONS,
|
reasons: PRESET_REASONS,
|
||||||
loading: false,
|
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();
|
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) => {
|
createRecord: (data) => {
|
||||||
@@ -64,7 +70,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
|||||||
createdAt: new Date().toISOString().slice(0, 10),
|
createdAt: new Date().toISOString().slice(0, 10),
|
||||||
};
|
};
|
||||||
const records = [...get().records, record];
|
const records = [...get().records, record];
|
||||||
set({ records });
|
set({ records, loaded: true });
|
||||||
saveStored({ records, reasons: get().reasons });
|
saveStored({ records, reasons: get().reasons });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -77,32 +83,32 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
set({ records });
|
set({ records, loaded: true });
|
||||||
saveStored({ records, reasons: get().reasons });
|
saveStored({ records, reasons: get().reasons });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteRecord: (id) => {
|
deleteRecord: (id) => {
|
||||||
const records = get().records.filter((r) => r.id !== id);
|
const records = get().records.filter((r) => r.id !== id);
|
||||||
set({ records });
|
set({ records, loaded: true });
|
||||||
saveStored({ records, reasons: get().reasons });
|
saveStored({ records, reasons: get().reasons });
|
||||||
},
|
},
|
||||||
|
|
||||||
addReason: (name) => {
|
addReason: (name) => {
|
||||||
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: DictItem = { id: `reason-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const reasons = [...get().reasons, item];
|
const reasons = [...get().reasons, item];
|
||||||
set({ reasons });
|
set({ reasons, loaded: true });
|
||||||
saveStored({ records: get().records, reasons });
|
saveStored({ records: get().records, reasons });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateReason: (id, name) => {
|
updateReason: (id, name) => {
|
||||||
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
|
const reasons = get().reasons.map((r) => r.id === id ? { ...r, name } : r);
|
||||||
set({ reasons });
|
set({ reasons, loaded: true });
|
||||||
saveStored({ records: get().records, reasons });
|
saveStored({ records: get().records, reasons });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteReason: (id) => {
|
deleteReason: (id) => {
|
||||||
const reasons = get().reasons.filter((r) => r.id !== id);
|
const reasons = get().reasons.filter((r) => r.id !== id);
|
||||||
set({ reasons });
|
set({ reasons, loaded: true });
|
||||||
saveStored({ records: get().records, reasons });
|
saveStored({ records: get().records, reasons });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { create } from 'zustand';
|
|||||||
import { Product } from '@ftb/shared';
|
import { Product } from '@ftb/shared';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence';
|
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 { Stage, Role } from '@/lib/stage';
|
||||||
import type { Priority, VersionLinks } from '@/lib/derive';
|
import type { Priority, VersionLinks } from '@/lib/derive';
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ interface VersionItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_OVERVIEW: ProductOverview[] = [];
|
const MOCK_OVERVIEW: ProductOverview[] = [];
|
||||||
|
let lastOverviewFetchAt = 0;
|
||||||
|
|
||||||
interface ProductWithCount {
|
interface ProductWithCount {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,6 +56,7 @@ interface ProductState {
|
|||||||
overview: ProductOverview[];
|
overview: ProductOverview[];
|
||||||
currentProduct: (Product & { requirements?: any[]; versions?: any[] }) | null;
|
currentProduct: (Product & { requirements?: any[]; versions?: any[] }) | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
overviewLoaded: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchProducts: () => Promise<void>;
|
fetchProducts: () => Promise<void>;
|
||||||
fetchOverview: () => Promise<void>;
|
fetchOverview: () => Promise<void>;
|
||||||
@@ -77,6 +79,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
overview: [],
|
overview: [],
|
||||||
currentProduct: null,
|
currentProduct: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
overviewLoaded: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchProducts: async () => {
|
fetchProducts: async () => {
|
||||||
@@ -90,22 +93,24 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchOverview: async () => {
|
fetchOverview: async () => {
|
||||||
if (get().overview.length > 0) return;
|
if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
const cached = await loadStoredOverview();
|
const cached = await loadStoredOverview();
|
||||||
|
if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
|
lastOverviewFetchAt = Date.now();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
// 有本地缓存,直接用,不再调远端覆盖(mock 模式核心数据在本地)
|
// 有本地缓存,直接用,不再调远端覆盖(mock 模式核心数据在本地)
|
||||||
set({ overview: cached, loading: false });
|
set({ overview: cached, loading: false, overviewLoaded: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const overview = await api.get<ProductOverview[]>('/products/overview');
|
const overview = await api.get<ProductOverview[]>('/products/overview');
|
||||||
set({ overview, loading: false });
|
set({ overview, loading: false, overviewLoaded: true });
|
||||||
if (shouldPersistRemoteOverview(overview)) {
|
if (shouldPersistRemoteOverview(overview)) {
|
||||||
saveStoredOverview(overview);
|
saveStoredOverview(overview);
|
||||||
}
|
}
|
||||||
} catch {
|
} 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 },
|
_count: { requirements: 0, projects: 0, versions: 0 },
|
||||||
};
|
};
|
||||||
const updated = [newProduct, ...get().overview];
|
const updated = [newProduct, ...get().overview];
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
// 异步尝试同步远端(失败也无所谓,本地已更新)
|
// 异步尝试同步远端(失败也无所谓,本地已更新)
|
||||||
try { await api.post('/products', data); } catch {}
|
try { await api.post('/products', data); } catch {}
|
||||||
@@ -146,14 +151,12 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
updateProduct: async (id, data) => {
|
updateProduct: async (id, data) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/products/${id}`, data);
|
await api.patch(`/products/${id}`, data);
|
||||||
await get().fetchOverview();
|
} catch {}
|
||||||
} catch {
|
|
||||||
const updated = get().overview.map((p) =>
|
const updated = get().overview.map((p) =>
|
||||||
p.id === id ? { ...p, ...data } : p,
|
p.id === id ? { ...p, ...data } : p,
|
||||||
);
|
);
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteProduct: async (id) => {
|
deleteProduct: async (id) => {
|
||||||
@@ -164,6 +167,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
products: get().products.filter((p) => p.id !== id),
|
products: get().products.filter((p) => p.id !== id),
|
||||||
overview: updated,
|
overview: updated,
|
||||||
|
overviewLoaded: true,
|
||||||
});
|
});
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
},
|
},
|
||||||
@@ -171,7 +175,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
reorderProducts: (ids) => {
|
reorderProducts: (ids) => {
|
||||||
const map = new Map(get().overview.map((p) => [p.id, p]));
|
const map = new Map(get().overview.map((p) => [p.id, p]));
|
||||||
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
|
const reordered = ids.map((id) => map.get(id)).filter(Boolean) as ProductOverview[];
|
||||||
set({ overview: reordered });
|
set({ overview: reordered, overviewLoaded: true });
|
||||||
saveStoredOverview(reordered);
|
saveStoredOverview(reordered);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -194,7 +198,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
const updated = get().overview
|
const updated = get().overview
|
||||||
.filter((p) => p.id !== sourceId)
|
.filter((p) => p.id !== sourceId)
|
||||||
.map((p) => (p.id === targetId ? migratedTarget : p));
|
.map((p) => (p.id === targetId ? migratedTarget : p));
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -213,7 +217,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
_count: { ...p._count, projects: (p._count?.projects ?? 0) + 1 } as any,
|
_count: { ...p._count, projects: (p._count?.projects ?? 0) + 1 } as any,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -250,7 +254,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
versions: nextVersions,
|
versions: nextVersions,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -271,7 +275,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
_count: { ...p._count, versions: (p._count?.versions ?? 0) + 1 } as any,
|
_count: { ...p._count, versions: (p._count?.versions ?? 0) + 1 } as any,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -285,7 +289,7 @@ export const useProductStore = create<ProductState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
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,
|
_count: { ...p._count, versions: Math.max(0, (p._count?.versions ?? 0) - 1) } as any,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
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,
|
_count: { ...p._count, projects: Math.max(0, (p._count?.projects ?? 0) - 1) } as any,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
set({ overview: updated });
|
set({ overview: updated, overviewLoaded: true });
|
||||||
saveStoredOverview(updated);
|
saveStoredOverview(updated);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
import type { Requirement, RequirementStatus, Effort, DictItem, SourceType, SourceTarget } from '@/lib/requirement';
|
||||||
import type { Priority } from '@/lib/derive';
|
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 {
|
interface RequirementState {
|
||||||
requirements: Requirement[];
|
requirements: Requirement[];
|
||||||
@@ -11,7 +11,8 @@ interface RequirementState {
|
|||||||
types: DictItem[];
|
types: DictItem[];
|
||||||
platforms: DictItem[];
|
platforms: DictItem[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fetchRequirements: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchRequirements: (options?: { force?: boolean }) => Promise<void>;
|
||||||
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
|
createRequirement: (data: Omit<Requirement, 'id' | 'code' | 'createdAt'>) => void;
|
||||||
updateRequirement: (id: string, data: Partial<Requirement>) => void;
|
updateRequirement: (id: string, data: Partial<Requirement>) => void;
|
||||||
deleteRequirement: (id: string) => void;
|
deleteRequirement: (id: string) => void;
|
||||||
@@ -52,6 +53,7 @@ const PRESET_PLATFORMS: DictItem[] = [
|
|||||||
// --- Mock requirements ---
|
// --- Mock requirements ---
|
||||||
|
|
||||||
const MOCK_REQUIREMENTS: Requirement[] = [];
|
const MOCK_REQUIREMENTS: Requirement[] = [];
|
||||||
|
let lastRequirementsFetchAt = 0;
|
||||||
|
|
||||||
// --- Server persistence helpers ---
|
// --- Server persistence helpers ---
|
||||||
|
|
||||||
@@ -75,11 +77,17 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
types: PRESET_TYPES,
|
types: PRESET_TYPES,
|
||||||
platforms: PRESET_PLATFORMS,
|
platforms: PRESET_PLATFORMS,
|
||||||
loading: false,
|
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();
|
const cached = await loadStored();
|
||||||
|
if (!options?.force && get().loaded && Date.now() - lastRequirementsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
|
lastRequirementsFetchAt = Date.now();
|
||||||
if (cached) {
|
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(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const updated = [...requirements, newReq];
|
const updated = [...requirements, newReq];
|
||||||
set({ requirements: updated });
|
set({ requirements: updated, loaded: true });
|
||||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateRequirement: (id, data) => {
|
updateRequirement: (id, data) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = requirements.map((r) => (r.id === id ? { ...r, ...data } : r));
|
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 });
|
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteRequirement: (id) => {
|
deleteRequirement: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = requirements.filter((r) => r.id !== id);
|
const updated = requirements.filter((r) => r.id !== id);
|
||||||
set({ requirements: updated });
|
set({ requirements: updated, loaded: true });
|
||||||
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
saveStored({ requirements: updated, sourceTargets, types, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -117,21 +125,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: SourceTarget = { id: `st-${Date.now()}`, name, sourceType, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const updated = [...sourceTargets, item];
|
const updated = [...sourceTargets, item];
|
||||||
set({ sourceTargets: updated });
|
set({ sourceTargets: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateSourceTarget: (id, name) => {
|
updateSourceTarget: (id, name) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = sourceTargets.map((s) => (s.id === id ? { ...s, name } : s));
|
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 });
|
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSourceTarget: (id) => {
|
deleteSourceTarget: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = sourceTargets.filter((s) => s.id !== id);
|
const updated = sourceTargets.filter((s) => s.id !== id);
|
||||||
set({ sourceTargets: updated });
|
set({ sourceTargets: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
saveStored({ requirements, sourceTargets: updated, types, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -140,21 +148,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: DictItem = { id: `type-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const updated = [...types, item];
|
const updated = [...types, item];
|
||||||
set({ types: updated });
|
set({ types: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
updateType: (id, name) => {
|
updateType: (id, name) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = types.map((t) => (t.id === id ? { ...t, name } : t));
|
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 });
|
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteType: (id) => {
|
deleteType: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = types.filter((t) => t.id !== id);
|
const updated = types.filter((t) => t.id !== id);
|
||||||
set({ types: updated });
|
set({ types: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
saveStored({ requirements, sourceTargets, types: updated, platforms });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -163,21 +171,21 @@ export const useRequirementStore = create<RequirementState>((set, get) => {
|
|||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
const item: DictItem = { id: `platform-${Date.now()}`, name, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const updated = [...platforms, item];
|
const updated = [...platforms, item];
|
||||||
set({ platforms: updated });
|
set({ platforms: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
||||||
},
|
},
|
||||||
|
|
||||||
updatePlatform: (id, name) => {
|
updatePlatform: (id, name) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = platforms.map((p) => (p.id === id ? { ...p, name } : p));
|
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 });
|
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePlatform: (id) => {
|
deletePlatform: (id) => {
|
||||||
const { requirements, sourceTargets, types, platforms } = get();
|
const { requirements, sourceTargets, types, platforms } = get();
|
||||||
const updated = platforms.filter((p) => p.id !== id);
|
const updated = platforms.filter((p) => p.id !== id);
|
||||||
set({ platforms: updated });
|
set({ platforms: updated, loaded: true });
|
||||||
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
saveStored({ requirements, sourceTargets, types, platforms: updated });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
|
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
|
||||||
import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } 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[]) {
|
function saveStored(items: TaskCategory[]) {
|
||||||
saveServerData('task-categories', items).catch(() => {});
|
saveServerData('task-categories', items).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastCategoriesFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<TaskCategory[] | null> {
|
async function loadStored(): Promise<TaskCategory[] | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<TaskCategory[]>('task-categories');
|
return await loadServerData<TaskCategory[]>('task-categories');
|
||||||
@@ -17,7 +19,8 @@ async function loadStored(): Promise<TaskCategory[] | null> {
|
|||||||
|
|
||||||
interface TaskCategoryState {
|
interface TaskCategoryState {
|
||||||
categories: TaskCategory[];
|
categories: TaskCategory[];
|
||||||
fetchCategories: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchCategories: (options?: { force?: boolean }) => Promise<void>;
|
||||||
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
|
addCategory: (name: string, group: CategoryGroup, color?: string) => void;
|
||||||
ensureCategory: (name: string, group: CategoryGroup, options?: { allowCreate?: boolean; fallbackCode?: string }) => TaskCategory;
|
ensureCategory: (name: string, group: CategoryGroup, options?: { allowCreate?: boolean; fallbackCode?: string }) => TaskCategory;
|
||||||
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
|
updateCategory: (id: string, data: Partial<TaskCategory>) => void;
|
||||||
@@ -26,10 +29,14 @@ interface TaskCategoryState {
|
|||||||
|
|
||||||
export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||||
categories: PRESET_CATEGORIES,
|
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();
|
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) => {
|
addCategory: (name, group, color) => {
|
||||||
@@ -44,14 +51,14 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
|||||||
isSystem: false,
|
isSystem: false,
|
||||||
};
|
};
|
||||||
const updated = [...list, item];
|
const updated = [...list, item];
|
||||||
set({ categories: updated });
|
set({ categories: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
ensureCategory: (name, group, options) => {
|
ensureCategory: (name, group, options) => {
|
||||||
const result = resolveAiTaskCategoryByName(get().categories, name, group, options);
|
const result = resolveAiTaskCategoryByName(get().categories, name, group, options);
|
||||||
if (result.created) {
|
if (result.created) {
|
||||||
set({ categories: result.categories });
|
set({ categories: result.categories, loaded: true });
|
||||||
saveStored(result.categories);
|
saveStored(result.categories);
|
||||||
}
|
}
|
||||||
return result.category;
|
return result.category;
|
||||||
@@ -59,7 +66,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
|||||||
|
|
||||||
updateCategory: (id, data) => {
|
updateCategory: (id, data) => {
|
||||||
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
|
const updated = get().categories.map((c) => (c.id === id ? { ...c, ...data } : c));
|
||||||
set({ categories: updated });
|
set({ categories: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -67,7 +74,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
|||||||
const target = get().categories.find((c) => c.id === id);
|
const target = get().categories.find((c) => c.id === id);
|
||||||
if (!target || target.isSystem) return false;
|
if (!target || target.isSystem) return false;
|
||||||
const updated = get().categories.filter((c) => c.id !== id);
|
const updated = get().categories.filter((c) => c.id !== id);
|
||||||
set({ categories: updated });
|
set({ categories: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { TaskWorklog } from '@/lib/task-worklog';
|
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[]) {
|
function saveStored(items: TaskWorklog[]) {
|
||||||
saveServerData('task-worklogs', items).catch(() => {});
|
saveServerData('task-worklogs', items).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastWorklogsFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<TaskWorklog[] | null> {
|
async function loadStored(): Promise<TaskWorklog[] | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<TaskWorklog[]>('task-worklogs');
|
return await loadServerData<TaskWorklog[]>('task-worklogs');
|
||||||
@@ -16,7 +18,8 @@ async function loadStored(): Promise<TaskWorklog[] | null> {
|
|||||||
|
|
||||||
interface TaskWorklogState {
|
interface TaskWorklogState {
|
||||||
worklogs: TaskWorklog[];
|
worklogs: TaskWorklog[];
|
||||||
fetchWorklogs: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchWorklogs: (options?: { force?: boolean }) => Promise<void>;
|
||||||
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
|
addWorklog: (data: Omit<TaskWorklog, 'id' | 'createdAt'>) => void;
|
||||||
deleteWorklog: (id: string) => void;
|
deleteWorklog: (id: string) => void;
|
||||||
getActualHours: (taskId: string) => number;
|
getActualHours: (taskId: string) => number;
|
||||||
@@ -24,10 +27,14 @@ interface TaskWorklogState {
|
|||||||
|
|
||||||
export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||||
worklogs: [],
|
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();
|
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) => {
|
addWorklog: (data) => {
|
||||||
@@ -37,13 +44,13 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const updated = [...get().worklogs, item];
|
const updated = [...get().worklogs, item];
|
||||||
set({ worklogs: updated });
|
set({ worklogs: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteWorklog: (id) => {
|
deleteWorklog: (id) => {
|
||||||
const updated = get().worklogs.filter((w) => w.id !== id);
|
const updated = get().worklogs.filter((w) => w.id !== id);
|
||||||
set({ worklogs: updated });
|
set({ worklogs: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { CreateTestCaseInput, TestCase, TestCaseStatus } from '@/lib/test-c
|
|||||||
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
import { generateCaseNo, normalizeTestCases } from '@/lib/test-case';
|
||||||
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
import { applyTestCaseTransition, normalizeTestCaseOnCreate } from '@/lib/test-case-workflow';
|
||||||
import { createEntityId, dedupeEntityIds } from '@/lib/entity-id';
|
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 {
|
import {
|
||||||
makeTestCaseCreatedActivity,
|
makeTestCaseCreatedActivity,
|
||||||
makeTestCaseStatusActivity,
|
makeTestCaseStatusActivity,
|
||||||
@@ -15,6 +15,8 @@ function saveStored(items: TestCase[]) {
|
|||||||
saveServerData('test-cases', items).catch(() => {});
|
saveServerData('test-cases', items).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastTestCasesFetchAt = 0;
|
||||||
|
|
||||||
async function loadStored(): Promise<TestCase[] | null> {
|
async function loadStored(): Promise<TestCase[] | null> {
|
||||||
try {
|
try {
|
||||||
return await loadServerData<TestCase[]>('test-cases');
|
return await loadServerData<TestCase[]>('test-cases');
|
||||||
@@ -24,7 +26,8 @@ async function loadStored(): Promise<TestCase[] | null> {
|
|||||||
|
|
||||||
interface TestCaseState {
|
interface TestCaseState {
|
||||||
testCases: TestCase[];
|
testCases: TestCase[];
|
||||||
fetchTestCases: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchTestCases: (options?: { force?: boolean }) => Promise<void>;
|
||||||
createTestCase: (data: CreateTestCaseInput) => TestCase;
|
createTestCase: (data: CreateTestCaseInput) => TestCase;
|
||||||
createTestCases: (items: CreateTestCaseInput[]) => TestCase[];
|
createTestCases: (items: CreateTestCaseInput[]) => TestCase[];
|
||||||
updateTestCase: (id: string, data: Partial<TestCase>) => void;
|
updateTestCase: (id: string, data: Partial<TestCase>) => void;
|
||||||
@@ -37,13 +40,19 @@ interface TestCaseState {
|
|||||||
|
|
||||||
export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
||||||
testCases: [],
|
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();
|
const cached = await loadStored();
|
||||||
|
if (!options?.force && get().loaded && Date.now() - lastTestCasesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
|
lastTestCasesFetchAt = Date.now();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const result = dedupeEntityIds(normalizeTestCases(cached), 'tc');
|
const result = dedupeEntityIds(normalizeTestCases(cached), 'tc');
|
||||||
if (result.changed) saveStored(result.items);
|
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,
|
updatedAt: now,
|
||||||
} as TestCase);
|
} as TestCase);
|
||||||
const updated = [...list, tc];
|
const updated = [...list, tc];
|
||||||
set({ testCases: updated });
|
set({ testCases: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||||
return tc;
|
return tc;
|
||||||
@@ -82,7 +91,7 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
created.push(tc);
|
created.push(tc);
|
||||||
list = [...list, tc];
|
list = [...list, tc];
|
||||||
}
|
}
|
||||||
set({ testCases: list });
|
set({ testCases: list, loaded: true });
|
||||||
saveStored(list);
|
saveStored(list);
|
||||||
created.forEach((tc) => {
|
created.forEach((tc) => {
|
||||||
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
useWorkActivityStore.getState().addActivity(makeTestCaseCreatedActivity(tc, tc.createdBy));
|
||||||
@@ -94,13 +103,13 @@ export const useTestCaseStore = create<TestCaseState>((set, get) => ({
|
|||||||
const updated = get().testCases.map((c) =>
|
const updated = get().testCases.map((c) =>
|
||||||
c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c,
|
c.id === id ? { ...c, ...data, aiDraft: false, updatedAt: new Date().toISOString() } : c,
|
||||||
);
|
);
|
||||||
set({ testCases: updated });
|
set({ testCases: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteTestCase: (id) => {
|
deleteTestCase: (id) => {
|
||||||
const updated = get().testCases.filter((c) => c.id !== id);
|
const updated = get().testCases.filter((c) => c.id !== id);
|
||||||
set({ testCases: updated });
|
set({ testCases: updated, loaded: true });
|
||||||
saveStored(updated);
|
saveStored(updated);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { VersionPlan, PlanType } from '@/lib/version-plan';
|
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 { getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +15,7 @@ import type { WorkActivityDraft } from '@/lib/work-activity';
|
|||||||
import { useWorkActivityStore } from './useWorkActivityStore';
|
import { useWorkActivityStore } from './useWorkActivityStore';
|
||||||
|
|
||||||
const MOCK_PLANS: VersionPlan[] = [];
|
const MOCK_PLANS: VersionPlan[] = [];
|
||||||
|
let lastPlansFetchAt = 0;
|
||||||
|
|
||||||
function saveStored(plans: VersionPlan[]) {
|
function saveStored(plans: VersionPlan[]) {
|
||||||
saveServerData('version-plans', plans).catch(() => {});
|
saveServerData('version-plans', plans).catch(() => {});
|
||||||
@@ -29,7 +30,8 @@ async function loadStored(): Promise<VersionPlan[] | null> {
|
|||||||
|
|
||||||
interface VersionPlanState {
|
interface VersionPlanState {
|
||||||
plans: VersionPlan[];
|
plans: VersionPlan[];
|
||||||
fetchPlans: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchPlans: (options?: { force?: boolean }) => Promise<void>;
|
||||||
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
createPlan: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
||||||
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
|
updatePlan: (id: string, data: Partial<VersionPlan>) => void;
|
||||||
completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string };
|
completePlan: (id: string, result: PlanResultPayload) => { ok: boolean; message?: string };
|
||||||
@@ -38,16 +40,20 @@ interface VersionPlanState {
|
|||||||
|
|
||||||
export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
||||||
plans: MOCK_PLANS,
|
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();
|
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) => {
|
createPlan: (data) => {
|
||||||
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
const plan: VersionPlan = { ...data, id: `plan-${Date.now()}`, createdAt: new Date().toISOString().slice(0, 10) };
|
||||||
const plans = [...get().plans, plan];
|
const plans = [...get().plans, plan];
|
||||||
set({ plans });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
saveStored(plans);
|
||||||
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
useWorkActivityStore.getState().addActivity(makeVersionPlanCreatedActivity(plan, plan.addedBy || plan.owner));
|
||||||
},
|
},
|
||||||
@@ -81,7 +87,7 @@ export const useVersionPlanStore = create<VersionPlanState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
set({ plans });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
saveStored(plans);
|
||||||
activities.forEach((activity) => useWorkActivityStore.getState().addActivity(activity));
|
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));
|
useWorkActivityStore.getState().addActivity(makeVersionPlanCompletedActivity(next, next.owner));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
set({ plans });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
saveStored(plans);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
deletePlan: (id) => {
|
deletePlan: (id) => {
|
||||||
const plans = get().plans.filter((p) => p.id !== id);
|
const plans = get().plans.filter((p) => p.id !== id);
|
||||||
set({ plans });
|
set({ plans, loaded: true });
|
||||||
saveStored(plans);
|
saveStored(plans);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { create } from 'zustand';
|
|||||||
import { formatLocalDate } from '@/lib/format';
|
import { formatLocalDate } from '@/lib/format';
|
||||||
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
|
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
|
||||||
import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory';
|
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 {
|
interface WorkActivityState {
|
||||||
activities: WorkActivity[];
|
activities: WorkActivity[];
|
||||||
fetchActivities: () => Promise<void>;
|
loaded: boolean;
|
||||||
|
fetchActivities: (options?: { force?: boolean }) => Promise<void>;
|
||||||
addActivity: (data: WorkActivityDraft) => WorkActivity;
|
addActivity: (data: WorkActivityDraft) => WorkActivity;
|
||||||
addProgressNote: (data: ProgressNoteActivityInput) => WorkActivity;
|
addProgressNote: (data: ProgressNoteActivityInput) => WorkActivity;
|
||||||
deleteActivity: (id: string) => void;
|
deleteActivity: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastActivitiesFetchAt = 0;
|
||||||
|
|
||||||
async function saveStored(
|
async function saveStored(
|
||||||
items: WorkActivity[],
|
items: WorkActivity[],
|
||||||
setActivities?: (items: WorkActivity[]) => void,
|
setActivities?: (items: WorkActivity[]) => void,
|
||||||
@@ -23,7 +26,7 @@ async function saveStored(
|
|||||||
await saveServerData('work-activities', items);
|
await saveServerData('work-activities', items);
|
||||||
return;
|
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);
|
const merged = mergeWorkActivities(Array.isArray(remote) ? remote : [], items);
|
||||||
setActivities?.(merged);
|
setActivities?.(merged);
|
||||||
await saveServerData('work-activities', merged);
|
await saveServerData('work-activities', merged);
|
||||||
@@ -45,10 +48,14 @@ function createActivityId(): string {
|
|||||||
|
|
||||||
export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||||
activities: [],
|
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();
|
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) => {
|
addActivity: (data) => {
|
||||||
@@ -60,8 +67,8 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
|||||||
occurredAt: data.occurredAt ?? now.toISOString(),
|
occurredAt: data.occurredAt ?? now.toISOString(),
|
||||||
};
|
};
|
||||||
const updated = [...get().activities, item];
|
const updated = [...get().activities, item];
|
||||||
set({ activities: updated });
|
set({ activities: updated, loaded: true });
|
||||||
saveStored(updated, (activities) => set({ activities }));
|
saveStored(updated, (activities) => set({ activities, loaded: true }));
|
||||||
return item;
|
return item;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -71,7 +78,7 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
|||||||
|
|
||||||
deleteActivity: (id) => {
|
deleteActivity: (id) => {
|
||||||
const updated = get().activities.filter((activity) => activity.id !== id);
|
const updated = get().activities.filter((activity) => activity.id !== id);
|
||||||
set({ activities: updated });
|
set({ activities: updated, loaded: true });
|
||||||
saveStored(updated, (activities) => set({ activities }), { mergeRemote: false });
|
saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { loadServerData, saveServerData } from '@/lib/server-data';
|
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||||
import {
|
import {
|
||||||
mergeDailySnapshotCacheForSave,
|
mergeDailySnapshotCacheForSave,
|
||||||
mergeInsightCacheForSave,
|
mergeInsightCacheForSave,
|
||||||
@@ -42,6 +42,7 @@ async function loadInsights(): Promise<XiaobaoRiskInsightCacheItem[] | null> {
|
|||||||
|
|
||||||
let snapshotSaveQueue: Promise<void> = Promise.resolve();
|
let snapshotSaveQueue: Promise<void> = Promise.resolve();
|
||||||
let insightSaveQueue: Promise<void> = Promise.resolve();
|
let insightSaveQueue: Promise<void> = Promise.resolve();
|
||||||
|
let lastRiskDataFetchAt = 0;
|
||||||
|
|
||||||
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||||
snapshots: [],
|
snapshots: [],
|
||||||
@@ -52,8 +53,11 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
error: undefined,
|
error: undefined,
|
||||||
|
|
||||||
fetchRiskData: async () => {
|
fetchRiskData: async () => {
|
||||||
|
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
set({ riskDataLoaded: false });
|
set({ riskDataLoaded: false });
|
||||||
const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]);
|
const [snapshots, insights] = await Promise.all([loadSnapshots(), loadInsights()]);
|
||||||
|
if (get().riskDataLoaded && Date.now() - lastRiskDataFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
|
lastRiskDataFetchAt = Date.now();
|
||||||
set({
|
set({
|
||||||
...(snapshots ? { snapshots } : {}),
|
...(snapshots ? { snapshots } : {}),
|
||||||
...(insights ? { insights } : {}),
|
...(insights ? { insights } : {}),
|
||||||
@@ -66,7 +70,7 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
const optimistic = upsertDailySnapshot(get().snapshots, item);
|
const optimistic = upsertDailySnapshot(get().snapshots, item);
|
||||||
set({ snapshots: optimistic, error: undefined });
|
set({ snapshots: optimistic, error: undefined });
|
||||||
const task = snapshotSaveQueue.then(async () => {
|
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);
|
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
||||||
set({ snapshots, error: undefined });
|
set({ snapshots, error: undefined });
|
||||||
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
||||||
@@ -84,7 +88,7 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
|||||||
const optimistic = upsertInsight(get().insights, item);
|
const optimistic = upsertInsight(get().insights, item);
|
||||||
set({ insights: optimistic, error: undefined });
|
set({ insights: optimistic, error: undefined });
|
||||||
const task = insightSaveQueue.then(async () => {
|
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);
|
const insights = mergeInsightCacheForSave(get().insights, Array.isArray(remote) ? remote : [], item);
|
||||||
set({ insights, error: undefined });
|
set({ insights, error: undefined });
|
||||||
await saveServerData('xiaobao-risk-insights', insights);
|
await saveServerData('xiaobao-risk-insights', insights);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
markXiaobaoWarningRead,
|
markXiaobaoWarningRead,
|
||||||
type XiaobaoWarningReadState,
|
type XiaobaoWarningReadState,
|
||||||
} from '@/lib/xiaobao-warning-view';
|
} 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 {
|
interface XiaobaoWarningReadStoreState {
|
||||||
readStates: XiaobaoWarningReadState[];
|
readStates: XiaobaoWarningReadState[];
|
||||||
@@ -17,6 +17,7 @@ interface XiaobaoWarningReadStoreState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let readStateSaveQueue: Promise<void> = Promise.resolve();
|
let readStateSaveQueue: Promise<void> = Promise.resolve();
|
||||||
|
let lastReadStatesFetchAt = 0;
|
||||||
|
|
||||||
async function loadReadStates(): Promise<XiaobaoWarningReadState[] | null> {
|
async function loadReadStates(): Promise<XiaobaoWarningReadState[] | null> {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +64,10 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
|
|||||||
error: undefined,
|
error: undefined,
|
||||||
|
|
||||||
fetchReadStates: async () => {
|
fetchReadStates: async () => {
|
||||||
|
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
const rows = await loadReadStates();
|
const rows = await loadReadStates();
|
||||||
|
if (get().readStateLoaded && Date.now() - lastReadStatesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||||
|
lastReadStatesFetchAt = Date.now();
|
||||||
set({
|
set({
|
||||||
readStates: rows ? mergeReadStates(rows, get().readStates) : get().readStates,
|
readStates: rows ? mergeReadStates(rows, get().readStates) : get().readStates,
|
||||||
readStateLoaded: rows !== null,
|
readStateLoaded: rows !== null,
|
||||||
@@ -77,7 +81,7 @@ export const useXiaobaoWarningReadStore = create<XiaobaoWarningReadStoreState>((
|
|||||||
set({ readStates: optimistic, error: undefined });
|
set({ readStates: optimistic, error: undefined });
|
||||||
|
|
||||||
const task = readStateSaveQueue.then(async () => {
|
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);
|
const merged = mergeReadStates(normalizeReadStates(remote), get().readStates);
|
||||||
set({ readStates: merged, readStateLoaded: true, error: undefined });
|
set({ readStates: merged, readStateLoaded: true, error: undefined });
|
||||||
await saveServerData('xiaobao-warning-views', merged);
|
await saveServerData('xiaobao-warning-views', merged);
|
||||||
|
|||||||
Reference in New Issue
Block a user