diff --git a/apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql b/apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql new file mode 100644 index 0000000..527f945 --- /dev/null +++ b/apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "BlockSnapshot" ( + "id" TEXT NOT NULL, + "blockPath" TEXT NOT NULL, + "version" TEXT NOT NULL, + "changelog" JSONB NOT NULL DEFAULT '[]', + "html" TEXT NOT NULL, + "css" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BlockSnapshot_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "BlockSnapshot_blockPath_idx" ON "BlockSnapshot"("blockPath"); + +-- CreateIndex +CREATE UNIQUE INDEX "BlockSnapshot_blockPath_version_key" ON "BlockSnapshot"("blockPath", "version"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index daa377e..60ac3cf 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -50,3 +50,16 @@ model Block { updatedAt DateTime @updatedAt createdAt DateTime @default(now()) } + +model BlockSnapshot { + id String @id @default(uuid()) + blockPath String + version String + changelog Json @default("[]") + html String @db.Text + css String @db.Text + createdAt DateTime @default(now()) + + @@unique([blockPath, version]) + @@index([blockPath]) +} diff --git a/apps/api/src/blocks/blocks.controller.ts b/apps/api/src/blocks/blocks.controller.ts index f3b59bb..858bb01 100644 --- a/apps/api/src/blocks/blocks.controller.ts +++ b/apps/api/src/blocks/blocks.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Patch, Query, Body } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Query, Body } from '@nestjs/common'; import { BlocksService } from './blocks.service'; @Controller('blocks') @@ -22,4 +22,22 @@ export class BlocksController { ) { return this.blocks.update(path, body); } + + @Post('snapshots') + createSnapshot( + @Query('path') path: string, + @Body() body: { version: string; changelog: object[]; html: string; css: string }, + ) { + return this.blocks.createSnapshot(path, body); + } + + @Get('snapshots') + listSnapshots(@Query('path') path: string) { + return this.blocks.listSnapshots(path); + } + + @Get('snapshots/:id') + getSnapshot(@Param('id') id: string) { + return this.blocks.getSnapshot(id); + } } diff --git a/apps/api/src/blocks/blocks.service.ts b/apps/api/src/blocks/blocks.service.ts index 01ab3ba..a926a92 100644 --- a/apps/api/src/blocks/blocks.service.ts +++ b/apps/api/src/blocks/blocks.service.ts @@ -20,4 +20,24 @@ export class BlocksService { update(path: string, data: { version?: string; isInPreview?: boolean; changelog?: object[] }) { return this.prisma.block.update({ where: { path }, data }); } + + createSnapshot(blockPath: string, data: { version: string; changelog: object[]; html: string; css: string }) { + return this.prisma.blockSnapshot.upsert({ + where: { blockPath_version: { blockPath, version: data.version } }, + update: { html: data.html, css: data.css, changelog: data.changelog }, + create: { blockPath, ...data }, + }); + } + + listSnapshots(blockPath: string) { + return this.prisma.blockSnapshot.findMany({ + where: { blockPath }, + select: { id: true, version: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + getSnapshot(id: string) { + return this.prisma.blockSnapshot.findUniqueOrThrow({ where: { id } }); + } } diff --git a/apps/web/app/blocks/ceo/CeoPageClient.tsx b/apps/web/app/blocks/ceo/CeoPageClient.tsx new file mode 100644 index 0000000..16aa473 --- /dev/null +++ b/apps/web/app/blocks/ceo/CeoPageClient.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useState } from "react"; +import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; +import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar"; +import { type ChangelogEntry } from "@/components/ui/BlockChangelog"; +import { CeoBlock, CEO_QUESTIONS } from "@/components/blocks/CeoBlock"; + +const BLOCK_PATH = "/blocks/ceo"; + +const LLM_CEO_TEXT = ` +БЛОК: Вводный текст клиники (CEO-блок) +Источник: perm.oclinica.ru/lor — секция после баннера +Версия: v1.1 + +НАЗНАЧЕНИЕ: +Информационный блок под hero-баннером. Рассказывает о специализации клиники +и вовлекает пациента через вопросы-стимулы. + +СТРУКТУРА: +1. Вводный абзац + «Клиника ухо, нос специализируется на оториноларингологии – лечении взрослых и детей + с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах: ул. Цитная, 9, ул. Г. Звезда, 31а...» + +2. Вопросы-стимулы (формат: «— Вопрос?») + Каждый вопрос — отдельный абзац с тире, связанный с симптомами пациентов: + — У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину? + — Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»? + — Заболел ребёнок? + — Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых... + — Вам назначили проведение эндоскопической операции на ухе, горле или носе? + +3. Заключительный абзац + «Обращайтесь в ЛОР центр ухо, горло, нос в Перми...» + «Будьте здоровы!» + +ТИПОГРАФИКА: + Шрифт: Fira Sans, 14px, line-height 1.6–1.8 + Цвет текста: #374151 (#bb-text) + Вопросы: тот же стиль, начинаются с «—» + Ключевые слова в тексте — обычно ссылки синего цвета (#0089c3) + Фон блока: #ffffff, отступы 40–60px сверху и снизу + +ПРАВИЛА: +✓ Вопросы начинаются с «—» (тире) +✓ Ключевые медицинские термины — ссылки #0089c3 +✓ Текст без H2 заголовка — просто связный параграф +✕ Не добавлять маркированные списки (только тире) +✕ Не менять стиль вопросов на другой формат +`.trim(); + + +const CHANGELOG: ChangelogEntry[] = [ + { + version: "v1.1", + date: "24.03.2026", + changes: [ + "Адрес: «ул. Цитная, 9» заменён на «ул. Клары Цеткин, 9»", + "Цвет ссылок: #52b4bd (было #0089c3)", + ], + }, +]; + +export default function CeoPageClient() { + const [snapshot, setSnapshot] = useState(null); + + return ( +
+ {/* Заголовок страницы */} +
+

+ Блоки +

+

+ Вводный текст (CEO-блок) +

+ +

+ Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники + + вопросы-стимулы для пациентов. +

+
+ + {/* Живой пример */} +
+

+ Живой пример +

+ {snapshot ? ( +
+