refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled

- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
2026-07-09 14:59:49 +08:00
parent 1e6fb0c7aa
commit 2e595c7e72
98 changed files with 2938 additions and 1441 deletions

View File

@@ -25,13 +25,30 @@ const KIND_LABEL: Record<GovernanceKind, string> = {
requirement_source: '需求来源',
};
const TASK_CATEGORY_GROUPS = [
{ value: 'development', label: '开发' },
{ value: 'testing', label: '测试' },
{ value: 'implementation', label: '实施' },
{ value: 'other', label: '其他' },
];
const SOURCE_TYPE_GROUPS = [
{ value: 'customer', label: '客户' },
{ value: 'internal', label: '内部' },
{ value: 'operation', label: '运营' },
{ value: 'aftersale', label: '售后' },
{ value: 'market', label: '市场' },
{ value: 'competitor', label: '竞品' },
{ value: 'management', label: '管理层' },
];
function GovernancePageInner() {
const user = useAuthStore((s) => s.user);
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
const [kind, setKind] = useState<GovernanceKind>('task_category');
const [items, setItems] = useState<GovernanceItem[]>([]);
const [name, setName] = useState('');
const [group, setGroup] = useState('other');
const [group, setGroup] = useState(defaultGroupForKind('task_category'));
const [exportText, setExportText] = useState('');
const actorId = user?.id ?? '';
const permissions = role?.permissions ?? [];
@@ -45,9 +62,13 @@ function GovernancePageInner() {
void reload().catch(() => setItems([]));
}, [kind]);
useEffect(() => {
setGroup(defaultGroupForKind(kind));
}, [kind]);
const create = async () => {
if (!actorId || !name.trim()) return;
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group });
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group: groupForPayload(kind, group) });
setName('');
await reload();
};
@@ -99,9 +120,15 @@ function GovernancePageInner() {
</div>
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
<div className="grid grid-cols-[1fr_140px_auto] gap-2 border-b border-[var(--line)] p-3">
<div className={`grid gap-2 border-b border-[var(--line)] p-3 ${groupOptionsForKind(kind) ? 'grid-cols-[1fr_140px_auto]' : 'grid-cols-[1fr_auto]'}`}>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<input value={group} onChange={(event) => setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
{groupOptionsForKind(kind) && (
<select value={group} onChange={(event) => setGroup(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
{groupOptionsForKind(kind)?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
<button onClick={create} disabled={!name.trim()} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
<Plus className="h-3.5 w-3.5" />
</button>
@@ -143,6 +170,22 @@ function GovernancePageInner() {
);
}
function groupOptionsForKind(kind: GovernanceKind) {
if (kind === 'task_category') return TASK_CATEGORY_GROUPS;
if (kind === 'requirement_source') return SOURCE_TYPE_GROUPS;
return null;
}
function defaultGroupForKind(kind: GovernanceKind) {
if (kind === 'task_category') return 'development';
if (kind === 'requirement_source') return 'customer';
return '';
}
function groupForPayload(kind: GovernanceKind, group: string) {
return groupOptionsForKind(kind) ? group : undefined;
}
export default function GovernancePage() {
return (
<RouteGuard permission="governance:manage">

View File

@@ -47,7 +47,6 @@ function OvertimePageContent() {
const [showReasonDrawer, setShowReasonDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
@@ -327,6 +326,7 @@ function OvertimePageContent() {
versions={allVersions}
reasons={reasons}
requirements={requirements.map((r) => ({ id: r.id, title: r.title, versionId: r.versionId }))}
onVersionChange={(version) => fetchRequirements({ productId: version.productId, versionId: version.id })}
onClose={() => setShowModal(false)}
onSubmit={(data) => {
createRecord(data as any);
@@ -408,13 +408,14 @@ function DepartmentButton({ active, label, count, hours, depth = 0, icon, onClic
);
}
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onClose, onSubmit }: {
function OvertimeModal({ defaultPerson, products, projects, versions, reasons, requirements, onVersionChange, onClose, onSubmit }: {
defaultPerson: string;
products: { id: string; name: string }[];
projects: { id: string; name: string; productId: string }[];
versions: { id: string; name: string; projectId?: string }[];
versions: { id: string; name: string; productId: string; projectId?: string }[];
reasons: { id: string; name: string }[];
requirements: { id: string; title: string; versionId?: string }[];
onVersionChange: (version: { id: string; productId: string }) => void;
onClose: () => void;
onSubmit: (data: any) => void;
}) {
@@ -469,7 +470,18 @@ function OvertimeModal({ defaultPerson, products, projects, versions, reasons, r
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={versionId} onChange={(e) => setVersionId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<select
value={versionId}
onChange={(e) => {
const nextVersionId = e.target.value;
setVersionId(nextVersionId);
setRequirementId('');
const version = versions.find((item) => item.id === nextVersionId);
if (version) onVersionChange(version);
}}
required
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none"
>
<option value=""></option>
{filteredVersions.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>

View File

@@ -45,7 +45,7 @@ export default function ProductDetailPage() {
useEffect(() => {
fetchProduct(productId);
fetchRequirements();
fetchRequirements({ productId });
fetchRecords();
}, [productId, fetchProduct, fetchRequirements, fetchRecords]);

View File

@@ -332,14 +332,21 @@ export default function ProjectDetailPage() {
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const project = useMemo(() => getProjectDetail(overview, projectId), [overview, projectId]);
useEffect(() => {
if (!project) return;
void fetchRequirements({ productId: project.productId, projectId });
project.versions.forEach((version) => {
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
});
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases, project, projectId]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
const { roles } = useMemberStore();

View File

@@ -35,15 +35,23 @@ function ProjectsPageContent() {
const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
useEffect(() => { fetchPlans(); }, [fetchPlans]);
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => {
overview.forEach((product) => {
void fetchRequirements({ productId: product.id });
});
}, [fetchRequirements, overview]);
useEffect(() => {
allVersions.forEach((version) => {
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
});
}, [allVersions, fetchDevTasks, fetchPlans, fetchTestCases]);
const versionProgressMap = useMemo(
() => buildVersionProgressMap(allVersions, plans, requirements, devTasks, testCases),
[allVersions, plans, requirements, devTasks, testCases],

View File

@@ -1,11 +1,10 @@
'use client';
import { RouteGuard } from '@/components/auth/Guard';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Search,
Plus,
Lightbulb,
ArrowUp,
ArrowDown,
ChevronDown,
@@ -42,7 +41,6 @@ import { getRequirementVersionSelectionPatch } from '@/lib/requirement-version-l
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';
@@ -199,9 +197,6 @@ function RequirementsPageContent() {
const {
requirements, fetchRequirements, createRequirement, updateRequirement, deleteRequirement,
sourceTargets, types, platforms,
addSourceTarget, updateSourceTarget, deleteSourceTarget,
addType, updateType, deleteType,
addPlatform, updatePlatform, deletePlatform,
loaded: requirementsLoaded,
} = useRequirementStore();
const { overview, fetchOverview } = useProductStore();
@@ -233,7 +228,6 @@ function RequirementsPageContent() {
const [showModal, setShowModal] = useState(false);
const [editingReq, setEditingReq] = useState<Requirement | null>(null);
const [viewingReq, setViewingReq] = useState<Requirement | null>(null);
const [drawerType, setDrawerType] = useState<null | 'source' | 'type' | 'platform'>(null);
const [rejectingReq, setRejectingReq] = useState<Requirement | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [dateSort, setDateSort] = useState<RequirementDateSort>('desc');
@@ -274,6 +268,23 @@ function RequirementsPageContent() {
if (selectedScope.type === 'project') return allVersions.filter((v) => v.projectId === selectedScope.projectId);
return allVersions;
}, [allVersions, selectedScope]);
const hydrateRequirementStoreFallback = useCallback(async () => {
const pendingLoads: Array<Promise<void>> = [];
if (selectedScope.type === 'product') {
pendingLoads.push(fetchRequirements({ productId: selectedScope.productId }));
} else if (selectedScope.type === 'project') {
const project = allProjects.find((item) => item.id === selectedScope.projectId);
if (project) pendingLoads.push(fetchRequirements({ productId: project.productId, projectId: selectedScope.projectId }));
} else {
for (const product of overview) {
pendingLoads.push(fetchRequirements({ productId: product.id }));
}
}
for (const version of scopedVersions) {
pendingLoads.push(fetchDevTasks({ versionId: version.id }));
}
await Promise.all(pendingLoads);
}, [allProjects, fetchDevTasks, fetchRequirements, overview, scopedVersions, selectedScope]);
const filterResetKey = useMemo(() => JSON.stringify({
selectedScopeKey,
@@ -319,9 +330,8 @@ function RequirementsPageContent() {
useEffect(() => {
if (v22RequirementQuery && !v22RequirementsFailed) return;
fetchRequirements();
fetchDevTasks();
}, [fetchDevTasks, fetchRequirements, v22RequirementQuery, v22RequirementsFailed]);
void hydrateRequirementStoreFallback();
}, [hydrateRequirementStoreFallback, v22RequirementQuery, v22RequirementsFailed]);
useEffect(() => {
setV22RequirementsFailed(false);
@@ -354,8 +364,7 @@ function RequirementsPageContent() {
setV22Requirements([]);
setV22NextCursor(undefined);
setV22RequirementsFailed(true);
fetchRequirements();
fetchDevTasks();
void hydrateRequirementStoreFallback();
})
.finally(() => {
if (!cancelled) setV22RequirementsLoading(false);
@@ -364,7 +373,7 @@ function RequirementsPageContent() {
return () => {
cancelled = true;
};
}, [fetchDevTasks, fetchRequirements, page, v22RequirementQuery]);
}, [hydrateRequirementStoreFallback, page, v22RequirementQuery]);
const selectedScopeTitle = useMemo(() => {
if (selectedScope.type === 'product') {
@@ -472,7 +481,7 @@ function RequirementsPageContent() {
};
const handleCreate = async () => {
await fetchRequirements();
await hydrateRequirementStoreFallback();
setEditingReq(null);
setShowModal(true);
};
@@ -480,10 +489,6 @@ function RequirementsPageContent() {
const handleSelectScope = (scope: RequirementScopeSelection) => {
setAutoScopeSelected(true);
setSelectedScope(scope);
if (scope.type === 'all') {
fetchRequirements();
fetchDevTasks();
}
};
return (
@@ -552,13 +557,6 @@ function RequirementsPageContent() {
<p className="mt-0.5 text-[11px] text-[var(--ink-muted)]">{selectedScopeMeta}</p>
</div>
<div className="flex items-center gap-2">
<button
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} />
</button>
<button
onClick={handleCreate}
className="flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] transition-colors"
@@ -888,41 +886,6 @@ function RequirementsPageContent() {
setShowModal(false);
setEditingReq(null);
}}
onOpenDrawer={(type) => setDrawerType(type)}
/>
)}
{/* Drawer */}
{drawerType === 'source' && (
<SourceDrawer
open={true}
items={sourceTargets}
onClose={() => setDrawerType(null)}
onAdd={addSourceTarget}
onUpdate={updateSourceTarget}
onDelete={deleteSourceTarget}
/>
)}
{drawerType === 'type' && (
<DictDrawer
open={true}
title="需求类型管理"
items={types}
onClose={() => setDrawerType(null)}
onAdd={addType}
onUpdate={updateType}
onDelete={deleteType}
/>
)}
{drawerType === 'platform' && (
<DictDrawer
open={true}
title="支持端管理"
items={platforms}
onClose={() => setDrawerType(null)}
onAdd={addPlatform}
onUpdate={updatePlatform}
onDelete={deletePlatform}
/>
)}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
import { useProductStore } from '@/stores/useProductStore';
@@ -142,6 +142,7 @@ export default function VersionDetailPage() {
() => allMembers.map((member) => ({
id: member.id,
name: member.name,
username: member.username,
departmentName: departments.find((department) => department.id === member.departmentId)?.name,
})),
[allMembers, departments],
@@ -163,6 +164,12 @@ export default function VersionDetailPage() {
const [showAnalysisDrawer, setShowAnalysisDrawer] = useState(false);
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
const [v22Scope, setV22Scope] = useState<VersionDataScope | null>(null);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const fetchScopedRequirements = useCallback((force = false) => {
if (!version) return Promise.resolve();
if (force) return fetchRequirements({ productId: version.productId, versionId, force: true });
return fetchRequirements({ productId: version.productId, versionId });
}, [fetchRequirements, version, versionId]);
useEffect(() => { fetchOverview(); }, [fetchOverview]);
useEffect(() => {
@@ -182,43 +189,44 @@ export default function VersionDetailPage() {
fetchRecords();
return;
}
if (!version) return;
if (activeTab === 'requirements') {
fetchRequirements();
fetchDevTasks();
fetchScopedRequirements();
fetchDevTasks({ versionId });
return;
}
if (activeTab === 'research' || activeTab === 'product' || activeTab === 'ui') {
fetchRequirements();
fetchPlans();
fetchScopedRequirements();
fetchPlans({ versionId });
return;
}
if (activeTab === 'tasks') {
fetchRequirements();
fetchDevTasks();
fetchScopedRequirements();
fetchDevTasks({ versionId });
return;
}
if (activeTab === 'testcases') {
fetchRequirements();
fetchDevTasks();
fetchTestCases();
fetchBugs();
fetchScopedRequirements();
fetchDevTasks({ versionId });
fetchTestCases({ versionId });
fetchBugs({ versionId });
return;
}
if (activeTab === 'bugs') {
fetchRequirements();
fetchTestCases();
fetchBugs();
fetchScopedRequirements();
fetchTestCases({ versionId });
fetchBugs({ versionId });
}
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases]);
}, [activeTab, fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, version, versionId]);
useEffect(() => {
if (!showRecommendModal) return;
fetchRecords();
fetchRequirements();
fetchPlans();
fetchDevTasks();
fetchTestCases();
fetchBugs();
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchRequirements, fetchTestCases, showRecommendModal]);
fetchScopedRequirements(true);
fetchPlans({ versionId });
fetchDevTasks({ versionId });
fetchTestCases({ versionId });
fetchBugs({ versionId });
}, [fetchBugs, fetchDevTasks, fetchPlans, fetchRecords, fetchScopedRequirements, fetchTestCases, showRecommendModal, versionId]);
useEffect(() => {
let active = true;
setRecommendationDataReady(false);
@@ -228,8 +236,7 @@ export default function VersionDetailPage() {
return () => { active = false; };
}, [fetchMembers, fetchCategories]);
const version = useMemo(() => getVersionDetail(overview, versionId), [overview, versionId]);
const appDataVersionScope = useMemo(
const storeVersionScope = useMemo(
() => version
? buildVersionDataScope({
versionId: version.id,
@@ -250,19 +257,20 @@ export default function VersionDetailPage() {
const testCaseWriteReady = requirementsLoaded && devTasksLoaded && testCasesLoaded && bugsLoaded;
const bugWriteReady = requirementsLoaded && testCasesLoaded && bugsLoaded;
const loadAllVersionStores = () => {
if (!version) return;
fetchRecords();
fetchRequirements();
fetchPlans();
fetchDevTasks();
fetchTestCases();
fetchBugs();
fetchRequirements({ productId: version.productId, versionId, force: true });
fetchPlans({ versionId });
fetchDevTasks({ versionId });
fetchTestCases({ versionId });
fetchBugs({ versionId });
};
const versionScope = useMemo(
() => selectVersionDataScope({
appDataScope: appDataVersionScope,
v22Scope: versionWriteStoresReady ? null : v22Scope,
storeScope: storeVersionScope,
v22Scope,
}),
[appDataVersionScope, v22Scope, versionWriteStoresReady],
[storeVersionScope, v22Scope],
);
const releaseProgress = versionScope
? calcScopedVersionProgress(versionScope.plans, versionScope.devTasks, versionScope.testCases)
@@ -327,15 +335,12 @@ export default function VersionDetailPage() {
.some((permission) => canWriteVersionPermission(permission));
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 displayRequirements = scopedVersionData.requirements;
const displayPlans = scopedVersionData.plans;
const displayDevTasks = scopedVersionData.devTasks;
const displayTestCases = scopedVersionData.testCases;
const displayBugs = scopedVersionData.bugs;
const displayRequirementIds = scopedVersionData.requirementIds;
const memberRecommendationGroups = showRecommendModal && recommendationDataReady ? (() => {
const currentSystemParticipation = new Map<string, number>();
overview.forEach((product) => {
@@ -1066,8 +1071,10 @@ export default function VersionDetailPage() {
version={version}
versionDeadline={version.expectedReleaseDate ?? undefined}
currentUserName={user?.name ?? ''}
currentUserReference={user?.username ?? user?.id ?? user?.name ?? ''}
planType={pt}
versionMembers={version.members ?? []}
allMembers={memberCandidates}
linkedRequirements={versionLinkedReqs}
allRequirements={displayRequirements}
readOnly={versionReadonly || !planWriteReady || !canWriteVersionPermission(PLAN_MANAGE_PERMISSION[pt])}

View File

@@ -94,16 +94,9 @@ function VersionsPageContent() {
useEffect(() => { fetchOverview(); }, [fetchOverview]);
const { requirements, fetchRequirements, updateRequirement } = useRequirementStore();
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
const { plans, fetchPlans } = useVersionPlanStore();
useEffect(() => { fetchPlans(); }, [fetchPlans]);
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
const { testCases, fetchTestCases } = useTestCaseStore();
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
const user = useAuthStore((s) => s.user);
const currentUserName = user?.name || '';
@@ -125,6 +118,14 @@ function VersionsPageContent() {
if (!v.members || v.members.length === 0) return true;
return v.members.some((m) => m.name === currentUserName);
}), [allVersionsRaw, currentUserName, isSuperAdmin]);
useEffect(() => {
for (const version of allVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchDevTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
}
}, [allVersions, fetchDevTasks, fetchPlans, fetchRequirements, fetchTestCases]);
const versionTree = useMemo(() => buildVersionScopeTree(allVersions, treeKeyword), [allVersions, treeKeyword]);
// Compute overall progress per version from actual data

View File

@@ -32,7 +32,7 @@ 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 };
type WorkspaceVersionContext = { id: string; name: string; productId: string; productName: string; projectName: string; status: VersionStatus };
type TreeVersion = { id: string; name: string; status: VersionStatus; pendingCount: number };
type ProductTree = Map<string, { name: string; projects: Map<string, { name: string; versions: TreeVersion[] }> }>;
@@ -56,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, 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 { plans, fetchPlans } = useVersionPlanStore();
const { requirements, fetchRequirements } = useRequirementStore();
const { tasks: devTasks, fetchTasks } = useDevTaskStore();
const { testCases, fetchTestCases } = useTestCaseStore();
const { bugs, fetchBugs } = useBugStore();
const { worklogs, fetchWorklogs } = useTaskWorklogStore();
const { activities, fetchActivities } = useWorkActivityStore();
const user = useAuthStore((s) => s.user);
@@ -81,6 +81,16 @@ export default function WorkspacePage() {
const userName = user?.name ?? '';
const workspaceUserKey = userId || userName;
const workspaceUserRefs = useMemo(() => [userName, userId].filter(Boolean), [userId, userName]);
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
const hydrateWorkspaceStoreFallback = useCallback(() => {
for (const version of allVersions) {
void fetchRequirements({ productId: version.productId, versionId: version.id });
void fetchPlans({ versionId: version.id });
void fetchTasks({ versionId: version.id });
void fetchTestCases({ versionId: version.id });
void fetchBugs({ versionId: version.id });
}
}, [allVersions, fetchBugs, fetchPlans, fetchRequirements, fetchTasks, fetchTestCases]);
useEffect(() => {
if (!workspaceUserKey.trim()) {
@@ -112,26 +122,19 @@ export default function WorkspacePage() {
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]);
hydrateWorkspaceStoreFallback();
}, [hydrateWorkspaceStoreFallback, v22WorkspaceFailed, workspaceUserKey]);
const versionMap = useMemo(() => {
const map = new Map<string, WorkspaceVersionContext>();
allVersions.forEach((v) => map.set(v.id, { id: v.id, name: v.name, productName: v.productName, projectName: v.projectName, status: v.status }));
allVersions.forEach((v) => map.set(v.id, {
id: v.id,
name: v.name,
productId: v.productId,
productName: v.productName,
projectName: v.projectName,
status: v.status,
}));
return map;
}, [allVersions]);
@@ -146,7 +149,7 @@ export default function WorkspacePage() {
v22Loaded: v22WorkspaceLoaded,
v22Failed: v22WorkspaceFailed,
v22Data: v22WorkspaceData,
appData: { plans, devTasks, testCases, bugs },
storeData: { plans, devTasks, testCases, bugs },
}),
[bugs, devTasks, plans, testCases, v22WorkspaceData, v22WorkspaceFailed, v22WorkspaceLoaded],
);
@@ -227,25 +230,24 @@ export default function WorkspacePage() {
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());
const versionContext = versionMap.get(item.versionId);
if (versionContext) {
pendingLoads.push(fetchRequirements({ productId: versionContext.productId, versionId: item.versionId }));
}
if (item.type === 'devTask' && !devTasksLoaded) pendingLoads.push(fetchTasks());
if (item.type === 'testCase' && !testCasesLoaded) pendingLoads.push(fetchTestCases());
if (item.type === 'bug' && !bugsLoaded) pendingLoads.push(fetchBugs());
if (item.type === 'plan_research' || item.type === 'plan_product' || item.type === 'plan_ui') {
pendingLoads.push(fetchPlans({ versionId: item.versionId }));
}
if (item.type === 'devTask') pendingLoads.push(fetchTasks({ versionId: item.versionId }));
if (item.type === 'testCase') pendingLoads.push(fetchTestCases({ versionId: item.versionId }));
if (item.type === 'bug') pendingLoads.push(fetchBugs({ versionId: item.versionId }));
await Promise.all(pendingLoads);
}, [
bugsLoaded,
devTasksLoaded,
fetchBugs,
fetchPlans,
fetchRequirements,
fetchTasks,
fetchTestCases,
plansLoaded,
requirementsLoaded,
testCasesLoaded,
versionMap,
fetchBugs,
]);
const openWorkItemDrawer = useCallback(async (item: WorkItem) => {
await ensureWorkspaceDrawerStores(item);

View File

@@ -9,15 +9,12 @@ import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
import { useAuthStore } from '@/stores/useAuthStore';
import { useXiaobaoWarningReadStore } from '@/stores/useXiaobaoWarningReadStore';
import type { XiaobaoRiskLevel, XiaobaoVersionRisk } from '@/lib/xiaobao-risk';
import { buildRiskInsightSignature, findPreviousRiskSnapshot, requestRiskInsight, shouldRequestRiskInsightWithRequestGate } from '@/lib/xiaobao-risk-ai';
import { buildRiskSignature, findLatestDailySnapshot, shouldSaveRiskSnapshot } from '@/lib/xiaobao-risk-trend';
import { attachXiaobaoRiskSuggestion, buildXiaobaoRiskInsightPendingKey } from '@/lib/xiaobao-risk-suggestion';
import { attachXiaobaoRiskSuggestion } from '@/lib/xiaobao-risk-suggestion';
import {
filterXiaobaoRiskWarnings,
formatRemainingWork,
isXiaobaoWarningUpdated,
shouldSkipXiaobaoRiskInsightRequestForReadState,
sanitizeRiskInsight,
} from '@/lib/xiaobao-warning-view';
import { formatDateTime } from '@/lib/format';
@@ -57,19 +54,13 @@ function XiaobaoWarningContent() {
insights,
pendingInsightKeys,
insightRequestAttempts,
riskDataLoaded,
saveSnapshot,
saveInsight,
beginInsightUpdate,
finishInsightUpdate,
today,
} = useXiaobaoWarningRisks({ loadRiskCache: true });
const { readStates, readStateLoaded, fetchReadStates, markRiskRead } = useXiaobaoWarningReadStore();
const [selectedRiskId, setSelectedRiskId] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState('');
const [selectedProjectId, setSelectedProjectId] = useState('');
const savedSnapshotKeysRef = useRef(new Set<string>());
const requestedInsightKeysRef = useRef(new Set<string>());
useEffect(() => {
if (user?.id) fetchReadStates();
@@ -89,52 +80,6 @@ function XiaobaoWarningContent() {
});
}, [risks, saveSnapshot, snapshots]);
useEffect(() => {
risks.forEach((risk) => {
if (shouldSkipXiaobaoRiskInsightRequestForReadState(risk, readStates, user?.id, readStateLoaded)) return;
const previous = findPreviousRiskSnapshot(snapshots, risk.versionId, today);
const key = buildXiaobaoRiskInsightPendingKey(risk);
if (!shouldRequestRiskInsightWithRequestGate({
riskCacheLoaded: riskDataLoaded,
cache: insights,
current: risk,
previous,
lastRequestedAt: insightRequestAttempts[key],
})) return;
const signature = buildRiskInsightSignature(risk);
if (pendingInsightKeys.includes(key)) return;
if (requestedInsightKeysRef.current.has(key)) return;
requestedInsightKeysRef.current.add(key);
beginInsightUpdate(key);
requestRiskInsight(risk).then((response) => {
if (!response.ok) return;
return saveInsight({
versionId: risk.versionId,
riskSignature: signature,
insight: sanitizeRiskInsight(response.result),
generatedAt: new Date().toISOString(),
providerInfo: { model: response.meta.model },
}).catch(() => {});
}).catch(() => {}).finally(() => {
finishInsightUpdate(key);
});
});
}, [
beginInsightUpdate,
finishInsightUpdate,
insights,
insightRequestAttempts,
pendingInsightKeys,
readStateLoaded,
readStates,
riskDataLoaded,
risks,
saveInsight,
snapshots,
today,
user?.id,
]);
const risksWithInsight = useMemo(() => risks.map((risk) => attachXiaobaoRiskSuggestion(risk, {
insights,
pendingInsightKeys,