Protect keyword context snapshots

This commit is contained in:
DCCONSTRUCTIONS 2026-07-05 17:38:43 +03:00
parent 3951a9cbc5
commit 22ec219383
1 changed files with 88 additions and 59 deletions

View File

@ -4387,6 +4387,7 @@ function SeoAnchorReviewWorkspace({
isCollapsed, isCollapsed,
isBulkApproving, isBulkApproving,
isDemandProfileDirty, isDemandProfileDirty,
isReadOnly,
isSavingDraft, isSavingDraft,
isManualAdding, isManualAdding,
onDecision, onDecision,
@ -4410,6 +4411,7 @@ function SeoAnchorReviewWorkspace({
isCollapsed: boolean; isCollapsed: boolean;
isBulkApproving: boolean; isBulkApproving: boolean;
isDemandProfileDirty: boolean; isDemandProfileDirty: boolean;
isReadOnly: boolean;
isSavingDraft: boolean; isSavingDraft: boolean;
isManualAdding: boolean; isManualAdding: boolean;
onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void; onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void;
@ -4447,7 +4449,11 @@ function SeoAnchorReviewWorkspace({
<small>Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat.</small> <small>Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat.</small>
<small className="keyword-stage-profile-line"> <small className="keyword-stage-profile-line">
Текущий активный профиль: <strong>{contextProfileLabel}</strong> Текущий активный профиль: <strong>{contextProfileLabel}</strong>
{contextProfileStatus === "loading" ? " · загружается" : contextProfileStatus === "snapshot" ? " · снимок" : ""} {contextProfileStatus === "loading"
? " · загружается"
: contextProfileStatus === "snapshot"
? " · снимок только для просмотра"
: ""}
</small> </small>
<small> <small>
{getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} · {getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} ·
@ -4462,12 +4468,21 @@ function SeoAnchorReviewWorkspace({
<button <button
aria-label="Сохранить раскладку семантического базиса" aria-label="Сохранить раскладку семантического базиса"
className={`keyword-stage-icon-action${draftChangeCount > 0 ? " active" : ""}`} 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} onClick={onSaveDraft}
title={ title={
draftChangeCount > 0 isReadOnly
? `Сохранить ${draftChangeCount} изменений Stage 1` ? "Snapshot открыт только для просмотра: переключись на текущий контекст, чтобы редактировать Stage 1"
: "Нет несохранённых изменений" : draftChangeCount > 0
? `Сохранить ${draftChangeCount} изменений Stage 1`
: "Нет несохранённых изменений"
} }
type="button" type="button"
> >
@ -4476,9 +4491,13 @@ function SeoAnchorReviewWorkspace({
<button <button
aria-label="Ручное добавление ключа" aria-label="Ручное добавление ключа"
className="keyword-stage-icon-action" className="keyword-stage-icon-action"
disabled={isBulkApproving || isManualAdding || anchorReview.state === "not_ready"} disabled={isReadOnly || isBulkApproving || isManualAdding || anchorReview.state === "not_ready"}
onClick={onManualAdd} onClick={onManualAdd}
title="Ручное добавление ключа" title={
isReadOnly
? "Snapshot открыт только для просмотра: ручные ключи добавляются в текущий контекст"
: "Ручное добавление ключа"
}
type="button" type="button"
> >
{isManualAdding ? <Loader2 className="spin" size={15} /> : <Plus size={15} />} {isManualAdding ? <Loader2 className="spin" size={15} /> : <Plus size={15} />}
@ -4532,9 +4551,9 @@ function SeoAnchorReviewWorkspace({
<button <button
aria-label="Удалить якорь" aria-label="Удалить якорь"
className="tiny-action icon-only anchor-action-button" className="tiny-action icon-only anchor-action-button"
disabled={isBulkApproving} disabled={isReadOnly || isBulkApproving}
onClick={() => onDeleteRequest(anchor)} onClick={() => onDeleteRequest(anchor)}
title="Удалить" title={isReadOnly ? "Snapshot открыт только для просмотра" : "Удалить"}
type="button" type="button"
> >
<X size={14} /> <X size={14} />
@ -4542,7 +4561,7 @@ function SeoAnchorReviewWorkspace({
<button <button
aria-label="Скрыть якорь" aria-label="Скрыть якорь"
className={`tiny-action icon-only anchor-action-button${anchor.decision.status === "disabled" ? " active" : ""}`} 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={() => onClick={() =>
onDecision(anchor, { onDecision(anchor, {
anchorId: anchor.id, anchorId: anchor.id,
@ -4550,7 +4569,7 @@ function SeoAnchorReviewWorkspace({
status: "disabled" status: "disabled"
}) })
} }
title="Скрыть" title={isReadOnly ? "Snapshot открыт только для просмотра" : "Скрыть"}
type="button" type="button"
> >
{isDisableBusy ? <Loader2 className="spin" size={14} /> : <EyeOff size={14} />} {isDisableBusy ? <Loader2 className="spin" size={14} /> : <EyeOff size={14} />}
@ -4558,7 +4577,7 @@ function SeoAnchorReviewWorkspace({
<button <button
aria-label="Оставить якорь на разборе" aria-label="Оставить якорь на разборе"
className={`tiny-action icon-only anchor-action-button${anchor.decision.status === "pending" ? " active" : ""}`} 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={() => onClick={() =>
onDecision(anchor, { onDecision(anchor, {
anchorId: anchor.id, anchorId: anchor.id,
@ -4566,7 +4585,7 @@ function SeoAnchorReviewWorkspace({
status: "pending" status: "pending"
}) })
} }
title="На разбор" title={isReadOnly ? "Snapshot открыт только для просмотра" : "На разбор"}
type="button" type="button"
> >
{isPendingBusy ? <Loader2 className="spin" size={14} /> : <RefreshCw size={14} />} {isPendingBusy ? <Loader2 className="spin" size={14} /> : <RefreshCw size={14} />}
@ -4574,7 +4593,7 @@ function SeoAnchorReviewWorkspace({
<button <button
aria-label="Добавить якорь в очередь Wordstat" aria-label="Добавить якорь в очередь Wordstat"
className={`tiny-action icon-only anchor-action-button${anchor.decision.status === "approved" ? " active" : ""}`} 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={() => onClick={() =>
onDecision(anchor, { onDecision(anchor, {
anchorId: anchor.id, anchorId: anchor.id,
@ -4584,7 +4603,7 @@ function SeoAnchorReviewWorkspace({
status: "approved" status: "approved"
}) })
} }
title="В очередь Wordstat" title={isReadOnly ? "Snapshot открыт только для просмотра" : "В очередь Wordstat"}
type="button" type="button"
> >
{isApproveBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {isApproveBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />}
@ -4611,7 +4630,7 @@ function SeoAnchorReviewWorkspace({
<NdcGlassSelect<DemandCollectionProfile> <NdcGlassSelect<DemandCollectionProfile>
ariaLabel="Выбрать профиль сбора спроса" ariaLabel="Выбрать профиль сбора спроса"
className="anchor-demand-profile-select" 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" menuClassName="anchor-demand-profile-menu"
onChange={onDemandProfileChange} onChange={onDemandProfileChange}
options={demandCollectionProfileOptions} options={demandCollectionProfileOptions}
@ -12488,7 +12507,8 @@ export function App() {
project: ProjectSummary, project: ProjectSummary,
source: "expansion_checkpoint" | "manual_save", source: "expansion_checkpoint" | "manual_save",
label?: string, label?: string,
note?: string note?: string,
options: { activate?: boolean } = {}
) { ) {
const keywordMap = keywordMaps[project.id] ?? null; const keywordMap = keywordMaps[project.id] ?? null;
const result = await saveKeywordContextSnapshot(project.id, { const result = await saveKeywordContextSnapshot(project.id, {
@ -12506,17 +12526,19 @@ export function App() {
[project.id]: [result.snapshot, ...projectSnapshots.filter((snapshot) => snapshot.id !== result.snapshot.id)] [project.id]: [result.snapshot, ...projectSnapshots.filter((snapshot) => snapshot.id !== result.snapshot.id)]
}; };
}); });
setActiveKeywordContextSnapshotIds((currentIds) => ({ if (options.activate !== false) {
...currentIds, setActiveKeywordContextSnapshotIds((currentIds) => ({
[project.id]: result.snapshot.id ...currentIds,
})); [project.id]: result.snapshot.id
}));
}
return result.snapshot; return result.snapshot;
} }
async function saveKeywordCurationLayoutToActiveSnapshot(project: ProjectSummary) { async function saveKeywordCurationLayoutToActiveSnapshot(project: ProjectSummary) {
const projectSnapshots = keywordContextSnapshots[project.id] ?? []; 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; const activeSnapshot = projectSnapshots.find((snapshot) => snapshot.id === activeSnapshotId) ?? null;
if (!activeSnapshot || activeSnapshotId === "current") { if (!activeSnapshot || activeSnapshotId === "current") {
@ -12722,7 +12744,8 @@ export function App() {
project, project,
"expansion_checkpoint", "expansion_checkpoint",
`Расширение ключей · ${new Date().toLocaleString("ru-RU")}`, `Расширение ключей · ${new Date().toLocaleString("ru-RU")}`,
"Checkpoint перед добором дополнительных вариантов: ручная канбан-раскладка не сбрасывается." "Checkpoint перед добором дополнительных вариантов: ручная канбан-раскладка не сбрасывается.",
{ activate: false }
); );
if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) { if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) {
@ -14285,9 +14308,13 @@ export function App() {
const activeProject = activeProjectId ? (projects.find((project) => project.id === activeProjectId) ?? null) : null; const activeProject = activeProjectId ? (projects.find((project) => project.id === activeProjectId) ?? null) : null;
const activeProjectScanSummary = activeProject ? scanSummaries[activeProject.id] : null; const activeProjectScanSummary = activeProject ? scanSummaries[activeProject.id] : null;
const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : []; const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : [];
const requestedKeywordContextSnapshotId = activeProject
? (activeKeywordContextSnapshotIds[activeProject.id] ?? "current")
: "current";
const activeKeywordContextSnapshotId = const activeKeywordContextSnapshotId =
activeProject && activeKeywordContextSnapshotList.length > 0 requestedKeywordContextSnapshotId !== "current" &&
? (activeKeywordContextSnapshotIds[activeProject.id] ?? activeKeywordContextSnapshotList[0]?.id ?? "current") activeKeywordContextSnapshotList.some((snapshot) => snapshot.id === requestedKeywordContextSnapshotId)
? requestedKeywordContextSnapshotId
: "current"; : "current";
const activeKeywordContextSnapshotKey = const activeKeywordContextSnapshotKey =
activeProject && activeKeywordContextSnapshotId !== "current" activeProject && activeKeywordContextSnapshotId !== "current"
@ -15297,7 +15324,8 @@ export function App() {
project, project,
"manual_save", "manual_save",
`Автоснимок перед новым анализом · ${new Date().toLocaleString("ru-RU")}`, `Автоснимок перед новым анализом · ${new Date().toLocaleString("ru-RU")}`,
"Safety snapshot: текущая карта ключей сохранена перед полным пересбором semantic/context/latest состояния проекта." "Safety snapshot: текущая карта ключей сохранена перед полным пересбором semantic/context/latest состояния проекта.",
{ activate: false }
); );
safetySnapshotLabel = safetySnapshot.label; safetySnapshotLabel = safetySnapshot.label;
} }
@ -19612,39 +19640,40 @@ export function App() {
{semanticAnalysis && marketEnrichment ? ( {semanticAnalysis && marketEnrichment ? (
<> <>
{anchorReview ? ( {anchorReview ? (
<SeoAnchorReviewWorkspace <SeoAnchorReviewWorkspace
actionKey={anchorReviewActionKey} actionKey={anchorReviewActionKey}
activeFilter={activeAnchorReviewFilter} activeFilter={activeAnchorReviewFilter}
anchorReview={anchorReview} anchorReview={anchorReview}
contextProfileLabel={projectKeywordContextProfileLabel} contextProfileLabel={projectKeywordContextProfileLabel}
contextProfileStatus={projectKeywordContextProfileStatus} contextProfileStatus={projectKeywordContextProfileStatus}
draftDecisionCount={anchorReviewDraftCount} draftDecisionCount={anchorReviewDraftCount}
demandCollectionProfile={anchorReviewDemandProfile} demandCollectionProfile={anchorReviewDemandProfile}
draftChangeCount={anchorReviewDraftChangeCount} draftChangeCount={anchorReviewDraftChangeCount}
isCollapsed={isAnchorStageCollapsed} isCollapsed={isAnchorStageCollapsed}
isDemandProfileDirty={isAnchorReviewDemandProfileDirty} isDemandProfileDirty={isAnchorReviewDemandProfileDirty}
isBulkApproving={isApprovingAnchorReview} isBulkApproving={isApprovingAnchorReview}
isSavingDraft={savingAnchorReviewDraftProjectId === project.id} isReadOnly={projectKeywordContextProfileStatus !== "current"}
isManualAdding={anchorReviewActionKey === `${project.id}:manual-anchor`} isSavingDraft={savingAnchorReviewDraftProjectId === project.id}
onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)} isManualAdding={anchorReviewActionKey === `${project.id}:manual-anchor`}
onDeleteRequest={(anchor) => openAnchorDeleteModal(project, anchor)} onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)}
onDemandProfileChange={(profile) => onDeleteRequest={(anchor) => openAnchorDeleteModal(project, anchor)}
handleAnchorReviewDemandProfileChange(project, profile) onDemandProfileChange={(profile) =>
} handleAnchorReviewDemandProfileChange(project, profile)
onFilterChange={(filter) => }
setAnchorReviewFilters((currentFilters) => ({ onFilterChange={(filter) =>
...currentFilters, setAnchorReviewFilters((currentFilters) => ({
[project.id]: filter ...currentFilters,
})) [project.id]: filter
} }))
onToggleCollapse={() => toggleKeywordStageCollapse(project.id, "anchors")} }
onManualAdd={() => openManualAnchorModal(project)} onToggleCollapse={() => toggleKeywordStageCollapse(project.id, "anchors")}
onSaveDraft={() => void handleSaveAnchorReviewDraft(project)} onManualAdd={() => openManualAnchorModal(project)}
stageProjectId={project.id} onSaveDraft={() => void handleSaveAnchorReviewDraft(project)}
sectionId={`keyword-anchor-review:${project.id}`} stageProjectId={project.id}
/> sectionId={`keyword-anchor-review:${project.id}`}
) : null} />
) : null}
{showKeywordDemandAnalysis ? ( {showKeywordDemandAnalysis ? (
<section <section