+
+ {isProjectRowRenaming ? (
+ void commitProjectRename(project)}
+ onChange={(event) => setEditingProjectName(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ void commitProjectRename(project);
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ cancelProjectRename();
+ }
+ }}
+ ref={titleInputRef}
+ value={editingProjectName}
+ />
+ ) : (
+
{
+ if (activeStageIndex === 0) {
+ startProjectRename(project);
+ }
+ }}
+ title={activeStageIndex === 0 ? "Двойной клик, чтобы переименовать" : undefined}
+ >
+ {activeStageIndex === 0 ? project.name : projectSiteTitle}
+
+ )}
+ {busyProjectId === project.id && isProjectRowRenaming ? (
+
+ ) : null}
+
+ {isProjectRowRenaming && editingProjectName.trim().length === 0 ? (
+
Название не должно быть пустым.
+ ) : null}
+ {isProjectRowRenaming && hasProjectNameConflict(editingProjectName, project.id) ? (
+
Проект с таким названием уже есть.
+ ) : null}
+
+
{getSourceDescription(project)}
+
+
+ {isScanning ? (
+
+ ) : scanSummary ? (
+
+ ) : (
+
+ )}
+ {isScanning
+ ? "Скан выполняется..."
+ : scanSummary
+ ? getProjectScanStatusMessage(scanSummary)
+ : "Скан ещё не запускался."}
+
+
+ {activeStageIndex === 1 ? (
+
+ {isScanning && !scanSummary ? (
+
+
+ Запускаю базовый аудит: ищу HTML-страницы, ассеты, title, description, H1 и медиа-поля.
+
+ ) : null}
+ {scanSummary ? (
+ <>
+
+
+
+ Страниц: {scanSummary.counts.pages}
+
+
+
+ Ассетов: {scanSummary.counts.assets}
+
+ {scanSummary.audit ? (
+ Замечаний: {scanSummary.audit.counts.total}
+ ) : (
+ Аудит собирается
+ )}
+
+ {scanSummary.pages.length > 0 ? (
+ <>
+
void handleApplyRecommendedPageScope(project, scanSummary)}
+ onClearSelection={() =>
+ void handleProjectPageSelection(
+ project,
+ scanSummary.pages.map((page) => page.pageId),
+ false
+ )
+ }
+ onTogglePage={(pageId, selected) =>
+ void handleProjectPageSelection(project, [pageId], selected)
+ }
+ projectId={project.id}
+ projectName={project.name}
+ scan={scanSummary}
+ />
+
+ Инженерный список аудита
+
+ {scanSummary.pages.map((page) => {
+ const workspaceKey = getWorkspaceKey(project.id, page.pageId);
+ const isOpeningPage = busyWorkspaceKey === workspaceKey;
+ const auditDetailsKey = `${scanSummary.id}:${page.pageId}`;
+ const isAuditDetailsOpen = Boolean(openPageAuditDetails[auditDetailsKey]);
+
+ return (
+
{
+ if (isOpeningPage) {
+ return;
+ }
+
+ if (isProjectCardInteractionTarget(event.target)) {
+ return;
+ }
+
+ if (isAuditDetailsOpen) {
+ setOpenPageAuditDetails((currentDetails) => ({
+ ...currentDetails,
+ [auditDetailsKey]: false
+ }));
+ return;
+ }
+
+ void openPageEditorFromAudit(project, page);
+ }}
+ onKeyDown={(event) => {
+ if (isOpeningPage || event.target !== event.currentTarget) {
+ return;
+ }
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+
+ if (isAuditDetailsOpen) {
+ setOpenPageAuditDetails((currentDetails) => ({
+ ...currentDetails,
+ [auditDetailsKey]: false
+ }));
+ return;
+ }
+
+ void openPageEditorFromAudit(project, page);
+ }
+ }}
+ role="button"
+ tabIndex={isOpeningPage ? -1 : 0}
+ title="Перейти к рабочей зоне страницы"
+ >
+
+
+ {getPageTitle(page)}
+ {getPagePathLabel(page)}
+
+ {scanSummary.audit ? (
+
+
+ {isAuditDetailsOpen ? (
+
{
+ setOpenPageAuditDetails((currentDetails) => ({
+ ...currentDetails,
+ [auditDetailsKey]: false
+ }));
+ }}
+ >
+ {getPageIssues(scanSummary, page.pageId).length > 0 ? (
+ getPageIssues(scanSummary, page.pageId).map((issue) => {
+ const explanation = getAuditIssueExplanation(issue.code);
+
+ return (
+
+
{getAuditSeverityLabel(issue.severity)}
+
{issue.message}
+
+ {getAuditScopeLabel(issue)}
+ {issue.sourcePath ? ` · ${issue.sourcePath}` : ""}
+ {issue.field ? ` · ${issue.field}` : ""}
+
+
+ Почему важно: {explanation.why}
+
+
+ Что делать: {explanation.action}
+
+
+ );
+ })
+ ) : (
+
Замечаний по странице не найдено.
+ )}
+
+ ) : null}
+
+ ) : (
+
{getPageAuditLine(scanSummary, page)}
+ )}
+
{getPageAuditPreview(scanSummary, page)}
+
+
+ {isOpeningPage ? : "→"}
+
+
+ );
+ })}
+
+
+ >
+ ) : (
+
+
+ HTML-страницы пока не найдены.
+
+ )}
+ >
+ ) : !isScanning ? (
+
+
+ Конфигурация проекта появится после автоматического сканирования источника.
+
+ ) : null}
+
+ ) : null}
+
+ {scanSummary && activeStageIndex !== 1 ? (
+
+
+
+
+ Страниц: {scanSummary.counts.pages}
+
+
+
+ Ассетов: {scanSummary.counts.assets}
+
+ Скан: {getScanStatusLabel(scanSummary.status)}
+ {scanSummary.completedAt ? {formatDate(scanSummary.completedAt)} : null}
+
+
{getScanResultMessage(scanSummary)}
+ {scanSummary.projectIndexKey ? (
+
+ projectIndex.json сохранён в MinIO. Что это?
+
+ Это JSON-снимок конкретного анализа: список файлов, найденные страницы, ассеты,
+ заголовки, media-ссылки и технические метаданные. Основная правда всё равно
+ хранится в Postgres, а этот файл нужен для дебага, отчётов, истории и будущих
+ модельных шагов.
+
+ {scanSummary.projectIndexKey}
+
+ ) : null}
+ {activeStageIndex === 3 ? (
+
+
+
+ Semantic analysis NodeDC
+ {getSemanticRunDate(semanticAnalysis)}
+
+
+
+
+ {(["json", "markdown", "csv"] as const).map((format) => (
+
+ ))}
+
+
+
+ {semanticAnalysis ? (
+ <>
+
+
+ {semanticAnalysis.summary.coverageScore}%
+ dev readiness
+
+ Lexical:{" "}
+ {semanticAnalysis.summary.lexicalCoverageScore ?? semanticAnalysis.summary.coverageScore}% ·
+ Evidence:{" "}
+ {semanticAnalysis.summary.evidenceScore ?? semanticAnalysis.summary.coverageScore}% ·
+ Посадочные: {semanticAnalysis.pageScope.indexablePages} · JSON:{" "}
+ {semanticAnalysis.pageScope.contentSources} · исключено HTML:{" "}
+ {semanticAnalysis.pageScope.excludedDocuments}
+
+
+
+
+ Intent:{" "}
+ {semanticAnalysis.summary.intentCoverageScore ?? semanticAnalysis.summary.coverageScore}%
+
+
+ Commerce:{" "}
+ {semanticAnalysis.summary.commercialReadinessScore ??
+ semanticAnalysis.summary.coverageScore}%
+
+ Покрыто: {semanticAnalysis.summary.coveredClusters}
+ Слабо: {semanticAnalysis.summary.weakClusters}
+ Пусто: {semanticAnalysis.summary.missingClusters}
+ 0 ? "danger" : "ok"}>
+ Legacy: {semanticAnalysis.summary.legacyFindings}
+
+ 0 ? "danger" : "ok"}>
+ Проверки: {semanticAnalysis.summary.qualityProblems}/{semanticAnalysis.summary.qualityWarnings}
+
+
+
+
+
+
+ Scope анализа
+
+ В покрытие попадают только посадочные HTML и смысловые JSON/copy-источники.
+
+
+
Посадочные: {semanticAnalysis.pageScope.indexablePages}
+
Шаблоны: {semanticAnalysis.pageScope.templateBlocks}
+
Админка: {semanticAnalysis.pageScope.adminPages}
+
JSON/copy: {semanticAnalysis.pageScope.contentSources}
+
+
+
+ {semanticAnalysis.qualitySignals.map((signal) => (
+
+ {getSemanticQualityLabel(signal.level)}
+
+ {signal.title}
+ {signal.message}
+
+
+ ))}
+
+
+ {semanticAnalysis.analysisVerdict ? (
+
+
+
+ Dev verdict
+ Детерминированная оценка до Wordstat, SERP и AI-анализа.
+
+
+
+ {getSemanticVerdictConfidenceLabel(semanticAnalysis.analysisVerdict.confidence)}
+
+
+ {getSemanticVerdictObjectivityLabel(
+ semanticAnalysis.analysisVerdict.marketObjectivity
+ )}
+
+
+
+ {semanticAnalysis.analysisVerdict.summary}
+
+
+ {semanticAnalysis.analysisVerdict.risks.slice(0, 3).map((risk) => (
+ {risk.title}
+ ))}
+
+
+
+ Полный dev-разбор
+
+
+
+ Что работает
+ {semanticAnalysis.analysisVerdict.whatWorks.map((item) => (
+ {item}
+ ))}
+
+
+ Ограничения
+ {semanticAnalysis.analysisVerdict.limits.map((item) => (
+ {item}
+ ))}
+
+
+ Внешние сигналы
+ {semanticAnalysis.analysisVerdict.externalSignalsNeeded.map((item) => (
+ {item}
+ ))}
+
+
+
+
+ {semanticAnalysis.analysisVerdict.risks.map((risk) => (
+
+ {getSemanticQualityLabel(risk.level)}
+
+ {risk.title}
+ {risk.message}
+
+
+ ))}
+
+
+
+
Оценка кластеров
+ {semanticAnalysis.analysisVerdict.clusterAssessments.slice(0, 6).map((assessment) => (
+
+
{getSemanticVerdictAssessmentLabel(assessment.assessment)}
+
+ {assessment.title}
+ {assessment.reason}
+
+
+ ))}
+
+
+
+ Следующая валидация
+ {semanticAnalysis.analysisVerdict.nextValidationStep}
+
+
+
+
+ ) : (
+
+ Dev verdict
+ Появится после нового semantic analysis.
+
+ )}
+
+
+
+ Профиль стиля
+
+ Язык: {semanticAnalysis.styleProfile.language} · среднее предложение:{" "}
+ {semanticAnalysis.styleProfile.averageSentenceWords} слов
+
+
+ {semanticAnalysis.styleProfile.toneSignals.length > 0 ? (
+
+ {semanticAnalysis.styleProfile.toneSignals.map((signal) => (
+ {signal}
+ ))}
+
+ ) : null}
+
+ {semanticAnalysis.styleProfile.topTerms.slice(0, 14).map((term) => (
+ {term.term} · {term.count}
+ ))}
+
+ {semanticAnalysis.styleProfile.ctaPhrases.length > 0 ? (
+
CTA: {semanticAnalysis.styleProfile.ctaPhrases.join(", ")}
+ ) : null}
+
+
+ {semanticAnalysis.legacyFindings.length > 0 ? (
+
+
Санитарные блокеры
+ {semanticAnalysis.legacyFindings.slice(0, 6).map((finding) => (
+
+
{finding.label}
+
+ {finding.sourcePath} · {finding.count} упом.
+
+
{finding.action}
+
+ ))}
+
+ ) : (
+
+
+ Legacy-упоминания из санитарного списка не найдены.
+
+ )}
+
+
+ {semanticAnalysis.clusters.slice(0, 8).map((cluster) => (
+
+ ))}
+
+
+ {activeSemanticCluster ? (
+
+
+
+ {getSemanticClusterStatusLabel(activeSemanticCluster.status)}
+ {activeSemanticCluster.title}
+ {activeSemanticCluster.targetIntent}
+
+
+ Score {activeSemanticCluster.score}%
+ Hits {activeSemanticCluster.hits}
+ {getSemanticPriorityLabel(activeSemanticCluster.priority)}
+ {activeSemanticCluster.evidence ? (
+ {getSemanticEvidenceLevelLabel(activeSemanticCluster.evidence.level)}
+ ) : null}
+
+
+
+
+
+ Matched terms
+
+ {activeSemanticCluster.matchedTerms.length > 0 ? (
+ activeSemanticCluster.matchedTerms.map((term) => {term})
+ ) : (
+ Совпадений пока нет.
+ )}
+
+
+
+ Missing terms
+
+ {activeSemanticCluster.missingTerms.slice(0, 10).map((term) => (
+ {term}
+ ))}
+
+
+
+
+ {activeSemanticCluster.evidence ? (
+
+
+ Evidence v2
+
+ lexical {activeSemanticCluster.evidence.lexicalScore}%
+ intent {activeSemanticCluster.evidence.intentScore}%
+ landing hits {activeSemanticCluster.evidence.landingPageHits}
+ copy hits {activeSemanticCluster.evidence.contentSourceHits}
+
+
+
+ Weak signals
+
+ {[
+ ...activeSemanticCluster.evidence.penalties,
+ ...activeSemanticCluster.evidence.missingIntentGroups
+ ].length > 0 ? (
+ [
+ ...activeSemanticCluster.evidence.penalties,
+ ...activeSemanticCluster.evidence.missingIntentGroups
+ ]
+ .slice(0, 8)
+ .map((signal) => {signal})
+ ) : (
+ Критичных слабых сигналов нет.
+ )}
+
+
+
+ ) : null}
+
+
+ Evidence snippets
+ {activeSemanticCluster.evidenceSnippets.length > 0 ? (
+ activeSemanticCluster.evidenceSnippets.map((snippet) => (
+
+ {snippet.text}
+
+ {snippet.sourcePath}
+ {snippet.matchedTerms.length > 0
+ ? ` · ${snippet.matchedTerms.join(", ")}`
+ : ""}
+
+
+ ))
+ ) : (
+ Для этого кластера пока нет текстовых доказательств.
+ )}
+
+
+
+
+ Источники
+
+ {activeSemanticCluster.sourceRefs.length > 0 ? (
+ activeSemanticCluster.sourceRefs.map((sourceRef) => (
+
+ {getSemanticScopeLabel(sourceRef.scope)}
+ {sourceRef.title}
+
+ {sourceRef.sourcePath} · hits {sourceRef.hits}
+
+
+ ))
+ ) : (
+
Источники не найдены.
+ )}
+
+
+
+ Целевые страницы
+
+ {activeSemanticCluster.targetPages.map((targetPage) => (
+ {targetPage}
+ ))}
+
+
+
+
+ ) : null}
+
+ {semanticAnalysis.gaps.length > 0 ? (
+
+
Gaps для расширения охвата
+ {semanticAnalysis.gaps.slice(0, 5).map((gap) => (
+
+
{getSemanticPriorityLabel(gap.priority)}
+
+ {gap.title}
+
+ {getSemanticClusterStatusLabel(gap.status)} · страницы:{" "}
+ {gap.targetPages.slice(0, 3).join(", ")}
+
+
+
+ ))}
+
+ ) : null}
+
+ {(semanticAnalysis.landingPagePlan ?? []).length > 0 ? (
+
+
+ План посадочных
+ Очередь страниц: что создать, что усилить, что отдать на Wordstat/SERP.
+
+ {(semanticAnalysis.landingPagePlan ?? []).slice(0, 6).map((page) => (
+
+
+ {getLandingPagePlanActionLabel(page.action)}
+ {page.path}
+ {getLandingOpportunityPageTypeLabel(page.pageType)}
+
+
+
+ opportunity {page.opportunityScore}% · readiness {page.readinessScore}%
+
+
+ {getSemanticPriorityLabel(page.priority)} · покрыто {page.coveredClusterCount} ·
+ слабо {page.weakClusterCount} · пусто {page.missingClusterCount}
+
+
{page.reason}
+
+ {page.clusters.slice(0, 4).map((cluster) => (
+ {cluster.title}
+ ))}
+
+ {page.seedPhrases.length > 0 ? (
+
+ {page.seedPhrases.slice(0, 4).map((phrase) => (
+ {phrase}
+ ))}
+
+ ) : null}
+
+
+ ))}
+
+ ) : null}
+
+ {(semanticAnalysis.landingPageBriefs ?? []).length > 0 ? (
+
+
+ SEO briefs
+ Черновая структура посадочных перед рерайтом, Wordstat и SERP.
+
+ {(semanticAnalysis.landingPageBriefs ?? []).slice(0, 4).map((brief) => {
+ const quality = brief.qualityContract;
+
+ return (
+
+
+ {getLandingPagePlanActionLabel(brief.action)}
+
+ {brief.path}
+ {brief.h1}
+
+
+ {quality ? `${quality.score}% · ${getBriefQualityLevelLabel(quality.level)}` : "legacy"}
+
+
+
+ {quality ? (
+
+
+ Качество брифа
+ {quality.score}%
+
+ {quality.summary}
+
+ {quality.checks.slice(0, 6).map((check) => (
+
+ {getBriefQualityCheckLabel(check.status)} · {check.title}
+
+ ))}
+
+ {quality.riskFlags.length > 0 ? (
+
+ {quality.riskFlags.slice(0, 3).map((risk) => (
+
+ {risk.title}
+
+ ))}
+
+ ) : null}
+ {quality.nextStep}
+
+ ) : null}
+
+ Title / meta
+ {brief.title}
+ {brief.metaDescription}
+
+
+ Интент и CTA
+ {brief.audience}
+ {brief.introAngle}
+
+ {brief.primaryCta}
+ {brief.secondaryCta}
+
+
+
+ Секции
+ {brief.sections.slice(0, 4).map((section) => (
+
+ {section.title}
+ {section.goal}
+
+ ))}
+
+
+ Checklist
+
+ {brief.readinessChecklist.map((item) => (
+
+ {item.title}
+
+ ))}
+
+
+
+ {brief.mustUseTerms.slice(0, 6).map((term) => (
+ {term}
+ ))}
+
+
+ {brief.internalLinks.slice(0, 5).map((link) => (
+ {link}
+ ))}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ {(semanticAnalysis.landingOpportunities ?? []).length > 0 ? (
+
+
+ Карта посадочных
+ Что оставить на главной, что вынести в целевые SEO-страницы.
+
+ {(semanticAnalysis.landingOpportunities ?? []).slice(0, 8).map((opportunity) => (
+
+ {getSemanticPriorityLabel(opportunity.priority)}
+
+
{opportunity.title}
+
+ {getLandingOpportunityActionLabel(opportunity.action)} ·{" "}
+ {getSemanticEvidenceLevelLabel(opportunity.evidenceLevel)} · score {opportunity.score}%
+
+
{opportunity.reason}
+
+ {opportunity.recommendedPages.length > 0 ? (
+ opportunity.recommendedPages.slice(0, 4).map((page) => (
+
+ {page.path} · {getLandingOpportunityPageTypeLabel(page.pageType)}
+
+ ))
+ ) : (
+ opportunity.targetPages.slice(0, 3).map((targetPage) => (
+ {targetPage}
+ ))
+ )}
+
+
+
+ ))}
+
+ ) : null}
+
+
+
Seed candidates для Wordstat
+
+ {semanticAnalysis.seedCandidates.slice(0, 10).map((seed) => (
+ {seed.phrase}
+ ))}
+
+
+
+
+
Следующие действия
+ {semanticAnalysis.nextActions.map((action) => (
+
{action}
+ ))}
+
+ >
+ ) : (
+
+
+ Запусти semantic analysis: модуль прочитает HTML, JSON-контент, посчитает покрытие
+ NodeDC-онтологии, gaps, legacy-блокеры и стартовые сиды для Wordstat.
+
+ )}
+
+ ) : null}
+ {activeStageIndex === 4 ? (
+
+
+
+ Market enrichment
+
+ Платформенный слой для Wordstat/SERP evidence. Пользователь не настраивает MCP и
+ внешние аккаунты вручную.
+
+
+
+
+
+ {semanticAnalysis && marketEnrichment ? (
+ <>
+
+
+ {getMarketStateLabel(marketEnrichment.state)}
+
+ Seeds: {marketEnrichment.readiness.seedCount} · Briefs:{" "}
+ {marketEnrichment.readiness.briefCount}
+
+
+ Provider connected: {marketEnrichment.readiness.configuredProviderCount}/
+ {marketEnrichment.providers.length} · user setup:{" "}
+ {marketEnrichment.readiness.userSetupRequired ? "required" : "not required"}
+
+
+
+ {marketEnrichment.state === "not_configured"
+ ? "Семантика готова к сбору рынка, но backend-провайдеры ещё не подключены."
+ : "Внешние evidence будут прикрепляться к seed, brief и page contract отдельно от deterministic-анализа."}
+
+
+
+
+
+ Wordstat job
+
+ {marketEnrichment.wordstat.latestJob
+ ? `${marketEnrichment.wordstat.latestJob.status} · ${marketEnrichment.wordstat.latestJob.provider}/${marketEnrichment.wordstat.latestJob.mode} · ${marketEnrichment.wordstat.latestJob.requestedPhraseCount} seeds`
+ : "job ещё не создавался"}
+
+
+
+ {formatCount(marketEnrichment.wordstat.summary.collectedSeedCount)} checked
+
+ {formatCount(marketEnrichment.wordstat.summary.exactFrequencySeedCount ?? null)} exact
+
+
+ {formatCount(marketEnrichment.wordstat.summary.relatedOnlySeedCount ?? null)} related
+
+
+ {formatCount(marketEnrichment.wordstat.summary.noSignalSeedCount ?? null)} empty
+
+ {formatCount(marketEnrichment.wordstat.summary.resultCount)} rows
+ {formatCount(marketEnrichment.wordstat.summary.totalFrequency)} total
+
+ {marketEnrichment.wordstat.latestJob?.errorMessage ? (
+
{marketEnrichment.wordstat.latestJob.errorMessage}
+ ) : null}
+
+
+
+ {marketEnrichment.providers.map((provider) => (
+
+ {getMarketProviderStatusLabel(provider.status)}
+
+
{provider.label}
+
{provider.role}
+
{provider.message}
+
+
+ ))}
+
+
+
+
+
+ Seed queue
+ Фразы готовы к Wordstat, но не собраны без provider.
+
+
+ {marketEnrichment.seedQueue.slice(0, 12).map((seed) => (
+
+ {getMarketEvidenceStatusLabel(seed.evidenceStatus)}
+ {seed.phrase}
+
+ {seed.frequency !== null
+ ? `${formatCount(seed.frequency)} · ${seed.frequencyGroup ?? "group n/a"} · related ${seed.relatedCount}`
+ : seed.relatedCount > 0
+ ? `${seed.clusterTitle} · related ${seed.relatedCount}`
+ : seed.clusterTitle}
+
+
+ ))}
+
+
+
+
+
+ Brief evidence
+ Куда будут пришиты Wordstat/SERP/competitor evidence.
+
+
+ {marketEnrichment.briefEvidence.slice(0, 8).map((brief) => (
+
+ {getMarketEvidenceStatusLabel(brief.evidenceStatus)}
+ {brief.path}
+
+ quality {brief.qualityScore ?? "n/a"} · {brief.missingEvidence.slice(0, 2).join("; ")}
+
+
+ ))}
+
+
+
+
+ {marketEnrichment.wordstat.topResults.length > 0 ? (
+
+
Top Wordstat rows
+
+ {marketEnrichment.wordstat.topResults.slice(0, 10).map((result) => (
+
+ {formatCount(result.frequency)}
+ {result.phrase}
+
+ ))}
+
+
+ ) : null}
+
+
+
Следующие platform actions
+ {marketEnrichment.nextActions.map((action) => (
+
{action}
+ ))}
+
+ >
+ ) : semanticAnalysis ? (
+
+
+ Market contract ещё не загружен. Нажми проверку providers, чтобы получить текущий
+ статус платформенных интеграций.
+
+ ) : (
+
+
+ Сначала нужен semantic analysis: он подготовит seed candidates и SEO briefs для
+ market enrichment.
+
+ )}
+
+ ) : null}
+ {scanSummary.pages.length > 0 ? (
+
+
+
+
+ SEO scope: {scanScopeCounts?.selectedIndexable ?? 0}/
+ {scanScopeCounts?.indexable ?? 0} посадочных
+
+
+ Исключено: шаблоны {scanScopeCounts?.template ?? 0}, админка{" "}
+ {scanScopeCounts?.admin ?? 0}, технические {scanScopeCounts?.technical ?? 0}
+ {scanScopeCounts && scanScopeCounts.selectedExcluded > 0
+ ? ` · выбрано исключённых: ${scanScopeCounts.selectedExcluded}`
+ : ""}
+
+
+
+
+
+
+
+
15 ? "scan-page-tree scrollable" : "scan-page-tree"}>
+ {pageTreeGroups.map((group) => (
+
+
+
+ {group.name}
+ {group.description}
+
+ {group.pages.length} шт.
+
+
+ {group.pages.map((page) => (
+
+ ))}
+
+
+ ))}
+
+
+ Что дальше будет в рабочей зоне страницы?
+
+ Выбранные страницы пойдут в следующий экран: там появится locked original
+ snapshot, рабочий draft, meta-поля, секции, видимый текст, media-контекст,
+ будущие seeds/Wordstat/keyword map и безопасный diff перед Apply.
+
+
+
+
+
+ Рабочая зона выбранных посадочных
+ Original read-only, working draft сохраняется в Postgres + MinIO.
+
+
Apply пока не трогаем
+
+
+ {selectedCoveragePages.map((page) => {
+ const workspaceKey = getWorkspaceKey(project.id, page.pageId);
+ const isActivePage = activeWorkspacePageId === page.pageId;
+ const isOpening = busyWorkspaceKey === workspaceKey;
+
+ return (
+
+ );
+ })}
+
+ {activeWorkspace && activeWorkspacePage && activeWorkspaceKey ? (
+
+
+
+ Выбранная страница
+ {getPageTitle(activeWorkspacePage)}
+ {getPagePathLabel(activeWorkspacePage)}
+
+
{activeWorkspaceIssues.length} замечаний
+
+
+
+
+ Зеркало страницы
+ Оранжевые точки ведут к полям правки ниже.
+
+
+ {WORKSPACE_PREVIEW_MODES.map((mode) => (
+
+ ))}
+
+
+
+
+
+ Ошибки этой страницы
+ {activeWorkspaceIssues.length} шт. из базового аудита
+
+ {activeWorkspaceIssues.length > 0 ? (
+
+ {activeWorkspaceIssues.map((issue) => {
+ const target = activeWorkspaceTargets.find((item) => item.issues.some((targetIssue) => targetIssue.id === issue.id));
+
+ return (
+
+ );
+ })}
+
+ ) : (
+ По этой странице ошибок аудита нет.
+ )}
+
+
+
+
+ Поля для правки
+ Title, meta, заголовки, текстовые блоки и media-поля редактируются здесь.
+
+
+
+ {activeWorkspaceTargets.map((target) => (
+
+ ))}
+
+ {activeWorkspaceTarget ? (
+
+
+
+ {activeWorkspaceTarget.number}. {activeWorkspaceTarget.title}
+
+ {activeWorkspaceTarget.selector ?? "selector пока не задан"}
+
+ {activeWorkspaceTarget.issues.length > 0 ? (
+
+ {activeWorkspaceTarget.issues.map((issue) => (
+
+ {getAuditSeverityLabel(issue.severity)} · {issue.message}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+ {activeStageIndex > 2 ? (
+
+ Технический draft целиком
+ {activeWorkspaceText}
+
+ ) : null}
+
+ ) : (
+
+ {activeWorkspaceKey && isWorkspaceBusy
+ ? "Открываю preview страницы и секции..."
+ : "Выбери страницу выше, чтобы открыть preview и секционный редактор."}
+
+ )}
+
+
+ ) : (
+
HTML-страницы пока не найдены.
+ )}
+ {scanSummary.audit ? (
+
+
+ {getSelectedAuditResultMessage(selectedPagesCount, selectedAuditCounts)}
+
+ Блокеры: {selectedAuditCounts.errors} · Предупреждения:{" "}
+ {selectedAuditCounts.warnings} · Инфо: {selectedAuditCounts.info}
+
+
+ Всего по скану: замечаний {scanSummary.audit.counts.total}, предупреждений{" "}
+ {scanSummary.audit.counts.warnings}, инфо {scanSummary.audit.counts.info}.
+
+
+
+ {(["all", "error", "warning", "info"] as AuditSeverityFilter[]).map((filter) => (
+
+ ))}
+
+
+ Что значат блокеры, предупреждения и инфо?
+
+
+ Блокер — жёсткая техническая проблема, которая позже должна
+ остановить Apply: например, нельзя безопасно записать файл или конфликтует diff.
+ Сейчас таких проверок почти нет, потому что мы ещё не меняем сайт.
+
+
+ Предупреждение — SEO-риск: missing title/description/H1,
+ отсутствующий ALT, повторяющиеся ALT. Title/description прямо важны для
+ сниппета и диагностики Яндекса, а H1/heading order, ALT и медиа-поля держим
+ как смысловую SEO/accessibility-подготовку для страниц, картинок и пользователя.
+
+
+ Инфо — мягкая подсказка: например, короткий description,
+ weak filename или видео без poster. Не всё нужно исправлять автоматически.
+
+
+
+
+
+ Медиа-ошибки выбранных страниц: {selectedMediaIssues.length}
+ {auditSeverityFilter !== "all" ? ` · фильтр: ${getAuditSeverityFilterLabel(auditSeverityFilter)}` : ""}
+
+ {selectedPagesCount === 0 ? (
+ Выбери страницы выше, и здесь появятся проблемы по изображениям/видео.
+ ) : selectedMediaIssues.length > 0 ? (
+
+ {selectedMediaIssues.map((issue) => (
+
+ {getAuditSeverityLabel(issue.severity)}
+ {issue.message}
+
+ {issue.sourcePath}
+ {issue.field ? ` · ${issue.field}` : ""}
+
+
+ ))}
+
+ ) : (
+ По выбранным страницам медиа-ошибок с текущим фильтром нет.
+ )}
+
+ {scanSummary.audit.issues.length > 0 ? (
+
+
+ Ниже показаны ошибки только выбранных страниц, в том же порядке, что и список выше.
+
+ {selectedPagesCount === 0 ? (
+
+ Выбери страницы выше, и здесь появятся ошибки только по ним.
+
+ ) : auditIssueGroups.length > 0 ? (
+ auditIssueGroups.map((group) => (
+
+
+
+ {group.title}
+ {group.subtitle}
+
+
{group.issues.length} шт.
+
+ {group.issues.map((issue) => {
+ const explanation = getAuditIssueExplanation(issue.code);
+
+ return (
+
+
+ {getAuditSeverityLabel(issue.severity)}
+ {issue.message}
+
+
+
+ Код: {issue.code} · Область: {getAuditScopeLabel(issue)}
+ {issue.sourcePath ? ` · Источник: ${issue.sourcePath}` : ""}
+
+
+ Почему важно: {explanation.why}
+
+
+ Что делать: {explanation.action}
+
+
+
+ );
+ })}
+
+ ))
+ ) : (
+
+ По выбранным страницам в базовом аудите ошибок не найдено.
+
+ )}
+ {auditIssueTotal > AUDIT_ISSUE_PAGE_SIZE ? (
+
+
+ Показано {visibleAuditIssues.length} ошибок выбранных страниц из {auditIssueTotal}.
+
+
+ {auditIssueLimit < auditIssueTotal ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+ ) : null}
+
+ ) : (
+
Пока всё спокойно: базовый аудит не нашёл явных проблем.
+ )}
+
+ ) : (
+
Базовый аудит появится после следующего анализа проекта.
+ )}
+
+ ) : null}
+ {projectActionProjectId === project.id && projectActionError ? (
+
+
+ {projectActionError}
+
+ ) : null}
+ {projectActionProjectId === project.id && projectActionMessage ? (
+
+
+ {projectActionMessage}
+
+ ) : null}
+ {pendingDeleteProjectId === project.id ? (
+
+
+ Удалить проект «{project.name}»?
+ Проект исчезнет из списка, связанные данные импорта будут удалены.
+
+
+
+
+
+
+ ) : null}
+
+