From 49b7732a242edcf3a5130290a640bb1cf6ebf00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 30 Sep 2026 15:42:52 +0300 Subject: [PATCH] =?UTF-8?q?Custom=20pages:=20=D1=84=D0=BB=D0=B0=D0=B6?= =?UTF-8?q?=D0=BE=D0=BA=20=C2=AB=D1=81=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20?= =?UTF-8?q?=D1=88=D0=B0=D0=BF=D0=BA=D1=83=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=C2=BB=20(=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=200089)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - custom_pages.hide_header: платформа не рендерит заголовок (название + описание) над виджетом — для полноэкранных отчётов/дашбордов, без CSS-хаков из кода страницы - UI: переключатель «Скрыть шапку страницы» в диалоге создания/редактирования - REST create/patch принимают hideHeader; MCP create_custom_page/update_custom_page — параметр hide_header (виден ИИ сразу, default false) --- client/src/components/bots/JsPagesSection.tsx | 10 ++++++++++ client/src/components/bots/useBotsController.tsx | 8 ++++---- client/src/pages/CustomPageView.tsx | 16 +++++++++------- migrations/0089_custom_pages_hide_header.sql | 4 ++++ server/mcp.ts | 8 ++++++-- server/routes/content.routes.ts | 6 ++++-- shared/schema.ts | 1 + 7 files changed, 38 insertions(+), 15 deletions(-) create mode 100644 migrations/0089_custom_pages_hide_header.sql diff --git a/client/src/components/bots/JsPagesSection.tsx b/client/src/components/bots/JsPagesSection.tsx index ea8e065..acdcc81 100644 --- a/client/src/components/bots/JsPagesSection.tsx +++ b/client/src/components/bots/JsPagesSection.tsx @@ -173,6 +173,16 @@ export function JsPagesSection({ onCheckedChange={(v) => setPageForm(f => ({ ...f, showInSidebar: v }))} /> +
+
+

Скрыть шапку страницы

+

Не показывать платформенный заголовок с названием и описанием (для полноэкранных отчётов и дашбордов)

+
+ setPageForm(f => ({ ...f, hideHeader: v }))} + /> +

Задачи из выбранных форм будут доступны через ctx.forms[formId]

diff --git a/client/src/components/bots/useBotsController.tsx b/client/src/components/bots/useBotsController.tsx index 8f6bf5f..ecf7a16 100644 --- a/client/src/components/bots/useBotsController.tsx +++ b/client/src/components/bots/useBotsController.tsx @@ -80,7 +80,7 @@ export function useBotsController() { // JS Pages state const [pageDialogOpen, setPageDialogOpen] = useState(false); const [editingPage, setEditingPage] = useState(null); - const [pageForm, setPageForm] = useState({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [] as number[], icon: 'FileCode', showInSidebar: false }); + const [pageForm, setPageForm] = useState({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [] as number[], icon: 'FileCode', showInSidebar: false, hideHeader: false }); const [pageShowTemplates, setPageShowTemplates] = useState(false); const [pagePreviewOpen, setPagePreviewOpen] = useState(false); const [pagePreviewDialogOpen, setPagePreviewDialogOpen] = useState(false); @@ -160,7 +160,7 @@ export function useBotsController() { queryClient.invalidateQueries({ queryKey: ['/api/custom-pages'] }); setPageDialogOpen(false); setEditingPage(null); - setPageForm({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [], icon: 'FileCode', showInSidebar: false }); + setPageForm({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [], icon: 'FileCode', showInSidebar: false, hideHeader: false }); toast({ title: 'Страница создана', description: pageForm.showInSidebar ? 'Ссылка появится в боковой панели' : 'Страница скрыта из бокового меню' }); }, onError: (e: any) => toast({ title: 'Ошибка', description: e?.message ?? 'Ошибка создания', variant: 'destructive' }), @@ -188,14 +188,14 @@ export function useBotsController() { const openCreatePage = () => { setEditingPage(null); - setPageForm({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [], icon: 'FileCode', showInSidebar: false }); + setPageForm({ name: '', slug: '', description: '', code: JS_PAGE_DEFAULT_CODE, formIds: [], icon: 'FileCode', showInSidebar: false, hideHeader: false }); setSlugCheckResult('idle'); setPageDialogOpen(true); }; const openEditPage = (page: any) => { setEditingPage(page); - setPageForm({ name: page.name, slug: page.slug, description: page.description ?? '', code: page.code ?? JS_PAGE_DEFAULT_CODE, formIds: page.formIds ?? [], icon: page.icon ?? 'FileCode', showInSidebar: page.showInSidebar ?? false }); + setPageForm({ name: page.name, slug: page.slug, description: page.description ?? '', code: page.code ?? JS_PAGE_DEFAULT_CODE, formIds: page.formIds ?? [], icon: page.icon ?? 'FileCode', showInSidebar: page.showInSidebar ?? false, hideHeader: page.hideHeader ?? false }); setSlugCheckResult('idle'); setPageDialogOpen(true); }; diff --git a/client/src/pages/CustomPageView.tsx b/client/src/pages/CustomPageView.tsx index 975cded..5a70fb7 100644 --- a/client/src/pages/CustomPageView.tsx +++ b/client/src/pages/CustomPageView.tsx @@ -113,13 +113,15 @@ export default function CustomPageView() { return (
-
- -

{page.name}

- {page.description && ( - {page.description} - )} -
+ {!page.hideHeader && ( +
+ +

{page.name}

+ {page.description && ( + {page.description} + )} +
+ )}
— bookmarkable regardless of this flag."), + hide_header: z.boolean().optional().describe("Hide the platform header (page title + description bar rendered above the widget). Use true for fullscreen reports/dashboards. Default false."), }, }, - async ({ name, slug, code, description, icon, form_ids, show_in_sidebar }) => { + async ({ name, slug, code, description, icon, form_ids, show_in_sidebar, hide_header }) => { const existing = await storage.getCustomPageBySlug(slug, organizationId); if (existing) { return { content: [{ type: "text" as const, text: `A page with slug "${slug}" already exists` }], isError: true }; @@ -1973,6 +1974,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: organizationId, createdBy, showInSidebar: show_in_sidebar ?? true, + hideHeader: hide_header === true, }); const createResult: Record = { success: true, @@ -2012,9 +2014,10 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: form_ids: z.array(z.number().int()).optional().describe("New list of associated form IDs"), is_active: z.boolean().optional().describe("Enable or disable the page"), show_in_sidebar: z.boolean().optional().describe("Show/hide in the app sidebar. The page is always reachable by its permanent URL /pages/."), + hide_header: z.boolean().optional().describe("Hide the platform header (title + description bar) above the widget"), }, }, - async ({ page_id, name, code, description, icon, form_ids, is_active, show_in_sidebar }) => { + async ({ page_id, name, code, description, icon, form_ids, is_active, show_in_sidebar, hide_header }) => { const existing = await storage.getCustomPage(page_id, organizationId); if (!existing) return { content: [{ type: "text" as const, text: "Custom page not found" }], isError: true }; @@ -2029,6 +2032,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: if (form_ids !== undefined) updates.formIds = form_ids; if (is_active !== undefined) updates.isActive = is_active; if (show_in_sidebar !== undefined) updates.showInSidebar = show_in_sidebar; + if (hide_header !== undefined) updates.hideHeader = hide_header; const page = await storage.updateCustomPage(page_id, organizationId, updates as any); const result: Record = { diff --git a/server/routes/content.routes.ts b/server/routes/content.routes.ts index aaa30b2..969fa25 100644 --- a/server/routes/content.routes.ts +++ b/server/routes/content.routes.ts @@ -393,7 +393,7 @@ export function registerContentRoutes(app: import("express").Express): void { app.post('/api/custom-pages', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { - const { name, slug, description, code, formIds, icon, showInSidebar } = req.body; + const { name, slug, description, code, formIds, icon, showInSidebar, hideHeader } = req.body; if (!name || !slug) return res.status(400).json({ error: 'name и slug обязательны' }); const page = await storage.createCustomPage({ organizationId: req.organizationId!, @@ -405,6 +405,7 @@ export function registerContentRoutes(app: import("express").Express): void { icon: icon ?? 'FileCode', isActive: true, showInSidebar: showInSidebar === true, + hideHeader: hideHeader === true, createdBy: req.user!.id, }); res.status(201).json({ success: true, page }); @@ -418,7 +419,7 @@ export function registerContentRoutes(app: import("express").Express): void { app.patch('/api/custom-pages/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); - const { name, slug, description, code, formIds, icon, isActive, showInSidebar } = req.body; + const { name, slug, description, code, formIds, icon, isActive, showInSidebar, hideHeader } = req.body; const updates: Record = {}; if (name !== undefined) updates.name = name; if (slug !== undefined) updates.slug = slug.toLowerCase().replace(/[^a-z0-9-_]/g, '-'); @@ -428,6 +429,7 @@ export function registerContentRoutes(app: import("express").Express): void { if (icon !== undefined) updates.icon = icon; if (isActive !== undefined) updates.isActive = isActive === true; if (showInSidebar !== undefined) updates.showInSidebar = showInSidebar === true; + if (hideHeader !== undefined) updates.hideHeader = hideHeader === true; const page = await storage.updateCustomPage(id, req.organizationId!, updates); res.json({ success: true, page }); } catch (error: unknown) { diff --git a/shared/schema.ts b/shared/schema.ts index 1d65bc1..f112c55 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -2722,6 +2722,7 @@ export const customPages = pgTable("custom_pages", { icon: varchar("icon", { length: 50 }).default("FileCode"), isActive: boolean("is_active").default(true), showInSidebar: boolean("show_in_sidebar").default(false), + hideHeader: boolean("hide_header").notNull().default(false), createdBy: integer("created_by").notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),