feat(v2.2): 完成高频读取热路径
This commit is contained in:
@@ -24,11 +24,13 @@ import {
|
||||
getRequirementTableTextClass,
|
||||
} from '@/lib/requirement-table-layout';
|
||||
import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-link';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { Pagination } from '@/components/Pagination';
|
||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||
import { DictDrawer, SourceDrawer } from '@/components/requirement/DictDrawer';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { buildV22RequirementQuery } from '@/lib/requirement-v22-query';
|
||||
import { loadV22RequirementsPage } from '@/lib/v22-api';
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
P0: 'bg-red-500/10 text-red-600',
|
||||
@@ -175,6 +177,7 @@ function RequirementsPageContent() {
|
||||
addSourceTarget, updateSourceTarget, deleteSourceTarget,
|
||||
addType, updateType, deleteType,
|
||||
addPlatform, updatePlatform, deletePlatform,
|
||||
loaded: requirementsLoaded,
|
||||
} = useRequirementStore();
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||
@@ -209,10 +212,16 @@ function RequirementsPageContent() {
|
||||
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSizeState] = useState(20);
|
||||
const [autoScopeSelected, setAutoScopeSelected] = useState(false);
|
||||
const [v22Requirements, setV22Requirements] = useState<Requirement[]>([]);
|
||||
const [v22NextCursor, setV22NextCursor] = useState<string | undefined>();
|
||||
const [v22CursorByPage, setV22CursorByPage] = useState<Record<number, string | undefined>>({ 1: undefined });
|
||||
const [v22RequirementsLoading, setV22RequirementsLoading] = useState(false);
|
||||
const [v22RequirementsFailed, setV22RequirementsFailed] = useState(false);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||
|
||||
const selectedScopeKey = selectedScope.type === 'all'
|
||||
? 'all'
|
||||
@@ -224,6 +233,14 @@ function RequirementsPageContent() {
|
||||
setVersionFilter('all');
|
||||
}, [selectedScopeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScopeSelected || selectedScope.type !== 'all') return;
|
||||
const firstProductId = overview[0]?.id;
|
||||
if (!firstProductId) return;
|
||||
setSelectedScope({ type: 'product', productId: firstProductId });
|
||||
setAutoScopeSelected(true);
|
||||
}, [autoScopeSelected, overview, selectedScope.type]);
|
||||
|
||||
const scopeTree = useMemo(() => buildRequirementScopeTree(overview, requirements), [overview, requirements]);
|
||||
const searchedScopeTree = useMemo(() => filterRequirementScopeTreeByKeyword(scopeTree, scopeSearch), [scopeTree, scopeSearch]);
|
||||
const scopedRequirements = useMemo(() => filterRequirementsByScope(requirements, selectedScope), [requirements, selectedScope]);
|
||||
@@ -233,6 +250,97 @@ function RequirementsPageContent() {
|
||||
return allVersions;
|
||||
}, [allVersions, selectedScope]);
|
||||
|
||||
const filterResetKey = useMemo(() => JSON.stringify({
|
||||
selectedScopeKey,
|
||||
search,
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
typeFilter,
|
||||
versionFilter,
|
||||
dateSort,
|
||||
}), [dateSort, priorityFilter, search, selectedScopeKey, statusFilter, typeFilter, versionFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setV22CursorByPage({ 1: undefined });
|
||||
setV22Requirements([]);
|
||||
setV22NextCursor(undefined);
|
||||
}, [filterResetKey]);
|
||||
|
||||
const v22Cursor = v22CursorByPage[page];
|
||||
const v22RequirementQuery = useMemo(() => buildV22RequirementQuery({
|
||||
selectedScope,
|
||||
projects: allProjects.map((project) => ({ id: project.id, productId: project.productId })),
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
typeFilter,
|
||||
versionFilter,
|
||||
search,
|
||||
dateSort,
|
||||
limit: pageSize,
|
||||
cursor: v22Cursor,
|
||||
}), [
|
||||
allProjects,
|
||||
dateSort,
|
||||
pageSize,
|
||||
priorityFilter,
|
||||
search,
|
||||
selectedScope,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
versionFilter,
|
||||
v22Cursor,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (v22RequirementQuery && !v22RequirementsFailed) return;
|
||||
fetchRequirements();
|
||||
fetchDevTasks();
|
||||
}, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
setV22RequirementsFailed(false);
|
||||
}, [filterResetKey, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!v22RequirementQuery) {
|
||||
setV22Requirements([]);
|
||||
setV22NextCursor(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setV22RequirementsLoading(true);
|
||||
loadV22RequirementsPage(v22RequirementQuery)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setV22Requirements(result.items);
|
||||
setV22NextCursor(result.nextCursor);
|
||||
if (result.nextCursor) {
|
||||
setV22CursorByPage((current) => (
|
||||
current[page + 1] === result.nextCursor
|
||||
? current
|
||||
: { ...current, [page + 1]: result.nextCursor }
|
||||
));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setV22Requirements([]);
|
||||
setV22NextCursor(undefined);
|
||||
setV22RequirementsFailed(true);
|
||||
fetchRequirements();
|
||||
fetchDevTasks();
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setV22RequirementsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]);
|
||||
|
||||
const selectedScopeTitle = useMemo(() => {
|
||||
if (selectedScope.type === 'product') {
|
||||
return overview.find((product) => product.id === selectedScope.productId)?.name ?? '产品需求';
|
||||
@@ -298,9 +406,30 @@ function RequirementsPageContent() {
|
||||
return list;
|
||||
}, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]);
|
||||
|
||||
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filtered, 20);
|
||||
const appDataRequirementIds = useMemo(() => new Set(requirements.map((item) => item.id)), [requirements]);
|
||||
const usingV22Requirements = Boolean(v22RequirementQuery) && !v22RequirementsFailed;
|
||||
const appTotalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const appPage = Math.min(page, appTotalPages);
|
||||
const appPaged = filtered.slice((appPage - 1) * pageSize, appPage * pageSize);
|
||||
const displayedRequirements = usingV22Requirements ? v22Requirements : appPaged;
|
||||
const total = usingV22Requirements
|
||||
? (page - 1) * pageSize + v22Requirements.length + (v22NextCursor ? 1 : 0)
|
||||
: filtered.length;
|
||||
const displayPage = usingV22Requirements ? page : appPage;
|
||||
const displayedScopeCount = usingV22Requirements ? total : scopedRequirements.length;
|
||||
const canMutateRequirement = (req: Requirement) => requirementsLoaded && appDataRequirementIds.has(req.id);
|
||||
const handlePageChange = (nextPage: number) => {
|
||||
if (usingV22Requirements && nextPage > page && !v22CursorByPage[nextPage]) return;
|
||||
setPage(nextPage);
|
||||
};
|
||||
const setPageSize = (size: number) => {
|
||||
setPageSizeState(size);
|
||||
setPage(1);
|
||||
setV22CursorByPage({ 1: undefined });
|
||||
};
|
||||
|
||||
const handleEdit = (req: Requirement) => {
|
||||
if (!canMutateRequirement(req)) return;
|
||||
setViewingReq(null);
|
||||
setEditingReq(req);
|
||||
setShowModal(true);
|
||||
@@ -310,11 +439,21 @@ function RequirementsPageContent() {
|
||||
setViewingReq(req);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const handleCreate = async () => {
|
||||
await fetchRequirements();
|
||||
setEditingReq(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSelectScope = (scope: RequirementScopeSelection) => {
|
||||
setAutoScopeSelected(true);
|
||||
setSelectedScope(scope);
|
||||
if (scope.type === 'all') {
|
||||
fetchRequirements();
|
||||
fetchDevTasks();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden bg-[var(--bg)]">
|
||||
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--line)] bg-[var(--bg-card)]">
|
||||
@@ -341,7 +480,7 @@ function RequirementsPageContent() {
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
onClick={() => setSelectedScope({ type: 'all' })}
|
||||
onClick={() => handleSelectScope({ type: 'all' })}
|
||||
className={`mb-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[12px] transition-colors ${
|
||||
selectedScope.type === 'all'
|
||||
? 'bg-[var(--accent-soft)] font-medium text-[var(--accent)]'
|
||||
@@ -357,7 +496,7 @@ function RequirementsPageContent() {
|
||||
key={product.id}
|
||||
product={product}
|
||||
selectedScope={selectedScope}
|
||||
onSelect={setSelectedScope}
|
||||
onSelect={handleSelectScope}
|
||||
/>
|
||||
))}
|
||||
{searchedScopeTree.length === 0 && scopeSearch.trim() && (
|
||||
@@ -375,14 +514,14 @@ function RequirementsPageContent() {
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="truncate text-[15px] font-semibold tracking-tight text-[var(--ink)]">{selectedScopeTitle}</h2>
|
||||
<span className="rounded-md bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-[var(--ink-soft)]">
|
||||
{scopedRequirements.length}
|
||||
{displayedScopeCount}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setDrawerType('source')}
|
||||
onClick={() => { fetchRequirements(); setDrawerType('source'); }}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<Lightbulb className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
@@ -458,7 +597,7 @@ function RequirementsPageContent() {
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto bg-[var(--bg)]">
|
||||
<div className="px-5 py-4">
|
||||
{filtered.length === 0 ? (
|
||||
{displayedRequirements.length === 0 && !v22RequirementsLoading ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--line)] bg-[var(--bg-card)] py-20 text-center">
|
||||
<p className="text-[13px] font-medium text-[var(--ink-soft)]">没有找到匹配的需求</p>
|
||||
<p className="mt-1.5 text-[12px] text-[var(--ink-muted)]">尝试调整筛选条件</p>
|
||||
@@ -497,7 +636,7 @@ function RequirementsPageContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((req) => (
|
||||
{displayedRequirements.map((req) => (
|
||||
<tr
|
||||
key={req.id}
|
||||
onClick={() => handleRowClick(req)}
|
||||
@@ -603,7 +742,7 @@ function RequirementsPageContent() {
|
||||
<td className="px-3 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-0.5 whitespace-nowrap">
|
||||
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
|
||||
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||||
{canMutateRequirement(req) && (['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||||
<button
|
||||
onClick={() => handleEdit(req)}
|
||||
className="h-6 rounded px-1.5 text-[11px] font-medium text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-subtle)]"
|
||||
@@ -612,7 +751,7 @@ function RequirementsPageContent() {
|
||||
</button>
|
||||
)}
|
||||
{/* 待评审:采纳/拒绝 */}
|
||||
{req.status === 'pending_review' && (
|
||||
{canMutateRequirement(req) && req.status === 'pending_review' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateRequirement(req.id, { status: 'adopted' })}
|
||||
@@ -629,7 +768,7 @@ function RequirementsPageContent() {
|
||||
</>
|
||||
)}
|
||||
{/* 已采纳/已规划:关闭 */}
|
||||
{(req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||||
{canMutateRequirement(req) && (req.status === 'adopted' || req.status === 'planned') && canCloseRequirement(req.id, devTasksByRequirement.get(req.id) ?? []) && (
|
||||
<button
|
||||
onClick={() => updateRequirement(req.id, { status: 'closed' })}
|
||||
className="h-6 rounded px-1.5 text-[11px] font-medium text-orange-600 transition-colors hover:bg-orange-50"
|
||||
@@ -638,7 +777,7 @@ function RequirementsPageContent() {
|
||||
</button>
|
||||
)}
|
||||
{/* 删除:待评审/已拒绝/已关闭(已采纳后不能直接删,需走拒绝) */}
|
||||
{(['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
||||
{canMutateRequirement(req) && (['pending_review', 'rejected', 'closed'] as const).includes(req.status as any) && (
|
||||
<button
|
||||
onClick={() => deleteRequirement(req.id)}
|
||||
className="h-6 rounded px-1.5 text-[11px] font-medium text-red-500 transition-colors hover:bg-red-50"
|
||||
@@ -653,7 +792,7 @@ function RequirementsPageContent() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={total} page={page} pageSize={pageSize} onChange={setPage} onPageSizeChange={setPageSize} />
|
||||
<Pagination total={total} page={displayPage} pageSize={pageSize} onChange={handlePageChange} onPageSizeChange={setPageSize} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -671,11 +810,11 @@ function RequirementsPageContent() {
|
||||
requirements={requirements}
|
||||
resolveVersionName={resolveVersionName}
|
||||
onClose={() => setViewingReq(null)}
|
||||
onEdit={canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined}
|
||||
onAdopt={() => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); }}
|
||||
onReject={() => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); }}
|
||||
onCloseReq={canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
|
||||
onDelete={() => { deleteRequirement(viewingReq.id); setViewingReq(null); }}
|
||||
onEdit={canMutateRequirement(viewingReq) && canEditRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => handleEdit(viewingReq) : undefined}
|
||||
onAdopt={canMutateRequirement(viewingReq) ? () => { updateRequirement(viewingReq.id, { status: 'adopted' }); setViewingReq(null); } : undefined}
|
||||
onReject={canMutateRequirement(viewingReq) ? () => { setRejectingReq(viewingReq); setRejectReason(''); setViewingReq(null); } : undefined}
|
||||
onCloseReq={canMutateRequirement(viewingReq) && canCloseRequirement(viewingReq.id, devTasksByRequirement.get(viewingReq.id) ?? []) ? () => { updateRequirement(viewingReq.id, { status: 'closed' }); setViewingReq(null); } : undefined}
|
||||
onDelete={canMutateRequirement(viewingReq) ? () => { deleteRequirement(viewingReq.id); setViewingReq(null); } : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ import { calcBugSeverityRanking, calcPersonalEffortRanking, calcStageEffortMetri
|
||||
import { addVersionMembers, DEFAULT_VERSION_MEMBER_ROLE, filterVersionMemberCandidates } from '@/lib/version-members';
|
||||
import { addRecommendedVersionMembers, getDefaultRecommendedMemberNames, recommendVersionMembers, type MemberRecommendationGroup, type RecommendableRole } from '@/lib/member-recommendation';
|
||||
import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
||||
import { buildVersionDataScope } from '@/lib/version-data-scope';
|
||||
import { buildVersionDataScope, selectVersionDataScope, type VersionDataScope } from '@/lib/version-data-scope';
|
||||
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||
import { isVersionReadonly } from '@/lib/version-status';
|
||||
import { loadV22VersionDetailData } from '@/lib/v22-api';
|
||||
import {
|
||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||
buildVersionName,
|
||||
@@ -89,12 +90,41 @@ export default function VersionDetailPage() {
|
||||
const router = useRouter();
|
||||
const versionId = params.id as string;
|
||||
const { overview, fetchOverview, updateVersion, deleteVersion } = useProductStore();
|
||||
const { requirements, fetchRequirements, updateRequirement, createRequirement } = useRequirementStore();
|
||||
const {
|
||||
requirements,
|
||||
loaded: requirementsLoaded,
|
||||
fetchRequirements,
|
||||
updateRequirement,
|
||||
createRequirement,
|
||||
} = useRequirementStore();
|
||||
const { records, fetchRecords } = useOvertimeStore();
|
||||
const { plans, fetchPlans, createPlan, updatePlan, completePlan, deletePlan } = useVersionPlanStore();
|
||||
const { tasks: devTasks, fetchTasks: fetchDevTasks, deleteTask: deleteDevTask } = useDevTaskStore();
|
||||
const { testCases, fetchTestCases, deleteTestCase } = useTestCaseStore();
|
||||
const { bugs, fetchBugs, deleteBug } = useBugStore();
|
||||
const {
|
||||
plans,
|
||||
loaded: plansLoaded,
|
||||
fetchPlans,
|
||||
createPlan,
|
||||
updatePlan,
|
||||
completePlan,
|
||||
deletePlan,
|
||||
} = useVersionPlanStore();
|
||||
const {
|
||||
tasks: devTasks,
|
||||
loaded: devTasksLoaded,
|
||||
fetchTasks: fetchDevTasks,
|
||||
deleteTask: deleteDevTask,
|
||||
} = useDevTaskStore();
|
||||
const {
|
||||
testCases,
|
||||
loaded: testCasesLoaded,
|
||||
fetchTestCases,
|
||||
deleteTestCase,
|
||||
} = useTestCaseStore();
|
||||
const {
|
||||
bugs,
|
||||
loaded: bugsLoaded,
|
||||
fetchBugs,
|
||||
deleteBug,
|
||||
} = useBugStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { departments, members: allMembers, roles, fetchMembers } = useMemberStore();
|
||||
const { categories: taskCategories, fetchCategories } = useTaskCategoryStore();
|
||||
@@ -122,14 +152,63 @@ export default function VersionDetailPage() {
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchRecords(); }, [fetchRecords]);
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setV22Scope(null);
|
||||
loadV22VersionDetailData(versionId)
|
||||
.then((data) => {
|
||||
if (active) setV22Scope(data.scope);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setV22Scope(null);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [versionId]);
|
||||
useEffect(() => {
|
||||
if (activeTab === 'overview') {
|
||||
fetchRecords();
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'requirements') {
|
||||
fetchRequirements();
|
||||
fetchDevTasks();
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') {
|
||||
fetchRequirements();
|
||||
fetchPlans();
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'tasks') {
|
||||
fetchRequirements();
|
||||
fetchDevTasks();
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'testcases') {
|
||||
fetchRequirements();
|
||||
fetchDevTasks();
|
||||
fetchTestCases();
|
||||
fetchBugs();
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'bugs') {
|
||||
fetchRequirements();
|
||||
fetchTestCases();
|
||||
fetchBugs();
|
||||
}
|
||||
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases]);
|
||||
useEffect(() => {
|
||||
if (!showRecommendModal) return;
|
||||
fetchRecords();
|
||||
fetchRequirements();
|
||||
fetchPlans();
|
||||
fetchDevTasks();
|
||||
fetchTestCases();
|
||||
fetchBugs();
|
||||
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases, showRecommendModal]);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setRecommendationDataReady(false);
|
||||
@@ -140,7 +219,7 @@ export default function VersionDetailPage() {
|
||||
}, [fetchMembers, fetchCategories]);
|
||||
|
||||
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
|
||||
const versionScope = useMemo(
|
||||
const appDataVersionScope = useMemo(
|
||||
() => version
|
||||
? buildVersionDataScope({
|
||||
versionId: version.id,
|
||||
@@ -154,6 +233,27 @@ export default function VersionDetailPage() {
|
||||
: null,
|
||||
[version, requirements, plans, devTasks, testCases, bugs, records],
|
||||
);
|
||||
const versionWriteStoresReady = requirementsLoaded && plansLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
|
||||
const requirementWriteReady = requirementsLoaded && devTasksLoaded;
|
||||
const planWriteReady = requirementsLoaded && plansLoaded;
|
||||
const devTaskWriteReady = requirementsLoaded && devTasksLoaded;
|
||||
const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
|
||||
const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded;
|
||||
const loadAllVersionStores = () => {
|
||||
fetchRecords();
|
||||
fetchRequirements();
|
||||
fetchPlans();
|
||||
fetchDevTasks();
|
||||
fetchTestCases();
|
||||
fetchBugs();
|
||||
};
|
||||
const versionScope = useMemo(
|
||||
() => selectVersionDataScope({
|
||||
appDataScope: appDataVersionScope,
|
||||
v22Scope: versionWriteStoresReady ? null : v22Scope,
|
||||
}),
|
||||
[appDataVersionScope, v22Scope, versionWriteStoresReady],
|
||||
);
|
||||
const releaseProgress = versionScope
|
||||
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
|
||||
: 0;
|
||||
@@ -203,6 +303,15 @@ export default function VersionDetailPage() {
|
||||
}
|
||||
|
||||
const scopedVersionData = versionScope!;
|
||||
const appDataScopedVersionData = appDataVersionScope ?? scopedVersionData;
|
||||
const displayRequirements = requirementsLoaded ? requirements : scopedVersionData.requirements;
|
||||
const displayPlans = plansLoaded ? appDataScopedVersionData.plans : scopedVersionData.plans;
|
||||
const displayDevTasks = devTasksLoaded ? appDataScopedVersionData.devTasks : scopedVersionData.devTasks;
|
||||
const displayTestCases = testCasesLoaded ? appDataScopedVersionData.testCases : scopedVersionData.testCases;
|
||||
const displayBugs = bugsLoaded ? appDataScopedVersionData.bugs : scopedVersionData.bugs;
|
||||
const displayRequirementIds = requirementsLoaded
|
||||
? appDataScopedVersionData.requirementIds
|
||||
: scopedVersionData.requirementIds;
|
||||
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
|
||||
const currentSystemParticipation = new Map<string, number>();
|
||||
overview.forEach((product) => {
|
||||
@@ -244,6 +353,11 @@ export default function VersionDetailPage() {
|
||||
buttons.push({ label: '删除', action: () => {
|
||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||
// 释放关联需求
|
||||
if (!versionWriteStoresReady) {
|
||||
loadAllVersionStores();
|
||||
alert('版本关联数据正在加载,请稍后再删除。');
|
||||
return;
|
||||
}
|
||||
scopedVersionData.requirements.forEach((r) => updateRequirement(r.id, getRequirementVersionUnlinkPatch(r)));
|
||||
// 清理计划任务
|
||||
scopedVersionData.plans.forEach((p) => deletePlan(p.id));
|
||||
@@ -866,13 +980,13 @@ export default function VersionDetailPage() {
|
||||
<VersionRequirementsTab
|
||||
versionId={version.id}
|
||||
projectId={version.projectId}
|
||||
requirements={requirements}
|
||||
devTasks={devTasks}
|
||||
requirements={displayRequirements}
|
||||
devTasks={displayDevTasks}
|
||||
versionMembers={version.members ?? []}
|
||||
currentUserName={user?.name ?? ''}
|
||||
readOnly={versionReadonly}
|
||||
readOnly={versionReadonly || !requirementWriteReady}
|
||||
onLink={(ids, addedBy) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !requirementWriteReady) return;
|
||||
ids.forEach((id) => {
|
||||
const requirement = requirements.find((item) => item.id === id);
|
||||
if (!requirement) return;
|
||||
@@ -880,13 +994,13 @@ export default function VersionDetailPage() {
|
||||
});
|
||||
}}
|
||||
onUnlink={(id) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !requirementWriteReady) return;
|
||||
const requirement = requirements.find((item) => item.id === id);
|
||||
if (!requirement) return;
|
||||
updateRequirement(id, getRequirementVersionUnlinkPatch(requirement));
|
||||
}}
|
||||
onCreateChange={(data) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !requirementWriteReady) return;
|
||||
createRequirement({
|
||||
...data,
|
||||
productId: version.productId,
|
||||
@@ -908,10 +1022,10 @@ export default function VersionDetailPage() {
|
||||
) : (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') ? (
|
||||
(() => {
|
||||
const pt = activeTab as 'research' | 'product' | 'ui';
|
||||
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(requirements, version.projectId);
|
||||
const projectAdoptedReqs = getProjectAdoptedRequirementCandidates(displayRequirements, version.projectId);
|
||||
return (
|
||||
<PlanTab
|
||||
plans={scopedVersionData.plans}
|
||||
plans={displayPlans}
|
||||
versionId={version.id}
|
||||
version={version}
|
||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||
@@ -919,10 +1033,10 @@ export default function VersionDetailPage() {
|
||||
planType={pt}
|
||||
versionMembers={version.members ?? []}
|
||||
linkedRequirements={projectAdoptedReqs}
|
||||
allRequirements={requirements}
|
||||
readOnly={versionReadonly}
|
||||
allRequirements={displayRequirements}
|
||||
readOnly={versionReadonly || !planWriteReady}
|
||||
onCreate={(data) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !planWriteReady) return;
|
||||
createPlan(data);
|
||||
if ((pt === 'product') && data.linkedRequirementIds?.length) {
|
||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||
@@ -935,18 +1049,18 @@ export default function VersionDetailPage() {
|
||||
}
|
||||
}}
|
||||
onUpdate={(id, data) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !planWriteReady) return;
|
||||
updatePlan(id, data);
|
||||
if ((pt === 'product') && data.linkedRequirementIds && data.owner) {
|
||||
data.linkedRequirementIds.forEach((rid) => updateRequirement(rid, { productOwner: data.owner }));
|
||||
}
|
||||
}}
|
||||
onComplete={(id, result) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !planWriteReady) return;
|
||||
return completePlan(id, result);
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
if (versionReadonly) return;
|
||||
if (versionReadonly || !planWriteReady) return;
|
||||
deletePlan(id);
|
||||
}}
|
||||
/>
|
||||
@@ -955,29 +1069,29 @@ export default function VersionDetailPage() {
|
||||
) : activeTab === 'tasks' ? (
|
||||
<DevTaskTab
|
||||
versionId={version.id}
|
||||
requirementIds={scopedVersionData.requirementIds}
|
||||
requirementIds={displayRequirementIds}
|
||||
versionDeadline={version.expectedReleaseDate ?? undefined}
|
||||
readOnly={versionReadonly}
|
||||
versionTasks={scopedVersionData.devTasks}
|
||||
versionRequirements={scopedVersionData.requirements}
|
||||
readOnly={versionReadonly || !devTaskWriteReady}
|
||||
versionTasks={displayDevTasks}
|
||||
versionRequirements={displayRequirements}
|
||||
/>
|
||||
) : activeTab === 'testcases' ? (
|
||||
<TestCaseTab
|
||||
versionId={version.id}
|
||||
requirementIds={scopedVersionData.requirementIds}
|
||||
readOnly={versionReadonly}
|
||||
versionCases={scopedVersionData.testCases}
|
||||
versionBugs={scopedVersionData.bugs}
|
||||
versionDevTasks={scopedVersionData.devTasks}
|
||||
versionRequirements={scopedVersionData.requirements}
|
||||
requirementIds={displayRequirementIds}
|
||||
readOnly={versionReadonly || !testCaseWriteReady}
|
||||
versionCases={displayTestCases}
|
||||
versionBugs={displayBugs}
|
||||
versionDevTasks={displayDevTasks}
|
||||
versionRequirements={displayRequirements}
|
||||
/>
|
||||
) : activeTab === 'bugs' ? (
|
||||
<BugTab
|
||||
versionId={version.id}
|
||||
requirementIds={scopedVersionData.requirementIds}
|
||||
readOnly={versionReadonly}
|
||||
versionBugs={scopedVersionData.bugs}
|
||||
versionTestCases={scopedVersionData.testCases}
|
||||
requirementIds={displayRequirementIds}
|
||||
readOnly={versionReadonly || !bugWriteReady}
|
||||
versionBugs={displayBugs}
|
||||
versionTestCases={displayTestCases}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-12 flex items-center justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, FileText, Palette, ClipboardList, Code2, ClipboardCheck, Bug as BugIcon, ExternalLink, CheckCircle2, ChevronRight, ChevronDown, FolderOpen, Layers } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
@@ -28,6 +28,8 @@ import { TEST_CASE_STATUS_LABEL, TEST_CASE_STATUS_COLOR } from '@/lib/test-case'
|
||||
import { BUG_STATUS_LABEL, BUG_STATUS_COLOR, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
|
||||
import { getWorkspaceDailyReport } from '@/lib/workspace-daily-report';
|
||||
import { VERSION_STATUS_BG, VERSION_STATUS_LABEL, getVersionReadonlyNotice, isVersionReadonly, type VersionStatus } from '@/lib/version-status';
|
||||
import { loadV22WorkspaceData, type V22WorkspaceData } from '@/lib/v22-api';
|
||||
import { selectWorkspaceCollections } from '@/lib/workspace-v22-source';
|
||||
|
||||
type TabKey = 'all' | 'plan_research' | 'plan_product' | 'plan_ui' | 'devTask' | 'testCase' | 'bug';
|
||||
type WorkspaceVersionContext = { id: string; name: string; productName: string; projectName: string; status: VersionStatus };
|
||||
@@ -54,11 +56,11 @@ const PLAN_STATUS_LABEL: Record<string, string> = { pending: '未开始', in_pro
|
||||
export default function WorkspacePage() {
|
||||
const router = useRouter();
|
||||
const { overview, fetchOverview } = useProductStore();
|
||||
const { plans, fetchPlans } = useVersionPlanStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
|
||||
const { testCases, fetchTestCases } = useTestCaseStore();
|
||||
const { bugs, fetchBugs } = useBugStore();
|
||||
const { plans, fetchPlans, loaded: plansLoaded } = useVersionPlanStore();
|
||||
const { requirements, fetchRequirements, loaded: requirementsLoaded } = useRequirementStore();
|
||||
const { tasks: devTasks, fetchTasks, loaded: devTasksLoaded } = useDevTaskStore();
|
||||
const { testCases, fetchTestCases, loaded: testCasesLoaded } = useTestCaseStore();
|
||||
const { bugs, fetchBugs, loaded: bugsLoaded } = useBugStore();
|
||||
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
|
||||
const { activities, fetchActivities } = useWorkActivityStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -67,17 +69,62 @@ export default function WorkspacePage() {
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||
const [drawerItem, setDrawerItem] = useState<WorkItem | null>(null);
|
||||
const [bugFromTestCaseId, setBugFromTestCaseId] = useState<string | null>(null);
|
||||
const [v22WorkspaceData, setV22WorkspaceData] = useState<V22WorkspaceData | null>(null);
|
||||
const [v22WorkspaceLoaded, setV22WorkspaceLoaded] = useState(false);
|
||||
const [v22WorkspaceFailed, setV22WorkspaceFailed] = useState(false);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchPlans(); }, [fetchPlans]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
|
||||
useEffect(() => { fetchBugs(); }, [fetchBugs]);
|
||||
useEffect(() => { fetchWorklogs(); }, [fetchWorklogs]);
|
||||
useEffect(() => { fetchActivities(); }, [fetchActivities]);
|
||||
|
||||
const userName = user?.name ?? '';
|
||||
const workspaceUserKey = userName || user?.id || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceUserKey.trim()) {
|
||||
setV22WorkspaceData(null);
|
||||
setV22WorkspaceLoaded(false);
|
||||
setV22WorkspaceFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setV22WorkspaceLoaded(false);
|
||||
setV22WorkspaceFailed(false);
|
||||
loadV22WorkspaceData(workspaceUserKey)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setV22WorkspaceData(data);
|
||||
setV22WorkspaceLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setV22WorkspaceData(null);
|
||||
setV22WorkspaceFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceUserKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceUserKey.trim() || !v22WorkspaceFailed) return;
|
||||
void fetchPlans();
|
||||
void fetchRequirements();
|
||||
void fetchTasks();
|
||||
void fetchTestCases();
|
||||
void fetchBugs();
|
||||
}, [
|
||||
fetchBugs,
|
||||
fetchPlans,
|
||||
fetchRequirements,
|
||||
fetchTasks,
|
||||
fetchTestCases,
|
||||
v22WorkspaceFailed,
|
||||
workspaceUserKey,
|
||||
]);
|
||||
|
||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||
|
||||
const versionMap = useMemo(() => {
|
||||
@@ -92,9 +139,27 @@ export default function WorkspacePage() {
|
||||
return map;
|
||||
}, [requirements]);
|
||||
|
||||
const workspaceCollections = useMemo(
|
||||
() => selectWorkspaceCollections({
|
||||
v22Loaded: v22WorkspaceLoaded,
|
||||
v22Failed: v22WorkspaceFailed,
|
||||
v22Data: v22WorkspaceData,
|
||||
appData: { plans, devTasks, testCases, bugs },
|
||||
}),
|
||||
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
|
||||
);
|
||||
|
||||
const workItems = useMemo(() =>
|
||||
aggregateWorkItems(userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap),
|
||||
[userName, plans, devTasks, testCases, bugs, versionMap, requirementVersionMap]
|
||||
aggregateWorkItems(
|
||||
userName,
|
||||
workspaceCollections.versionPlans,
|
||||
workspaceCollections.devTasks,
|
||||
workspaceCollections.testCases,
|
||||
workspaceCollections.bugs,
|
||||
versionMap,
|
||||
requirementVersionMap,
|
||||
),
|
||||
[userName, workspaceCollections, versionMap, requirementVersionMap]
|
||||
);
|
||||
const pendingCountByVersion = useMemo(() => getWorkspacePendingCountByVersion(workItems), [workItems]);
|
||||
|
||||
@@ -158,6 +223,32 @@ export default function WorkspacePage() {
|
||||
const selectedVersion = selectedVersionId ? versionMap.get(selectedVersionId) : undefined;
|
||||
const drawerVersionStatus = drawerItem ? versionMap.get(drawerItem.versionId)?.status : undefined;
|
||||
const drawerReadOnly = drawerVersionStatus ? isVersionReadonly(drawerVersionStatus) : false;
|
||||
const ensureWorkspaceDrawerStores = useCallback(async (item: WorkItem) => {
|
||||
const pendingLoads: Array<Promise<void>> = [];
|
||||
if (!requirementsLoaded) pendingLoads.push(fetchRequirements());
|
||||
if ((item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') && !plansLoaded) {
|
||||
pendingLoads.push(fetchPlans());
|
||||
}
|
||||
if (item.type === 'devTask' && !devTasksLoaded) pendingLoads.push(fetchTasks());
|
||||
if (item.type === 'testCase' && !testCasesLoaded) pendingLoads.push(fetchTestCases());
|
||||
if (item.type === 'bug' && !bugsLoaded) pendingLoads.push(fetchBugs());
|
||||
await Promise.all(pendingLoads);
|
||||
}, [
|
||||
bugsLoaded,
|
||||
devTasksLoaded,
|
||||
fetchBugs,
|
||||
fetchPlans,
|
||||
fetchRequirements,
|
||||
fetchTasks,
|
||||
fetchTestCases,
|
||||
plansLoaded,
|
||||
requirementsLoaded,
|
||||
testCasesLoaded,
|
||||
]);
|
||||
const openWorkItemDrawer = useCallback(async (item: WorkItem) => {
|
||||
await ensureWorkspaceDrawerStores(item);
|
||||
setDrawerItem(item);
|
||||
}, [ensureWorkspaceDrawerStores]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
@@ -245,7 +336,7 @@ export default function WorkspacePage() {
|
||||
item={item}
|
||||
versionStatus={versionMap.get(item.versionId)?.status}
|
||||
onNavigate={() => item.versionId && router.push(`/versions/${item.versionId}`)}
|
||||
onClick={() => setDrawerItem(item)}
|
||||
onClick={() => { void openWorkItemDrawer(item); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user