Compare commits

...

3 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS bcc3d9e00e Lazy load SEO project diagnostics 2026-07-05 17:42:20 +03:00
DCCONSTRUCTIONS 74abe909bd Stabilize SEO project stage navigation 2026-07-05 17:41:53 +03:00
DCCONSTRUCTIONS 22ec219383 Protect keyword context snapshots 2026-07-05 17:38:43 +03:00
1 changed files with 182 additions and 92 deletions

View File

@ -4387,6 +4387,7 @@ function SeoAnchorReviewWorkspace({
isCollapsed,
isBulkApproving,
isDemandProfileDirty,
isReadOnly,
isSavingDraft,
isManualAdding,
onDecision,
@ -4410,6 +4411,7 @@ function SeoAnchorReviewWorkspace({
isCollapsed: boolean;
isBulkApproving: boolean;
isDemandProfileDirty: boolean;
isReadOnly: boolean;
isSavingDraft: boolean;
isManualAdding: boolean;
onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void;
@ -4447,7 +4449,11 @@ function SeoAnchorReviewWorkspace({
<small>Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat.</small>
<small className="keyword-stage-profile-line">
Текущий активный профиль: <strong>{contextProfileLabel}</strong>
{contextProfileStatus === "loading" ? " · загружается" : contextProfileStatus === "snapshot" ? " · снимок" : ""}
{contextProfileStatus === "loading"
? " · загружается"
: contextProfileStatus === "snapshot"
? " · снимок только для просмотра"
: ""}
</small>
<small>
{getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} ·
@ -4462,12 +4468,21 @@ function SeoAnchorReviewWorkspace({
<button
aria-label="Сохранить раскладку семантического базиса"
className={`keyword-stage-icon-action${draftChangeCount > 0 ? " active" : ""}`}
disabled={isBulkApproving || isManualAdding || isSavingDraft || draftChangeCount === 0 || anchorReview.state === "not_ready"}
disabled={
isReadOnly ||
isBulkApproving ||
isManualAdding ||
isSavingDraft ||
draftChangeCount === 0 ||
anchorReview.state === "not_ready"
}
onClick={onSaveDraft}
title={
draftChangeCount > 0
? `Сохранить ${draftChangeCount} изменений Stage 1`
: "Нет несохранённых изменений"
isReadOnly
? "Snapshot открыт только для просмотра: переключись на текущий контекст, чтобы редактировать Stage 1"
: draftChangeCount > 0
? `Сохранить ${draftChangeCount} изменений Stage 1`
: "Нет несохранённых изменений"
}
type="button"
>
@ -4476,9 +4491,13 @@ function SeoAnchorReviewWorkspace({
<button
aria-label="Ручное добавление ключа"
className="keyword-stage-icon-action"
disabled={isBulkApproving || isManualAdding || anchorReview.state === "not_ready"}
disabled={isReadOnly || isBulkApproving || isManualAdding || anchorReview.state === "not_ready"}
onClick={onManualAdd}
title="Ручное добавление ключа"
title={
isReadOnly
? "Snapshot открыт только для просмотра: ручные ключи добавляются в текущий контекст"
: "Ручное добавление ключа"
}
type="button"
>
{isManualAdding ? <Loader2 className="spin" size={15} /> : <Plus size={15} />}
@ -4532,9 +4551,9 @@ function SeoAnchorReviewWorkspace({
<button
aria-label="Удалить якорь"
className="tiny-action icon-only anchor-action-button"
disabled={isBulkApproving}
disabled={isReadOnly || isBulkApproving}
onClick={() => onDeleteRequest(anchor)}
title="Удалить"
title={isReadOnly ? "Snapshot открыт только для просмотра" : "Удалить"}
type="button"
>
<X size={14} />
@ -4542,7 +4561,7 @@ function SeoAnchorReviewWorkspace({
<button
aria-label="Скрыть якорь"
className={`tiny-action icon-only anchor-action-button${anchor.decision.status === "disabled" ? " active" : ""}`}
disabled={isBulkApproving || isDisableBusy || anchor.decision.status === "disabled"}
disabled={isReadOnly || isBulkApproving || isDisableBusy || anchor.decision.status === "disabled"}
onClick={() =>
onDecision(anchor, {
anchorId: anchor.id,
@ -4550,7 +4569,7 @@ function SeoAnchorReviewWorkspace({
status: "disabled"
})
}
title="Скрыть"
title={isReadOnly ? "Snapshot открыт только для просмотра" : "Скрыть"}
type="button"
>
{isDisableBusy ? <Loader2 className="spin" size={14} /> : <EyeOff size={14} />}
@ -4558,7 +4577,7 @@ function SeoAnchorReviewWorkspace({
<button
aria-label="Оставить якорь на разборе"
className={`tiny-action icon-only anchor-action-button${anchor.decision.status === "pending" ? " active" : ""}`}
disabled={isBulkApproving || isPendingBusy || anchor.decision.status === "pending"}
disabled={isReadOnly || isBulkApproving || isPendingBusy || anchor.decision.status === "pending"}
onClick={() =>
onDecision(anchor, {
anchorId: anchor.id,
@ -4566,7 +4585,7 @@ function SeoAnchorReviewWorkspace({
status: "pending"
})
}
title="На разбор"
title={isReadOnly ? "Snapshot открыт только для просмотра" : "На разбор"}
type="button"
>
{isPendingBusy ? <Loader2 className="spin" size={14} /> : <RefreshCw size={14} />}
@ -4574,7 +4593,7 @@ function SeoAnchorReviewWorkspace({
<button
aria-label="Добавить якорь в очередь Wordstat"
className={`tiny-action icon-only anchor-action-button${anchor.decision.status === "approved" ? " active" : ""}`}
disabled={isBulkApproving || isApproveBusy || anchor.decision.status === "approved"}
disabled={isReadOnly || isBulkApproving || isApproveBusy || anchor.decision.status === "approved"}
onClick={() =>
onDecision(anchor, {
anchorId: anchor.id,
@ -4584,7 +4603,7 @@ function SeoAnchorReviewWorkspace({
status: "approved"
})
}
title="В очередь Wordstat"
title={isReadOnly ? "Snapshot открыт только для просмотра" : "В очередь Wordstat"}
type="button"
>
{isApproveBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />}
@ -4611,7 +4630,7 @@ function SeoAnchorReviewWorkspace({
<NdcGlassSelect<DemandCollectionProfile>
ariaLabel="Выбрать профиль сбора спроса"
className="anchor-demand-profile-select"
disabled={isBulkApproving || isManualAdding || isSavingDraft || anchorReview.state === "not_ready"}
disabled={isReadOnly || isBulkApproving || isManualAdding || isSavingDraft || anchorReview.state === "not_ready"}
menuClassName="anchor-demand-profile-menu"
onChange={onDemandProfileChange}
options={demandCollectionProfileOptions}
@ -5687,8 +5706,8 @@ function getPipelineStageAccess(
const marketReady = Boolean(input.marketEnrichment && input.marketEnrichment.state !== "not_ready");
const keywordReady = Boolean(input.keywordMap && input.keywordMap.readiness.mappedItemCount > 0);
const strategyReady = Boolean(input.seoStrategy && input.seoStrategy.state !== "blocked");
const synthesisReady = input.strategySynthesis?.state === "ready";
const qualityReady = input.strategyQualityReview?.state === "ready";
const synthesisReady = strategyReady && input.strategySynthesis?.state === "ready";
const qualityReady = synthesisReady && input.strategyQualityReview?.state === "ready";
if (stageIndex === 0) {
return { reason: "Импортируй сайт или выбери уже созданный проект.", status: "open", unlocked: true };
@ -5765,6 +5784,18 @@ function getPipelineStageAccess(
};
}
function getPreferredProjectStageIndex(input: Parameters<typeof getPipelineStageAccess>[1]) {
const stageAccess = pipeline.map((_, stageIndex) => getPipelineStageAccess(stageIndex, input));
if (stageAccess[7]?.unlocked) return 7;
if (stageAccess[6]?.unlocked) return 6;
if (stageAccess[5]?.unlocked) return 5;
if (stageAccess[4]?.unlocked) return 4;
if (stageAccess[3]?.unlocked) return 3;
if (stageAccess[2]?.unlocked) return 2;
return 1;
}
function getAuditIssueCounts(issues: AuditIssue[]) {
return issues.reduce(
(counts, issue) => {
@ -9974,19 +10005,19 @@ export function App() {
setPatchArtifacts(Object.fromEntries(patchEntries));
}
async function loadModelProviderStatuses(projectList: ProjectSummary[]) {
const statusEntries = await Promise.all(
projectList.map(async (project) => {
try {
const result = await fetchModelProviderStatus(project.id);
return [project.id, result.modelProvider] as const;
} catch {
return [project.id, null] as const;
}
})
);
setModelProviderStatuses(Object.fromEntries(statusEntries));
async function loadModelProviderStatusForProject(projectId: string) {
try {
const result = await fetchModelProviderStatus(projectId);
setModelProviderStatuses((currentStatuses) => ({
...currentStatuses,
[projectId]: result.modelProvider
}));
} catch {
setModelProviderStatuses((currentStatuses) => ({
...currentStatuses,
[projectId]: null
}));
}
}
async function loadExternalServices() {
@ -10212,7 +10243,9 @@ export function App() {
try {
const result = await fetchProjects();
setProjects(result.projects);
await Promise.all([
setProjectsLoading(false);
void Promise.all([
loadScanSummaries(result.projects),
loadSemanticAnalyses(result.projects),
loadContextReviews(result.projects),
@ -10227,15 +10260,15 @@ export function App() {
loadSeoStrategies(result.projects),
loadStrategySyntheses(result.projects),
loadStrategyQualityReviews(result.projects),
loadRewritePlans(result.projects),
loadMaterializationContracts(result.projects),
loadRewriteDiffContracts(result.projects),
loadPatchArtifacts(result.projects),
loadModelProviderStatuses(result.projects)
]);
loadRewritePlans(result.projects),
loadMaterializationContracts(result.projects),
loadRewriteDiffContracts(result.projects),
loadPatchArtifacts(result.projects)
]).catch(() => {
// Project rows should remain usable even when a secondary contract endpoint is temporarily unavailable.
});
} catch (error: unknown) {
setProjectsError(getErrorMessage(error, "Не удалось загрузить проекты."));
} finally {
setProjectsLoading(false);
}
}
@ -10751,6 +10784,14 @@ export function App() {
}
}, [activeProjectId, projects, projectsLoading]);
useEffect(() => {
if (!activeProjectId || !isWorkPanelOpen || Object.prototype.hasOwnProperty.call(modelProviderStatuses, activeProjectId)) {
return;
}
void loadModelProviderStatusForProject(activeProjectId);
}, [activeProjectId, isWorkPanelOpen, modelProviderStatuses]);
useEffect(() => {
folderInputRef.current?.setAttribute("webkitdirectory", "");
folderInputRef.current?.setAttribute("directory", "");
@ -12488,7 +12529,8 @@ export function App() {
project: ProjectSummary,
source: "expansion_checkpoint" | "manual_save",
label?: string,
note?: string
note?: string,
options: { activate?: boolean } = {}
) {
const keywordMap = keywordMaps[project.id] ?? null;
const result = await saveKeywordContextSnapshot(project.id, {
@ -12506,17 +12548,19 @@ export function App() {
[project.id]: [result.snapshot, ...projectSnapshots.filter((snapshot) => snapshot.id !== result.snapshot.id)]
};
});
setActiveKeywordContextSnapshotIds((currentIds) => ({
...currentIds,
[project.id]: result.snapshot.id
}));
if (options.activate !== false) {
setActiveKeywordContextSnapshotIds((currentIds) => ({
...currentIds,
[project.id]: result.snapshot.id
}));
}
return result.snapshot;
}
async function saveKeywordCurationLayoutToActiveSnapshot(project: ProjectSummary) {
const projectSnapshots = keywordContextSnapshots[project.id] ?? [];
const activeSnapshotId = activeKeywordContextSnapshotIds[project.id] ?? projectSnapshots[0]?.id ?? "current";
const activeSnapshotId = activeKeywordContextSnapshotIds[project.id] ?? "current";
const activeSnapshot = projectSnapshots.find((snapshot) => snapshot.id === activeSnapshotId) ?? null;
if (!activeSnapshot || activeSnapshotId === "current") {
@ -12722,7 +12766,8 @@ export function App() {
project,
"expansion_checkpoint",
`Расширение ключей · ${new Date().toLocaleString("ru-RU")}`,
"Checkpoint перед добором дополнительных вариантов: ручная канбан-раскладка не сбрасывается."
"Checkpoint перед добором дополнительных вариантов: ручная канбан-раскладка не сбрасывается.",
{ activate: false }
);
if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) {
@ -14285,9 +14330,13 @@ export function App() {
const activeProject = activeProjectId ? (projects.find((project) => project.id === activeProjectId) ?? null) : null;
const activeProjectScanSummary = activeProject ? scanSummaries[activeProject.id] : null;
const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : [];
const requestedKeywordContextSnapshotId = activeProject
? (activeKeywordContextSnapshotIds[activeProject.id] ?? "current")
: "current";
const activeKeywordContextSnapshotId =
activeProject && activeKeywordContextSnapshotList.length > 0
? (activeKeywordContextSnapshotIds[activeProject.id] ?? activeKeywordContextSnapshotList[0]?.id ?? "current")
requestedKeywordContextSnapshotId !== "current" &&
activeKeywordContextSnapshotList.some((snapshot) => snapshot.id === requestedKeywordContextSnapshotId)
? requestedKeywordContextSnapshotId
: "current";
const activeKeywordContextSnapshotKey =
activeProject && activeKeywordContextSnapshotId !== "current"
@ -14454,6 +14503,14 @@ export function App() {
const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null;
const activeStrategySynthesis = activeProject ? (strategySyntheses[activeProject.id] ?? null) : null;
const activeStrategyQualityReview = activeProject ? (strategyQualityReviews[activeProject.id] ?? null) : null;
const activeLiveSemanticAnalysis = activeProject ? (semanticAnalyses[activeProject.id] ?? null) : null;
const activeLiveContextReviewRaw = activeProject ? (contextReviews[activeProject.id] ?? null) : null;
const activeLiveContextReview =
activeLiveSemanticAnalysis && activeLiveContextReviewRaw?.semanticRunId === activeLiveSemanticAnalysis.runId
? activeLiveContextReviewRaw
: null;
const activeLiveMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null;
const activeLiveKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null;
const activeProjectScanScopeCounts = activeProjectScanSummary ? getScanScopeCounts(activeProjectScanSummary) : null;
const activeProjectSelectedWorkspaceScopeItems = activeProjectScanSummary
? getSelectedWorkspaceScopeItems(activeProjectScanSummary)
@ -14535,12 +14592,12 @@ export function App() {
const visibleProjects = activeStageIndex === 0 ? projects : activeProject ? [activeProject] : [];
const stageAccessList = pipeline.map((_, stageIndex) =>
getPipelineStageAccess(stageIndex, {
contextReview: activeContextReview,
contextReview: activeLiveContextReview,
hasProject: Boolean(activeProject),
keywordMap: activeKeywordMap,
marketEnrichment: activeMarketEnrichment,
keywordMap: activeLiveKeywordMap,
marketEnrichment: activeLiveMarketEnrichment,
scan: activeProjectScanSummary,
semanticAnalysis: activeSemanticAnalysis,
semanticAnalysis: activeLiveSemanticAnalysis,
seoStrategy: activeSeoStrategy,
strategyQualityReview: activeStrategyQualityReview,
strategySynthesis: activeStrategySynthesis
@ -14939,6 +14996,7 @@ export function App() {
disabled={isSavingActiveKeywordContextSnapshot}
menuClassName="keyword-context-profile-menu"
onChange={(snapshotId) => {
clearProjectActionFeedback();
setActiveKeywordContextSnapshotIds((currentIds) => ({
...currentIds,
[activeProject.id]: snapshotId
@ -15194,11 +15252,41 @@ export function App() {
</div>
) : null;
function clearProjectActionFeedback() {
setProjectActionProjectId(null);
setProjectActionError(null);
setProjectActionMessage(null);
}
function getProjectPipelineAccessInput(projectId: string): Parameters<typeof getPipelineStageAccess>[1] {
const semanticAnalysis = semanticAnalyses[projectId] ?? null;
const contextReviewRaw = contextReviews[projectId] ?? null;
const contextReview =
semanticAnalysis && contextReviewRaw?.semanticRunId === semanticAnalysis.runId ? contextReviewRaw : null;
return {
contextReview,
hasProject: true,
keywordMap: keywordMaps[projectId] ?? null,
marketEnrichment: marketEnrichments[projectId] ?? null,
scan: scanSummaries[projectId],
semanticAnalysis,
seoStrategy: seoStrategies[projectId] ?? null,
strategyQualityReview: strategyQualityReviews[projectId] ?? null,
strategySynthesis: strategySyntheses[projectId] ?? null
};
}
function getPreferredStageIndexForProject(projectId: string) {
return getPreferredProjectStageIndex(getProjectPipelineAccessInput(projectId));
}
function openProjectScanStage(project: ProjectSummary) {
if (editingProjectId === project.id || pendingDeleteProjectId === project.id) {
return;
}
clearProjectActionFeedback();
setActiveProjectId(project.id);
setActiveStageIndex(1);
setIsProjectMenuOpen(false);
@ -15212,17 +15300,20 @@ export function App() {
}
function openProjectFromMenu(project: ProjectSummary) {
const nextStageIndex = activeProjectId === project.id ? activeStageIndex : 1;
const nextStageIndex =
activeProjectId === project.id && isWorkPanelOpen ? activeStageIndex : getPreferredStageIndexForProject(project.id);
clearProjectActionFeedback();
setActiveProjectId(project.id);
setActiveStageIndex(nextStageIndex);
setIsProjectMenuOpen(false);
setIsStageRailOpen(true);
setIsWorkPanelOpen(false);
setIsWorkPanelOpen(true);
setIsWorkPanelFullscreen(false);
}
function closeActiveProject() {
clearProjectActionFeedback();
setActiveProjectId(null);
setActiveStageIndex(0);
setIsProjectMenuOpen(false);
@ -15252,10 +15343,7 @@ export function App() {
return;
}
if (!projectActionProjectId) {
setProjectActionError(null);
setProjectActionMessage(null);
}
clearProjectActionFeedback();
setActiveStageIndex(stageIndex);
setIsStageRailOpen(true);
@ -15297,7 +15385,8 @@ export function App() {
project,
"manual_save",
`Автоснимок перед новым анализом · ${new Date().toLocaleString("ru-RU")}`,
"Safety snapshot: текущая карта ключей сохранена перед полным пересбором semantic/context/latest состояния проекта."
"Safety snapshot: текущая карта ключей сохранена перед полным пересбором semantic/context/latest состояния проекта.",
{ activate: false }
);
safetySnapshotLabel = safetySnapshot.label;
}
@ -19612,39 +19701,40 @@ export function App() {
{semanticAnalysis && marketEnrichment ? (
<>
{anchorReview ? (
<SeoAnchorReviewWorkspace
actionKey={anchorReviewActionKey}
activeFilter={activeAnchorReviewFilter}
anchorReview={anchorReview}
contextProfileLabel={projectKeywordContextProfileLabel}
contextProfileStatus={projectKeywordContextProfileStatus}
draftDecisionCount={anchorReviewDraftCount}
demandCollectionProfile={anchorReviewDemandProfile}
draftChangeCount={anchorReviewDraftChangeCount}
isCollapsed={isAnchorStageCollapsed}
isDemandProfileDirty={isAnchorReviewDemandProfileDirty}
isBulkApproving={isApprovingAnchorReview}
isSavingDraft={savingAnchorReviewDraftProjectId === project.id}
isManualAdding={anchorReviewActionKey === `${project.id}:manual-anchor`}
onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)}
onDeleteRequest={(anchor) => openAnchorDeleteModal(project, anchor)}
onDemandProfileChange={(profile) =>
handleAnchorReviewDemandProfileChange(project, profile)
}
onFilterChange={(filter) =>
setAnchorReviewFilters((currentFilters) => ({
...currentFilters,
[project.id]: filter
}))
}
onToggleCollapse={() => toggleKeywordStageCollapse(project.id, "anchors")}
onManualAdd={() => openManualAnchorModal(project)}
onSaveDraft={() => void handleSaveAnchorReviewDraft(project)}
stageProjectId={project.id}
sectionId={`keyword-anchor-review:${project.id}`}
/>
) : null}
{anchorReview ? (
<SeoAnchorReviewWorkspace
actionKey={anchorReviewActionKey}
activeFilter={activeAnchorReviewFilter}
anchorReview={anchorReview}
contextProfileLabel={projectKeywordContextProfileLabel}
contextProfileStatus={projectKeywordContextProfileStatus}
draftDecisionCount={anchorReviewDraftCount}
demandCollectionProfile={anchorReviewDemandProfile}
draftChangeCount={anchorReviewDraftChangeCount}
isCollapsed={isAnchorStageCollapsed}
isDemandProfileDirty={isAnchorReviewDemandProfileDirty}
isBulkApproving={isApprovingAnchorReview}
isReadOnly={projectKeywordContextProfileStatus !== "current"}
isSavingDraft={savingAnchorReviewDraftProjectId === project.id}
isManualAdding={anchorReviewActionKey === `${project.id}:manual-anchor`}
onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)}
onDeleteRequest={(anchor) => openAnchorDeleteModal(project, anchor)}
onDemandProfileChange={(profile) =>
handleAnchorReviewDemandProfileChange(project, profile)
}
onFilterChange={(filter) =>
setAnchorReviewFilters((currentFilters) => ({
...currentFilters,
[project.id]: filter
}))
}
onToggleCollapse={() => toggleKeywordStageCollapse(project.id, "anchors")}
onManualAdd={() => openManualAnchorModal(project)}
onSaveDraft={() => void handleSaveAnchorReviewDraft(project)}
stageProjectId={project.id}
sectionId={`keyword-anchor-review:${project.id}`}
/>
) : null}
{showKeywordDemandAnalysis ? (
<section