From 5a16a512e6efba4a838acf3819f067f492c7211b Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 14 Oct 2026 10:43:48 +0000 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=81=D1=8B=D0=BB=D0=BA=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?=D0=B2=20CRM:=20=C2=AB=D0=A1=D0=BA=D0=BE=D0=BF=D1=96=D1=8E?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B8=20=D0=BF=D0=BE=D1=81=D0=B8=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8F=C2=BB=20=D0=BF=D0=BE=20link=5Ftemplate,=20cr?= =?UTF-8?q?m:navigate,=20=C2=AB=D0=92=D1=96=D0=B4=D0=BA=D1=80=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=20=D0=B2=20CRM=C2=BB=20=D0=B2=D0=BD=D0=B5=20iframe=20(CR?= =?UTF-8?q?M=5FLINK=5FTEMPLATE,=20CRM=5FAUTO=5FREDIRECT)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++++++ backend/Wiki.Api/Program.cs | 11 ++++++- docker-compose.yml | 5 +++ frontend/src/App.tsx | 16 +++++++++- frontend/src/crm.ts | 62 +++++++++++++++++++++++++++++++++++-- frontend/src/links.ts | 32 +++++++++++++++++++ frontend/src/router.ts | 4 +++ 7 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 frontend/src/links.ts diff --git a/README.md b/README.md index b2097b6..f0419da 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ Пока CRM не передаёт workspace (задача Олега `mv6p5b92slw1an`), фронтенд работает в `shared`. Когда в `crm:auth` появится `workspace: { id, kind, title }`, он подхватится автоматически: `kind: "shared"` → `shared`, `kind: "personal"` → `personal:`, иначе `id`. Сообщение о смене workspace тоже обрабатывается (`{ type: "crm:workspace", workspace: {…} }`), если Олег выберет другое имя — поправить в `frontend/src/crm.ts`. +## Ссылки на страницы в CRM + +Спецификация: `inbox/a2a-20261014-103541-Спецификация._Ссылки_на_страницы_вики_в_CRM.md` (часть CRM — задача Олега `mv7z4jkhgzaelh`). + +- **CRM → вики `crm:context`:** `{ type: "crm:context", version: 1, link_template: "https:///#/module/?path={path}&ws={ws}" }`. Принимается только с origin из `Crm__Origins`. Формат терпимый: `link_template` (или `linkTemplate`) ищется в самом сообщении `crm:context` или `crm:auth`, а также во вложенных `context` / `crm_context` / `crm`. Шаблон без `{path}` или не http(s) игнорируется. +- **Кнопка «Скопіювати посилання»** (страница и папка): ссылка по `link_template`, `{path}` = `/p/` или `/f/`, `{ws}` = `workspace.id` из CRM (оба через `encodeURIComponent`). Пока шаблона нет — прямая ссылка `https://wiki-crm.tes.gd/p/`. Копирование с запасным путём (textarea + `execCommand`). +- **Вики → CRM `crm:navigate`:** `{ type: "crm:navigate", version: 1, path }` при каждом переходе (страница, папка, главная, назад/вперёд) и один раз после первого `crm:auth`. Только в iframe и только на origin CRM. +- **Вне iframe:** для `/p/` и `/f/` кнопка «Відкрити в CRM» по env `CRM_LINK_TEMPLATE` (`{ws}` пустой). `CRM_AUTO_REDIRECT=true` — сразу `location.replace` туда. Без шаблона всё как раньше. Оба значения отдаёт `GET /api/config` (`crmLinkTemplate`, `crmAutoRedirect`). + ## REST API Все ответы JSON (кроме `/markdown` и картинок). Ошибки: `{ "error": "…" }`. diff --git a/backend/Wiki.Api/Program.cs b/backend/Wiki.Api/Program.cs index 863fbee..48045cd 100644 --- a/backend/Wiki.Api/Program.cs +++ b/backend/Wiki.Api/Program.cs @@ -20,6 +20,15 @@ var crmOrigins = (cfg["Crm:Origins"] ?? cfg["Crm:Origin"] ?? "https://test.units .Select(o => o.TrimEnd('/')).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); // Откуда грузятся стили CRM (ссылки в index.html). var crmAssets = (cfg["Crm:AssetsOrigin"] ?? "https://test.units.tvorymo.com").TrimEnd('/'); +// Ссылка на страницу в CRM для вики, открытой вне iframe: https:///#/module/?path={path}&ws={ws}. +// Без шаблона (или без {path}) кнопки «Відкрити в CRM» нет. Авторедирект — только CRM_AUTO_REDIRECT=true. +var crmLinkTemplate = (cfg["CRM_LINK_TEMPLATE"] ?? cfg["Crm:LinkTemplate"])?.Trim(); +if (string.IsNullOrEmpty(crmLinkTemplate) || !crmLinkTemplate.Contains("{path}") + || !Uri.TryCreate(crmLinkTemplate.Replace("{path}", "").Replace("{ws}", ""), UriKind.Absolute, out var tplUri) + || (tplUri.Scheme != "https" && tplUri.Scheme != "http")) + crmLinkTemplate = null; +var crmAutoRedirect = crmLinkTemplate != null + && string.Equals((cfg["CRM_AUTO_REDIRECT"] ?? cfg["Crm:AutoRedirect"])?.Trim(), "true", StringComparison.OrdinalIgnoreCase); app.Services.GetRequiredService().Init(); // ---------------------------------------------------------------- заголовки @@ -121,7 +130,7 @@ app.UseStaticFiles(new StaticFileOptions // ---------------------------------------------------------------- открытые app.MapGet("/health", (Db db) => Results.Ok(new { status = "ok", dbCreatedAt = db.Meta("created_at") })); -app.MapGet("/api/config", () => Results.Ok(new { crmOrigins })); +app.MapGet("/api/config", () => Results.Ok(new { crmOrigins, crmLinkTemplate, crmAutoRedirect })); app.MapPost("/api/verify", async (HttpContext ctx, SupabaseAuth sa) => { diff --git a/docker-compose.yml b/docker-compose.yml index b718c75..743c2b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,11 @@ services: Supabase__Url: "https://yjxbkcqsquwbsyoonfkn.supabase.co" # publishable-ключ публичный по дизайну Supabase__PublishableKey: "sb_publishable_TQaZAtnBpImL49A-o87bKg_q5sYvhPR" + # вики открыта не в CRM: кнопка «Відкрити в CRM» для /p/ и /f/; пусто — кнопки нет + # формат: https:///#/module/?path={path}&ws={ws} + CRM_LINK_TEMPLATE: ${CRM_LINK_TEMPLATE:-} + # true — сразу перенаправлять в CRM вместо кнопки + CRM_AUTO_REDIRECT: ${CRM_AUTO_REDIRECT:-false} volumes: - wiki-data:/data healthcheck: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 478c633..2bbf4d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Api, ApiError, type Crumb, type Node, type Page, type SearchHit } from "./api"; -import { retryReady, useAuth } from "./crm"; +import { retryReady, useAuth, useShareLink } from "./crm"; +import { fillTemplate } from "./links"; import { CopyButton } from "./CopyButton"; import { ConfirmDialog, MoveDialog, PromptDialog } from "./Dialogs"; import { Icon } from "./Icon"; @@ -28,6 +29,10 @@ function Waiting() { useEffect(() => { const t = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(t); }, []); const d = auth.diag; const slow = auth.status === "waiting" && now - d.startedAt > 10_000; + const route = useRoute(); + // Вне iframe: ссылка /p/ или /f/ открывается в модуле вики CRM (шаблон из CRM_LINK_TEMPLATE). + const openInCrm = auth.status === "standalone" && auth.outsideTemplate && route.kind !== "root" + ? fillTemplate(auth.outsideTemplate, hrefOf(route.kind, route.id), "") : null; const parentOk = d.parentOrigin != null && d.allowed.includes(d.parentOrigin); const text = { @@ -48,6 +53,7 @@ function Waiting() { {auth.status === "error" ? "помилка авторизації" : auth.status === "logout" ? "немає сесії" : auth.status === "checking" ? "перевірка" : "чекаю на авторизацію від CRM"}
{text}
+ {openInCrm && } @@ -171,6 +177,7 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { } + {n.kind === "page" && } @@ -308,6 +315,13 @@ function Wiki(props: { ws: string; wsTitle?: string; userName: string }) { ); } +/** «Скопіювати посилання»: ссылка на страницу в CRM (по link_template), пока CRM его не прислала — прямая. */ +function ShareButton({ path }: { path: string }) { + const link = useShareLink(path); + return ; +} + function isInside(id: string, ancestor: string, byId: Map) { let cur = byId.get(id); let guard = 0; diff --git a/frontend/src/crm.ts b/frontend/src/crm.ts index 8b990d1..bf35c8e 100644 --- a/frontend/src/crm.ts +++ b/frontend/src/crm.ts @@ -1,6 +1,8 @@ // Мост с CRM: postMessage (crm:ready → crm:auth, crm:refresh, crm:logout). // Токен живёт только в памяти, никуда не логируется и не выводится. import { useSyncExternalStore } from "react"; +import { directLink, fillTemplate, templateFrom } from "./links"; +import { currentPath, onPathChange, parse } from "./router"; export type User = { id: string; email: string | null; name: string | null }; export type CrmWorkspace = { id?: string; kind?: string; title?: string }; @@ -21,6 +23,9 @@ export type AuthState = { user?: User; workspace: string; // shared | personal: | workspaceTitle?: string; + crmWsId?: string; // id workspace так, как его прислала CRM (для {ws} в ссылке) + linkTemplate?: string; // link_template из crm:context + outsideTemplate?: string; // CRM_LINK_TEMPLATE: вики открыта не в iframe diag: Diag; }; @@ -77,6 +82,7 @@ async function onAuth(d: Record) { token = t; expiresAt = toMs(d.expires_at); if ("workspace" in d) lastWs = (d.workspace as CrmWorkspace) ?? null; + set({ crmWsId: rawWsId() }); const ws0 = wsKey(lastWs, state.user?.id); // Если пользователь тот же, повторно не проверяем: каждый запрос к API всё равно проверяется на сервере. if (!changedUser && state.status === "ok") { @@ -103,6 +109,42 @@ async function onAuth(d: Record) { } } +function rawWsId() { + const id = lastWs && typeof lastWs === "object" ? lastWs.id : undefined; + return id == null ? undefined : String(id); +} + +/** crm:context (или те же поля внутри crm:auth): шаблон ссылки на страницу в CRM. */ +function onContext(d: Record) { + const t = templateFrom(d); + if (t && t !== state.linkTemplate) set({ linkTemplate: t }); +} + +/** Сообщить CRM текущий путь, чтобы её адрес указывал на эту страницу. CRM без поддержки просто игнорирует. */ +function sendNavigate(path: string) { + if (window.parent === window) return; + let target = crmOrigin; + if (!target) { + const p = detectParent().origin; + if (p && allowed.includes(p)) target = p; + } + if (target) window.parent.postMessage({ type: "crm:navigate", version: 1, path }, target); +} + +/** Вики открыта не в iframe: кнопка «Відкрити в CRM» и (по флагу) авторедирект для /p/ и /f/. */ +async function initOutside() { + let cfg: { crmLinkTemplate?: unknown; crmAutoRedirect?: unknown }; + try { cfg = await (await fetch("/api/config")).json(); } catch { return; } + const tpl = typeof cfg.crmLinkTemplate === "string" ? cfg.crmLinkTemplate : ""; + if (!tpl || !fillTemplate(tpl, "/", "")) return; + set({ outsideTemplate: tpl }); + const path = currentPath(); + if (cfg.crmAutoRedirect === true && parse(path).kind !== "root") { + const url = fillTemplate(tpl, path, ""); + if (url) location.replace(url); + } +} + function flushWaiters() { const w = waiters; waiters = []; @@ -131,7 +173,7 @@ function sendReady() { } export async function initCrm() { - if (window.parent === window) { set({ status: "standalone" }); return; } + if (window.parent === window) { set({ status: "standalone" }); void initOutside(); return; } const parent = detectParent(); setDiag({ parentOrigin: parent.origin, parentSource: parent.source, startedAt: Date.now() }); @@ -150,6 +192,7 @@ export async function initCrm() { return; } setDiag({ allowed }); + onPathChange(sendNavigate); const early = pending ?? []; pending = null; early.forEach(handle); @@ -163,12 +206,18 @@ function handle(ev: MessageEvent) { const rec = state.diag.received; if (rec.length < 20) setDiag({ received: [...rec, { origin: ev.origin, type, accepted: ok, why: ok ? undefined : "origin не в списку CRM" }] }); if (!ok || !d || typeof d !== "object") return; - if (d.type === "crm:auth") { crmOrigin = ev.origin; void onAuth(d); } + if (d.type === "crm:auth") { + const first = !crmOrigin; + crmOrigin = ev.origin; + onContext(d); + void onAuth(d); + if (first) sendNavigate(currentPath()); // первый раз: CRM узнаёт, где сейчас вики + } else if (d.type === "crm:context") onContext(d); else if (d.type === "crm:workspace") { // Будущее сообщение о смене workspace (задача Олега); формат: { workspace: { id, kind, title } }. lastWs = d.workspace ?? null; const ws = wsKey(lastWs, state.user?.id); - set({ workspace: ws.id, workspaceTitle: ws.title }); + set({ workspace: ws.id, workspaceTitle: ws.title, crmWsId: rawWsId() }); } else if (d.type === "crm:logout") { token = null; expiresAt = null; set({ status: "logout", user: undefined }); @@ -202,6 +251,13 @@ export async function getToken(force = false): Promise { return token; } +/** Ссылка на путь вики для копирования: в CRM по link_template, пока его нет — прямая. */ +export function useShareLink(path: string): { url: string; inCrm: boolean } { + const a = useAuth(); + const url = a.linkTemplate ? fillTemplate(a.linkTemplate, path, a.crmWsId ?? "") : null; + return url ? { url, inCrm: true } : { url: directLink(path), inCrm: false }; +} + export function currentWorkspace() { return state.workspace; } diff --git a/frontend/src/links.ts b/frontend/src/links.ts new file mode 100644 index 0000000..3ed6e9f --- /dev/null +++ b/frontend/src/links.ts @@ -0,0 +1,32 @@ +// Ссылки на страницы вики внутри CRM (deep link модуля вики). +// Шаблон: https:///#/module/?path={path}&ws={ws} — из crm:context или из env CRM_LINK_TEMPLATE. + +/** Подставить путь (/p/, /f/, /) и workspace CRM. null — шаблон негодный. */ +export function fillTemplate(tpl: string, path: string, ws: string): string | null { + if (!tpl.includes("{path}")) return null; + const url = tpl.split("{path}").join(encodeURIComponent(path)).split("{ws}").join(encodeURIComponent(ws)); + try { + const u = new URL(url); + return u.protocol === "https:" || u.protocol === "http:" ? url : null; + } catch { + return null; + } +} + +/** Прямая ссылка на вики (пока CRM не прислала crm:context). */ +export const directLink = (path: string) => location.origin + path; + +/** + * link_template из сообщения CRM. Формат терпимый: поля могут лежать прямо в сообщении + * (crm:context или crm:auth) или во вложенном объекте context / crm_context / crm; имя — link_template или linkTemplate. + */ +export function templateFrom(d: Record): string | null { + const boxes = [d, d.context, d.crm_context, d.crm]; + for (const b of boxes) { + if (!b || typeof b !== "object") continue; + const o = b as Record; + const t = o.link_template ?? o.linkTemplate; + if (typeof t === "string" && fillTemplate(t, "/", "") != null) return t; + } + return null; +} diff --git a/frontend/src/router.ts b/frontend/src/router.ts index df41309..dfa3077 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -9,6 +9,10 @@ const subs = new Set<() => void>(); let path = location.pathname; window.addEventListener("popstate", () => { path = location.pathname; subs.forEach((f) => f()); }); +export const currentPath = () => path; +/** Подписка на каждый переход (в том числе назад/вперёд). */ +export function onPathChange(f: (p: string) => void) { subs.add(() => f(path)); } + let guard: (() => Promise) | null = null; /** Перед уходом со страницы: дописать изменения. false — уйти нельзя (не сохранилось, причина видна на странице). */ export function setLeaveGuard(g: (() => Promise) | null) { guard = g; }