✨ feat: запуск без .env — всё настраивается в UI, и порталы по дизайну

Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе.
Ни одной переменной окружения задавать не нужно и негде.

Запуск и секреты:
- .env.example удалён, мёртвые переменные вычищены; секреты инстанса
  генерирует одноразовый сервис secrets в именованный том, пароли БД —
  через POSTGRES_*_PASSWORD_FILE;
- Caddy: catch-all :80 и on_demand TLS вместо хостов в конфиге — свежая
  коробка отвечает по IP и по любому домену, до мастера дойти можно;
- адрес установки, SMTP и TURN переехали в настройки (InstanceSettings,
  миграции 0027–0030), внешние ссылки строятся от него;
- deploy/cli больше не читает .env; сборка образов в ghcr через GitHub
  Actions, release.env с digest-пинами;
- куки Secure/__Host- выставляются по факту TLS запроса, а не настройкой.

Порталы (дизайн-базлайн v2, кадры PT1–PT8):
- список, карточка портала, библиотека материалов, редактор статьи и
  настройки — ширины и ритм как в остальных разделах;
- файлы статьи: изображение вставляется в текст своим механизмом, файл
  прикрепляется вложением и выводится на портале списком с иконкой формата;
- ссылки на файлы приводятся к относительным: абсолютный хост резал CSP
  портала и картинка не появлялась;
- «Опубликовать» сверяется с сервером и публикует то, что на экране, а не
  ранее выбранную редакцию;
- колонка «Оценки» в списке статей и блок оценок в редакторе.

Первый запуск: полоса «демо-данные устанавливаются» — установка идёт в
worker, и без неё человек видел пустые разделы без объяснения.

Починен фон: вторичное хранилище стало best-effort — при живых остаточных
S3-ключах повторная загрузка файла падала в 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Andrey committed 2026-09-07 20:37:48 +03:00
1 parent 3672463363
commit 41a4b0dd84
128 files changed
+7575 -1547

No files matched your search

-1
View File
@@ -1,6 +1,5 @@
.env
.env.*
!.env.example
!.env.production.example
.git
.venv
-84
View File
@@ -1,84 +0,0 @@
COMPOSE_PROJECT_NAME=chatballs
CHATBALLS_APP_DOMAIN=app.localhost
CHATBALLS_PLATFORM_DOMAIN=platform.localhost
CHATBALLS_HELP_BASE_DOMAIN=localhost
CHATBALLS_HELP_PUBLIC_SCHEME=http
CHATBALLS_HELP_PUBLIC_PORT=8080
CHATBALLS_ACME_EMAIL=local@example.invalid
CHATBALLS_ADMIN_PORT=18001
CHATBALLS_ENV=local
CHATBALLS_DEBUG=true
CHATBALLS_DELIVERY_MODE=CLOUD
CHATBALLS_SECRET_KEY=change-me-only-for-local-development
CHATBALLS_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost
CHATBALLS_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175
CHATBALLS_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost
CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011
CHATBALLS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
# Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из
# CHATBALLS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
CHATBALLS_FIELD_ENCRYPTION_KEY=
# AI-провайдер. Локально по умолчанию тестовый адаптер; в production задайте ключ.
# CHATBALLS_AI_PROVIDER=openrouter
CHATBALLS_OPENROUTER_API_KEY=
# CHATBALLS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# CHATBALLS_AI_REQUEST_TIMEOUT=30
# CHATBALLS_AI_MAX_RETRIES=2
# CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
# Транспортная безопасность. Вне CHATBALLS_DEBUG включается автоматически; здесь — для переопределения.
# CHATBALLS_COOKIE_SECURE=true
# CHATBALLS_SSL_REDIRECT=true
# CHATBALLS_HSTS_SECONDS=31536000
# CHATBALLS_COOKIE_SAMESITE=Lax
# Email (по умолчанию console backend; для реальной отправки задайте SMTP).
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# EMAIL_HOST=
# EMAIL_HOST_USER=
# EMAIL_HOST_PASSWORD=
# DEFAULT_FROM_EMAIL=Chatballs <no-reply@example.com>
INTERNAL_UI_BASE_URL=http://localhost:5173
POSTGRES_DB=chatballs
POSTGRES_USER=chatballs_bootstrap
POSTGRES_PASSWORD=chatballs_bootstrap
POSTGRES_APP_USER=chatballs_app
POSTGRES_APP_PASSWORD=chatballs_app
POSTGRES_PLATFORM_USER=chatballs_platform
POSTGRES_PLATFORM_PASSWORD=chatballs_platform
POSTGRES_MIGRATION_USER=chatballs_migration
POSTGRES_MIGRATION_PASSWORD=chatballs_migration
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
REDIS_URL=redis://redis:6379/0
# Хранилище файлов: по умолчанию локальный диск (data/media). Внешнее S3
# включается администратором в «Настройки → Хранилище файлов», не здесь.
# P2P calls: invite — 5 минут, access token — 1 час.
CHATBALLS_CALL_INVITE_TTL_SECONDS=300
CHATBALLS_CALL_ACCESS_TTL_SECONDS=3600
CHATBALLS_CALL_CONNECT_GRACE_SECONDS=120
CHATBALLS_CALL_RECONNECT_GRACE_SECONDS=60
# STUN для WebRTC (через запятую). Локально обычно пусто: direct ICE на localhost.
CHATBALLS_CALL_STUN_URLS=
# TURN (Coturn) — production-контур; локально пусто (direct/STUN достаточно).
CHATBALLS_CALL_TURN_URLS=
CHATBALLS_CALL_TURN_SECRET=
CHATBALLS_CALL_TURN_TTL_SECONDS=3600
INTERNAL_UI_PORT=5173
WEB_CHAT_PORT=5175
BACKEND_APP_PORT=8010
BACKEND_PLATFORM_PORT=8011
VITE_API_BASE_URL=http://app.localhost/api/v1
# Публичный домен для сниппета Web-виджета в рантайме выводится от текущего origin
# (ADR-HUB-0028 §10); build-time аргумент не нужен.
+45 -38
View File
@@ -1,54 +1,61 @@
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
# Подставляется в release bundle и монтируется в контейнер gateway.
# Caddy: TLS termination + ACME, HTTP->HTTPS redirect, WebSocket upgrade (native).
# App-маршрутизация делегирована frontend-контейнеру, platform API идёт в
# отдельный backend. Admin принципиально отсутствует в public gateway.
#
# Свежая установка не знает своего домена: человек поднимает докер на сервере и
# открывает её по IP. Поэтому здесь нет блоков с зашитым именем хоста — они бы
# заставили Caddy выпускать сертификат и уводить http на https до того, как
# домен вообще появился. Вместо них: :80 отвечает по http на любом адресе, а
# https обслуживает любой хост, который одобрил ask-эндпоинт (домены порталов и
# домен установки, заданный в UI). Сертификаты выписываются по мере надобности,
# без перезапуска шлюза.
{
email {$CHATBALLS_ACME_EMAIL:}
# ACME-контакт не задаётся при установке: пока домена нет, нет и выпуска
# сертификатов. Адрес для уведомлений добавляется вместе с доменом в UI.
# Caddy admin API не публикуется наружу (default localhost:2019).
on_demand_tls {
ask http://backend-platform:8000/api/v1/gateway/help-domain/
}
}
# Published hosted and verified custom Help Center domains. Certificate
# issuance is authorized against the platform ingress directory above.
(surfaces) {
# Платформенная поверхность живёт на своём домене; пока он не задан,
# матчер намеренно не совпадает ни с чем.
@platform host {$CHATBALLS_PLATFORM_DOMAIN:platform.invalid}
handle @platform {
reverse_proxy backend-platform:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# Всё остальное — приложение и публичные порталы: маршрутизация внутри
# frontend-контейнера.
handle {
reverse_proxy frontend:80 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
}
# Обычный http на любом адресе: IP сервера, localhost, ещё не подтверждённый
# домен. Редиректа на https здесь нет намеренно — его делает блок ниже, когда
# для хоста реально выписан сертификат.
:80 {
encode gzip zstd
import surfaces
}
# Любой хост, которому ask-эндпоинт разрешил сертификат.
https:// {
encode gzip zstd
tls {
on_demand
}
reverse_proxy frontend:80 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# Tenant-facing application surface.
{$CHATBALLS_APP_DOMAIN} {
encode gzip zstd
reverse_proxy frontend:80 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# Platform surface. На C01 опубликован только health endpoint; API
# управления tenant'ами появится на C06.
{$CHATBALLS_PLATFORM_DOMAIN} {
encode gzip zstd
reverse_proxy backend-platform:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
import surfaces
}
+22 -5
View File
@@ -5,7 +5,9 @@ Canonical implementation workspace for Chatballs.
## Быстрый старт (одна минута)
Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose
на Linux). Никаких параметров заранее задавать не нужно — всё спросит браузер.
на Linux). Ни одной переменной задавать не нужно и негде: у продукта нет `.env`.
Организацию, владельца, домены, интеграции, почту и хранилище человек настраивает
в интерфейсе.
Windows (PowerShell):
@@ -23,14 +25,29 @@ cd chatballs/code/chatballs
./scripts/start.sh
```
Скрипт копирует `.env.example` в `.env` (если его нет), собирает образы и
поднимает стек. Когда в логах появится готовность, откройте
Настраивать нечего: файла `.env` у продукта нет, секреты инстанса (ключ
подписи, пароли ролей БД) генерирует сам первый старт и держит их в томе
`chatballs-secrets`.
Если положить рядом со скриптом `release.env` со страницы релиза, образы
скачаются из реестра по digest и сборки не будет — это самый быстрый путь.
Без `release.env` стек собирается из исходников: так работают те, кто правит
код. Когда в логах появится готовность, откройте
**http://localhost** — вместо входа система покажет **мастер первого запуска**:
название организации, ваше имя, e-mail и пароль владельца, переключатель
«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под
владельцем. Мастер доступен только пока в системе нет ни одной организации;
после создания владельца он закрывается навсегда.
### Доступ по http и переход на TLS
Свежая установка отвечает по обычному http — по адресу сервера, пока домена и
сертификата ещё нет. Продукт не уводит себя на https принудительно: этим
занимается шлюз, когда у него появляется настоящий домен и сертификат.
Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https —
cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные
имена без `Secure`. Настраивать для этого нечего.
### Демо-данные
Демо — вымышленное ателье «Норд» (дизайн-базлайн v2): сотрудники и группы,
@@ -75,8 +92,8 @@ CHATBALLS_INSTANCE_DIR=/opt/chatballs/instance ./deploy/migrate/rename-to-chatba
```bash
# dev-стек из каталога репозитория
CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml --env-file .env.example" deploy/migrate/rename-to-chatballs.sh
docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up -d
CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml" deploy/migrate/rename-to-chatballs.sh
docker compose -f compose.yaml -f compose.dev.yaml up -d
```
Скрипт останавливает старый compose-проект, переписывает `.env` (резервная
+3 -2
View File
@@ -97,11 +97,12 @@ class KnowledgeAttachment(TenantRelationModel):
def public_url(self) -> str:␍
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому␍
# строится от публичного адреса Hub, а не от request.␍
from django.conf import settings␍
from django.urls import reverse␍
␍
from chatballs.identity.instance_settings import public_base_url␍
␍
path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id})␍
return settings.CHATBALLS_PUBLIC_BASE_URL.rstrip("/") + path␍
return public_base_url() + path␍
␍
␍
class KnowledgeFragment(TenantRelationModel):␍
@@ -7,7 +7,6 @@
import logging
from django.conf import settings
from django.db import transaction
from chatballs.calls.lifecycle import transition_call
@@ -16,6 +15,7 @@ from chatballs.calls.services import CALL_INVITE_SEND
from chatballs.calls.tokens import issue_invite_token
from chatballs.conversations import transports
from chatballs.events.handlers import register
from chatballs.identity.instance_settings import public_base_url
from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
@@ -51,7 +51,7 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non
invite.save(update_fields=["token_hash"])
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку."
url = f"{settings.CHATBALLS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}"
url = f"{public_base_url()}/calls/{token}?kind={call.kind}"
sent = transports.send_call_invite(
call.delivery_connection,
chat_id=call.conversation.external_chat_id,
+5 -3
View File
@@ -2,6 +2,7 @@ from django.conf import settings
from chatballs.calls.models import CallSession
from chatballs.calls.turn import turn_credentials
from chatballs.identity.instance_settings import turn_config
def _iso(value):
@@ -68,11 +69,12 @@ def ice_servers_payload() -> list[dict]:
servers: list[dict] = []
if settings.CHATBALLS_CALL_STUN_URLS:
servers.append({"urls": list(settings.CHATBALLS_CALL_STUN_URLS)})
if settings.CHATBALLS_CALL_TURN_URLS and settings.CHATBALLS_CALL_TURN_SECRET:
username, credential = turn_credentials()
turn_urls, ttl = turn_config()
if turn_urls and settings.CHATBALLS_CALL_TURN_SECRET:
username, credential = turn_credentials(ttl_seconds=ttl)
servers.append(
{
"urls": list(settings.CHATBALLS_CALL_TURN_URLS),
"urls": turn_urls,
"username": username,
"credential": credential,
}
+4 -2
View File
@@ -8,7 +8,9 @@ import time
from django.conf import settings
def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str, str]:
def turn_credentials(
*, label: str = "hub", now: int | None = None, ttl_seconds: int | None = None
) -> tuple[str, str]:
"""Краткоживущие TURN REST credentials для Coturn (SPEC-HUB-0013 §11).
Схема coturn `use-auth-secret`:
@@ -20,7 +22,7 @@ def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str
coturn и наружу не отдаётся — клиент получает только производные credentials.
"""
moment = int(time.time()) if now is None else int(now)
expiry = moment + settings.CHATBALLS_CALL_TURN_TTL_SECONDS
expiry = moment + (ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS)
username = f"{expiry}:{label}"
digest = hmac.new(
settings.CHATBALLS_CALL_TURN_SECRET.encode("utf-8"),
+58
View File
@@ -39,3 +39,61 @@ class ContentSecurityPolicyMiddleware:
if policy and not response.has_header("Content-Security-Policy"):
response["Content-Security-Policy"] = policy
return response
class TlsAwareCookieMiddleware:
"""Жёсткость cookie по факту TLS, а не по переменной окружения.
Коробку ставят одной командой и сначала открывают по http — по адресу
сервера, до того как заведён домен и выписан сертификат. Если бы Secure-
cookie и префикс ``__Host-`` включались настройкой, такая установка не
смогла бы даже завести владельца в мастере. Поэтому решение принимается на
каждый запрос: пришли по https — отдаём ``__Host-`` + Secure, пришли по
http — обычное имя без Secure. Настраивать нечего, а установка сама
ужесточается в тот момент, когда перед ней появляется TLS.
Фронтенд выбирает имя cookie по протоколу страницы тем же правилом
(``api/client.ts``), поэтому стороны всегда сходятся.
"""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def _pairs(self) -> tuple[tuple[str, str], ...]:
"""Пары «имя по http» → «имя по https» из настроек поверхности.
Имена заданы явно, а не собираются префиксом: их знает и фронтенд
(api/client.ts), и они не должны разъезжаться.
"""
mapping = getattr(settings, "CHATBALLS_TLS_COOKIE_NAMES", {})
return tuple(
(plain, hardened)
for plain, hardened in mapping.items()
if plain != hardened
)
def __call__(self, request: HttpRequest) -> HttpResponse:
secure = request.is_secure()
pairs = self._pairs()
if secure:
# Браузер прислал защищённые имена — отдаём их Django под обычными.
for plain, hardened in pairs:
if hardened in request.COOKIES and plain not in request.COOKIES:
request.COOKIES[plain] = request.COOKIES[hardened]
response = self.get_response(request)
if not secure:
return response
for plain, hardened in pairs:
cookie = response.cookies.get(plain)
if cookie is None:
continue
response.cookies[hardened] = cookie.value
target = response.cookies[hardened]
for key, value in cookie.items():
if value != "":
target[key] = value
# Требования префикса __Host-: Secure, Path=/ и без Domain.
target["secure"] = True
target["path"] = "/"
target["domain"] = ""
del response.cookies[plain]
return response
+42
View File
@@ -1,3 +1,4 @@
from django.conf import settings
from django.test import TestCase, override_settings
@@ -33,3 +34,44 @@ class ContentSecurityPolicyMiddlewareTests(TestCase):
response = self.client.get("/api/v1/health/live/")
self.assertFalse(response.has_header("Content-Security-Policy"))
class TlsAwareCookieMiddlewareTests(TestCase):
"""Коробку сначала открывают по http, а TLS появляется позже (см. middleware)."""
def test_plain_http_gets_usable_cookies_without_secure(self) -> None:
# Без этого мастер первого запуска не смог бы завести владельца:
# Secure-cookie по http браузер не сохраняет.
response = self.client.get("/api/v1/auth/session/")
cookie = response.cookies.get(settings.CSRF_COOKIE_NAME)
self.assertIsNotNone(cookie)
self.assertFalse(cookie["secure"])
self.assertNotIn(settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME], response.cookies)
def test_https_request_gets_host_prefixed_secure_cookies(self) -> None:
response = self.client.get("/api/v1/auth/session/", secure=True)
hardened = response.cookies.get(settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME])
self.assertIsNotNone(hardened)
self.assertTrue(hardened["secure"])
self.assertEqual(hardened["path"], "/")
self.assertEqual(hardened["domain"], "")
self.assertNotIn(settings.CSRF_COOKIE_NAME, response.cookies)
def test_https_request_reads_back_host_prefixed_cookie(self) -> None:
first = self.client.get("/api/v1/auth/session/", secure=True)
token = first.cookies[settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME]].value
self.client.cookies.clear()
self.client.cookies[settings.CHATBALLS_TLS_COOKIE_NAMES[settings.CSRF_COOKIE_NAME]] = token
response = self.client.get("/api/v1/auth/session/", secure=True)
self.assertEqual(response.status_code, 200)
def test_plain_http_is_not_redirected_to_https(self) -> None:
# Установка без домена обязана отвечать по http, а не уводить браузер
# на несуществующий https (иначе редирект залипает в кеше).
response = self.client.get("/api/v1/health/live/")
self.assertEqual(response.status_code, 200)
@@ -1,6 +1,11 @@
from django.urls import path
from chatballs.identity import administration_views, demo_views, group_views
from chatballs.identity import (
administration_views,
demo_views,
group_views,
instance_views,
)
from chatballs.integrations import feature_views
from chatballs.tenancy import storage_views
@@ -12,6 +17,16 @@ urlpatterns = [
administration_views.OrganizationSettingsView.as_view(),
name="organization-settings",
),
path(
"administration/instance/",
instance_views.InstanceAddressView.as_view(),
name="instance-address",
),
path(
"administration/instance/email-check/",
instance_views.InstanceEmailCheckView.as_view(),
name="instance-email-check",
),
path(
"administration/logo/",
administration_views.OrganizationLogoView.as_view(),
@@ -86,7 +86,8 @@
"status": "PUBLISHED",
"helpfulVotes": 14,
"unhelpfulVotes": 1,
"publishedDaysAgo": 27
"publishedDaysAgo": 27,
"files": ["katalog-tkanej-2026.pdf"]
},
{
"slug": "sroki-dostavki",
@@ -5,6 +5,8 @@ from __future__ import annotations
from datetime import timedelta
from django.core.files.base import ContentFile
from chatballs.identity.demo_seed import manifest
from chatballs.identity.demo_seed.loaders.common import backdate, now
from chatballs.identity.demo_seed.refs import DemoRefs
@@ -21,7 +23,11 @@ from chatballs.support_portals.content_services import (
publish_revision,
record_feedback,
)
from chatballs.support_portals.models import PortalCategory, SupportPortal
from chatballs.support_portals.models import (
PortalArticleFile,
PortalCategory,
SupportPortal,
)
from chatballs.support_portals.portal_services import (
PortalInput,
create_portal,
@@ -30,6 +36,7 @@ from chatballs.support_portals.portal_services import (
)
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
def load(context: TenantContext, refs: DemoRefs) -> None:
@@ -130,7 +137,9 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N
categories[item["slug"]] = category
for item in portal_data.get("articles", []):
_ensure_article(context, refs, portal, categories, item, current)
article = _ensure_article(context, refs, portal, categories, item, current)
for spec in item.get("files", []):
_attach_article_file(context, refs, article, spec)
target_status = portal_data.get("status", PortalStatus.PUBLISHED)
if portal.status != target_status:
@@ -138,7 +147,14 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N
refs.portal = portal
def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPortal, categories, item: dict, current) -> None:
def _ensure_article(
context: TenantContext,
refs: DemoRefs,
portal: SupportPortal,
categories,
item: dict,
current,
):
article = portal.articles.filter(slug=item["slug"]).first()
if article is None:
article = create_article(
@@ -183,6 +199,7 @@ def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPorta
if status == ArticleStatus.ARCHIVED:
archive_article(article)
refs.portal_articles[item["slug"]] = article
return article
def _link_agent_articles(refs: DemoRefs) -> None:
@@ -194,3 +211,35 @@ def _link_agent_articles(refs: DemoRefs) -> None:
article = refs.portal_articles.get(slug)
if article is not None and article.status == ArticleStatus.PUBLISHED:
agent.portal_articles.add(article)
def _attach_article_file(context: TenantContext, refs: DemoRefs, article, spec) -> None:
"""Файл статьи портала: картинка или документ рядом с текстом
(кадр PT7 дизайн-базлайна).
"""
if isinstance(spec, str):
spec = {"file": spec}
filename = spec["file"]
if article.files.filter(original_name=filename).exists():
return
payload = manifest.media_bytes(filename)
suffix = filename.rsplit(".", 1)[-1].lower()
content_type = spec.get("contentType") or {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
"pdf": "application/pdf",
"md": "text/markdown",
"txt": "text/plain",
}.get(suffix, "application/octet-stream")
article_file = PortalArticleFile(
organization=refs.organization,
article=article,
original_name=filename,
content_type=content_type,
size=len(payload),
)
article_file.file.save(filename, ContentFile(payload), save=True)
adjust_storage_usage(context=context, delta_bytes=len(payload))
+12 -4
View File
@@ -1,16 +1,22 @@
from django.conf import settings
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from chatballs.identity.instance_settings import (
email_connection,
email_from_address,
public_base_url,
)
from chatballs.identity.models import HumanUser
def _password_setup_url(user: HumanUser) -> str:
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)
return f"{settings.INTERNAL_UI_BASE_URL.rstrip('/')}/reset-password?uid={uid}&token={token}"
# Ссылка уходит человеку в почту, поэтому строится от адреса установки,
# который знает сама система, а не от адреса dev-сервера в переменной.
return f"{public_base_url()}/reset-password?uid={uid}&token={token}"
def send_initial_access_email(user: HumanUser) -> None:
@@ -24,8 +30,9 @@ def send_initial_access_email(user: HumanUser) -> None:
f"{setup_url}\n\n"
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
),
from_email=settings.DEFAULT_FROM_EMAIL,
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
@@ -39,6 +46,7 @@ def send_password_reset_email(user: HumanUser) -> None:
f"{reset_url}\n\n"
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
),
from_email=settings.DEFAULT_FROM_EMAIL,
from_email=email_from_address(),
recipient_list=[user.email],
connection=email_connection(),
)
@@ -0,0 +1,191 @@
"""Настройки самой установки: одна строка на инсталляцию, без RLS.
Адрес, по которому открывают установку, продукт узнаёт не из переменной
окружения, а от человека: он поднимает докер на сервере, открывает его по IP
или по своему домену и проходит мастер первого запуска. Адрес из мастера
запоминается здесь, и дальше именно он считается публичным адресом установки —
на нём строятся ссылки и по нему проверяются входящие Host.
"""
from __future__ import annotations
import os
import threading
import time
from django.db import models
from chatballs.identity.crypto import EncryptedCharField
from chatballs.support_portals.addressing import normalize_domain
class InstanceSettings(models.Model):
SINGLETON_PK = 1
# Хост без схемы и порта: «crm.example.com» или «203.0.113.10».
public_host = models.CharField(max_length=253, blank=True, default="")
# Схема, по которой установку открывают снаружи. Меняется вместе с
# адресом, когда перед установкой появляется домен и сертификат.
public_scheme = models.CharField(max_length=5, blank=True, default="")
# Почта установки: через неё уходят приглашения сотрудникам и сброс
# пароля. Пока не задана, письма пишутся в лог — приглашать некого.
email_host = models.CharField(max_length=253, blank=True, default="")
email_port = models.PositiveIntegerField(default=587)
email_user = models.CharField(max_length=255, blank=True, default="")
email_password = EncryptedCharField(max_length=512, blank=True, default="")
email_use_tls = models.BooleanField(default=True)
email_from = models.CharField(max_length=255, blank=True, default="")
# Адреса TURN-серверов для звонков через relay. Секрет сюда не пишется:
# он общий с coturn и живёт в томе секретов, чтобы не вводить его дважды.
turn_urls = models.TextField(blank=True, default="")
turn_ttl_seconds = models.PositiveIntegerField(default=3600)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Настройки установки"
def __str__(self) -> str:
return f"instance:{self.public_host or 'не задан'}"
def save(self, *args, **kwargs):
self.pk = self.SINGLETON_PK
super().save(*args, **kwargs)
invalidate_cache()
@classmethod
def load(cls) -> InstanceSettings:
obj, _ = cls.objects.get_or_create(pk=cls.SINGLETON_PK)
return obj
_CACHE_TTL_SECONDS = 10.0
_lock = threading.Lock()
_cached: tuple[float, str] | None = None
def invalidate_cache() -> None:
global _cached
with _lock:
_cached = None
def public_host() -> str:
"""Адрес установки, запомненный мастером, или пустая строка."""
global _cached
now = time.monotonic()
with _lock:
if _cached is not None and now - _cached[0] < _CACHE_TTL_SECONDS:
return _cached[1]
try:
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
value = row.public_host if row is not None else ""
except Exception: # таблицы ещё нет (первые миграции)
return ""
with _lock:
_cached = (now, value)
return value
def remember_public_host(raw_host: str, scheme: str = "http") -> None:
"""Запомнить адрес, на котором прошли мастер, если он ещё не задан."""
host = normalize_domain(raw_host.partition(":")[0])
if not host:
return
row = InstanceSettings.load()
if row.public_host:
return
row.public_host = host
row.public_scheme = "https" if scheme == "https" else "http"
row.save(update_fields=["public_host", "public_scheme", "updated_at"])
def public_base_url() -> str:
"""Адрес установки для абсолютных ссылок, уходящих наружу.
Такие ссылки агент отдаёт клиенту в мессенджер, поэтому «localhost»
здесь недопустим. Источник — адрес, на котором прошли мастер (и который
владелец может поменять в «Настройках»); переменная окружения остаётся
переопределением для установок, ведущих конфигурацию сами.
"""
from django.conf import settings
configured = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "").strip()
if configured:
return configured.rstrip("/")
try:
row = InstanceSettings.objects.filter(
pk=InstanceSettings.SINGLETON_PK
).first()
except Exception:
row = None
if row is not None and row.public_host:
scheme = row.public_scheme or "http"
return f"{scheme}://{row.public_host}"
return str(settings.CHATBALLS_PUBLIC_BASE_URL).rstrip("/")
def email_is_configured() -> bool:
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
return bool(row and row.email_host)
def email_connection():
"""Соединение с почтовым сервером из настроек установки.
Пока владелец не задал SMTP, возвращается None — письма уходят в бэкенд по
умолчанию (в коробке это консоль), и в «Настройках» видно, что почта не
настроена.
"""
from django.core.mail import get_connection
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
if row is None or not row.email_host:
return None
return get_connection(
backend="django.core.mail.backends.smtp.EmailBackend",
host=row.email_host,
port=row.email_port,
username=row.email_user or None,
password=row.email_password or None,
use_tls=row.email_use_tls,
)
def email_from_address() -> str:
"""Адрес отправителя: из настроек установки, иначе — общий дефолт."""
from django.conf import settings
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
if row is not None and row.email_from:
return row.email_from
return str(settings.DEFAULT_FROM_EMAIL)
def turn_config() -> tuple[list[str], int]:
"""Адреса TURN и время жизни credentials из настроек установки.
Пустой список означает «relay не настроен»: звонки идут напрямую и через
STUN. Переменная окружения, если задана, побеждает.
"""
from django.conf import settings
if settings.CHATBALLS_CALL_TURN_URLS:
return list(settings.CHATBALLS_CALL_TURN_URLS), settings.CHATBALLS_CALL_TURN_TTL_SECONDS
try:
row = InstanceSettings.objects.filter(pk=InstanceSettings.SINGLETON_PK).first()
except Exception:
# Нет БД (юнит-тест без базы, ранние миграции) — берём то, что в
# настройках процесса.
return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS
if row is None or not row.turn_urls.strip():
return [], settings.CHATBALLS_CALL_TURN_TTL_SECONDS
urls = [line.strip() for line in row.turn_urls.splitlines() if line.strip()]
return urls, row.turn_ttl_seconds or settings.CHATBALLS_CALL_TURN_TTL_SECONDS
@@ -0,0 +1,185 @@
"""Адрес установки в «Настройках».
Мастер первого запуска запоминает адрес, на котором его открыли. Дальше владелец
меняет его здесь — когда завёл домен и поставил перед установкой TLS. Никаких
переменных окружения: адрес живёт в настройках инсталляции и из него строятся
внешние ссылки (вложения знаний, файлы статей, приглашения на звонок).
"""
from django.conf import settings
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from chatballs.api.permissions import HasCapability
from chatballs.identity.instance_settings import (
InstanceSettings,
email_connection,
email_from_address,
invalidate_cache,
public_base_url,
)
from chatballs.support_portals.addressing import normalize_domain, validate_domain
SCHEMES = ("http", "https")
def instance_payload(row: InstanceSettings) -> dict:
return {
"publicHost": row.public_host,
"publicScheme": row.public_scheme or "http",
"publicUrl": public_base_url(),
"updatedAt": row.updated_at,
"email": {
"host": row.email_host,
"port": row.email_port,
"user": row.email_user,
# Пароль наружу не возвращается: пустое поле при сохранении
# означает «оставить прежний».
"hasPassword": bool(row.email_password),
"useTls": row.email_use_tls,
"from": row.email_from,
"configured": bool(row.email_host),
},
"turn": {
# Секрет общий с coturn и лежит в томе секретов: наружу не отдаём
# и в настройках не показываем — вводить его человеку не нужно.
"urls": [line for line in row.turn_urls.splitlines() if line.strip()],
"ttlSeconds": row.turn_ttl_seconds,
"secretReady": bool(settings.CHATBALLS_CALL_TURN_SECRET),
},
}
class InstanceAddressView(APIView):
permission_classes = [HasCapability]
required_capabilities = {"GET": "settings.view", "PATCH": "company.manage"}
def get(self, request: Request) -> Response:
return Response({"instance": instance_payload(InstanceSettings.load())})
def patch(self, request: Request) -> Response:
row = InstanceSettings.load()
body = request.data if isinstance(request.data, dict) else {}
errors: dict[str, str] = {}
# Владелец может вставить и целый URL из адресной строки — берём хост.
raw_host = str(body.get("publicHost", row.public_host)).strip()
if "//" in raw_host:
raw_host = raw_host.split("//", 1)[1]
host = normalize_domain(raw_host.split("/", 1)[0].split(":", 1)[0])
if not host:
errors["publicHost"] = "Укажите адрес установки"
else:
try:
# IP-адрес — обычный случай коробки: домена может не быть вовсе.
if not _looks_like_ipv4(host):
validate_domain(host)
except ValidationError:
errors["publicHost"] = "Некорректный адрес"
scheme = str(body.get("publicScheme", row.public_scheme or "http")).lower()
if scheme not in SCHEMES:
errors["publicScheme"] = "http или https"
if errors:
return Response(
{"detail": next(iter(errors.values())), "errors": errors}, status=400
)
fields = ["public_host", "public_scheme", "updated_at"]
row.public_host = host
row.public_scheme = scheme
email = body.get("email")
if isinstance(email, dict):
row.email_host = str(email.get("host", row.email_host)).strip()
row.email_user = str(email.get("user", row.email_user)).strip()
row.email_from = str(email.get("from", row.email_from)).strip()
row.email_use_tls = bool(email.get("useTls", row.email_use_tls))
try:
row.email_port = int(email.get("port", row.email_port))
except (TypeError, ValueError):
return Response(
{"detail": "Порт — число", "errors": {"emailPort": "Порт — число"}},
status=400,
)
# Пустой пароль означает «оставить прежний»: наружу он не отдаётся.
password = str(email.get("password", ""))
if password:
row.email_password = password
fields += [
"email_host",
"email_port",
"email_user",
"email_password",
"email_use_tls",
"email_from",
]
turn = body.get("turn")
if isinstance(turn, dict):
urls = turn.get("urls", [])
if isinstance(urls, list):
row.turn_urls = sep_join(urls)
try:
row.turn_ttl_seconds = max(60, int(turn.get("ttlSeconds", row.turn_ttl_seconds)))
except (TypeError, ValueError):
return Response(
{"detail": "Время жизни — число секунд",
"errors": {"turnTtlSeconds": "Число секунд"}},
status=400,
)
fields += ["turn_urls", "turn_ttl_seconds"]
row.save(update_fields=fields)
invalidate_cache()
return Response({"instance": instance_payload(row)})
def _looks_like_ipv4(value: str) -> bool:
parts = value.split(".")
return len(parts) == 4 and all(part.isdigit() and 0 <= int(part) <= 255 for part in parts)
class InstanceEmailCheckView(APIView):
"""Проверка почты: отправить письмо себе и увидеть ошибку сразу.
Без этого владелец узнаёт о неверном SMTP только тогда, когда сотрудник не
получил приглашение.
"""
permission_classes = [HasCapability]
required_capability = "company.manage"
def post(self, request: Request) -> Response:
from django.core.mail import send_mail
connection = email_connection()
if connection is None:
return Response(
{"detail": "Сначала укажите сервер исходящей почты"}, status=400
)
recipient = str(request.data.get("email", "")).strip() or request.user.email
try:
send_mail(
subject="Проверка почты Chatballs",
message=(
"Это тестовое письмо из «Настроек». "
"Если вы его читаете, почта установки работает."
),
from_email=email_from_address(),
recipient_list=[recipient],
connection=connection,
)
except Exception as error: # ошибки SMTP разнообразны — показываем текст
return Response({"detail": str(error)[:300]}, status=400)
return Response({"sent": recipient})
def sep_join(urls) -> str:
"""Адреса TURN хранятся строками: по одному на строку."""
lines = [str(item).strip() for item in urls if str(item).strip()]
return chr(10).join(lines)
@@ -0,0 +1,32 @@
# Generated by Django 5.2.15 on 2026-09-07 00:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0026_humanuser_password_changed_at'),
]
operations = [
migrations.CreateModel(
name='InstanceSettings',
fields=[
(
'id',
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID',
),
),
('public_host', models.CharField(blank=True, default='', max_length=253)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Настройки установки',
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-09-07 00:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0027_instance_settings'),
]
operations = [
migrations.AddField(
model_name='instancesettings',
name='public_scheme',
field=models.CharField(blank=True, default='', max_length=5),
),
]
@@ -0,0 +1,44 @@
# Generated by Django 5.2.15 on 2026-09-07 01:55
import chatballs.identity.crypto
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0028_instance_public_scheme'),
]
operations = [
migrations.AddField(
model_name='instancesettings',
name='email_from',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='instancesettings',
name='email_host',
field=models.CharField(blank=True, default='', max_length=253),
),
migrations.AddField(
model_name='instancesettings',
name='email_password',
field=chatballs.identity.crypto.EncryptedCharField(blank=True, default='', max_length=512),
),
migrations.AddField(
model_name='instancesettings',
name='email_port',
field=models.PositiveIntegerField(default=587),
),
migrations.AddField(
model_name='instancesettings',
name='email_use_tls',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='instancesettings',
name='email_user',
field=models.CharField(blank=True, default='', max_length=255),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.15 on 2026-09-07 02:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('identity', '0029_instance_email'),
]
operations = [
migrations.AddField(
model_name='instancesettings',
name='turn_ttl_seconds',
field=models.PositiveIntegerField(default=3600),
),
migrations.AddField(
model_name='instancesettings',
name='turn_urls',
field=models.TextField(blank=True, default=''),
),
]
+12 -9
View File
@@ -53,7 +53,7 @@ def user_storage():
return storages["users"]
def user_avatar_upload_path(instance: "HumanUser", filename: str) -> str:
def user_avatar_upload_path(instance: HumanUser, filename: str) -> str:
suffix = Path(filename).suffix.lower()
return f"users/{instance.id}/avatar-{uuid.uuid4()}{suffix}"
@@ -100,7 +100,7 @@ class OrganizationStatus(models.TextChoices):
PENDING_OWNER = "PENDING_OWNER", "Pending owner"
def organization_logo_upload_path(instance: "Organization", filename: str) -> str:
def organization_logo_upload_path(instance: Organization, filename: str) -> str:
suffix = Path(filename).suffix.lower()
return (
f"organizations/{instance.public_id}/branding/"
@@ -252,14 +252,17 @@ class AuditEvent(models.Model):
# Django imports only models.py by convention. Re-export related models after the core
# identity entities are defined so they are registered without growing this file.
from chatballs.identity.group_models import ( # noqa: E402, F401
EmployeeGroup,
EmployeeGroupMember,
)
from chatballs.identity.invitation_models import ( # noqa: E402, F401
OrganizationInvitation,
)
from chatballs.identity.demo_models import ( # noqa: E402, F401
DemoDataset,
DemoRecord,
)
from chatballs.identity.group_models import ( # noqa: E402, F401
EmployeeGroup,
EmployeeGroupMember,
)
from chatballs.identity.instance_settings import ( # noqa: E402, F401
InstanceSettings,
)
from chatballs.identity.invitation_models import ( # noqa: E402, F401
OrganizationInvitation,
)
+8 -1
View File
@@ -23,6 +23,7 @@ from django.utils.text import slugify
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.identity.audit import record_audit_event
from chatballs.identity.instance_settings import remember_public_host
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
@@ -109,9 +110,15 @@ def _unique_slug(name: str) -> str:
return candidate
def complete_setup(data: SetupInput) -> SetupResult:
def complete_setup(
data: SetupInput, public_host: str = "", public_scheme: str = "http"
) -> SetupResult:
clean = _clean(data)
with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS):
# Адрес, на котором человек прошёл мастер, и есть публичный адрес
# установки: другого источника у коробки нет.
if public_host:
remember_public_host(public_host, public_scheme)
# Блокировка от гонки двух вкладок: второй запрос дождётся первого и
# увидит созданную организацию.
with connections[INSTANCE_DB_ALIAS].cursor() as cursor:
@@ -63,7 +63,9 @@ class SetupView(APIView):
email=str(body.get("email", "")),
password=str(body.get("password", "")),
install_demo=bool(body.get("installDemo", False)),
)
),
public_host=request.get_host(),
public_scheme=request.scheme,
)
except SetupAlreadyCompleted:
return Response(SETUP_CLOSED, status=409)
@@ -6,6 +6,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from chatballs.identity.audit import record_audit_event
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
@@ -193,3 +194,104 @@ class AdministrationApiTests(TestCase):
event = response.json()["items"][0]
self.assertEqual(event["action"], "Добавлен продукт")
self.assertNotIn("products.", event["action"])
class InstanceAddressTests(TestCase):
"""Адрес установки правится в «Настройках», а не в переменных окружения."""
def setUp(self) -> None:
result = bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = result.organization
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def test_owner_sets_domain_and_scheme(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
{"publicHost": "crm.example.com", "publicScheme": "https"},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
body = response.json()["instance"]
self.assertEqual(body["publicHost"], "crm.example.com")
self.assertEqual(body["publicUrl"], "https://crm.example.com")
def test_address_may_be_a_bare_ip(self) -> None:
# Коробку часто так и оставляют: сервер по IP, без домена.
response = self.client.patch(
"/api/v1/company/administration/instance/",
{"publicHost": "203.0.113.10", "publicScheme": "http"},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["instance"]["publicUrl"], "http://203.0.113.10")
def test_url_is_accepted_and_trimmed_to_host(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
{"publicHost": "https://crm.example.com/settings", "publicScheme": "https"},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["instance"]["publicHost"], "crm.example.com")
def test_garbage_is_rejected(self) -> None:
response = self.client.patch(
"/api/v1/company/administration/instance/",
{"publicHost": "не адрес!", "publicScheme": "ftp"},
format="json",
)
self.assertEqual(response.status_code, 400)
class InstanceEmailTests(TestCase):
"""Почта установки задаётся в «Настройках»: без неё некого приглашать."""
def setUp(self) -> None:
result = bootstrap_owner(email="owner@example.com", password="temporary-password")
self.client = TenantAPIClient()
self.client.force_authenticate(result.owner)
def patch(self, **email):
return self.client.patch(
"/api/v1/company/administration/instance/",
{"publicHost": "crm.example.com", "publicScheme": "https", "email": email},
format="json",
)
def test_smtp_is_saved_and_password_is_not_returned(self) -> None:
response = self.patch(
host="smtp.example.com",
port=465,
user="robot@example.com",
password="s3cret",
useTls=True,
**{"from": "Chatballs <robot@example.com>"},
)
self.assertEqual(response.status_code, 200, response.content)
email = response.json()["instance"]["email"]
self.assertEqual(email["host"], "smtp.example.com")
self.assertEqual(email["port"], 465)
self.assertTrue(email["configured"])
self.assertTrue(email["hasPassword"])
self.assertNotIn("password", email)
def test_empty_password_keeps_the_stored_one(self) -> None:
self.patch(host="smtp.example.com", password="s3cret")
response = self.patch(host="smtp.example.com", password="")
self.assertTrue(response.json()["instance"]["email"]["hasPassword"])
def test_check_without_smtp_explains_itself(self) -> None:
response = self.client.post(
"/api/v1/company/administration/instance/email-check/", {}, format="json"
)
self.assertEqual(response.status_code, 400)
self.assertIn("сервер исходящей почты", response.json()["detail"])
@@ -45,6 +45,9 @@ COVERAGE_EXEMPT = {
("identity", "demorecord"),
# Настройки хранилища — одна строка на инстанс, не данные организации.
("tenancy", "storagesettings"),
# Настройки установки (адрес, по которому её открывают) — тоже одна
# строка на инстанс: их пишет мастер первого запуска, а не демо.
("identity", "instancesettings"),
}
COVERAGE_EXEMPT_APPS = {"platform", "events"}
+46 -2
View File
@@ -1,9 +1,13 @@
import json
from django.test import TestCase
from chatballs.testing import TenantAPIClient as APIClient
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.instance_settings import (
InstanceSettings,
invalidate_cache,
public_base_url,
)
from chatballs.identity.models import (
AuditEvent,
EmployeeRole,
@@ -11,6 +15,7 @@ from chatballs.identity.models import (
Organization,
OrganizationMembership,
)
from chatballs.testing import TenantAPIClient as APIClient
VALID = {
"organizationName": "Ателье Норд",
@@ -27,11 +32,50 @@ class SetupWizardTests(TestCase):
self.client = APIClient()
def complete(self, **overrides):
extra = {key: overrides.pop(key) for key in list(overrides) if key.startswith("HTTP_")}
body = {**VALID, **overrides}
return self.client.post(
"/api/v1/setup/complete/", data=json.dumps(body), content_type="application/json"
"/api/v1/setup/complete/",
data=json.dumps(body),
content_type="application/json",
**extra,
)
def test_setup_remembers_the_address_it_was_opened_on(self) -> None:
"""Коробку открывают по IP сервера: этот адрес и есть её публичный.
Другого источника адреса у установки нет — в .env его никто не задаёт,
а без него сразу после мастера все запросы упёрлись бы в проверку хоста.
"""
invalidate_cache()
response = self.complete(HTTP_HOST="203.0.113.10")
self.assertEqual(response.status_code, 201, response.content)
self.assertEqual(InstanceSettings.load().public_host, "203.0.113.10")
def test_remembered_address_keeps_working_after_setup(self) -> None:
self.complete(HTTP_HOST="crm.example.test")
invalidate_cache()
response = self.client.get("/api/v1/setup/", HTTP_HOST="crm.example.test")
self.assertEqual(response.status_code, 200)
self.assertFalse(response.json()["needsSetup"])
def test_external_links_use_the_address_from_the_wizard(self) -> None:
"""Ссылки на файлы уходят клиенту в мессенджер: «localhost» там мёртв.
Адрес берётся из мастера, а не из переменной окружения, которой на
свежей установке никто не задаёт.
"""
invalidate_cache()
self.complete(HTTP_HOST="crm.example.test")
invalidate_cache()
self.assertEqual(public_base_url(), "http://crm.example.test")
def test_fresh_instance_needs_setup(self) -> None:
response = self.client.get("/api/v1/setup/")
self.assertEqual(response.status_code, 200)
@@ -12,6 +12,7 @@ import logging
from django.conf import settings
from chatballs.identity.instance_settings import public_base_url
from chatballs.conversations import transports
from chatballs.identity.models import OrganizationMembership
from chatballs.notifications.models import (
@@ -55,7 +56,7 @@ def _message_text(notification: Notification) -> str:
lines = [f"{mark} {notification.title}"]
if notification.body:
lines.append(notification.body)
base_url = getattr(settings, "INTERNAL_UI_BASE_URL", "") or ""
base_url = public_base_url()
if base_url:
lines.append(base_url)
return "\n".join(lines)
@@ -0,0 +1,24 @@
"""Ссылки на файлы статьи внутри Markdown.
Портал отдаётся на своём домене (``help.example.com``), а строгий CSP
документа разрешает картинки только с ``'self'``. Абсолютная ссылка на файл —
пусть даже на ту же установку, но с другим хостом — браузером режется, и
изображение в статье просто не появляется. Поэтому ссылки на файлы статьи
приводятся к относительному виду: и при записи ревизии, и при отдаче — чтобы
вылечился текст, написанный до этого правила.
"""
from __future__ import annotations
import re
# Абсолютный адрес до пути файла статьи: схема и хост отбрасываются, путь
# остаётся. Хост не проверяется — файл всегда отдаётся тем же сайтом.
_ABSOLUTE_FILE_LINK = re.compile(
r"https?://[^\s)\]<>\"']*?(/api/v1/help/files/[0-9a-fA-F-]{36}/)"
)
def normalize_file_links(content: str) -> str:
"""Сделать ссылки на файлы статьи относительными."""
return _ABSOLUTE_FILE_LINK.sub(r"\1", content)
@@ -1,19 +1,32 @@
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
from django.utils.text import slugify
from chatballs.ai.indexing import reindex_portal_article
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
PortalArticleFeedback,
PortalArticleFile,
PortalArticleRevision,
PortalCategory,
SupportPortal,
)
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
from chatballs.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
)
# Лимит на файл статьи — как у вложений знаний и подпись в drop-зоне редактора.
MAX_ARTICLE_FILE_BYTES = 25 * 1024 * 1024
def create_category(
@@ -97,7 +110,7 @@ def create_article(
def add_revision(
*, context: TenantContext, article: PortalArticle, data: dict
*, context: TenantContext, article: PortalArticle, data: dict, author=None
) -> PortalArticleRevision:
ensure_portal_editable(article.portal)
if article.status == ArticleStatus.ARCHIVED:
@@ -109,7 +122,8 @@ def add_revision(
revision=last + 1,
title=str(data.get("title", "")).strip(),
summary=str(data.get("summary", "")).strip(),
content=str(data.get("content", "")),
content=normalize_file_links(str(data.get("content", ""))),
created_by=author,
)
revision.full_clean()
revision.save()
@@ -186,3 +200,62 @@ def ensure_portal_editable(portal: SupportPortal) -> None:
raise ValidationError(
{"portal": "Восстановите портал, чтобы изменить его содержимое"}
)
def add_article_file(
*,
context: TenantContext,
article: PortalArticle,
upload: UploadedFile,
author=None,
) -> PortalArticleFile:
"""Загрузка файла статьи: одноимённый файл заменяется, квота учитывается."""
ensure_portal_editable(article.portal)
if article.status == ArticleStatus.ARCHIVED:
raise ValidationError({"article": "Архивную статью нельзя изменять"})
original_name = (upload.name or "").strip()
if not original_name:
raise ValidationError({"file": "Имя файла обязательно"})
if upload.size and upload.size > MAX_ARTICLE_FILE_BYTES:
raise ValidationError({"file": "Файл больше 25 МБ"})
existing = article.files.filter(original_name=original_name).first()
existing_size = existing.size if existing is not None else 0
data = upload.read()
reservation_key = f"portal-article-file:{article.id}:{original_name}"
reserve_storage(
context=context, expected_bytes=len(data), idempotency_key=reservation_key
)
try:
if existing is not None:
existing.file.delete(save=False)
existing.delete()
if existing_size:
adjust_storage_usage(context=context, delta_bytes=-existing_size)
article_file = PortalArticleFile(
organization=context.organization,
article=article,
original_name=original_name,
content_type=upload.content_type or "",
size=len(data),
uploaded_by=author,
)
article_file.file.save(original_name, ContentFile(data), save=True)
except Exception:
release_storage(context=context, idempotency_key=reservation_key)
raise
finalize_storage(
context=context, idempotency_key=reservation_key, actual_bytes=len(data)
)
return article_file
def delete_article_file(
*, context: TenantContext, article_file: PortalArticleFile
) -> None:
ensure_portal_editable(article_file.article.portal)
released_bytes = article_file.size
article_file.file.delete(save=False)
article_file.delete()
if released_bytes:
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
@@ -1,32 +1,36 @@
from django.core.exceptions import ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from chatballs.support_portals.api import validation_response
from chatballs.support_portals.content_services import (
add_article_file,
add_revision,
archive_article,
create_article,
create_category,
delete_article_file,
delete_category,
publish_revision,
update_category,
update_article,
update_category,
)
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.portal_views import PortalBaseView
from chatballs.support_portals.selectors import category_article_counts
from chatballs.support_portals.serializers import (
article_file_payload,
article_payload,
category_payload,
revision_payload,
)
from chatballs.support_portals.selectors import category_article_counts
def _article(portal, article_id: int) -> PortalArticle | None:
return (
portal.articles.select_related("portal", "category", "published_revision")
.prefetch_related("revisions")
.prefetch_related("revisions", "revisions__created_by", "files", "feedback")
.filter(id=article_id)
.first()
)
@@ -111,7 +115,7 @@ class ArticleListView(PortalBaseView):
return Response({"detail": "Портал не найден"}, status=404)
articles = portal.articles.select_related(
"category", "published_revision"
).prefetch_related("revisions").all()
).prefetch_related("revisions", "files", "feedback").all()
return Response({"items": [article_payload(item) for item in articles]})
def post(self, request: Request, portal_id: int) -> Response:
@@ -129,7 +133,10 @@ class ArticleListView(PortalBaseView):
return validation_response(error)
return Response({"detail": "Некорректная статья"}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True)}, status=201)
return Response(
{"article": article_payload(article, revisions=True, files=True)},
status=201,
)
class ArticleDetailView(PortalBaseView):
@@ -140,7 +147,7 @@ class ArticleDetailView(PortalBaseView):
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response({"article": article_payload(article, revisions=True)})
return Response({"article": article_payload(article, revisions=True, files=True)})
def patch(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
@@ -156,7 +163,7 @@ class ArticleDetailView(PortalBaseView):
return validation_response(error)
return Response({"detail": "Некорректные данные статьи"}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True)})
return Response({"article": article_payload(article, revisions=True, files=True)})
class ArticleRevisionListView(PortalBaseView):
@@ -172,6 +179,7 @@ class ArticleRevisionListView(PortalBaseView):
context=request.tenant_context,
article=article,
data=request.data,
author=request.user,
)
except ValidationError as error:
return validation_response(error)
@@ -196,7 +204,7 @@ class ArticlePublishView(PortalBaseView):
return validation_response(error)
return Response({"detail": "Некорректная версия"}, status=400)
article = _article(portal, article.id)
return Response({"article": article_payload(article, revisions=True)})
return Response({"article": article_payload(article, revisions=True, files=True)})
class ArticleArchiveView(PortalBaseView):
@@ -208,4 +216,66 @@ class ArticleArchiveView(PortalBaseView):
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
article = archive_article(article)
return Response({"article": article_payload(article, revisions=True)})
return Response({"article": article_payload(article, revisions=True, files=True)})
class ArticleFileListView(PortalBaseView):
"""Файлы статьи портала: список и загрузка (кадры PT7/PT8)."""
parser_classes = [MultiPartParser, FormParser, JSONParser]
def get(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
return Response(
{"items": [article_file_payload(item) for item in article.files.all()]}
)
def post(self, request: Request, portal_id: int, article_id: int) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
upload = request.FILES.get("file")
if upload is None:
return Response(
{"detail": "Нужен файл в поле file (multipart/form-data)"}, status=400
)
try:
article_file = add_article_file(
context=request.tenant_context,
article=article,
upload=upload,
author=request.user,
)
except ValidationError as error:
return validation_response(error)
return Response({"file": article_file_payload(article_file)}, status=201)
class ArticleFileDetailView(PortalBaseView):
def delete(
self, request: Request, portal_id: int, article_id: int, file_id: int
) -> Response:
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
article = _article(portal, article_id)
if article is None:
return Response({"detail": "Статья не найдена"}, status=404)
article_file = article.files.filter(id=file_id).first()
if article_file is None:
return Response({"detail": "Файл не найден"}, status=404)
try:
delete_article_file(
context=request.tenant_context, article_file=article_file
)
except ValidationError as error:
return validation_response(error)
return Response(status=204)
@@ -1,7 +1,5 @@
from __future__ import annotations
import uuid
import dns.exception
import dns.resolver
from django.conf import settings
@@ -13,14 +11,6 @@ from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.statuses import PortalStatus
def domain_verification_name(portal: SupportPortal) -> str:
return f"_chatballs.{portal.custom_domain}" if portal.custom_domain else ""
def domain_verification_value(portal: SupportPortal) -> str:
return f"chatballs-verification={portal.custom_domain_verification_token}"
def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal:
if portal.status == PortalStatus.ARCHIVED:
raise ValidationError(
@@ -29,15 +19,9 @@ def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal:
normalized = normalize_domain(value)
portal.custom_domain = validate_domain(normalized) if normalized else ""
portal.custom_domain_verified_at = None
portal.custom_domain_verification_token = uuid.uuid4()
portal.full_clean()
portal.save(
update_fields=[
"custom_domain",
"custom_domain_verified_at",
"custom_domain_verification_token",
"updated_at",
]
update_fields=["custom_domain", "custom_domain_verified_at", "updated_at"]
)
return portal
@@ -49,6 +33,10 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
)
if not portal.custom_domain:
raise ValidationError({"customDomain": "Сначала укажите домен"})
# Подтверждать владение доменом нечем и незачем: продукт self-hosted, домен
# и установка принадлежат одному владельцу (README дизайн-базлайна, решение
# 6). Остаётся техническая проверка «ведёт ли домен на этот сервер» — она
# нужна, чтобы выписать сертификат.
if settings.CHATBALLS_HELP_PUBLIC_IPV4:
try:
address_answers = dns.resolver.resolve(portal.custom_domain, "A")
@@ -70,26 +58,6 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
{"customDomain": "A-запись домена указывает не на сервер Chatballs"}
)
expected = domain_verification_value(portal)
try:
answers = dns.resolver.resolve(domain_verification_name(portal), "TXT")
values = {
b"".join(answer.strings).decode("utf-8", errors="replace")
for answer in answers
}
except (
dns.resolver.NoAnswer,
dns.resolver.NXDOMAIN,
dns.resolver.NoNameservers,
dns.exception.Timeout,
) as error:
raise ValidationError(
{"customDomain": "TXT-запись пока не найдена"}
) from error
if expected not in values:
raise ValidationError(
{"customDomain": "TXT-запись не содержит код подтверждения"}
)
portal.custom_domain_verified_at = timezone.now()
portal.full_clean()
portal.save(update_fields=["custom_domain_verified_at", "updated_at"])
@@ -0,0 +1,50 @@
from django.http import FileResponse, Http404
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.views import APIView
from chatballs.identity.models import Organization
from chatballs.support_portals.models import PortalArticleFile
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import portal_article_file_route
class PortalArticleFileView(APIView):
"""Файл статьи портала по публичной ссылке.
Ссылка попадает в Markdown статьи и открывается посетителем портала, у
которого нет сессии хаба: защита — непредсказуемый UUID и резолв
организации через ingress-директорию (как у вложений знаний).
"""
permission_classes = [AllowAny]
authentication_classes: list = []
def get(self, request: Request, public_id) -> FileResponse:
route = portal_article_file_route(str(public_id))
if route is None:
raise Http404
try:
organization = Organization.objects.get(pk=route.organization_id)
except Organization.DoesNotExist as error:
raise Http404 from error
context = TenantContext.for_resource(organization)
with tenant_atomic(context):
article_file = PortalArticleFile.objects.filter(
id=route.resource_id,
public_id=public_id,
organization=organization,
article__organization=organization,
).first()
if article_file is None:
raise Http404
opened_file = article_file.file.open("rb")
original_name = article_file.original_name
content_type = article_file.content_type
# Картинка встроена в статью тегом <img>, поэтому файл отдаётся inline;
# тип берётся сохранённым, чтобы браузер не угадывал по расширению.
response = FileResponse(opened_file, filename=original_name)
if content_type:
response.headers["Content-Type"] = content_type
return response
@@ -11,7 +11,15 @@ from chatballs.tenancy.ingress import support_portal_route
class SupportPortalHostBoundaryMiddleware:
"""Accept app hosts and hosts present in the published portal directory."""
"""Accept app hosts and hosts present in the published portal directory.
Пока установка не прошла мастер первого запуска, адрес себе она не знает:
человек поднял докер на сервере и открывает её по IP или по своему домену.
Отвечать на это «Invalid host» — значит не дать дойти до мастера, поэтому
до создания первой организации принимается любой хост: данных, порталов и
ссылок, которые можно было бы отравить чужим Host, ещё нет. После
установки список хостов снова закрыт.
"""
def __init__(self, get_response):
self.get_response = get_response
@@ -19,7 +27,7 @@ class SupportPortalHostBoundaryMiddleware:
def __call__(self, request: HttpRequest):
raw_host = request.META.get("HTTP_HOST") or request.META.get("SERVER_NAME", "")
host = normalize_domain(raw_host.partition(":")[0])
if not self._allowed(host):
if not self._allowed(host) and not _instance_is_unconfigured():
return HttpResponseBadRequest("Invalid host")
return self.get_response(request)
@@ -31,6 +39,15 @@ class SupportPortalHostBoundaryMiddleware:
or str(connections["default"].settings_dict["NAME"]).startswith("test_")
):
return True
# Адрес, который человек ввёл в браузере на первом запуске: продукт
# запомнил его в настройках установки и признаёт своим.
from chatballs.identity.instance_settings import public_host
try:
if host and host == public_host():
return True
except Exception:
pass
for allowed in settings.CHATBALLS_APP_PRIMARY_HOSTS:
normalized = normalize_domain(allowed.lstrip("."))
if host == normalized or (
@@ -41,3 +58,24 @@ class SupportPortalHostBoundaryMiddleware:
return support_portal_route(host) is not None
except Exception:
return False
# Признак «мастер ещё не пройден» кэшируется: до установки его спрашивает
# каждый запрос, а после установки он больше не меняется.
_configured = False
def _instance_is_unconfigured() -> bool:
global _configured
if _configured:
return False
from chatballs.identity.setup import instance_needs_setup
try:
needs_setup = instance_needs_setup()
except Exception:
# База ещё не поднялась: на этом этапе хост тем более не проверить.
return True
if not needs_setup:
_configured = True
return needs_setup
@@ -0,0 +1,96 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import chatballs.support_portals.models
class Migration(migrations.Migration):
dependencies = [
("support_portals", "0009_portal_theme"),
("identity", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name="supportportal",
name="custom_domain_verification_token",
),
migrations.AddField(
model_name="portalarticlerevision",
name="created_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.CreateModel(
name="PortalArticleFile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"public_id",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
(
"file",
models.FileField(
max_length=512,
upload_to=chatballs.support_portals.models.article_file_upload_path,
),
),
("original_name", models.CharField(max_length=255)),
("content_type", models.CharField(blank=True, max_length=128)),
("size", models.PositiveBigIntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"article",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="support_portals.portalarticle",
),
),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="identity.organization",
),
),
(
"uploaded_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={"ordering": ["-created_at", "-id"]},
),
migrations.AddConstraint(
model_name="portalarticlefile",
constraint=models.UniqueConstraint(
fields=("article", "original_name"),
name="uniq_portal_article_file_name",
),
),
]
@@ -1,15 +1,17 @@
from __future__ import annotations
import uuid
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from chatballs.tenancy.models import TenantRelationModel
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
from chatballs.support_portals.themes import (
DEFAULT_PORTAL_THEME,
PortalThemeScheme,
)
from chatballs.tenancy.models import TenantRelationModel
class SupportPortal(TenantRelationModel):
@@ -20,10 +22,10 @@ class SupportPortal(TenantRelationModel):
slug = models.SlugField(max_length=64, unique=True)
hosted_domain = models.CharField(max_length=253, unique=True)
custom_domain = models.CharField(max_length=253, blank=True, default="")
# Отметка технической проверки «домен ведёт на этот сервер». Подтверждения
# владения доменом нет: домен и установка у одного владельца (README
# дизайн-базлайна «Порталы», решение 6).
custom_domain_verified_at = models.DateTimeField(null=True, blank=True)
custom_domain_verification_token = models.UUIDField(
default=uuid.uuid4, editable=False
)
name = models.CharField(max_length=255)
default_locale = models.CharField(max_length=16, default="ru")
status = models.CharField(
@@ -271,6 +273,14 @@ class PortalArticleRevision(TenantRelationModel):
title = models.CharField(max_length=255)
summary = models.CharField(max_length=500, blank=True)
content = models.TextField()
# Автор редакции показывается в рейке версий редактора статьи (кадр PT7).
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="+",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True, blank=True)
@@ -288,6 +298,76 @@ class PortalArticleRevision(TenantRelationModel):
]
def article_file_upload_path(instance: PortalArticleFile, filename: str) -> str:
organization = instance.article.organization
return (
f"organizations/{organization.public_id}/portal-articles/"
f"{instance.article_id}/{instance.public_id}/{filename}"
)
class PortalArticleFile(TenantRelationModel):
"""Файл статьи портала: картинка или документ, вставленный в Markdown.
Ссылка публичная и защищена непредсказуемым UUID: файл открывается
посетителем портала, у которого нет аутентификации хаба (как у вложений
знаний, ADR-HUB-0023).
"""
tenant_relation_fields = ("article",)
article = models.ForeignKey(
PortalArticle,
on_delete=models.CASCADE,
related_name="files",
)
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
file = models.FileField(upload_to=article_file_upload_path, max_length=512)
# Оригинальное имя уникально в рамках статьи: текст статьи ссылается на файл
# по ссылке, а редактор показывает имя в рейке файлов.
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=128, blank=True)
size = models.PositiveBigIntegerField(default=0)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="+",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "-id"]
constraints = [
models.UniqueConstraint(
fields=["article", "original_name"],
name="uniq_portal_article_file_name",
)
]
def __str__(self) -> str:
return f"portal-file:{self.article_id}/{self.original_name}"
def public_path(self) -> str:
"""Ссылка для Markdown статьи — относительная.
Портал открывается на своём домене, а страница отдаётся с CSP
``img-src 'self'``: абсолютная ссылка на домен установки была бы для
неё чужим origin и картинка не отобразилась бы. Относительный путь
работает и на портале, и в предпросмотре редактора.
"""
from django.urls import reverse
return reverse("portal-article-file", kwargs={"public_id": self.public_id})
def public_url(self) -> str:
# Абсолютная ссылка — для мест, где нужен полный адрес (письма,
# сообщения в мессенджер): там относительный путь бесполезен.
from chatballs.identity.instance_settings import public_base_url
return public_base_url() + self.public_path()
class PortalArticleFeedback(TenantRelationModel):
tenant_relation_fields = ("article",)
article = models.ForeignKey(
@@ -8,11 +8,11 @@ from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
from chatballs.support_portals.api import validation_response
from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.domain_services import (
set_custom_domain,
verify_custom_domain,
)
from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.portal_services import (
PortalInput,
create_portal,
@@ -20,12 +20,16 @@ from chatballs.support_portals.portal_services import (
set_portal_status,
update_portal,
)
from chatballs.support_portals.selectors import portal_for_context, portals_for_context
from chatballs.support_portals.selectors import (
portal_content_counts,
portal_for_context,
portals_for_context,
)
from chatballs.support_portals.serializers import portal_payload
from chatballs.support_portals.themes import (
DEFAULT_PORTAL_THEME,
PortalThemeScheme,
)
from chatballs.support_portals.serializers import portal_payload
from chatballs.webchat.models import (
WebChatWidget,
WebChatWidgetMode,
@@ -112,11 +116,22 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu
class PortalListView(PortalBaseView):
def get(self, request: Request) -> Response:
portals = list(portals_for_context(request.tenant_context))
counts = portal_content_counts(request.tenant_context)
# Тарифные лимиты порталов удалены (ADR-HUB-0042 §2): создание доступно всегда.
active_count = sum(item.status != "ARCHIVED" for item in portals)
return Response(
{
"items": [portal_payload(item) for item in portals],
"items": [
portal_payload(
item,
counts={
"categories": 0,
"articles": 0,
**counts.get(item.id, {}),
},
)
for item in portals
],
"creation": {
"available": True,
"canCreate": True,
@@ -153,7 +168,8 @@ class PortalDetailView(PortalBaseView):
portal = self.portal(request, portal_id)
if portal is None:
return Response({"detail": "Портал не найден"}, status=404)
return Response({"portal": portal_payload(portal)})
counts = portal_content_counts(request.tenant_context).get(portal.id, {})
return Response({"portal": portal_payload(portal, counts=counts)})
def patch(self, request: Request, portal_id: int) -> Response:
portal = self.portal(request, portal_id)
@@ -1,9 +1,10 @@
from django.urls import path
from chatballs.support_portals import public_views
from chatballs.support_portals import file_views, public_views
urlpatterns = [
path("", public_views.PublicPortalDetailView.as_view(), name="public-support-portal"),
path("files/<uuid:public_id>/", file_views.PortalArticleFileView.as_view(), name="portal-article-file"),
path("articles/", public_views.PublicArticleListView.as_view(), name="public-support-articles"),
path("articles/<slug:article_slug>/", public_views.PublicArticleDetailView.as_view(), name="public-support-article"),
path("articles/<slug:article_slug>/feedback/", public_views.PublicArticleFeedbackView.as_view(), name="public-support-feedback"),
@@ -1,6 +1,10 @@
from django.db.models import Count, Q, QuerySet
from chatballs.support_portals.models import PortalArticle, SupportPortal
from chatballs.support_portals.models import (
PortalArticle,
PortalCategory,
SupportPortal,
)
from chatballs.tenancy.context import TenantContext
@@ -44,6 +48,7 @@ def public_articles(
published_revision__isnull=False,
)
.select_related("category", "published_revision")
.prefetch_related("files")
.order_by("category__sort_order", "published_revision__title")
)
if category:
@@ -106,3 +111,24 @@ def category_article_counts(
for category in categories:
total(category.id)
return totals
def portal_content_counts(context: TenantContext) -> dict[int, dict[str, int]]:
"""Разделы и статьи по порталам — колонка «Материалы» списка (кадр PT1)."""
counts: dict[int, dict[str, int]] = {}
categories = (
PortalCategory.objects.filter(organization=context.organization)
.values("portal_id")
.annotate(total=Count("id"))
)
for row in categories:
counts.setdefault(row["portal_id"], {})["categories"] = row["total"]
articles = (
PortalArticle.objects.filter(organization=context.organization)
.values("portal_id")
.annotate(total=Count("id"))
)
for row in articles:
counts.setdefault(row["portal_id"], {})["articles"] = row["total"]
return counts
@@ -1,18 +1,16 @@
from django.conf import settings
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
from chatballs.support_portals.addressing import portal_public_url
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
PortalArticleFile,
PortalArticleRevision,
PortalCategory,
SupportPortal,
SupportPortalProduct,
)
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
from chatballs.support_portals.addressing import portal_public_url
from chatballs.support_portals.domain_services import (
domain_verification_name,
domain_verification_value,
)
def product_link_payload(link: SupportPortalProduct) -> dict:
@@ -31,7 +29,7 @@ def product_link_payload(link: SupportPortalProduct) -> dict:
}
def portal_payload(portal: SupportPortal) -> dict:
def portal_payload(portal: SupportPortal, *, counts: dict | None = None) -> dict:
public_url = portal_public_url(
hosted=portal.hosted_domain,
custom=portal.custom_domain,
@@ -53,15 +51,6 @@ def portal_payload(portal: SupportPortal) -> dict:
else None
),
"customDomainVerifiedAt": portal.custom_domain_verified_at,
"customDomainVerification": (
{
"name": domain_verification_name(portal),
"type": "TXT",
"value": domain_verification_value(portal),
}
if portal.custom_domain and portal.custom_domain_verified_at is None
else None
),
"publicUrl": public_url,
"name": portal.name,
"defaultLocale": portal.default_locale,
@@ -77,6 +66,19 @@ def portal_payload(portal: SupportPortal) -> dict:
"products": [product_link_payload(link) for link in portal.product_links.all()],
"createdAt": portal.created_at,
"updatedAt": portal.updated_at,
# Колонка «Материалы» списка порталов (кадр PT1) и подзаголовок карточки.
# В списке счётчики приходят одним запросом, для одиночного ответа
# считаются здесь — иначе после сохранения настроек шапка обнулилась бы.
"categoryCount": (
counts["categories"]
if counts and "categories" in counts
else portal.categories.count()
),
"articleCount": (
counts["articles"]
if counts and "articles" in counts
else portal.articles.count()
),
}
@@ -104,13 +106,19 @@ def revision_payload(revision: PortalArticleRevision, *, content: bool = True) -
"summary": revision.summary,
"createdAt": revision.created_at,
"publishedAt": revision.published_at,
"authorName": _author_name(revision.created_by),
}
if content:
payload["content"] = revision.content
# Ссылки на файлы приводятся к относительным и на отдаче: тексты,
# написанные до этого правила, иначе остались бы с абсолютным хостом
# и картинки резал бы CSP портала.
payload["content"] = normalize_file_links(revision.content)
return payload
def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict:
def article_payload(
article: PortalArticle, *, revisions: bool = False, files: bool = False
) -> dict:
latest_revision = next(iter(article.revisions.all()), None)
payload = {
"id": article.id,
@@ -131,6 +139,20 @@ def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict:
"createdAt": article.created_at,
"updatedAt": article.updated_at,
}
# files всегда prefetch-нуты в селекторах статей: без этого счётчик
# скрепки в списке дал бы запрос на строку.
# Оценки посетителей: две кнопки под статьёй на портале. Автору важно
# видеть их сумму, иначе кнопки собирают отзывы в никуда.
votes = list(article.feedback.all())
payload["feedback"] = {
"helpful": sum(1 for vote in votes if vote.helpful),
"unhelpful": sum(1 for vote in votes if not vote.helpful),
}
payload["fileCount"] = len(article.files.all())
if files:
payload["files"] = [
article_file_payload(item) for item in article.files.all()
]
if revisions:
payload["revisions"] = [
revision_payload(revision) for revision in article.revisions.all()
@@ -138,6 +160,29 @@ def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict:
return payload
def article_file_payload(article_file: PortalArticleFile) -> dict:
return {
"id": article_file.id,
"name": article_file.original_name,
"contentType": article_file.content_type,
"size": article_file.size,
# path — для вставки в Markdown (тот же origin, что и страница),
# url — полный адрес для мест, где нужен абсолютный.
"path": article_file.public_path(),
"url": article_file.public_url(),
"createdAt": article_file.created_at,
}
def _author_name(user) -> str:
if user is None:
return ""
full_name = " ".join(
part for part in (user.first_name, user.last_name) if part
).strip()
return full_name or user.get_username()
def public_portal_payload(portal: SupportPortal) -> dict:
widget_key = _public_widget_key(portal.widget, "ANONYMOUS")
return {
@@ -208,10 +253,26 @@ def _public_widget_key(widget, expected_mode: str) -> str | None:
def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict:
return {
payload = {
"slug": article.slug,
"locale": article.locale,
"category": category_payload(article.category),
"revision": revision_payload(article.published_revision, content=content),
"updatedAt": article.updated_at,
}
if content:
# Вложения статьи — всё, чего нет в самом тексте. Картинку, вставленную
# в статью, посетитель уже видит; остальное (в том числе картинку,
# просто прикреплённую к статье) он скачивает списком под текстом.
body = payload["revision"]["content"]
payload["attachments"] = [
{
"name": item.original_name,
"path": item.public_path(),
"size": item.size,
"contentType": item.content_type,
}
for item in article.files.all()
if item.public_path() not in body
]
return payload
@@ -140,7 +140,9 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertEqual(link.support_channel, self.channel)
@override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42")
def test_custom_domain_requires_matching_dns_records(self) -> None:
def test_custom_domain_checks_only_the_address_record(self) -> None:
# Подтверждения владения доменом нет — только «ведёт ли домен сюда»
# (README дизайн-базлайна «Порталы», решение 6).
portal_id = self.create_portal().json()["portal"]["id"]
configured = self.client.put(
f"/api/v1/support/portals/{portal_id}/domain/",
@@ -148,7 +150,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
format="json",
)
self.assertEqual(configured.status_code, 200, configured.content)
verification = configured.json()["portal"]["customDomainVerification"]
self.assertNotIn("customDomainVerification", configured.json()["portal"])
self.assertEqual(
configured.json()["portal"]["customDomainAddress"],
{
@@ -158,13 +160,10 @@ class SupportPortalManagementTests(SupportPortalTestCase):
},
)
address_answer = mock.Mock(address="203.0.113.42")
verification_answer = mock.Mock(
strings=[verification["value"].encode("utf-8")]
)
with mock.patch(
"dns.resolver.resolve",
side_effect=[[address_answer], [verification_answer]],
return_value=[address_answer],
) as resolve:
verified = self.client.post(
f"/api/v1/support/portals/{portal_id}/domain/verify/",
@@ -174,13 +173,9 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertEqual(verified.status_code, 200, verified.content)
self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"])
self.assertIsNone(verified.json()["portal"]["customDomainVerification"])
self.assertEqual(
resolve.call_args_list,
[
mock.call("help.customer.example", "A"),
mock.call("_chatballs.help.customer.example", "TXT"),
],
[mock.call("help.customer.example", "A")],
)
@override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42")
@@ -1,3 +1,5 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from chatballs.support_portals.tests.base import SupportPortalTestCase
from chatballs.webchat.testing import create_web_widget
@@ -41,6 +43,129 @@ class SupportPortalContentManagementTests(SupportPortalTestCase):
self.assertEqual(len(body["revisions"]), 2)
self.assertEqual(body["publishedRevision"]["content"], "Версия 2")
def test_article_files_are_uploaded_listed_and_deleted(self) -> None:
# Файлы статьи — рейка редактора и drop в текст (кадры PT7/PT8).
portal_id = self.create_portal().json()["portal"]["id"]
category = self.client.post(
f"/api/v1/support/portals/{portal_id}/categories/",
{"name": "Мерки"},
format="json",
).json()["category"]
article_id = self.client.post(
f"/api/v1/support/portals/{portal_id}/articles/",
{
"categoryId": category["id"],
"slug": "kak-snyat-merki",
"title": "Как снять мерки",
"summary": "",
"content": "# Как снять мерки",
},
format="json",
).json()["article"]["id"]
upload = self.client.post(
f"/api/v1/support/portals/{portal_id}/articles/{article_id}/files/",
{
"file": SimpleUploadedFile(
"measure-points.png", b"png-bytes", content_type="image/png"
)
},
format="multipart",
)
self.assertEqual(upload.status_code, 201, upload.content)
uploaded = upload.json()["file"]
self.assertEqual(uploaded["name"], "measure-points.png")
self.assertEqual(uploaded["size"], len(b"png-bytes"))
self.assertIn("/api/v1/help/files/", uploaded["url"])
detail = self.client.get(
f"/api/v1/support/portals/{portal_id}/articles/{article_id}/"
)
self.assertEqual(detail.status_code, 200, detail.content)
self.assertEqual(
[item["name"] for item in detail.json()["article"]["files"]],
["measure-points.png"],
)
self.assertEqual(detail.json()["article"]["fileCount"], 1)
removed = self.client.delete(
f"/api/v1/support/portals/{portal_id}/articles/{article_id}"
f"/files/{uploaded['id']}/"
)
self.assertEqual(removed.status_code, 204, removed.content)
listing = self.client.get(
f"/api/v1/support/portals/{portal_id}/articles/{article_id}/files/"
)
self.assertEqual(listing.json()["items"], [])
def test_absolute_file_links_become_relative(self) -> None:
"""CSP портала (img-src 'self') режет картинку с чужим хостом.
Ссылка на файл статьи всегда относительная: и в новой редакции, и в
отдаче старых — иначе изображение просто не появляется на портале.
"""
portal_id = self.create_portal().json()["portal"]["id"]
category = self.client.post(
f"/api/v1/support/portals/{portal_id}/categories/",
{"name": "Доставка"},
format="json",
)
path = "/api/v1/help/files/4ecee829-30d2-4a2a-904e-a09be28d7708/"
article = self.client.post(
f"/api/v1/support/portals/{portal_id}/articles/",
{
"categoryId": category.json()["category"]["id"],
"slug": "delivery",
"title": "Доставка",
"summary": "Сроки",
"content": f"![кот](http://localhost{path})",
},
format="json",
)
self.assertEqual(article.status_code, 201, article.content)
content = article.json()["article"]["revisions"][0]["content"]
self.assertEqual(content, f"![кот]({path})")
article_id = article.json()["article"]["id"]
revision = self.client.post(
f"/api/v1/support/portals/{portal_id}/articles/{article_id}/revisions/",
{
"title": "Доставка",
"summary": "Сроки",
"content": f"![кот](https://help.example.com:8443{path})",
},
format="json",
)
self.assertEqual(revision.status_code, 201, revision.content)
self.assertEqual(revision.json()["revision"]["content"], f"![кот]({path})")
def test_revision_keeps_its_author(self) -> None:
# Рейка версий показывает автора редакции (кадр PT7).
portal_id = self.create_portal().json()["portal"]["id"]
category = self.client.post(
f"/api/v1/support/portals/{portal_id}/categories/",
{"name": "Аккаунт"},
format="json",
).json()["category"]
article_id = self.client.post(
f"/api/v1/support/portals/{portal_id}/articles/",
{
"categoryId": category["id"],
"slug": "sign-in",
"title": "Вход",
"summary": "",
"content": "Версия 1",
},
format="json",
).json()["article"]["id"]
revision = self.client.post(
f"/api/v1/support/portals/{portal_id}/articles/{article_id}/revisions/",
{"title": "Вход", "summary": "", "content": "Версия 2"},
format="json",
)
self.assertEqual(revision.status_code, 201, revision.content)
self.assertTrue(revision.json()["revision"]["authorName"])
def test_archived_portal_rejects_every_content_mutation(self) -> None:
portal_id = self.create_portal().json()["portal"]["id"]
category = self.client.post(
@@ -1,3 +1,4 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.utils import timezone
@@ -56,6 +57,8 @@ class PublicSupportPortalTests(TestCase):
},
format="json",
).json()["article"]
self.owner = result.owner
self.article_id = article["id"]
revision_id = article["revisions"][0]["id"]
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
@@ -148,6 +151,62 @@ class PublicSupportPortalTests(TestCase):
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.json()["portal"]["name"], "Acme Help")
def test_attachments_list_every_file_absent_from_the_text(self) -> None:
"""Картинка, вставленная в статью, видна в тексте; всё остальное —
вложение под статьёй, независимо от типа файла: посетитель должен
как-то добраться до прикреплённого PDF и до прикреплённой картинки.
"""
self.client.force_authenticate(self.owner)
published = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/status/",
{"status": "PUBLISHED"},
format="json",
)
self.assertEqual(published.status_code, 200, published.content)
article_id = self.article_id
inserted = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/files/",
{"file": SimpleUploadedFile("scheme.png", b"png-bytes", content_type="image/png")},
format="multipart",
).json()["file"]
attached_image = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/files/",
{"file": SimpleUploadedFile("logo.svg", b"<svg/>", content_type="image/svg+xml")},
format="multipart",
).json()["file"]
attached_doc = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/files/",
{"file": SimpleUploadedFile("price.pdf", b"%PDF-", content_type="application/pdf")},
format="multipart",
).json()["file"]
revision = self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/revisions/",
{
"title": "Первые шаги",
"summary": "Начало работы с продуктом",
"content": "Схема: ![схема](" + inserted["path"] + ")",
},
format="json",
).json()["revision"]
self.client.post(
f"/api/v1/support/portals/{self.portal_id}/articles/{article_id}/publish/",
{"revisionId": revision["id"]},
format="json",
)
self.client.logout()
response = self.client.get(
"/api/v1/help/articles/first-steps/", HTTP_HOST=self.portal_host
)
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()["article"]
self.assertIn(inserted["path"], payload["revision"]["content"])
self.assertEqual(
sorted(item["name"] for item in payload["attachments"]),
sorted([attached_doc["name"], attached_image["name"]]),
)
@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
custom_domain = "help.app.example"
@@ -15,6 +15,8 @@ urlpatterns = [
path("<int:portal_id>/articles/", content_views.ArticleListView.as_view(), name="support-portal-articles"),
path("<int:portal_id>/articles/import/", article_import_views.ArticleImportView.as_view(), name="support-portal-article-import"),
path("<int:portal_id>/articles/<int:article_id>/", content_views.ArticleDetailView.as_view(), name="support-portal-article"),
path("<int:portal_id>/articles/<int:article_id>/files/", content_views.ArticleFileListView.as_view(), name="support-portal-article-files"),
path("<int:portal_id>/articles/<int:article_id>/files/<int:file_id>/", content_views.ArticleFileDetailView.as_view(), name="support-portal-article-file-detail"),
path("<int:portal_id>/articles/<int:article_id>/revisions/", content_views.ArticleRevisionListView.as_view(), name="support-portal-article-revisions"),
path("<int:portal_id>/articles/<int:article_id>/publish/", content_views.ArticlePublishView.as_view(), name="support-portal-article-publish"),
path("<int:portal_id>/articles/<int:article_id>/archive/", content_views.ArticleArchiveView.as_view(), name="support-portal-article-archive"),
@@ -63,6 +63,10 @@ def attachment_route(public_id: str) -> IngressRoute | None:
return _unique_route("attachment_directory", public_id)
def portal_article_file_route(public_id: str) -> IngressRoute | None:
return _unique_route("portal_article_file_directory", public_id)
def call_invite_route(token_hash: str) -> IngressRoute | None:
return _unique_route("call_invite_directory", token_hash)
@@ -0,0 +1,74 @@
# Файлы статей портала (support_portals_portalarticlefile): tenant-таблица с
# organization_id, RLS и триггером связи со статьёй по образцу tenancy/0010.
# Скачивание публичное по непредсказуемому UUID, поэтому строка попадает и в
# ingress-директорию — платформенная роль резолвит организацию по public_id.
from django.db import migrations
TABLE = "support_portals_portalarticlefile"
FORWARD_RLS = f"""
ALTER TABLE {TABLE} OWNER TO chatballs_schema;
ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY;
REVOKE ALL ON TABLE {TABLE} FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {TABLE} TO chatballs_runtime_app;
GRANT ALL ON TABLE {TABLE} TO chatballs_schema;
GRANT USAGE, SELECT ON SEQUENCE {TABLE}_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform, chatballs_schema;
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
CREATE POLICY chatballs_tenant_isolation ON {TABLE}
FOR ALL TO chatballs_runtime_app
USING (organization_id = chatballs.current_organization_id())
WITH CHECK (organization_id = chatballs.current_organization_id());
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
CREATE POLICY chatballs_schema_access ON {TABLE}
FOR ALL TO chatballs_schema USING (true) WITH CHECK (true);
DROP TRIGGER IF EXISTS sp_article_file_article ON {TABLE};
CREATE CONSTRAINT TRIGGER sp_article_file_article
AFTER INSERT OR UPDATE ON {TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_fk('support_portals_portalarticle', 'article_id');
DROP TRIGGER IF EXISTS sp_article_file_user ON {TABLE};
CREATE CONSTRAINT TRIGGER sp_article_file_user
AFTER INSERT OR UPDATE ON {TABLE}
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_user('uploaded_by_id');
DROP TRIGGER IF EXISTS sp_revision_user ON support_portals_portalarticlerevision;
CREATE CONSTRAINT TRIGGER sp_revision_user
AFTER INSERT OR UPDATE ON support_portals_portalarticlerevision
DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION
chatballs.enforce_tenant_user('created_by_id');
CREATE OR REPLACE VIEW chatballs.portal_article_file_directory
WITH (security_barrier = true) AS
SELECT article_file.id AS resource_id,
article_file.organization_id,
article_file.public_id::text AS lookup_key
FROM {TABLE} article_file;
ALTER VIEW chatballs.portal_article_file_directory OWNER TO chatballs_schema;
REVOKE ALL ON chatballs.portal_article_file_directory FROM PUBLIC;
GRANT SELECT ON chatballs.portal_article_file_directory TO chatballs_runtime_platform;
"""
REVERSE_RLS = f"""
DROP VIEW IF EXISTS chatballs.portal_article_file_directory;
DROP TRIGGER IF EXISTS sp_revision_user ON support_portals_portalarticlerevision;
DROP TRIGGER IF EXISTS sp_article_file_user ON {TABLE};
DROP TRIGGER IF EXISTS sp_article_file_article ON {TABLE};
DROP POLICY IF EXISTS chatballs_tenant_isolation ON {TABLE};
DROP POLICY IF EXISTS chatballs_schema_access ON {TABLE};
ALTER TABLE {TABLE} NO FORCE ROW LEVEL SECURITY;
ALTER TABLE {TABLE} DISABLE ROW LEVEL SECURITY;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0026_contact_merge_rls"),
("support_portals", "0010_article_files"),
]
operations = [migrations.RunSQL(FORWARD_RLS, REVERSE_RLS)]
@@ -0,0 +1,25 @@
# Настройки установки (identity_instancesettings): одна строка на инсталляцию,
# не тенантная таблица, без RLS — как tenancy/0025 для настроек хранилища.
# Мастер первого запуска пишет её на платформенном соединении, приложение читает.
from django.db import migrations
GRANTS = """
GRANT SELECT, INSERT, UPDATE ON identity_instancesettings
TO chatballs_runtime_app, chatballs_runtime_platform;
GRANT USAGE, SELECT ON SEQUENCE identity_instancesettings_id_seq
TO chatballs_runtime_app, chatballs_runtime_platform;
"""
REVOKE = """
REVOKE ALL ON identity_instancesettings
FROM chatballs_runtime_app, chatballs_runtime_platform;
"""
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0027_portal_article_file_guards"),
("identity", "0027_instance_settings"),
]
operations = [migrations.RunSQL(GRANTS, REVOKE)]
@@ -12,6 +12,8 @@
from __future__ import annotations
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.storage import FileSystemStorage, Storage
@@ -19,6 +21,8 @@ from django.utils.deconstruct import deconstructible
from chatballs.tenancy.database import current_tenant_id
logger = logging.getLogger(__name__)
def _assert_tenant_key(name: str) -> None:
organization_id = current_tenant_id()
@@ -84,15 +88,31 @@ class _DynamicStorage(Storage):
return self._s3(config)
return None
def _secondary_has(self, name: str) -> object | None:
"""Второе хранилище, если файл лежит там.
Второе хранилище — необязательный запасной путь для файлов, оставшихся
после переключения бэкенда. Оно может быть недоступно (выключенный
MinIO, устаревшие реквизиты, сеть), и это не должно ронять операцию над
активным хранилищем: недоступное второе считаем пустым и пишем в лог.
"""
secondary = self._secondary()
if secondary is None:
return None
try:
return secondary if secondary.exists(name) else None
except Exception as error: # boto/сетевые ошибки разнообразны
logger.warning(
"secondary storage unavailable, ignoring: %s", error, exc_info=False
)
return None
def _reader_for(self, name: str):
"""Хранилище, где файл реально лежит: активное, иначе второе."""
active = self._active()
if active.exists(name):
return active
secondary = self._secondary()
if secondary is not None and secondary.exists(name):
return secondary
return active
return self._secondary_has(name) or active
# --- Storage API ---------------------------------------------------------
def _open(self, name, mode="rb"):
@@ -111,16 +131,23 @@ class _DynamicStorage(Storage):
def delete(self, name):
self._guard(name)
for storage in (self._active(), self._secondary()):
if storage is not None and storage.exists(name):
storage.delete(name)
active = self._active()
if active.exists(name):
active.delete(name)
secondary = self._secondary_has(name)
if secondary is not None:
try:
secondary.delete(name)
except Exception as error:
logger.warning(
"secondary storage delete failed, ignoring: %s", error, exc_info=False
)
def exists(self, name):
self._guard(name)
if self._active().exists(name):
return True
secondary = self._secondary()
return secondary is not None and secondary.exists(name)
return self._secondary_has(name) is not None
def size(self, name):
self._guard(name)
@@ -86,6 +86,44 @@ class StorageSettingsTests(TestCase):
storage.delete(self.key)
self.assertFalse(storage.exists(self.key))
def test_unreachable_secondary_storage_does_not_break_local_writes(self) -> None:
"""Недоступное второе хранилище не должно ронять работу с активным.
Реквизиты S3 остаются в строке настроек и после возврата на локальный
диск (чтобы дочитать файлы из бакета). Если бакет недоступен —
выключенный MinIO, устаревшие ключи, — перезапись и удаление файла на
локальном диске обязаны продолжать работать.
"""
row = ss.StorageSettings.load()
row.backend = ss.StorageBackend.LOCAL
row.s3_bucket = "demo"
row.s3_access_key = "AKIA-demo-access"
row.s3_secret_key = "very-secret"
row.save()
DynamicTenantStorage._s3_cache = None
class _DeadS3:
def exists(self, name):
raise OSError("Could not connect to the endpoint URL")
def delete(self, name):
raise OSError("Could not connect to the endpoint URL")
patcher = mock.patch.object(ss, "build_s3_storage", lambda *args, **kwargs: _DeadS3())
patcher.start()
self.addCleanup(patcher.stop)
with tenant_atomic(self.organization.id):
storage = storages["default"]
storage.save(self.key, ContentFile(b"local"))
self.assertTrue(storage.exists(self.key))
# Перезапись одноимённого файла: сначала удаление, потом запись.
storage.delete(self.key)
self.assertFalse(storage.exists(self.key))
storage.save(self.key, ContentFile(b"replaced"))
with storage.open(self.key) as handle:
self.assertEqual(handle.read(), b"replaced")
def test_secrets_are_encrypted_at_rest_and_masked_in_api(self) -> None:
self._enable_s3()
from django.db import connection
+11 -5
View File
@@ -1,8 +1,6 @@
# ruff: noqa: F403,F405
import os
from django.core.exceptions import ImproperlyConfigured
from chatballs_backend.settings_base import *
CHATBALLS_RUNTIME_SURFACE = "app"
@@ -10,9 +8,9 @@ ROOT_URLCONF = "chatballs_backend.urls_app"
ASGI_APPLICATION = "chatballs_backend.asgi_app.application"
WSGI_APPLICATION = "chatballs_backend.wsgi_app.application"
_app_hosts = os.environ.get("CHATBALLS_APP_ALLOWED_HOSTS", "")
if not DEBUG and not TESTING and not _app_hosts:
raise ImproperlyConfigured("CHATBALLS_APP_ALLOWED_HOSTS is required for the app surface")
# Домены установки задаются в UI (мастер первого запуска), а не переменной
# окружения: коробка поднимается одной командой и до настройки отвечает на
# локальные адреса. Проверку хоста несёт middleware/список ниже.
CHATBALLS_APP_PRIMARY_HOSTS = env_list(
"CHATBALLS_APP_ALLOWED_HOSTS",
env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]),
@@ -42,6 +40,14 @@ CSRF_COOKIE_NAME = os.environ.get(
"CHATBALLS_APP_CSRF_COOKIE_NAME",
"__Host-chatballs-app-csrf" if CSRF_COOKIE_SECURE else "chatballs_app_csrftoken",
)
# Имя cookie по http и его защищённая пара для запросов по TLS.
# Переключает TlsAwareCookieMiddleware по факту протокола запроса — тем же
# правилом, что и фронтенд (api/client.ts).
CHATBALLS_TLS_COOKIE_NAMES = {
SESSION_COOKIE_NAME: "__Host-chatballs-app-session",
CSRF_COOKIE_NAME: "__Host-chatballs-app-csrf",
}
SESSION_COOKIE_DOMAIN = None
CSRF_COOKIE_DOMAIN = None
SESSION_COOKIE_PATH = "/"
+31 -11
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from chatballs_backend.settings_database import build_databases
from chatballs_backend.settings_env import env_bool, env_list
from chatballs_backend.settings_env import env_bool, env_list, env_secret
from chatballs_backend.settings_storage import build_storage_settings
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -16,11 +16,16 @@ INSECURE_SECRET_KEY = "local-development-only"
# Автоопределение тестового прогона, чтобы manage.py test / pytest работали
# без ручного выставления production-окружения.
TESTING = "test" in sys.argv or "pytest" in sys.modules
SECRET_KEY = os.environ.get("CHATBALLS_SECRET_KEY", INSECURE_SECRET_KEY)
# Ключ подписи не задаётся человеком: его генерирует первый старт стека в том
# с секретами (deploy/secrets). Переменная окружения остаётся как переопределение
# для установок, которые ведут конфигурацию сами.
SECRET_KEY = env_secret("CHATBALLS_SECRET_KEY", "secret_key", INSECURE_SECRET_KEY)
DEBUG = env_bool("CHATBALLS_DEBUG")
# Режим поставки — свойство установки, а не переменной окружения: коробку
# ставят self-hosted, облако выставляет режим явно.
_delivery_mode = os.environ.get("CHATBALLS_DELIVERY_MODE", "").strip().upper()
if not _delivery_mode and (DEBUG or TESTING):
_delivery_mode = "CLOUD"
if not _delivery_mode:
_delivery_mode = "CLOUD" if (DEBUG or TESTING) else "SELF_HOSTED"
if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
raise ImproperlyConfigured(
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
@@ -31,10 +36,12 @@ if TESTING:
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", [])
# Запрещаем запуск в production с дефолтным/пустым ключом подписи.
# Слабый ключ подписи в production недопустим. При пустом томе секретов это
# означает сломанную установку, а не забытую человеком переменную.
if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}:
raise ImproperlyConfigured(
"CHATBALLS_SECRET_KEY must be set to a strong value when CHATBALLS_DEBUG is disabled"
"Не удалось прочитать ключ подписи инстанса: том с секретами пуст или "
"недоступен (deploy/secrets/generate-instance-secrets.sh)"
)
INSTALLED_APPS = [
@@ -67,6 +74,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"chatballs.http.middleware.TlsAwareCookieMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"chatballs.http.middleware.ContentSecurityPolicyMiddleware",
"chatballs.http.middleware.LocalCorsMiddleware",
@@ -182,16 +190,25 @@ CHATBALLS_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("CHATBALLS_MESSENG
# Password reset link lifetime. UI обещает 30 минут (default_token_generator uses this setting).
PASSWORD_RESET_TIMEOUT = int(os.environ.get("PASSWORD_RESET_TIMEOUT", str(30 * 60)))
# Транспорт и cookie. По умолчанию безопасно вне DEBUG; локальная разработка и тесты не ломаются.
# Транспорт и cookie.
#
# Коробку ставят одной командой и первый раз открывают по http — по адресу
# сервера, когда домена и сертификата ещё нет. Поэтому жёсткость транспорта
# не включается настройкой «вне DEBUG»: редирект на https делает шлюз, когда
# у него реально есть сертификат, а Secure-cookie и префикс __Host- ставит
# TlsAwareCookieMiddleware по факту TLS у конкретного запроса. Так установка
# работает сразу и ужесточается сама, как только перед ней появляется TLS.
_secure_default = not DEBUG and not TESTING
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = os.environ.get("CHATBALLS_COOKIE_SAMESITE", "Lax")
CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE
SESSION_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", _secure_default)
CSRF_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", _secure_default)
SECURE_SSL_REDIRECT = env_bool("CHATBALLS_SSL_REDIRECT", _secure_default)
SESSION_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", False)
CSRF_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", False)
SECURE_SSL_REDIRECT = env_bool("CHATBALLS_SSL_REDIRECT", False)
# HSTS Django отдаёт только на запросах, пришедших по TLS, поэтому установка
# на голом http его не получает и не «залипает» на несуществующий https.
SECURE_HSTS_SECONDS = int(
os.environ.get("CHATBALLS_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0")
)
@@ -269,8 +286,11 @@ if (
CHATBALLS_CALL_STUN_URLS = env_list("CHATBALLS_CALL_STUN_URLS", [])
# TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials
# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE.
# Адреса TURN владелец задаёт в «Настройках» (там же, где адрес установки);
# переменная остаётся переопределением для установок, ведущих конфигурацию сами.
CHATBALLS_CALL_TURN_URLS = env_list("CHATBALLS_CALL_TURN_URLS", [])
CHATBALLS_CALL_TURN_SECRET = os.environ.get("CHATBALLS_CALL_TURN_SECRET", "")
# Общий с coturn секрет генерирует первый старт стека — человек его не вводит.
CHATBALLS_CALL_TURN_SECRET = env_secret("CHATBALLS_CALL_TURN_SECRET", "turn_secret", "")
CHATBALLS_CALL_TURN_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_TURN_TTL_SECONDS", str(60 * 60)))
if CHATBALLS_CALL_TURN_TTL_SECONDS <= 0:
raise ImproperlyConfigured("CHATBALLS_CALL_TURN_TTL_SECONDS must be positive")
@@ -2,28 +2,30 @@ import os
␍
from django.core.exceptions import ImproperlyConfigured␍
␍
from chatballs_backend.settings_env import env_secret␍
␍
␍
def _credentials() -> tuple[dict[str, str], dict[str, str]]:␍
# Имена ролей — константы продукта, а не настройка установки: их создаёт␍
# deploy/postgres/init-runtime-roles.sh при первом старте. Переменные␍
# окружения остаются переопределением для нестандартных установок.␍
users = {␍
"app": os.environ.get(␍
"POSTGRES_APP_USER", os.environ.get("POSTGRES_USER", "chatballs")␍
),␍
"platform": os.environ.get(␍
"POSTGRES_PLATFORM_USER", os.environ.get("POSTGRES_USER", "chatballs")␍
),␍
"app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"),␍
"platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"),␍
"migration": os.environ.get(␍
"POSTGRES_MIGRATION_USER", os.environ.get("POSTGRES_USER", "chatballs")␍
"POSTGRES_MIGRATION_USER", "chatballs_migration"␍
),␍
}␍
# Пароли ролей генерирует первый старт стека в том с секретами; человек их␍
# не вводит и не хранит. Переменные окружения остаются переопределением.␍
fallback = env_secret("POSTGRES_PASSWORD", "postgres_password", "chatballs")␍
passwords = {␍
"app": os.environ.get(␍
"POSTGRES_APP_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")␍
"app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback),␍
"platform": env_secret(␍
"POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback␍
),␍
"platform": os.environ.get(␍
"POSTGRES_PLATFORM_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")␍
),␍
"migration": os.environ.get(␍
"POSTGRES_MIGRATION_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")␍
"migration": env_secret(␍
"POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback␍
),␍
}␍
return users, passwords␍
@@ -78,9 +80,14 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
"platform": config("platform"),␍
}␍
if testing:␍
databases["default"]["USER"] = os.environ.get("POSTGRES_USER", "chatballs")␍
databases["default"]["PASSWORD"] = os.environ.get(␍
"POSTGRES_PASSWORD", "chatballs"␍
# Тесты создают свою БД и подключаются владельцем кластера. Его пароль␍
# приходит оттуда же, откуда у остальных ролей: файл секрета инстанса,␍
# переменная окружения — переопределение.␍
databases["default"]["USER"] = os.environ.get(␍
"POSTGRES_USER", "chatballs_bootstrap"␍
)␍
databases["default"]["PASSWORD"] = env_secret(␍
"POSTGRES_PASSWORD", "postgres_password", "chatballs"␍
)␍
databases["platform"]["TEST"] = {"MIRROR": "default"}␍
return databases␍
@@ -1,4 +1,30 @@
import os
from pathlib import Path
# Секреты инстанса генерируются при первом старте и лежат файлами в томе
# (deploy/secrets/generate-instance-secrets.sh). В .env их нет — установка не
# требует от человека ни одной переменной.
SECRETS_DIR = Path(os.environ.get("CHATBALLS_SECRETS_DIR", "/run/chatballs/secrets"))
def env_secret(name: str, secret_file: str, default: str = "") -> str:
"""Значение из переменной окружения, иначе из файла секрета, иначе default.
Переменная окружения имеет приоритет: так существующие установки с .env
продолжают работать без изменений.
"""
value = os.environ.get(name)
if value:
return value
path_override = os.environ.get(f"{name}_FILE")
path = Path(path_override) if path_override else SECRETS_DIR / secret_file
try:
content = path.read_text(encoding="utf-8").strip()
except OSError:
return default
return content or default
def env_bool(name: str, default: bool = False) -> bool:
@@ -1,8 +1,6 @@
# ruff: noqa: F403,F405
import os
from django.core.exceptions import ImproperlyConfigured
from chatballs_backend.settings_base import *
CHATBALLS_RUNTIME_SURFACE = "platform"
@@ -10,11 +8,9 @@ ROOT_URLCONF = "chatballs_backend.urls_platform"
ASGI_APPLICATION = "chatballs_backend.asgi_platform.application"
WSGI_APPLICATION = "chatballs_backend.wsgi_platform.application"
_platform_hosts = os.environ.get("CHATBALLS_PLATFORM_ALLOWED_HOSTS", "")
if not DEBUG and not TESTING and not _platform_hosts:
raise ImproperlyConfigured(
"CHATBALLS_PLATFORM_ALLOWED_HOSTS is required for the platform surface"
)
# Домены установки задаются в UI (мастер первого запуска), а не переменной
# окружения: коробка поднимается одной командой и до настройки отвечает на
# локальные адреса. Проверку хоста несёт middleware/список ниже.
ALLOWED_HOSTS = env_list(
"CHATBALLS_PLATFORM_ALLOWED_HOSTS",
["localhost", "127.0.0.1", "platform.localhost"],
@@ -42,6 +38,14 @@ CSRF_COOKIE_NAME = os.environ.get(
if CSRF_COOKIE_SECURE
else "chatballs_platform_csrftoken",
)
# Имя cookie по http и его защищённая пара для запросов по TLS.
# Переключает TlsAwareCookieMiddleware по факту протокола запроса — тем же
# правилом, что и фронтенд (api/client.ts).
CHATBALLS_TLS_COOKIE_NAMES = {
SESSION_COOKIE_NAME: "__Host-chatballs-platform-session",
CSRF_COOKIE_NAME: "__Host-chatballs-platform-csrf",
}
SESSION_COOKIE_DOMAIN = None
CSRF_COOKIE_DOMAIN = None
SESSION_COOKIE_PATH = "/"
+2 -2
View File
@@ -84,7 +84,7 @@ export function App() {
if (activeUser && !initialRoute.organizationPublicId) {
const nextPath = pathFromRoute(
initialRoute.route,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.settingsSection,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.portalSettingsSection || initialRoute.settingsSection,
initialRoute.productCode,
activeUser.organizationPublicId,
);
@@ -170,7 +170,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedProductCode={navigation.selectedProductCode} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentCreateRoute={() => navigate("agents")} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
)}
</ConfigProvider>
);
+31 -9
View File
@@ -97,13 +97,35 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
return response.json() as Promise<T>;
}
// Multipart-загрузка (вложения знаний): Content-Type выставляет браузер (boundary).
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
const headers = new Headers({ Accept: "application/json", "X-CSRFToken": getCookie(CSRF_COOKIE_NAME) });
const response = await fetch(resolveApiUrl(path), { method: "POST", body: form, credentials: "include", headers });
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })) as ApiErrorPayload;
throw new ApiError(response.status, payload);
}
return response.json() as Promise<T>;
// Multipart-загрузка (вложения знаний, файлы статей портала): Content-Type
// выставляет браузер (boundary). На XHR, а не на fetch, потому что прогресс
// отправки нужен рейке файлов редактора статьи (кадр PT8) — у fetch его нет.
export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent: number) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("POST", resolveApiUrl(path));
request.withCredentials = true;
request.setRequestHeader("Accept", "application/json");
request.setRequestHeader("X-CSRFToken", getCookie(CSRF_COOKIE_NAME));
if (onProgress) {
request.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
};
}
request.onerror = () => reject(new ApiError(0, { detail: "Ошибка сети" }));
request.onload = () => {
let payload: unknown = null;
try {
payload = request.responseText ? JSON.parse(request.responseText) : null;
} catch {
payload = null;
}
if (request.status >= 200 && request.status < 300) {
resolve(payload as T);
return;
}
reject(new ApiError(request.status, (payload ?? { detail: "Ошибка запроса" }) as ApiErrorPayload));
};
request.send(form);
});
}
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
import { fetchHelpArticle, sendArticleFeedback } from "./api";
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
import { HelpLayout } from "./HelpLayout";
import { HelpAttachments } from "./HelpAttachments";
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
@@ -94,6 +95,7 @@ export function HelpArticle({
</time>
</header>
<MarkdownContent content={article.revision.content ?? ""} />
<HelpAttachments attachments={article.attachments ?? []} />
<section className="help-feedback">
{feedback === "sent" ? (
<div className="help-feedback-thanks">
@@ -0,0 +1,67 @@
import type { HelpAttachment } from "./types";
// Вложения статьи портала: картинки вставлены в текст самой статьёй, а
// документы посетитель скачивает списком под ней — с иконкой формата и
// размером, чтобы было понятно, что открываешь.
const EXTENSION_LABEL: Record<string, string> = {
pdf: "PDF",
doc: "DOC",
docx: "DOC",
xls: "XLS",
xlsx: "XLS",
csv: "CSV",
ppt: "PPT",
pptx: "PPT",
txt: "TXT",
md: "MD",
zip: "ZIP",
rar: "ZIP",
"7z": "ZIP",
png: "PNG",
jpg: "JPG",
jpeg: "JPG",
webp: "WEBP",
gif: "GIF",
svg: "SVG",
avif: "AVIF",
mp4: "MP4",
mp3: "MP3",
};
function extensionOf(name: string): string {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
}
function formatLabel(name: string): string {
const extension = extensionOf(name);
return EXTENSION_LABEL[extension] ?? (extension ? extension.toUpperCase().slice(0, 4) : "ФАЙЛ");
}
function readableSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} МБ`;
return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
}
export function HelpAttachments({ attachments }: { attachments: HelpAttachment[] }) {
if (attachments.length === 0) return null;
return (
<section className="help-attachments">
<h2>Файлы к статье</h2>
<ul>
{attachments.map((attachment) => (
<li key={attachment.path}>
<a download href={attachment.path}>
<i data-format={formatLabel(attachment.name)}>{formatLabel(attachment.name)}</i>
<span>
<strong>{attachment.name}</strong>
<small>{readableSize(attachment.size)}</small>
</span>
</a>
</li>
))}
</ul>
</section>
);
}
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Loader } from "@chatballs/ui";
import { fetchHelpManifest } from "./api";
import { HelpArticle } from "./HelpArticle";
import { HelpHome } from "./HelpHome";
@@ -51,7 +53,7 @@ export function HelpCenterApp() {
);
}
if (!manifest || !themeReady) {
return <main className="help-boot-loading"><i /><i /><i /></main>;
return <main className="help-boot-loading"><Loader size={44} /></main>;
}
return route.articleSlug
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
@@ -310,3 +310,66 @@
.help-article-error a {
color: var(--help-text);
}
/* Файлы к статье: картинки живут в тексте, документы — списком под статьёй.
Иконка — метка формата, чтобы посетитель понимал, что скачивает. */
.help-attachments {
margin-top: var(--help-space-lg, 28px);
}
.help-attachments h2 {
margin: 0 0 12px;
font-size: 1rem;
}
.help-attachments ul {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.help-attachments a {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--help-border, rgba(0, 0, 0, 0.12));
border-radius: var(--help-radius-md, 10px);
color: inherit;
text-decoration: none;
}
.help-attachments a:hover {
border-color: var(--help-accent, currentcolor);
}
.help-attachments i {
display: flex;
width: 38px;
height: 38px;
flex: none;
align-items: center;
justify-content: center;
border-radius: var(--help-radius-xs, 6px);
background: var(--help-surface-muted, rgba(0, 0, 0, 0.05));
font-size: 11px;
font-style: normal;
font-weight: 700;
letter-spacing: 0.02em;
}
.help-attachments strong {
display: block;
overflow-wrap: anywhere;
font-size: 0.95rem;
font-weight: 600;
}
.help-attachments small {
display: block;
margin-top: 2px;
font-size: 0.8rem;
opacity: 0.7;
}
@@ -275,22 +275,3 @@ html.help-center-document body {
color: var(--help-text-muted);
}
.help-boot-loading {
grid-template-columns: repeat(3, 8px);
}
.help-boot-loading i {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--help-ink);
animation: helpPulse 900ms ease-in-out infinite alternate;
}
.help-boot-loading i:nth-child(2) { animation-delay: 150ms; }
.help-boot-loading i:nth-child(3) { animation-delay: 300ms; }
@keyframes helpPulse {
from { opacity: 0.2; transform: translateY(2px); }
to { opacity: 1; transform: translateY(-2px); }
}
@@ -14,7 +14,8 @@
**Имя папки обязано совпадать с `id` в манифесте** — это проверяет тест.
2. Заполните `manifest.ts`: `name` и `description` видны владельцу портала в
настройках, `schemes` перечисляет поддерживаемые цветовые схемы, `preview` —
три цвета для превью.
три цвета для превью (`bg`/`ink`/`accent`) и необязательный `radius`
скругления плиток на мини-превью.
3. Переопределите нужные токены в `theme.css`.
4. Выберите тему в карточке портала: `Настройки → Оформление`.
@@ -5,5 +5,5 @@ export const manifest: PortalThemeManifest = {
name: "Классическая",
description: "Базовое оформление Help Center: светлая подложка, чернильный акцент.",
schemes: ["light", "dark"],
preview: { bg: "#ffffff", ink: "#1f1f1f", accent: "#1f1f1f" },
preview: { bg: "#ffffff", ink: "#1f1f1f", accent: "#1f1f1f", radius: "5px" },
};
@@ -5,5 +5,5 @@ export const manifest: PortalThemeManifest = {
name: "Ocean",
description: "Мягкое океаническое оформление: синий акцент, крупные скругления.",
schemes: ["light", "dark"],
preview: { bg: "#ffffff", ink: "#10283d", accent: "#1b5e8f" },
preview: { bg: "#ffffff", ink: "#10283d", accent: "#1b5e8f", radius: "9px" },
};
@@ -15,7 +15,8 @@ export type PortalThemeManifest = {
// Схемы, которые тема реально поддерживает. Первая — основная: на неё
// деградирует портал, если сохранённая схема темой не поддержана.
schemes: PortalThemeScheme[];
// Три цвета для превью в настройках портала: подложка, текст, акцент.
preview: { bg: string; ink: string; accent: string };
// Превью в настройках портала (кадр PT6): подложка, текст, акцент и
// скругление плиток — тем же радиусом, каким тема скругляет карточки.
preview: { bg: string; ink: string; accent: string; radius?: string };
author?: string;
};
@@ -29,12 +29,21 @@ export type HelpRevision = {
publishedAt: string | null;
};
export type HelpAttachment = {
name: string;
path: string;
size: number;
contentType: string;
};
export type HelpArticle = {
slug: string;
locale: string;
category: HelpCategory;
revision: HelpRevision;
updatedAt: string;
// Файлы статьи: картинки вставлены в текст, остальное посетитель скачивает.
attachments?: HelpAttachment[];
};
export type HelpPortal = {
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../api/client";
import { Icon, LogoSpinner } from "../../shared/icons";
import type { DemoState } from "./DemoDataCard";
// Полоса поверх рабочей области, пока worker ставит демо-данные. Без неё
// человек, поставивший галочку в мастере, видит пустую систему и не понимает,
// что установка ещё идёт (запись в outbox, разбор — секунды, но не мгновение).
const POLL_INTERVAL_MS = 2000;
export function DemoInstallBanner({ reload }: { reload: () => void }) {
const [status, setStatus] = useState<DemoState["status"] | null>(null);
const [dismissed, setDismissed] = useState(false);
const wasInstalling = useRef(false);
const load = useCallback(async () => {
try {
const state = await api<DemoState>("/api/v1/company/demo/");
setStatus(state.status);
} catch {
// Нет прав или сеть моргнула — полоса просто не показывается.
setStatus("ABSENT");
}
}, []);
useEffect(() => {
void load();
}, [load]);
const installing = status === "INSTALLING";
useEffect(() => {
if (!installing) return undefined;
wasInstalling.current = true;
const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [installing, load]);
// Установка закончилась — подтянуть сотрудников, группы и агентов сразу.
useEffect(() => {
if (status === "INSTALLED" && wasInstalling.current) reload();
}, [reload, status]);
if (dismissed) return null;
if (installing) {
return (
<div className="demo-install-banner">
<LogoSpinner size={17} />
<span>Демо-данные устанавливаются — разделы наполнятся через несколько секунд.</span>
</div>
);
}
if (status === "INSTALLED" && wasInstalling.current) {
return (
<div className="demo-install-banner is-done">
<Icon name="check" size={15} strokeWidth={2.2} />
<span>Демо-данные готовы.</span>
<button type="button" onClick={() => window.location.reload()}>Показать</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(true)} aria-label="Скрыть">
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
);
}
return null;
}
@@ -0,0 +1,311 @@
import { useEffect, useState, type FormEvent } from "react";
import { api } from "../../api/client";
import { FormField, TextAreaField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
// «Адрес установки и почта» (Настройки). Адрес запомнил мастер первого запуска —
// по нему человек и зашёл, поднимая докер на сервере; здесь его меняют, когда
// завели домен и поставили TLS. Из него строятся внешние ссылки: вложения знаний
// и файлы статей уходят клиенту в мессенджер, и «localhost» там мёртв.
// Почта — тем же местом: без неё нельзя ни пригласить сотрудника, ни сбросить
// пароль, а задавать её в переменных окружения больше негде.
type EmailPayload = {
host: string;
port: number;
user: string;
hasPassword: boolean;
useTls: boolean;
from: string;
configured: boolean;
};
type InstancePayload = {
publicHost: string;
publicScheme: "http" | "https";
publicUrl: string;
updatedAt: string | null;
email: EmailPayload;
turn: { urls: string[]; ttlSeconds: number; secretReady: boolean };
};
type EmailDraft = {
host: string;
port: string;
user: string;
password: string;
useTls: boolean;
from: string;
};
const BASE = "/api/v1/company/administration/instance/";
function emailDraftOf(email: EmailPayload): EmailDraft {
return {
host: email.host,
port: String(email.port || 587),
user: email.user,
password: "",
useTls: email.useTls,
from: email.from,
};
}
function errorMessage(error: unknown): { detail: string; errors: Record<string, string> } {
if (error && typeof error === "object" && "payload" in error) {
const payload = (error as { payload?: { detail?: string; errors?: Record<string, string> } }).payload;
return { detail: payload?.detail ?? "Не удалось сохранить", errors: payload?.errors ?? {} };
}
return { detail: error instanceof Error ? error.message : "Не удалось сохранить", errors: {} };
}
export function InstanceAddressCard({ canManage }: { canManage: boolean }) {
const [current, setCurrent] = useState<InstancePayload | null>(null);
const [host, setHost] = useState("");
const [scheme, setScheme] = useState<"http" | "https">("http");
const [email, setEmail] = useState<EmailDraft | null>(null);
const [turnUrls, setTurnUrls] = useState("");
const [turnTtl, setTurnTtl] = useState("3600");
const [busy, setBusy] = useState<"" | "save" | "check">("");
const [message, setMessage] = useState("");
const [errorText, setErrorText] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
function apply(payload: InstancePayload) {
setCurrent(payload);
setHost(payload.publicHost);
setScheme(payload.publicScheme);
setEmail(emailDraftOf(payload.email));
setTurnUrls(payload.turn.urls.join("\n"));
setTurnTtl(String(payload.turn.ttlSeconds || 3600));
}
useEffect(() => {
api<{ instance: InstancePayload }>(BASE)
.then((payload) => apply(payload.instance))
.catch(() => setErrorText("Не удалось загрузить настройки установки"));
}, []);
if (!current || !email) return null;
const touch = () => { setFieldErrors({}); setMessage(""); };
const setEmailField = (key: keyof EmailDraft) => (value: string) => {
setEmail({ ...email, [key]: value });
touch();
};
async function save() {
if (!email) return;
setBusy("save");
setMessage("");
setErrorText("");
setFieldErrors({});
try {
const payload = await api<{ instance: InstancePayload }>(BASE, {
method: "PATCH",
body: JSON.stringify({
publicHost: host,
publicScheme: scheme,
email: {
host: email.host,
port: Number(email.port) || 587,
user: email.user,
password: email.password,
useTls: email.useTls,
from: email.from,
},
turn: {
urls: turnUrls.split("\n").map((line) => line.trim()).filter(Boolean),
ttlSeconds: Number(turnTtl) || 3600,
},
}),
});
apply(payload.instance);
setMessage("Сохранено. Ссылки и письма пойдут по этим настройкам.");
} catch (error) {
const { detail, errors } = errorMessage(error);
setErrorText(detail);
setFieldErrors(errors);
} finally {
setBusy("");
}
}
async function check() {
setBusy("check");
setMessage("");
setErrorText("");
try {
const payload = await api<{ sent: string }>(`${BASE}email-check/`, { method: "POST", body: "{}" });
setMessage(`Письмо отправлено на ${payload.sent}. Не пришло — проверьте папку «Спам».`);
} catch (error) {
setErrorText(errorMessage(error).detail);
} finally {
setBusy("");
}
}
function submit(event: FormEvent) {
event.preventDefault();
void save();
}
return (
<form className="administration-card" onSubmit={submit}>
<div className="settings-storage-head">
<div>
<strong>Адрес установки</strong>
<small>По нему открывают систему и по нему строятся ссылки на файлы</small>
</div>
<div className="appearance-theme-options">
{(["http", "https"] as const).map((value) => (
<button
className={scheme === value ? "active" : ""}
disabled={!canManage || Boolean(busy)}
key={value}
type="button"
onClick={() => { setScheme(value); touch(); }}
>
{value}
</button>
))}
</div>
</div>
<p className="settings-section-note">
Пока домена нет, здесь стоит адрес сервера — тот, на котором вы прошли первый запуск.
Заведёте домен и поставите перед установкой TLS — впишите его и переключите на https.
</p>
<div className="administration-fields">
<FormField
disabled={!canManage}
error={fieldErrors.publicHost}
label="Домен или IP"
mono
placeholder="crm.example.com"
value={host}
wide
onChange={(value) => { setHost(value); touch(); }}
/>
</div>
<p className="settings-section-note">
Ссылки будут вида <b>{scheme}://{host || "адрес"}/…</b>
</p>
<div className="settings-storage-head">
<div>
<strong>Исходящая почта</strong>
<small>Приглашения сотрудникам и сброс пароля</small>
</div>
<div className="appearance-theme-options">
{([[true, "TLS"], [false, "без TLS"]] as Array<[boolean, string]>).map(([value, label]) => (
<button
className={email.useTls === value ? "active" : ""}
disabled={!canManage || Boolean(busy)}
key={label}
type="button"
onClick={() => { setEmail({ ...email, useTls: value }); touch(); }}
>
{label}
</button>
))}
</div>
</div>
{!current.email.configured && (
<p className="settings-section-note">
Почта не настроена: письма никуда не уходят, пригласить сотрудника не получится.
</p>
)}
<div className="administration-fields">
<FormField
disabled={!canManage}
error={fieldErrors.emailHost}
label="SMTP-сервер"
mono
placeholder="smtp.example.com"
value={email.host}
onChange={setEmailField("host")}
/>
<FormField
disabled={!canManage}
error={fieldErrors.emailPort}
label="Порт"
mono
placeholder="587"
value={email.port}
onChange={setEmailField("port")}
/>
<FormField
disabled={!canManage}
label="Пользователь"
mono
placeholder="robot@example.com"
value={email.user}
onChange={setEmailField("user")}
/>
<FormField
disabled={!canManage}
label="Пароль"
mono
placeholder={current.email.hasPassword ? "•••••••• (сохранён)" : ""}
type="password"
value={email.password}
onChange={setEmailField("password")}
/>
<FormField
disabled={!canManage}
label="Отправитель"
placeholder="Chatballs <no-reply@example.com>"
value={email.from}
wide
onChange={setEmailField("from")}
/>
</div>
<div className="settings-storage-head">
<div>
<strong>TURN для звонков</strong>
<small>Relay на случай, когда прямое соединение не проходит</small>
</div>
</div>
<p className="settings-section-note">
Нужен, только если звонки идут через сети со строгим NAT. Общий секрет с сервером
relay {current.turn.secretReady ? "уже создан установкой — вводить его не нужно." : "будет создан при следующем запуске стека."}
</p>
<div className="administration-fields">
<TextAreaField
label="Адреса TURN — по одному в строке"
value={turnUrls}
onChange={(value) => { setTurnUrls(value); touch(); }}
/>
<FormField
disabled={!canManage}
error={fieldErrors.turnTtlSeconds}
label="Время жизни доступа, секунд"
mono
value={turnTtl}
onChange={(value) => { setTurnTtl(value); touch(); }}
/>
</div>
{errorText && <div className="administration-message error" role="alert">{errorText}</div>}
{message && <div className="administration-message">{message}</div>}
{canManage && (
<div className="administration-actions">
<Button variant="primary" disabled={Boolean(busy)} type="submit">
{busy === "save" ? "Сохраняем…" : "Сохранить"}
</Button>
{current.email.configured && (
<Button variant="secondary" disabled={Boolean(busy)} onClick={() => void check()}>
{busy === "check" ? "Отправляем…" : "Отправить тестовое письмо"}
</Button>
)}
{current.updatedAt && (
<span className="settings-section-note">сохранено {shortDateTime(current.updatedAt)}</span>
)}
</div>
)}
</form>
);
}
@@ -14,6 +14,7 @@ import { Icon } from "../../shared/icons";
import { useMediaQuery } from "../../shared/useMediaQuery";
import { DemoDataCard } from "./DemoDataCard";
import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
import { InstanceAddressCard } from "./InstanceAddressCard";
import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections";
@@ -172,17 +173,22 @@ function OrganizationSection({ user, onUserUpdated }: { user: SessionUser; onUse
if (page.loading) return <LoadingState />;
if (!page.organization) return <EmptyState title={page.error || "Не удалось загрузить настройки"} />;
return (
<OrganizationSettingsForm
organization={page.organization}
canManage={canManageSettings(user)}
saving={page.saving}
message={page.message}
error={page.error}
timezones={page.timezones}
onChange={page.setOrganization}
onSave={() => void page.save()}
onUploadLogo={(file) => void page.uploadLogo(file)}
onRemoveLogo={() => void page.removeLogo()}
/>
<>
<OrganizationSettingsForm
organization={page.organization}
canManage={canManageSettings(user)}
saving={page.saving}
message={page.message}
error={page.error}
timezones={page.timezones}
onChange={page.setOrganization}
onSave={() => void page.save()}
onUploadLogo={(file) => void page.uploadLogo(file)}
onRemoveLogo={() => void page.removeLogo()}
/>
{/* Адрес установки — свойство инсталляции, а не организации, но живёт
рядом: в облаке он один на всех, в коробке организация одна. */}
<InstanceAddressCard canManage={canManageSettings(user)} />
</>
);
}
@@ -1,15 +1,36 @@
import { useEffect, useMemo, useState } from "react";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { Segmented } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import {
listPortalThemes,
resolvePortalTheme,
schemeOptions,
} from "../help-center/themes/registry";
import type { PortalThemeSchemeSetting } from "../help-center/themes/types";
import type { PortalThemeManifest, PortalThemeSchemeSetting } from "../help-center/themes/types";
import { portalErrorMessage, updateSupportPortal, type SupportPortal } from "./model";
// Кадр PT6: тема выбирается карточкой с мини-превью на цветах манифеста. Ни
// названия, ни описания, ни цвета в UI не придумываются — всё из манифеста
// темы (features/help-center/themes/*/manifest.ts).
function ThemePreview({ theme }: { theme: PortalThemeManifest }) {
const { bg, ink, accent, radius = "5px" } = theme.preview;
return (
<span className="portal-theme-preview" style={{ background: bg }}>
<span className="portal-theme-bar" style={{ background: ink }} />
<span className="portal-theme-line" style={{ background: ink, width: "80%" }} />
<span className="portal-theme-line" style={{ background: ink, width: "62%", marginBottom: 9 }} />
<span className="portal-theme-tiles">
<i style={{ background: accent, borderRadius: radius }} />
<i style={{ background: accent, borderRadius: radius }} />
</span>
</span>
);
}
export function PortalAppearanceSettings({
canManage,
portal,
@@ -23,6 +44,7 @@ export function PortalAppearanceSettings({
const [scheme, setScheme] = useState<PortalThemeSchemeSetting>(portal.themeScheme);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
const [savedAt, setSavedAt] = useState<string | null>(null);
useEffect(() => {
setTheme(portal.theme);
@@ -35,20 +57,15 @@ export function PortalAppearanceSettings({
const schemes = schemeOptions(selected);
// Схема, которую выбранная тема не поддерживает, не должна молча уехать в
// сохранение: показываем ближайшую поддерживаемую.
const effectiveScheme = schemes.some(([value]) => value === scheme)
? scheme
: schemes[0][0];
const effectiveScheme = schemes.some(([value]) => value === scheme) ? scheme : schemes[0][0];
async function save() {
setBusy(true);
setFeedback("");
try {
const payload = await updateSupportPortal(portal.id, {
theme,
themeScheme: effectiveScheme,
});
const payload = await updateSupportPortal(portal.id, { theme, themeScheme: effectiveScheme });
onChanged(payload.portal);
setFeedback("Оформление обновлено");
setSavedAt(payload.portal.updatedAt);
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить оформление"));
} finally {
@@ -57,40 +74,56 @@ export function PortalAppearanceSettings({
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div>
<h2>Оформление</h2>
<p>{selected.description || "Тема публичных страниц портала и её цветовая схема."}</p>
<div className="portal-settings-card">
<div>
<span className="portal-field-label">Тема</span>
<div className="portal-theme-grid">
{themes.map((item) => (
<button
className={`portal-theme-card${item.id === theme ? " is-selected" : ""}`}
disabled={!canManage}
key={item.id}
type="button"
onClick={() => setTheme(item.id)}
>
<ThemePreview theme={item} />
<span className="portal-theme-name">
<strong>{item.name}</strong>
{item.id === theme && <small>выбрана</small>}
</span>
<small className="portal-theme-desc">{item.description}</small>
</button>
))}
{!known && (
// Темы больше нет в сборке: показываем как есть, чтобы сохранение
// не подменило её молча.
<span className="portal-theme-card is-missing">
<span className="portal-theme-name"><strong>{theme}</strong><small>недоступна</small></span>
<small className="portal-theme-desc">Темы нет в этой сборке установки. Выберите другую, чтобы сохранить оформление.</small>
</span>
)}
</div>
</div>
<div className="portal-settings-fields">
<SelectField
disabled={!canManage}
label="Тема"
value={theme}
onChange={setTheme}
options={[
...themes.map((item): [string, string] => [item.id, item.name]),
// Тема, которой больше нет в сборке: показываем как есть, чтобы
// сохранение не подменило её молча.
...(known ? [] : [[theme, `${theme} — тема недоступна`] as [string, string]]),
]}
/>
<SelectField
disabled={!canManage}
label="Цветовая схема"
<div>
<span className="portal-field-label">Цветовая схема</span>
<Segmented
className="portal-scheme-segment"
items={schemes}
value={effectiveScheme}
onChange={(value) => setScheme(value as PortalThemeSchemeSetting)}
options={schemes}
setValue={(value) => setScheme(value)}
/>
</div>
{canManage && (
<Button variant="secondary" disabled={busy} onClick={() => void save()}>
Сохранить оформление
</Button>
)}
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
<div className="portal-settings-actions">
{canManage && <Button variant="primary" disabled={busy} onClick={() => void save()}>Сохранить оформление</Button>}
<a className="secondary-button" href={portal.publicUrl} rel="noreferrer" target="_blank">
<Icon name="eye" size={15} strokeWidth={1.9} />Предпросмотр портала
</a>
<span className="portal-settings-gap" />
{savedAt && <span className="portal-settings-note">сохранено {shortDateTime(savedAt)}</span>}
</div>
{feedback && <div className="portal-form-error">{feedback}</div>}
</div>
);
}
@@ -1,47 +1,95 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ContentEditorBreadcrumb } from "../../shared/content-library/ContentEditorBreadcrumb";
import { ContentEditorCard } from "../../shared/content-library/ContentEditorCard";
import { FormField, SelectField, TextAreaField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { Segmented } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { formatDate } from "../../shared/utils";
import { shortDateTime } from "../../shared/utils";
import { MarkdownContent } from "../help-center/MarkdownContent";
import { resolvePortalTheme } from "../help-center/themes/registry";
import {
addArticleRevision,
createPortalArticle,
deleteArticleFile,
loadPortalArticle,
portalErrorMessage,
updatePortalArticle,
uploadArticleFile,
type PortalArticle,
type PortalArticleFile,
type PortalCategory,
type SupportPortal,
} from "./model";
import {
applyMarkdownTool,
cursorPosition,
editorStats,
fileMarkdown,
fileMeta,
isImageFile,
MARKDOWN_TOOLS,
} from "./markdownTools";
import { LOCALE_OPTIONS } from "./portalText";
// Редактор статьи (дизайн-базлайн v2, кадры PT7/PT8): полноэкранный split —
// слева текст с панелью Markdown, в центре живой предпросмотр в теме портала,
// справа рейка с разделом, файлами и версиями. Модалок нет.
type Mode = "edit" | "split" | "view";
type UploadingFile = { name: string; percent: number };
const MODES: Array<[Mode, string, "edit" | "split" | "eye", string]> = [
["edit", "Текст", "edit", "Только редактор"],
["split", "Вместе", "split", "Текст и предпросмотр"],
["view", "Просмотр", "eye", "Только предпросмотр"],
];
function draftKey(portalId: number, articleId: number | null): string {
return `chatballs:portal-article-draft:${portalId}:${articleId ?? "new"}`;
}
export function PortalArticleEditor({
article,
canManage,
categories,
defaultLocale,
portal,
onClose,
onPublish,
onSaved,
portalId,
}: {
article: PortalArticle | null;
canManage: boolean;
categories: PortalCategory[];
defaultLocale: string;
portal: SupportPortal;
onClose: () => void;
onPublish: (article: PortalArticle, revisionId: number) => void;
onSaved: () => Promise<void>;
portalId: number;
}) {
const portalId = portal.id;
const [loaded, setLoaded] = useState<PortalArticle | null>(article);
const [mode, setMode] = useState<Mode>("split");
const [categoryId, setCategoryId] = useState(String(article?.category.id ?? categories[0]?.id ?? ""));
const [locale, setLocale] = useState(article?.locale ?? defaultLocale);
const [locale, setLocale] = useState(article?.locale ?? portal.defaultLocale);
const [slug, setSlug] = useState(article?.slug ?? "");
const [editingSlug, setEditingSlug] = useState(!article);
const [selectedRevisionId, setSelectedRevisionId] = useState<number | null>(null);
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [content, setContent] = useState("");
const [caret, setCaret] = useState(0);
const [files, setFiles] = useState<PortalArticleFile[]>([]);
const [uploading, setUploading] = useState<UploadingFile | null>(null);
const [dropping, setDropping] = useState(false);
const [railDropping, setRailDropping] = useState(false);
const [autoSavedAt, setAutoSavedAt] = useState<Date | null>(null);
// Снимок последней сохранённой редакции: по нему видно, есть ли правки,
// которых ещё нет ни в одной редакции — и, значит, нет на портале.
const [baseline, setBaseline] = useState<{ title: string; summary: string; content: string } | null>(null);
const [busy, setBusy] = useState(Boolean(article));
const [error, setError] = useState("");
const textRef = useRef<HTMLTextAreaElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!article) return;
@@ -49,43 +97,141 @@ export function PortalArticleEditor({
loadPortalArticle(portalId, article.id)
.then(({ article: detail }) => {
setLoaded(detail);
setFiles(detail.files ?? []);
const revision = detail.revisions?.[0] ?? detail.publishedRevision;
setSelectedRevisionId(revision?.id ?? null);
setTitle(revision?.title ?? "");
setSummary(revision?.summary ?? "");
setContent(revision?.content ?? "");
// Локальный автосейв поднимается поверх последней редакции: он новее.
const draft = window.localStorage.getItem(draftKey(portalId, detail.id));
setContent(draft ?? revision?.content ?? "");
setBaseline({
title: revision?.title ?? "",
summary: revision?.summary ?? "",
content: revision?.content ?? "",
});
})
.catch((caught) => setError(portalErrorMessage(caught, "Не удалось загрузить статью")))
.finally(() => setBusy(false));
}, [article, portalId]);
const revisions = useMemo(() => loaded?.revisions ?? [], [loaded]);
// Автосохранение черновика в браузере: статусная строка показывает время.
useEffect(() => {
if (!content) return undefined;
const timer = window.setTimeout(() => {
try {
window.localStorage.setItem(draftKey(portalId, loaded?.id ?? null), content);
setAutoSavedAt(new Date());
} catch {
// Приватный режим браузера: автосейв просто недоступен.
}
}, 1200);
return () => window.clearTimeout(timer);
}, [content, loaded?.id, portalId]);
function selectRevision(value: string) {
const revision = revisions.find((item) => item.id === Number(value));
const revisions = useMemo(() => loaded?.revisions ?? [], [loaded]);
const latestRevision = revisions[0] ?? null;
const publishedRevisionId = loaded?.publishedRevision?.id ?? null;
const category = categories.find((item) => item.id === Number(categoryId));
const theme = resolvePortalTheme(portal.theme);
function selectRevision(revisionId: number) {
const revision = revisions.find((item) => item.id === revisionId);
if (!revision) return;
setSelectedRevisionId(revision.id);
setTitle(revision.title);
setSummary(revision.summary);
setContent(revision.content);
setBaseline({ title: revision.title, summary: revision.summary, content: revision.content });
}
async function save() {
function applyEdit(next: { value: string; selectionStart: number; selectionEnd: number }) {
setContent(next.value);
window.requestAnimationFrame(() => {
const field = textRef.current;
if (!field) return;
field.focus();
field.setSelectionRange(next.selectionStart, next.selectionEnd);
setCaret(next.selectionStart);
});
}
function insertText(snippet: string) {
const field = textRef.current;
const start = field?.selectionStart ?? content.length;
const end = field?.selectionEnd ?? content.length;
applyEdit({
value: `${content.slice(0, start)}${snippet}${content.slice(end)}`,
selectionStart: start + snippet.length,
selectionEnd: start + snippet.length,
});
}
const upload = useCallback(async (list: FileList | File[], insert: boolean) => {
if (!loaded) {
setError("Сначала создайте статью — файлы прикрепляются к существующей статье");
return;
}
setError("");
for (const file of Array.from(list)) {
setUploading({ name: file.name, percent: 0 });
try {
const payload = await uploadArticleFile(portalId, loaded.id, file, (percent) => {
setUploading({ name: file.name, percent });
});
setFiles((current) => [payload.file, ...current.filter((item) => item.id !== payload.file.id)]);
// Вставляем в текст только по явной команде «изображение»: файл,
// прикреплённый к статье, в текст не лезет — он вложение.
if (insert && isImageFile(payload.file.contentType, payload.file.name)) {
insertText(`\n${fileMarkdown(payload.file.name, payload.file.path, true)}\n`);
}
} catch (caught) {
setError(portalErrorMessage(caught, `Не удалось загрузить ${file.name}`));
} finally {
setUploading(null);
}
}
// content нужен в зависимостях: вставка файла дописывает текущий текст.
}, [loaded, portalId, content]);
async function removeFile(file: PortalArticleFile) {
try {
if (loaded) await deleteArticleFile(portalId, loaded.id, file.id);
setFiles((current) => current.filter((item) => item.id !== file.id));
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось удалить файл"));
}
}
async function save(): Promise<{ article: PortalArticle; revisionId: number } | null> {
setBusy(true);
setError("");
try {
if (article) {
await updatePortalArticle(portalId, article.id, {
if (loaded) {
await updatePortalArticle(portalId, loaded.id, {
categoryId: Number(categoryId),
locale,
slug,
});
const payload = await addArticleRevision(portalId, article.id, { title, summary, content });
const detail = await loadPortalArticle(portalId, article.id);
const payload = await addArticleRevision(portalId, loaded.id, { title, summary, content });
const detail = await loadPortalArticle(portalId, loaded.id);
setLoaded(detail.article);
setFiles(detail.article.files ?? []);
setSelectedRevisionId(payload.revision.id);
window.localStorage.removeItem(draftKey(portalId, loaded.id));
// Бэкенд приводит ссылки на файлы к относительным — снимок и текст
// берутся из сохранённой редакции, иначе экран сразу считался бы
// изменённым.
setContent(payload.revision.content);
setBaseline({
title: payload.revision.title,
summary: payload.revision.summary,
content: payload.revision.content,
});
await onSaved();
return { article: detail.article, revisionId: payload.revision.id };
} else {
await createPortalArticle(portalId, {
const created = await createPortalArticle(portalId, {
categoryId: Number(categoryId),
slug,
locale,
@@ -93,73 +239,384 @@ export function PortalArticleEditor({
summary,
content,
});
window.localStorage.removeItem(draftKey(portalId, null));
setLoaded(created.article);
const createdRevisionId = created.article.revisions?.[0]?.id ?? null;
setSelectedRevisionId(createdRevisionId);
setEditingSlug(false);
const createdRevision = created.article.revisions?.[0];
if (createdRevision) {
setContent(createdRevision.content);
setBaseline({
title: createdRevision.title,
summary: createdRevision.summary,
content: createdRevision.content,
});
}
await onSaved();
return createdRevisionId === null ? null : { article: created.article, revisionId: createdRevisionId };
}
await onSaved();
if (!article) onClose();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось сохранить статью"));
return null;
} finally {
setBusy(false);
}
}
const ready = Boolean(title.trim() && content.trim() && (article || (categoryId && slug.trim())));
const categoryName = categories.find((item) => item.id === Number(categoryId))?.name;
const active = loaded?.status === "PUBLISHED";
// «Опубликовать» публикует то, что человек видит на экране: несохранённый
// текст сначала становится новой редакцией, иначе публиковалась бы старая
// и правка молча пропадала бы.
async function publish() {
if (!loaded) {
const created = await save();
if (created) onPublish(created.article, created.revisionId);
return;
}
// Сверяемся с сервером, а не с состоянием экрана: публикуется ровно то,
// что человек видит. Совпало с последней редакцией — публикуем её,
// иначе сначала сохраняем правки новой редакцией.
const latest = await loadPortalArticle(portalId, loaded.id)
.then(({ article: detail }) => detail.revisions?.[0] ?? null)
.catch(() => null);
if (latest && latest.title === title && latest.summary === summary && latest.content === content) {
onPublish(loaded, latest.id);
return;
}
const saved = await save();
if (saved) onPublish(saved.article, saved.revisionId);
}
const ready = Boolean(title.trim() && content.trim() && categoryId && slug.trim());
const draftPending = latestRevision !== null && latestRevision.id !== publishedRevisionId;
// Правки живут в браузере, пока не создана редакция: на портале их нет.
const dirty = baseline !== null
&& (baseline.title !== title || baseline.summary !== summary || baseline.content !== content);
const badge = loaded === null
? { tone: "draft", text: "Новая статья · не сохранена" }
: dirty
? { tone: "draft", text: "Есть несохранённые правки — их ещё нет на портале" }
: draftPending
? { tone: "draft", text: `Редакция ${latestRevision!.revision} · черновик` }
: { tone: "live", text: `Редакция ${loaded.publishedRevision?.revision ?? "—"} · опубликована` };
return (
<div className="knowledge-editor-page">
<ContentEditorBreadcrumb
backLabel="Материалы"
category={categoryName}
title={article?.latestRevision?.title ?? "Создание статьи"}
onBack={onClose}
/>
<div className="knowledge-editor-grid create">
<div className="knowledge-editor-main">
<ContentEditorCard
heading={<h3>{article ? "Статья" : "Новая статья"}</h3>}
meta={<><span className={`knowledge-card-status${active ? "" : " disabled"}`}><i />{active ? "Опубликована" : "Черновик"}</span>{loaded && <span className="knowledge-card-updated">обновлено {formatDate(loaded.updatedAt)}</span>}</>}
error={error}
actions={<>
<Button variant="primary" icon={article ? "save" : "plus"} disabled={busy || !ready} onClick={() => void save()}>
{article ? "Сохранить новую версию" : "Создать статью"}
</Button>
{article && selectedRevisionId && (
<Button variant="secondary" disabled={busy} onClick={() => onPublish(article, selectedRevisionId)}>
Опубликовать версию
</Button>
)}
</>}
<div className="portal-editor">
<div className="portal-editor-head">
<button className="portal-editor-back" type="button" onClick={onClose}>
<Icon name="chevron" size={16} strokeWidth={2} />Материалы
</button>
<span className="portal-editor-crumb">{category?.name ?? ""}</span>
<span className="portal-editor-gap" />
<span className={`portal-editor-badge is-${badge.tone}`}><i />{badge.text}</span>
<Segmented className="portal-editor-modes" items={MODES} value={mode} setValue={setMode} />
{canManage && (
<Button variant="secondary" className="portal-editor-save" disabled={busy || !ready} onClick={() => void save()}>
Сохранить черновик
</Button>
)}
{canManage && (
<Button
variant="primary"
className="portal-editor-publish"
icon="check"
disabled={busy || !ready}
onClick={() => void publish()}
>
<div className="knowledge-editor-fields">
<div className="knowledge-editor-scope-row">
<SelectField label="Раздел" value={categoryId} onChange={setCategoryId} options={categories.map((category) => [String(category.id), category.name])} />
<SelectField label="Язык" value={locale} onChange={setLocale} options={[["ru", "Русский"], ["en", "English"]]} />
Опубликовать
</Button>
)}
</div>
{error && <div className="portal-form-error portal-editor-error">{error}</div>}
<div className="portal-editor-body">
{mode !== "view" && (
<div className="portal-editor-column">
<div className="portal-editor-title">
<input
placeholder="Заголовок статьи"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<div className="portal-editor-slug">
<span>Адрес:</span>
{editingSlug
? <input
autoFocus
value={slug}
onBlur={() => loaded && setEditingSlug(false)}
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
/>
: <code>/articles/{slug}</code>}
{!editingSlug && <button type="button" onClick={() => setEditingSlug(true)}>изменить</button>}
</div>
<div className="knowledge-editor-scope-row">
<FormField label="Адрес статьи" value={slug} onChange={setSlug} mono wide />
{article && revisions.length > 0 && (
<SelectField
label="Версия"
value={String(selectedRevisionId ?? "")}
onChange={selectRevision}
options={revisions.map((revision) => [
String(revision.id),
`Версия ${revision.revision}${revision.id === article.publishedRevision?.id ? " · опубликована" : ""}`,
])}
/>
)}
</div>
<FormField label="Заголовок" value={title} onChange={setTitle} wide />
<TextAreaField label="Краткое описание" value={summary} onChange={setSummary} />
<label className="knowledge-content-field">
<span>Содержимое (Markdown)</span>
<textarea value={content} rows={10} onChange={(event) => setContent(event.target.value)} />
</label>
</div>
</ContentEditorCard>
</div>
<div className="portal-md-toolbar">
{MARKDOWN_TOOLS.map((tool) => (tool.divider
? <i className="portal-md-divider" key={tool.key} />
: (
<button
className={tool.accent ? "is-accent" : ""}
key={tool.key}
title={tool.title}
type="button"
onClick={() => {
if (tool.key === "image") {
imageInputRef.current?.click();
return;
}
if (tool.key === "attach") {
fileInputRef.current?.click();
return;
}
const field = textRef.current;
applyEdit(applyMarkdownTool(
tool.key,
content,
field?.selectionStart ?? content.length,
field?.selectionEnd ?? content.length,
));
}}
>
{tool.text && <span>{tool.text}</span>}
{tool.icon && <Icon name={tool.icon} size={15} strokeWidth={1.9} />}
</button>
)))}
<span className="portal-editor-gap" />
<span className="portal-md-format">GFM</span>
</div>
<div
className="portal-editor-text"
onDragEnter={(event) => { event.preventDefault(); setDropping(true); }}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => { if (event.currentTarget === event.target) setDropping(false); }}
onDrop={(event) => {
event.preventDefault();
setDropping(false);
if (event.dataTransfer.files.length) void upload(event.dataTransfer.files, true);
}}
>
<textarea
ref={textRef}
spellCheck
value={content}
onChange={(event) => { setContent(event.target.value); setCaret(event.target.selectionStart); }}
onKeyUp={(event) => setCaret(event.currentTarget.selectionStart)}
onClick={(event) => setCaret(event.currentTarget.selectionStart)}
/>
{dropping && (
<div className="portal-editor-drop">
<Icon name="upload" size={30} strokeWidth={1.8} />
<strong>Отпустите, чтобы добавить в статью</strong>
<span>Изображение встанет в текст, остальные файлы прикрепятся вложением к статье. PNG, JPG, WebP, PDF, DOCX · до 25 МБ</span>
</div>
)}
</div>
<div className="portal-editor-status">
<span>{editorStats(content)}</span>
<span>{cursorPosition(content, caret)}</span>
<span className="portal-editor-gap" />
{autoSavedAt && <span>Черновик сохранён автоматически в {autoSavedAt.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>}
</div>
</div>
)}
{mode !== "edit" && (
<div className="portal-preview-column">
<div className="portal-preview-head">
<span>ПРЕДПРОСМОТР · ТЕМА ПОРТАЛА {theme.name.toLocaleUpperCase()}</span>
<span className="portal-editor-gap" />
<a href={portal.publicUrl} rel="noreferrer" target="_blank" title="Открыть как посетитель">
<Icon name="external" size={13} strokeWidth={1.9} />Как у посетителя
</a>
</div>
<div className="portal-preview-body">
<article className="portal-preview-article">
<div className="portal-preview-crumbs">
<span>Помощь</span><span>/</span><span>{category?.name ?? ""}</span>
</div>
<h1>{title || "Без заголовка"}</h1>
{summary && <p className="portal-preview-lead">{summary}</p>}
<MarkdownContent content={content} />
</article>
</div>
</div>
)}
<aside className="portal-editor-rail">
<div className="portal-rail-fields">
<label>
<span>Раздел</span>
<span className="portal-select">
<select value={categoryId} onChange={(event) => setCategoryId(event.target.value)}>
{categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<label>
<span>Язык</span>
<span className="portal-select">
<select value={locale} onChange={(event) => setLocale(event.target.value)}>
{LOCALE_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Icon name="chevron" size={13} strokeWidth={2} />
</span>
</label>
<label>
<span>Краткое описание <small>· показывается в списке и поиске</small></span>
<textarea value={summary} onChange={(event) => setSummary(event.target.value)} />
</label>
</div>
<div className="portal-rail-block">
<div className="portal-rail-head">
<span>ФАЙЛЫ СТАТЬИ</span>
<button type="button" onClick={() => fileInputRef.current?.click()}>Загрузить</button>
</div>
{/* Два механизма и два поля выбора: скрепка и рейка прикрепляют
файл к статье, кнопка «изображение» ставит картинку в текст. */}
<input
ref={fileInputRef}
hidden
multiple
type="file"
onChange={(event) => {
if (event.target.files?.length) void upload(event.target.files, false);
event.target.value = "";
}}
/>
<input
ref={imageInputRef}
hidden
multiple
accept="image/*"
type="file"
onChange={(event) => {
if (event.target.files?.length) void upload(event.target.files, true);
event.target.value = "";
}}
/>
<div
className={`portal-rail-drop${railDropping ? " is-hot" : ""}`}
onDragEnter={(event) => { event.preventDefault(); setRailDropping(true); }}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setRailDropping(false)}
onDrop={(event) => {
event.preventDefault();
setRailDropping(false);
if (event.dataTransfer.files.length) void upload(event.dataTransfer.files, false);
}}
>
<Icon name="upload" size={20} strokeWidth={1.8} />
<p>Перетащите файлы</p>
<p>или <button type="button" onClick={() => fileInputRef.current?.click()}>выберите на диске</button> · до 25 МБ</p>
</div>
<div className="portal-rail-files">
{uploading && (
<div className="portal-rail-file is-fresh">
<i><Icon name="image" size={14} strokeWidth={1.8} /></i>
<span>
<strong>{uploading.name}</strong>
<small className="is-progress">загружаем… {uploading.percent} %</small>
<span className="portal-rail-progress"><i style={{ width: `${uploading.percent}%` }} /></span>
</span>
</div>
)}
{files.map((file) => {
const image = isImageFile(file.contentType, file.name);
const inserted = content.includes(file.path) || content.includes(file.url);
return (
<div className="portal-rail-file" key={file.id}>
<i><Icon name={image ? "image" : "file"} size={14} strokeWidth={1.8} /></i>
<span>
<strong>{file.name}</strong>
<small>{fileMeta(file.name, file.size)}{inserted ? " · вставлен в текст" : " · вложение статьи"}</small>
</span>
{/* В текст вставляется только картинка: документ — вложение
статьи, посетитель скачивает его списком под текстом. */}
{image && !inserted && (
<button
className="portal-rail-file-insert"
title="Вставить в текст"
type="button"
onClick={() => insertText(`\n${fileMarkdown(file.name, file.path, true)}\n`)}
>
<Icon name="plus" size={14} strokeWidth={2} />
</button>
)}
<button
title="Копировать Markdown"
type="button"
onClick={() => void navigator.clipboard?.writeText(fileMarkdown(file.name, file.path, image))}
>
<Icon name="copy" size={13} strokeWidth={1.9} />
</button>
{canManage && (
<button className="portal-rail-file-remove" title="Удалить файл" type="button" onClick={() => void removeFile(file)}>
<Icon name="trash" size={13} strokeWidth={1.9} />
</button>
)}
</div>
);
})}
</div>
</div>
{loaded && (
<div className="portal-rail-block">
<div className="portal-rail-head">
<span>ОЦЕНКИ ЧИТАТЕЛЕЙ</span>
</div>
<div className="portal-rail-votes">
<span className="is-helpful">
<Icon name="check" size={13} strokeWidth={2.4} />
{loaded.feedback?.helpful ?? 0}
<small>полезно</small>
</span>
<span className="is-unhelpful">
<Icon name="close" size={13} strokeWidth={2.4} />
{loaded.feedback?.unhelpful ?? 0}
<small>не помогло</small>
</span>
</div>
</div>
)}
<div className="portal-rail-block">
<div className="portal-rail-head">
<span>ВЕРСИИ</span>
<span className="portal-rail-hint">неизменяемые</span>
</div>
<div className="portal-rail-revisions">
{revisions.map((revision) => {
const published = revision.id === publishedRevisionId;
return (
<button
className={`portal-rail-revision${revision.id === selectedRevisionId ? " is-current" : ""}`}
key={revision.id}
type="button"
onClick={() => selectRevision(revision.id)}
>
<i className={published ? "is-published" : revision.id === selectedRevisionId ? "is-draft" : ""} />
<span>
<strong>Редакция {revision.revision}</strong>
<small>{published ? "" : "черновик · "}{shortDateTime(revision.createdAt)}{revision.authorName ? ` · ${revision.authorName}` : ""}</small>
</span>
{revision.id === selectedRevisionId && !published && <small className="portal-rail-tag is-current">текущая</small>}
{published && <small className="portal-rail-tag is-published">опубликована</small>}
</button>
);
})}
</div>
<p>Публикация выбирает одну редакцию. Посетители видят только опубликованную.</p>
</div>
</aside>
</div>
</div>
);
@@ -1,107 +0,0 @@
import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { formatDate } from "../../shared/utils";
import type { PortalArticle } from "./model";
import { ARTICLE_STATUS_LABEL } from "./model";
export function PortalArticleTable({
articles,
canManage,
canSelect,
onArchive,
onEdit,
onToggleSelected,
onToggleVisible,
selectedIds,
}: {
articles: PortalArticle[];
canManage: boolean;
canSelect: boolean;
onArchive: (article: PortalArticle) => void;
onEdit: (article: PortalArticle) => void;
onToggleSelected: (articleId: number) => void;
onToggleVisible: () => void;
selectedIds: Set<number>;
}) {
const allVisibleSelected = articles.length > 0 && articles.every((article) => selectedIds.has(article.id));
return (
<table className="baseline-table knowledge-table portal-article-table">
<colgroup>
{canSelect && <col className="knowledge-col-select" />}
<col className="portal-article-col-title" />
<col className="portal-article-col-category" />
<col className="portal-article-col-language" />
<col className="portal-article-col-version" />
<col className="portal-article-col-status" />
<col className="portal-article-col-updated" />
<col className="portal-article-col-actions" />
</colgroup>
<thead>
<tr>
{canSelect && (
<th className="knowledge-select-cell">
<input aria-label="Выбрать все статьи" checked={allVisibleSelected} type="checkbox" onChange={onToggleVisible} />
</th>
)}
<th>СТАТЬЯ</th><th>РАЗДЕЛ</th><th>ЯЗЫК</th><th>ВЕРСИЯ</th><th>СТАТУС</th><th>ОБНОВЛЕНО</th><th />
</tr>
</thead>
<tbody>
{articles.map((article) => {
const title = article.latestRevision?.title || article.slug;
const menuItems = [
{
key: "edit",
label: <button type="button" onClick={() => onEdit(article)}><Icon name="edit" size={15} />Изменить</button>,
},
...(article.status !== "ARCHIVED" ? [{
key: "archive",
label: <button className="danger" type="button" onClick={() => onArchive(article)}><Icon name="trash" size={15} />В архив</button>,
}] : []),
];
return (
<tr className={`knowledge-row${selectedIds.has(article.id) ? " selected" : ""}`} key={article.id} onClick={() => onEdit(article)}>
{canSelect && (
<td className="knowledge-select-cell" onClick={(event) => event.stopPropagation()}>
<input
aria-label={`Выбрать ${title}`}
checked={selectedIds.has(article.id)}
type="checkbox"
onChange={() => onToggleSelected(article.id)}
/>
</td>
)}
<td>
<button
className="link is-strong is-neutral"
type="button"
onClick={(event) => {
event.stopPropagation();
onEdit(article);
}}
>
{title}
</button>
<small className="knowledge-description">{article.latestRevision?.summary || article.slug}</small>
</td>
<td className="knowledge-category-path">{article.category.name}</td>
<td>{article.locale.toLocaleUpperCase()}</td>
<td>{article.publishedRevision ? article.publishedRevision.revision : "—"}</td>
<td><StatusPill status={article.status === "PUBLISHED" ? "published" : article.status === "ARCHIVED" ? "archived" : "draft"} label={ARTICLE_STATUS_LABEL[article.status]} /></td>
<td>{formatDate(article.updatedAt)}</td>
<td className="row-actions" onClick={(event) => event.stopPropagation()}>
{canManage && (
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown" placement="bottomRight" trigger={["click"]}>
<button aria-label={`Действия: ${title}`} className="row-menu-button" type="button"><Icon name="more" size={18} /></button>
</Dropdown>
)}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { Button, CopyButton } from "../../shared/ui-controls";
import {
portalErrorMessage,
portalFieldErrors,
updateSupportPortal,
type PortalAddressConfig,
type SupportPortal,
} from "./model";
import { LOCALE_OPTIONS } from "./portalText";
// Кадр PT4: имя, ключ портала на этой установке и язык. Базовый домен —
// из ответа API (`address.baseDomain`), в UI он никогда не зашивается.
export function PortalBasicsSettings({
address,
canManage,
portal,
onChanged,
}: {
address: PortalAddressConfig;
canManage: boolean;
portal: SupportPortal;
onChanged: (portal: SupportPortal) => void;
}) {
const [name, setName] = useState(portal.name);
const [slug, setSlug] = useState(portal.slug);
const [locale, setLocale] = useState(portal.defaultLocale);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
setName(portal.name);
setSlug(portal.slug);
setLocale(portal.defaultLocale);
}, [portal]);
const port = address.port ? `:${address.port}` : "";
const finalUrl = `${address.scheme}://${slug || portal.slug}.${address.baseDomain}${port}/`;
async function save() {
setBusy(true);
setFeedback("");
setFieldErrors({});
try {
const payload = await updateSupportPortal(portal.id, { name, slug, defaultLocale: locale });
onChanged(payload.portal);
setFeedback("Настройки сохранены");
} catch (caught) {
setFieldErrors(portalFieldErrors(caught));
setFeedback(portalErrorMessage(caught, "Не удалось сохранить настройки"));
} finally {
setBusy(false);
}
}
return (
<div className="portal-settings-card">
<label className="portal-field">
<span className="portal-field-label">Название портала</span>
<input
disabled={!canManage}
value={name}
onChange={(event) => setName(event.target.value)}
/>
<small>Показывается в шапке публичных страниц и в заголовке браузера.</small>
{fieldErrors.name && <small className="portal-field-error">{fieldErrors.name}</small>}
</label>
<div className="portal-field">
<span className="portal-field-label">Адрес на этой установке</span>
<span className="portal-key-input">
<input
disabled={!canManage}
maxLength={63}
value={slug}
onChange={(event) => setSlug(event.target.value.toLocaleLowerCase())}
/>
<b>.{address.baseDomain}</b>
</span>
<small>
Строчные латинские буквы, цифры и дефис. Базовый домен <b>{address.baseDomain}</b> задан
администратором при установке и одинаков для всех порталов этой инсталляции. Адрес входит
в ссылки на статьи — после смены старые ссылки перестанут работать.
</small>
{fieldErrors.slug && <small className="portal-field-error">{fieldErrors.slug}</small>}
</div>
<label className="portal-field is-narrow">
<span className="portal-field-label">Основной язык</span>
<span className="portal-select">
<select disabled={!canManage} value={locale} onChange={(event) => setLocale(event.target.value)}>
{LOCALE_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<Icon name="chevron" size={14} strokeWidth={2} />
</span>
<small>Язык, который подставляется новым статьям. Статьи на других языках остаются как есть.</small>
</label>
<div className="portal-final-link">
<span>
<small>Итоговая ссылка</small>
<code>{finalUrl}</code>
</span>
<CopyButton className="portal-inline-button" label="Копировать" value={finalUrl} />
<a className="portal-inline-button is-accent" href={finalUrl} rel="noreferrer" target="_blank">
<Icon name="external" size={13} strokeWidth={2} />Открыть
</a>
</div>
{canManage && (
<div className="portal-settings-actions">
<Button variant="primary" disabled={busy} onClick={() => void save()}>Сохранить настройки</Button>
{feedback && <span className="portal-settings-note">{feedback}</span>}
</div>
)}
</div>
);
}
@@ -1,265 +0,0 @@
import { useMemo, useState } from "react";
import {
AgentLinkDialog,
type AgentLinkAction,
type AgentLinkOutcome,
} from "../../shared/content-library/AgentLinkDialog";
import { BulkSelectionBar } from "../../shared/content-library/BulkSelectionBar";
import { CategoryTree } from "../../shared/content-library/CategoryTree";
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import { ARTICLE_STATUS_LABEL } from "./model";
import { agentLinkOptions } from "../ai/agentOptions";
import { linkPortalArticlesToAgent } from "../ai/knowledge/model";
import { useAiAgents } from "../ai/useAiAgents";
import { PortalArticleEditor } from "./PortalArticleEditor";
import { PortalArticleImportModal } from "./PortalArticleImportModal";
import { PortalArticleTable } from "./PortalArticleTable";
import { PortalCategoryManagement } from "./PortalCategoryManagement";
import {
archivePortalArticle,
portalErrorMessage,
publishArticleRevision,
type PortalArticle,
type PortalCategory,
} from "./model";
type Decision = {
article: PortalArticle;
revisionId?: number;
type: "archive" | "publish";
};
const ARTICLE_FORMS: [string, string, string] = ["статья", "статьи", "статей"];
export function PortalContent({
articles,
canLinkAgents,
canManage,
categories,
locale,
portalId,
reload,
}: {
articles: PortalArticle[];
canLinkAgents: boolean;
canManage: boolean;
categories: PortalCategory[];
locale: string;
portalId: number;
reload: () => Promise<void>;
}) {
const [selectedCategory, setSelectedCategory] = useState<number | undefined>();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [agentOpen, setAgentOpen] = useState(false);
const [outcome, setOutcome] = useState<AgentLinkOutcome | null>(null);
const [agentError, setAgentError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [language, setLanguage] = useState("");
const [status, setStatus] = useState("");
const [editing, setEditing] = useState<PortalArticle | null | undefined>(undefined);
const [managingCategories, setManagingCategories] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [decision, setDecision] = useState<Decision | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const filtered = useMemo(() => {
const childIds = new Set<number>();
if (selectedCategory !== undefined) {
const pending = [selectedCategory];
while (pending.length) {
const id = pending.pop()!;
childIds.add(id);
categories.filter((item) => item.parentId === id).forEach((item) => pending.push(item.id));
}
}
const normalized = query.trim().toLocaleLowerCase();
return articles.filter((article) => (
(selectedCategory === undefined || childIds.has(article.category.id))
&& (!language || article.locale === language)
&& (!status || article.status === status)
&& (!normalized || [
article.slug,
article.latestRevision?.title,
article.latestRevision?.summary,
].some((value) => value?.toLocaleLowerCase().includes(normalized)))
));
}, [articles, categories, language, query, selectedCategory, status]);
const { agents } = useAiAgents();
const bulkMode = selectedIds.size > 0;
function toggleSelected(articleId: number) {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(articleId)) next.delete(articleId);
else next.add(articleId);
return next;
});
}
function toggleVisible() {
setSelectedIds((current) => (
filtered.every((article) => current.has(article.id))
? new Set()
: new Set(filtered.map((article) => article.id))
));
}
async function submitAgentLink(agentId: number, action: AgentLinkAction) {
setBusy(true);
setAgentError(null);
try {
const result = await linkPortalArticlesToAgent({ agentId, action, articleIds: [...selectedIds] });
setOutcome({ action, changed: result.changed, skipped: result.skippedIds.length });
} catch (caught) {
setAgentError(portalErrorMessage(caught, "Не удалось изменить статьи агента"));
} finally {
setBusy(false);
}
}
async function applyDecision() {
if (!decision) return;
setBusy(true);
setError("");
try {
if (decision.type === "archive") {
await archivePortalArticle(portalId, decision.article.id);
} else if (decision.revisionId) {
await publishArticleRevision(portalId, decision.article.id, decision.revisionId);
}
setDecision(null);
setEditing(undefined);
await reload();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось изменить статью"));
setDecision(null);
} finally {
setBusy(false);
}
}
if (editing !== undefined) {
return (
<PortalArticleEditor
article={editing}
categories={categories}
defaultLocale={locale}
portalId={portalId}
onClose={() => setEditing(undefined)}
onPublish={(article, revisionId) => setDecision({ article, revisionId, type: "publish" })}
onSaved={reload}
/>
);
}
return (
<>
<div className="knowledge-library-layout">
<CategoryTree
allLabel="Все материалы"
canManage={canManage}
categories={categories.map((category) => ({
id: category.id,
name: category.name,
parentId: category.parentId,
count: category.articleCount,
}))}
error={false}
loading={false}
selectedId={selectedCategory}
onManage={() => setManagingCategories(true)}
onRetry={() => void reload()}
onSelect={setSelectedCategory}
/>
<main className="knowledge-library-list">
{bulkMode ? (
<BulkSelectionBar count={selectedIds.size} forms={ARTICLE_FORMS} onClear={() => setSelectedIds(new Set())}>
{canLinkAgents && (
<button type="button" onClick={() => { setAgentOpen(true); setOutcome(null); setAgentError(null); }}>
<Icon name="robot" size={15} />Прикрепить к агенту
</button>
)}
</BulkSelectionBar>
) : (
<ContentLibraryToolbar
query={query}
onQueryChange={setQuery}
action={canManage && categories.length > 0 && (
<>
<Button variant="secondary" icon="download" onClick={() => setImportOpen(true)}>Импорт YAML</Button>
<Button variant="primary" icon="plus" onClick={() => setEditing(null)}>Новая статья</Button>
</>
)}
>
<label className="knowledge-filter-select"><span>Язык:</span><select value={language} onChange={(event) => setLanguage(event.target.value)}><option value="">Все</option><option value="ru">Русский</option><option value="en">English</option></select></label>
<label className="knowledge-filter-select"><span>Статус:</span><select value={status} onChange={(event) => setStatus(event.target.value)}><option value="">Все</option><option value="DRAFT">{ARTICLE_STATUS_LABEL.DRAFT}</option><option value="PUBLISHED">{ARTICLE_STATUS_LABEL.PUBLISHED}</option><option value="ARCHIVED">{ARTICLE_STATUS_LABEL.ARCHIVED}</option></select></label>
</ContentLibraryToolbar>
)}
{error && <div className="portal-form-error">{error}</div>}
<ContentLibraryTable
canCreate={canManage && categories.length > 0 && selectedCategory !== undefined && !query && !language && !status}
createLabel="Создать статью"
emptyDescription={articles.length === 0 && selectedCategory !== undefined ? "Создайте статью в этом разделе или переместите существующую." : undefined}
emptyTitle={articles.length ? "Ничего не найдено" : "Материалов пока нет"}
error={false}
errorTitle="Не удалось загрузить материалы"
hasItems={filtered.length > 0}
loading={false}
onCreate={() => setEditing(null)}
onRetry={() => void reload()}
footer={<div className="ai-table-footer"><span>{pluralRu(filtered.length, ["материал", "материала", "материалов"])}</span></div>}
>
<PortalArticleTable
articles={filtered}
canManage={canManage}
canSelect={canLinkAgents}
selectedIds={selectedIds}
onArchive={(article) => setDecision({ article, type: "archive" })}
onEdit={setEditing}
onToggleSelected={toggleSelected}
onToggleVisible={toggleVisible}
/>
</ContentLibraryTable>
{bulkMode && <p className="knowledge-bulk-note">Агент отвечает только по опубликованным статьям: черновики и архив в выдачу не попадают.</p>}
</main>
</div>
{agentOpen && (
<AgentLinkDialog
agents={agentLinkOptions(agents)}
busy={busy}
error={agentError}
forms={ARTICLE_FORMS}
outcome={outcome}
title="Статьи агента поддержки"
onCancel={() => { setAgentOpen(false); if (outcome) setSelectedIds(new Set()); }}
onSubmit={(agentId, action) => void submitAgentLink(agentId, action)}
/>
)}
{managingCategories && <PortalCategoryManagement categories={categories} portalId={portalId} onChanged={reload} onClose={() => setManagingCategories(false)} />}
{importOpen && (
<PortalArticleImportModal
portalId={portalId}
onClose={() => setImportOpen(false)}
onImported={() => void reload()}
/>
)}
<DecisionDialog
open={Boolean(decision)}
onClose={() => setDecision(null)}
tone={decision?.type === "archive" ? "danger" : "warning"}
icon={decision?.type === "archive" ? "trash" : "check"}
title={decision?.type === "archive" ? "Перенести статью в архив?" : "Опубликовать выбранную версию?"}
description={decision?.type === "archive" ? "Статья исчезнет с публичного портала." : "Эта версия станет доступна посетителям портала."}
actions={<><Button variant="secondary" onClick={() => setDecision(null)}>Отмена</Button><Button variant={decision?.type === "archive" ? "danger-outline" : "primary"} disabled={busy} onClick={() => void applyDecision()}>{decision?.type === "archive" ? "В архив" : "Опубликовать"}</Button></>}
/>
</>
);
}
@@ -1,13 +1,19 @@
import { useEffect, useState } from "react";
import { FormField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { Icon } from "../../shared/icons";
import { Button, CopyButton } from "../../shared/ui-controls";
import { shortDate } from "../../shared/utils";
import {
portalErrorMessage,
setPortalCustomDomain,
verifyPortalCustomDomain,
type SupportPortal,
} from "./model";
import { hostedHost } from "./portalText";
// Кадр PT5. Подтверждения владения доменом нет: продукт self-hosted, домен и
// установка принадлежат одному владельцу. Остаётся одна A-запись на IP
// установки и техническая проверка «ведёт ли домен сюда» — она нужна для TLS.
export function PortalDomainSettings({
canManage,
@@ -21,83 +27,124 @@ export function PortalDomainSettings({
const [domain, setDomain] = useState(portal.customDomain ?? "");
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
const [failure, setFailure] = useState("");
useEffect(() => setDomain(portal.customDomain ?? ""), [portal.customDomain]);
useEffect(() => {
setDomain(portal.customDomain ?? "");
setFailure("");
}, [portal.customDomain]);
async function save() {
const verifiedAt = portal.customDomainVerifiedAt;
const record = portal.customDomainAddress;
async function run(action: "save" | "verify") {
setBusy(true);
setFeedback("");
setFailure("");
try {
const payload = await setPortalCustomDomain(portal.id, domain);
const payload = action === "save"
? await setPortalCustomDomain(portal.id, domain.trim())
: await verifyPortalCustomDomain(portal.id);
onChanged(payload.portal);
setFeedback(domain.trim() ? "Домен сохранён. Добавьте показанные DNS-записи и проверьте домен." : "Свой домен отключён");
} catch (error) {
setFeedback(portalErrorMessage(error, "Не удалось сохранить домен"));
setFeedback(action === "verify"
? "Домен ведёт сюда"
: domain.trim() ? "Домен сохранён. Добавьте A-запись и проверьте DNS." : "Свой домен отключён");
} catch (caught) {
setFailure(portalErrorMessage(
caught,
action === "verify" ? "Не удалось проверить домен" : "Не удалось сохранить домен",
));
} finally {
setBusy(false);
}
}
async function verify() {
async function disconnect() {
setDomain("");
setBusy(true);
setFeedback("");
setFailure("");
try {
const payload = await verifyPortalCustomDomain(portal.id);
const payload = await setPortalCustomDomain(portal.id, "");
onChanged(payload.portal);
setFeedback("Домен подтверждён");
} catch (error) {
setFeedback(portalErrorMessage(error, "Не удалось подтвердить домен"));
setFeedback("Свой домен отключён");
} catch (caught) {
setFailure(portalErrorMessage(caught, "Не удалось отключить домен"));
} finally {
setBusy(false);
}
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Свой домен</h2><p>Подключите адрес вашей компании к опубликованному порталу.</p></div>
<div className="portal-settings-card">
<div className="portal-domain-row">
<label className="portal-field">
<span className="portal-field-label">Домен</span>
<input
className="is-mono"
disabled={!canManage}
placeholder={hostedHost(portal)}
value={domain}
onChange={(event) => setDomain(event.target.value.trim().toLocaleLowerCase())}
/>
</label>
{portal.customDomain && (
<span className={`portal-domain-state${verifiedAt ? " is-live" : " is-pending"}`}>
<Icon name={verifiedAt ? "check" : "clock"} size={14} strokeWidth={2.4} />
{verifiedAt
? `Домен ведёт сюда · проверено ${shortDate(verifiedAt)}`
: "Домен ещё не проверялся"}
</span>
)}
</div>
<div className="portal-settings-fields">
<FormField
disabled={!canManage}
label="Домен"
placeholder="help.example.com"
value={domain}
onChange={canManage ? setDomain : undefined}
mono
wide
/>
</div>
{(portal.customDomainAddress || portal.customDomainVerification) && (
<div className="portal-domain-verification">
<strong>DNS-записи для {portal.customDomain}</strong>
{portal.customDomainAddress && (
<div className="portal-domain-record">
<b>A-запись</b>
<dl>
<div><dt>Тип</dt><dd>{portal.customDomainAddress.type}</dd></div>
<div><dt>Имя</dt><dd><code>{portal.customDomainAddress.name}</code></dd></div>
<div><dt>Значение</dt><dd><code>{portal.customDomainAddress.value}</code></dd></div>
</dl>
</div>
)}
{portal.customDomainVerification && (
<div className="portal-domain-record">
<b>TXT-запись</b>
<dl>
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
</dl>
</div>
{portal.customDomain && (
<div>
<div className="portal-dns-head">
<strong>Запись у регистратора домена</strong>
<span>обновление DNS занимает до 24 часов</span>
</div>
<table className="portal-dns-table">
<thead>
<tr><th>ТИП</th><th>ИМЯ</th><th>ЗНАЧЕНИЕ</th><th /></tr>
</thead>
<tbody>
{record ? (
<tr>
<td className="portal-dns-type">{record.type}</td>
<td><code>{record.name}</code></td>
<td><code>{record.value} · IP этой установки</code></td>
<td><CopyButton className="portal-dns-copy" label="" value={record.value} /></td>
</tr>
) : (
<tr>
<td className="portal-dns-type">A</td>
<td><code>{portal.customDomain}</code></td>
<td><code>IP этой установки не задан администратором</code></td>
<td />
</tr>
)}
</tbody>
</table>
<p className="portal-dns-note">
Больше ничего добавлять не нужно: домен ваш, установка ваша — проверка лишь смотрит,
ведёт ли запись на этот сервер, и выписывает сертификат.
</p>
</div>
)}
{canManage && (
<div className="portal-settings-actions">
<Button variant="primary" disabled={busy} onClick={() => void run("save")}>Сохранить домен</Button>
<Button variant="secondary" disabled={busy || !portal.customDomain} onClick={() => void run("verify")}>Проверить DNS</Button>
<span className="portal-settings-gap" />
{portal.customDomain && (
<Button variant="danger-outline" disabled={busy} onClick={() => void disconnect()}>Отключить домен</Button>
)}
</div>
)}
<div className="portal-domain-actions">
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить домен</Button>}
{canManage && portal.customDomainVerification && <Button variant="primary" disabled={busy} onClick={() => void verify()}>Проверить DNS</Button>}
</div>
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
{feedback && <span className="portal-settings-note">{feedback}</span>}
{failure && <div className="portal-form-error">{failure}</div>}
</div>
);
}
@@ -0,0 +1,131 @@
import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { Button, CopyButton } from "../../shared/ui-controls";
import { PORTAL_STATUS_LABEL, type PortalStatus, type SupportPortal } from "./model";
import { contentSummary, hostedHost, localeName, publicHost } from "./portalText";
// Шапка карточки портала (дизайн-базлайн v2, кадр PT3): публичный адрес —
// постоянный элемент, а не текст в подзаголовке: копирование, открытие,
// состояние домена и резервный адрес установки видны всегда, включая черновик.
function pillStatus(status: PortalStatus): "published" | "archived" | "draft" {
if (status === "PUBLISHED") return "published";
if (status === "ARCHIVED") return "archived";
return "draft";
}
export function PortalHeader({
portal,
canManage,
settingsActive,
onOpenPortals,
onOpenContent,
onOpenSettings,
onCreateArticle,
onChangeStatus,
}: {
portal: SupportPortal;
canManage: boolean;
settingsActive: boolean;
onOpenPortals: () => void;
onOpenContent: () => void;
onOpenSettings: () => void;
onCreateArticle: () => void;
onChangeStatus: (status: PortalStatus) => void;
}) {
const ownDomain = Boolean(portal.customDomain);
const domainLive = ownDomain && Boolean(portal.customDomainVerifiedAt);
const archived = portal.status === "ARCHIVED";
const menuItems = [
...(canManage && portal.status === "DRAFT" ? [{
key: "publish",
label: <button type="button" onClick={() => onChangeStatus("PUBLISHED")}><Icon name="check" size={15} strokeWidth={2.2} />Опубликовать портал</button>,
}] : []),
...(canManage && portal.status === "PUBLISHED" ? [{
key: "unpublish",
label: <button type="button" onClick={() => onChangeStatus("DRAFT")}><Icon name="undo" size={15} strokeWidth={1.9} />Снять с публикации</button>,
}] : []),
...(canManage && archived ? [{
key: "restore",
label: <button type="button" onClick={() => onChangeStatus("DRAFT")}><Icon name="undo" size={15} strokeWidth={1.9} />Вернуть из архива</button>,
}] : []),
{ key: "open", label: <button type="button" onClick={() => window.open(portal.publicUrl, "_blank", "noreferrer")}><Icon name="external" size={15} strokeWidth={1.9} />Открыть портал</button> },
...(canManage && !archived ? [
{ key: "divider", type: "divider" as const },
{ key: "archive", label: <button className="danger" type="button" onClick={() => onChangeStatus("ARCHIVED")}><Icon name="trash" size={15} strokeWidth={1.9} />Перенести в архив</button> },
] : []),
];
return (
<div className="portal-card-head">
<div className="portal-card-head-inner">
<nav className="portal-breadcrumbs">
<button className="link is-strong" type="button" onClick={onOpenPortals}>Порталы</button>
<span>/</span>
{settingsActive
? <><button className="link is-muted" type="button" onClick={onOpenContent}>{portal.name}</button><span>/</span><b>Настройки</b></>
: <span>{portal.name}</span>}
</nav>
<div className="portal-card-head-row">
<div className="portal-card-identity">
<div className="portal-card-title">
<h2>{portal.name}</h2>
<StatusPill status={pillStatus(portal.status)} label={PORTAL_STATUS_LABEL[portal.status]} />
<span>{contentSummary(portal)} · {localeName(portal.defaultLocale)}</span>
</div>
<div className="portal-address-row">
<span className={`portal-address-chip${domainLive ? " is-live" : ""}`}>
<Icon name="globe" size={15} strokeWidth={1.9} />
<code>{publicHost(portal)}</code>
{ownDomain && (
<span className={`portal-domain-tag${domainLive ? "" : " is-pending"}`}>
свой домен · {domainLive ? "работает" : "не проверен"}
</span>
)}
<CopyButton className="portal-address-copy" label="" value={portal.publicUrl} />
<a
className="portal-address-open"
href={portal.publicUrl}
rel="noreferrer"
target="_blank"
title="Открыть портал в новой вкладке"
>
<Icon name="external" size={14} strokeWidth={2} />
</a>
</span>
{ownDomain && (
<span className="portal-hosted-note">
адрес на этой установке <code>{hostedHost(portal)}</code>
</span>
)}
</div>
</div>
<div className="portal-card-actions">
<button
className={`portal-settings-button${settingsActive ? " is-active" : ""}`}
type="button"
onClick={onOpenSettings}
>
<Icon name="settings" size={15} strokeWidth={1.9} />Настройки
</button>
{canManage && !archived && (
<Button variant="primary" className="portal-primary-action" icon="plus" onClick={onCreateArticle}>
Новая статья
</Button>
)}
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown is-portal-menu" placement="bottomRight" trigger={["click"]}>
<button aria-label="Ещё" className="portal-more-button" title="Ещё" type="button">
<Icon name="more" size={16} strokeWidth={2} />
</button>
</Dropdown>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,387 @@
import { Dropdown } from "antd";
import { useMemo, useState } from "react";
import {
AgentLinkDialog,
type AgentLinkAction,
type AgentLinkOutcome,
} from "../../shared/content-library/AgentLinkDialog";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { Button, FilterDropdown, SearchInput } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import { agentLinkOptions } from "../ai/agentOptions";
import { linkPortalArticlesToAgent } from "../ai/knowledge/model";
import { useAiAgents } from "../ai/useAiAgents";
import {
archivePortalArticle,
portalErrorMessage,
ARTICLE_STATUS_LABEL,
type ArticleStatus,
type PortalArticle,
type PortalCategory,
} from "./model";
import { PortalArticleImportModal } from "./PortalArticleImportModal";
import { PortalCategoryManagement } from "./PortalCategoryManagement";
import { PortalTableFooter } from "./PortalTableFooter";
import { LOCALE_OPTIONS, revisionSummary, updatedAt } from "./portalText";
// Библиотека материалов портала (дизайн-базлайн v2, кадр PT3): дерево разделов
// 260px с вложенностью и счётчиками, тулбар и таблица статей — без вкладок.
const PAGE_SIZE = 25;
const ARTICLE_FORMS: [string, string, string] = ["статья", "статьи", "статей"];
const STATUS_FILTER = [
{ value: "PUBLISHED", label: ARTICLE_STATUS_LABEL.PUBLISHED },
{ value: "DRAFT", label: ARTICLE_STATUS_LABEL.DRAFT },
{ value: "ARCHIVED", label: ARTICLE_STATUS_LABEL.ARCHIVED },
];
type SectionRow = { category: PortalCategory; depth: number; count: number };
function pillStatus(status: ArticleStatus): "published" | "archived" | "draft" {
if (status === "PUBLISHED") return "published";
if (status === "ARCHIVED") return "archived";
return "draft";
}
/** Разделы в порядке дерева с отступом по глубине (кадр PT3, левая панель). */
function sectionRows(categories: PortalCategory[]): SectionRow[] {
const byParent = new Map<number | null, PortalCategory[]>();
categories.forEach((category) => {
const siblings = byParent.get(category.parentId) ?? [];
siblings.push(category);
byParent.set(category.parentId, siblings);
});
byParent.forEach((items) => items.sort((left, right) => (
left.sortOrder - right.sortOrder || left.name.localeCompare(right.name, "ru")
)));
const rows: SectionRow[] = [];
const walk = (parentId: number | null, depth: number) => {
(byParent.get(parentId) ?? []).forEach((category) => {
rows.push({ category, depth, count: category.articleCount });
walk(category.id, depth + 1);
});
};
walk(null, 0);
return rows;
}
export function PortalLibrary({
articles,
canLinkAgents,
canManage,
categories,
portalId,
reload,
onEditArticle,
}: {
articles: PortalArticle[];
canLinkAgents: boolean;
canManage: boolean;
categories: PortalCategory[];
portalId: number;
reload: () => Promise<void>;
onEditArticle: (article: PortalArticle) => void;
}) {
const [selectedCategory, setSelectedCategory] = useState<number | undefined>();
const [query, setQuery] = useState("");
const [language, setLanguage] = useState<string[]>([]);
const [languageOpen, setLanguageOpen] = useState(false);
const [status, setStatus] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
const [page, setPage] = useState(1);
const [managingCategories, setManagingCategories] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [linking, setLinking] = useState<PortalArticle | null>(null);
const [linkOutcome, setLinkOutcome] = useState<AgentLinkOutcome | null>(null);
const [linkError, setLinkError] = useState<string | null>(null);
const [archiving, setArchiving] = useState<PortalArticle | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const { agents } = useAiAgents();
const rows = useMemo(() => sectionRows(categories), [categories]);
const total = categories
.filter((category) => category.parentId === null)
.reduce((sum, category) => sum + category.articleCount, 0);
const filtered = useMemo(() => {
const childIds = new Set<number>();
if (selectedCategory !== undefined) {
const pending = [selectedCategory];
while (pending.length) {
const id = pending.pop()!;
childIds.add(id);
categories.filter((item) => item.parentId === id).forEach((item) => pending.push(item.id));
}
}
const normalized = query.trim().toLocaleLowerCase();
return articles.filter((article) => (
(selectedCategory === undefined || childIds.has(article.category.id))
&& (language.length === 0 || language.includes(article.locale))
&& (status.length === 0 || status.includes(article.status))
&& (!normalized || [
article.slug,
article.latestRevision?.title,
article.latestRevision?.summary,
].some((value) => value?.toLocaleLowerCase().includes(normalized)))
));
}, [articles, categories, language, query, selectedCategory, status]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const visible = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
async function submitAgentLink(agentId: number, action: AgentLinkAction) {
if (!linking) return;
setBusy(true);
setLinkError(null);
try {
const result = await linkPortalArticlesToAgent({ agentId, action, articleIds: [linking.id] });
setLinkOutcome({ action, changed: result.changed, skipped: result.skippedIds.length });
} catch (caught) {
setLinkError(portalErrorMessage(caught, "Не удалось изменить статьи агента"));
} finally {
setBusy(false);
}
}
async function archive() {
if (!archiving) return;
setBusy(true);
setError("");
try {
await archivePortalArticle(portalId, archiving.id);
setArchiving(null);
await reload();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось изменить статью"));
setArchiving(null);
} finally {
setBusy(false);
}
}
return (
<div className="portal-library">
<aside className="portal-sections">
<header>
<span>РАЗДЕЛЫ</span>
{canManage && <button type="button" onClick={() => setManagingCategories(true)}>Управлять</button>}
</header>
<nav>
<button
className={`portal-section-row${selectedCategory === undefined ? " is-active" : ""}`}
style={{ paddingLeft: 9 }}
type="button"
onClick={() => { setSelectedCategory(undefined); setPage(1); }}
>
<Icon name="folder" size={15} strokeWidth={1.8} />
<span>Все материалы</span>
<small>{total}</small>
</button>
{rows.map(({ category, depth, count }) => (
<button
className={`portal-section-row${selectedCategory === category.id ? " is-active" : ""}`}
key={category.id}
style={{ paddingLeft: 9 + depth * 16 }}
type="button"
onClick={() => { setSelectedCategory(category.id); setPage(1); }}
>
<span>{category.name}</span>
<small>{count}</small>
</button>
))}
</nav>
{canManage && (
<div className="portal-sections-foot">
<button type="button" onClick={() => setManagingCategories(true)}>
<Icon name="plus" size={14} strokeWidth={2} />Добавить раздел
</button>
</div>
)}
</aside>
<div className="portal-library-main">
<div className="portal-library-inner">
<div className="portal-library-toolbar">
<SearchInput
className="portal-library-search"
placeholder="Поиск по статьям"
value={query}
onChange={(value) => { setQuery(value); setPage(1); }}
/>
<FilterDropdown
caption="Язык:"
label={language.length === 1 ? LOCALE_OPTIONS.find(([value]) => value === language[0])![1] : "Все"}
multiple
open={languageOpen}
options={LOCALE_OPTIONS.map(([value, label]) => ({ value, label }))}
selected={language}
onOpenChange={setLanguageOpen}
onSelect={(value) => {
setPage(1);
setLanguage((current) => (current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]));
}}
/>
<FilterDropdown
caption="Статус:"
label={status.length === 1 ? STATUS_FILTER.find((item) => item.value === status[0])!.label : "Все"}
multiple
open={statusOpen}
options={STATUS_FILTER}
selected={status}
onOpenChange={setStatusOpen}
onSelect={(value) => {
setPage(1);
setStatus((current) => (current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]));
}}
/>
<span className="portal-library-toolbar-gap" />
{canManage && (
<Button variant="secondary" className="portal-import-button" icon="import" onClick={() => setImportOpen(true)}>
Импорт статей
</Button>
)}
</div>
{error && <div className="portal-form-error">{error}</div>}
<table className="portal-articles-table">
<thead>
<tr>
<th>СТАТЬЯ</th>
<th>РАЗДЕЛ</th>
<th>ЯЗЫК</th>
<th>РЕДАКЦИЯ</th>
<th>ОЦЕНКИ</th>
<th>СТАТУС</th>
<th>ОБНОВЛЕНО</th>
<th />
</tr>
</thead>
<tbody>
{visible.map((article) => {
const title = article.latestRevision?.title || article.slug;
const { revision, note } = revisionSummary(article);
const helpful = article.feedback?.helpful ?? 0;
const unhelpful = article.feedback?.unhelpful ?? 0;
const votes = { helpful, unhelpful, total: helpful + unhelpful };
const menuItems = [
{ key: "edit", label: <button type="button" onClick={() => onEditArticle(article)}><Icon name="edit" size={15} strokeWidth={1.9} />Изменить</button> },
...(canLinkAgents ? [{
key: "agent",
label: <button type="button" onClick={() => { setLinking(article); setLinkOutcome(null); setLinkError(null); }}><Icon name="robot" size={15} strokeWidth={1.9} />Прикрепить к агенту</button>,
}] : []),
...(canManage && article.status !== "ARCHIVED" ? [
{ key: "divider", type: "divider" as const },
{ key: "archive", label: <button className="danger" type="button" onClick={() => setArchiving(article)}><Icon name="trash" size={15} strokeWidth={1.9} />В архив</button> },
] : []),
];
return (
<tr
className={`portal-article-row${article.status === "ARCHIVED" ? " is-archived" : ""}`}
key={article.id}
onClick={() => onEditArticle(article)}
>
<td>
<span className="portal-article-name">
<i><Icon name="file" size={14} strokeWidth={1.8} /></i>
<span>
<strong>{title}</strong>
<small>/{article.slug}</small>
</span>
{article.fileCount > 0 && (
<small className="portal-article-files" title="Файлы в статье">
<Icon name="attach" size={12} strokeWidth={1.9} />{article.fileCount}
</small>
)}
</span>
</td>
<td className="portal-article-category">{article.category.name}</td>
<td className="portal-article-locale">{article.locale.toLocaleUpperCase()}</td>
<td className="portal-article-revision">
<span>{revision}</span>
{note && <small>{note}</small>}
</td>
<td className="portal-article-votes">
{votes.total === 0 ? (
<span className="portal-votes-empty" title="Читатели ещё не оценивали">—</span>
) : (
<span className="portal-votes" title={`Полезно ${votes.helpful} · не помогло ${votes.unhelpful}`}>
<b className="is-up"><Icon name="thumbUp" size={13} strokeWidth={1.9} />{votes.helpful}</b>
<b className="is-down"><Icon name="thumbDown" size={13} strokeWidth={1.9} />{votes.unhelpful}</b>
</span>
)}
</td>
<td><StatusPill status={pillStatus(article.status)} label={ARTICLE_STATUS_LABEL[article.status]} /></td>
<td className="portal-article-updated">{updatedAt(article.updatedAt)}</td>
<td className="row-actions" onClick={(event) => event.stopPropagation()}>
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown is-portal-menu" placement="bottomRight" trigger={["click"]}>
<button aria-label={`Действия: ${title}`} className="row-menu-button" type="button"><Icon name="more" size={16} strokeWidth={2} /></button>
</Dropdown>
</td>
</tr>
);
})}
</tbody>
</table>
<PortalTableFooter
note={`${pluralRu(articles.length, ARTICLE_FORMS)} · показаны ${visible.length} · агент отвечает только по опубликованным`}
page={currentPage}
pageCount={pageCount}
onPageChange={setPage}
/>
</div>
</div>
{linking && (
<AgentLinkDialog
agents={agentLinkOptions(agents)}
busy={busy}
error={linkError}
forms={ARTICLE_FORMS}
outcome={linkOutcome}
title="Статьи агента поддержки"
onCancel={() => setLinking(null)}
onSubmit={(agentId, action) => void submitAgentLink(agentId, action)}
/>
)}
{managingCategories && (
<PortalCategoryManagement
categories={categories}
portalId={portalId}
onChanged={reload}
onClose={() => setManagingCategories(false)}
/>
)}
{importOpen && (
<PortalArticleImportModal
portalId={portalId}
onClose={() => setImportOpen(false)}
onImported={() => void reload()}
/>
)}
<DecisionDialog
open={archiving !== null}
onClose={() => setArchiving(null)}
tone="danger"
icon="trash"
title="Перенести статью в архив?"
description="Статья исчезнет с публичного портала."
actions={<>
<Button variant="secondary" onClick={() => setArchiving(null)}>Отмена</Button>
<Button variant="danger-outline" disabled={busy} onClick={() => void archive()}>В архив</Button>
</>}
/>
</div>
);
}
@@ -67,10 +67,7 @@ export function PortalProductSettings({
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и виджеты, через которые клиенты смогут обратиться в поддержку.</p></div>
</div>
<div className="portal-settings-card">
<div className="portal-product-list">
{products.map((product) => {
const enabled = product.id in selected;
@@ -106,8 +103,12 @@ export function PortalProductSettings({
);
})}
</div>
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить продукты</Button>}
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
{canManage && (
<div className="portal-settings-actions">
<Button variant="primary" disabled={busy} onClick={() => void save()}>Сохранить продукты</Button>
{feedback && <span className="portal-settings-note">{feedback}</span>}
</div>
)}
</div>
);
}
@@ -0,0 +1,110 @@
import { useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import {
changePortalStatus,
portalErrorMessage,
PORTAL_STATUS_LABEL,
type PortalStatus,
type SupportPortal,
} from "./model";
// Раздел «Публикация и архив» субменю настроек (кадры PT4–PT6): здесь живут
// действия, которые в шапке карточки спрятаны в ⋯.
const COPY: Record<Exclude<PortalStatus, "ARCHIVED"> | "ARCHIVED", { title: string; description: string }> = {
PUBLISHED: {
title: "Опубликовать портал?",
description: "Опубликованные статьи станут доступны по публичному адресу.",
},
DRAFT: {
title: "Снять портал с публикации?",
description: "Посетители перестанут видеть материалы, адрес останется за порталом.",
},
ARCHIVED: {
title: "Перенести портал в архив?",
description: "Портал и его материалы станут недоступны посетителям до восстановления.",
},
};
export function PortalPublishSettings({
canManage,
portal,
onChanged,
}: {
canManage: boolean;
portal: SupportPortal;
onChanged: (portal: SupportPortal) => void;
}) {
const [pending, setPending] = useState<PortalStatus | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function apply(status: PortalStatus) {
setBusy(true);
setError("");
try {
const payload = await changePortalStatus(portal.id, status);
onChanged(payload.portal);
setPending(null);
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось изменить статус портала"));
setPending(null);
} finally {
setBusy(false);
}
}
const archived = portal.status === "ARCHIVED";
return (
<div className="portal-settings-card">
<div className="portal-status-row">
<span>
<small>Текущее состояние</small>
<StatusPill
status={portal.status === "PUBLISHED" ? "published" : archived ? "archived" : "draft"}
label={PORTAL_STATUS_LABEL[portal.status]}
/>
</span>
{portal.publishedAt && <span className="portal-settings-note">опубликован {shortDateTime(portal.publishedAt)}</span>}
</div>
{canManage && (
<div className="portal-settings-actions">
{portal.status === "DRAFT" && <Button variant="primary" disabled={busy} onClick={() => setPending("PUBLISHED")}>Опубликовать портал</Button>}
{portal.status === "PUBLISHED" && <Button variant="secondary" disabled={busy} onClick={() => setPending("DRAFT")}>Снять с публикации</Button>}
{archived && <Button variant="secondary" disabled={busy} onClick={() => void apply("DRAFT")}>Вернуть из архива</Button>}
<span className="portal-settings-gap" />
{!archived && <Button variant="danger-outline" disabled={busy} onClick={() => setPending("ARCHIVED")}>Перенести в архив</Button>}
</div>
)}
<p className="portal-dns-note">
Архивный портал перестаёт открываться по публичному адресу, а его материалы исчезают из
ответов агента. Ничего не удаляется: портал можно вернуть из архива.
</p>
{error && <div className="portal-form-error">{error}</div>}
<DecisionDialog
open={pending !== null}
onClose={() => setPending(null)}
tone={pending === "ARCHIVED" ? "danger" : "warning"}
icon={pending === "ARCHIVED" ? "trash" : "check"}
title={pending ? COPY[pending].title : ""}
description={pending ? COPY[pending].description : ""}
actions={<>
<Button variant="secondary" onClick={() => setPending(null)}>Отмена</Button>
<Button
variant={pending === "ARCHIVED" ? "danger-outline" : "primary"}
disabled={busy}
onClick={() => pending && void apply(pending)}
>
{pending === "ARCHIVED" ? "В архив" : pending === "DRAFT" ? "Снять" : "Опубликовать"}
</Button>
</>}
/>
</div>
);
}
@@ -1,44 +1,46 @@
import { useEffect, useState } from "react";
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import { Icon } from "../../shared/icons";
import type { Product } from "../../types";
import {
listPortalSupportChannels,
portalErrorMessage,
updateSupportPortal,
type PortalAddressConfig,
type PortalWidgetOption,
type SupportPortal,
} from "./model";
import { PortalAppearanceSettings } from "./PortalAppearanceSettings";
import { PortalBasicsSettings } from "./PortalBasicsSettings";
import { PortalDomainSettings } from "./PortalDomainSettings";
import { PortalProductSettings } from "./PortalProductSettings";
import { PortalPublishSettings } from "./PortalPublishSettings";
import { PortalWidgetSettings } from "./PortalWidgetSettings";
import {
PORTAL_SETTINGS_SECTIONS,
type PortalSettingsSectionKey,
} from "./sections";
// Настройки портала (дизайн-базлайн v2, кадры PT4–PT6): не модалка на 960px с
// пятью секциями подряд, а страница с субменю разделов 250px.
export function PortalSettings({
address,
canManage,
portal,
products,
canManage,
section,
onChanged,
openSection,
}: {
address: PortalAddressConfig;
canManage: boolean;
portal: SupportPortal;
products: Product[];
canManage: boolean;
section: PortalSettingsSectionKey;
onChanged: (portal: SupportPortal) => void;
openSection: (section: PortalSettingsSectionKey) => void;
}) {
const [name, setName] = useState(portal.name);
const [slug, setSlug] = useState(portal.slug);
const [locale, setLocale] = useState(portal.defaultLocale);
const [supportWidgets, setSupportWidgets] = useState<PortalWidgetOption[]>([]);
const [anonymousWidgets, setAnonymousWidgets] = useState<PortalWidgetOption[]>([]);
const [busy, setBusy] = useState(false);
const [feedback, setFeedback] = useState("");
useEffect(() => {
setName(portal.name);
setSlug(portal.slug);
setLocale(portal.defaultLocale);
}, [portal]);
useEffect(() => {
listPortalSupportChannels(portal.id)
@@ -52,65 +54,64 @@ export function PortalSettings({
});
}, [portal.id]);
async function saveBasics() {
setBusy(true);
setFeedback("");
try {
const payload = await updateSupportPortal(portal.id, {
name,
slug,
defaultLocale: locale,
});
onChanged(payload.portal);
setFeedback("Настройки сохранены");
} catch (caught) {
setFeedback(portalErrorMessage(caught, "Не удалось сохранить настройки"));
} finally {
setBusy(false);
}
}
const current = PORTAL_SETTINGS_SECTIONS.find((item) => item.key === section)
?? PORTAL_SETTINGS_SECTIONS[0];
const domainLive = Boolean(portal.customDomain && portal.customDomainVerifiedAt);
const hints: Partial<Record<PortalSettingsSectionKey, { text: string; tone: "ok" | "muted" }>> = {
domain: domainLive ? { text: "работает", tone: "ok" } : undefined,
products: portal.products.length ? { text: String(portal.products.length), tone: "muted" } : undefined,
};
return (
<div className="portal-settings-grid">
<section className="portal-section">
<div className="portal-section-heading">
<div><h2>Основные настройки</h2><p>Название, публичный адрес и язык материалов.</p></div>
</div>
<div className="portal-settings-fields">
<FormField disabled={!canManage} label="Название портала" value={name} onChange={canManage ? setName : undefined} wide />
<FormField disabled={!canManage} label="Адрес портала" value={slug} onChange={canManage ? setSlug : undefined} mono wide />
<p className="portal-address-preview">{portal.publicUrl}</p>
<SelectField
disabled={!canManage}
label="Язык по умолчанию"
value={locale}
onChange={setLocale}
options={[["ru", "Русский"], ["en", "English"]]}
/>
</div>
{canManage && <Button variant="primary" disabled={busy} onClick={() => void saveBasics()}>Сохранить настройки</Button>}
</section>
<div className="portal-settings-layout">
<nav className="portal-settings-nav">
{PORTAL_SETTINGS_SECTIONS.map((item) => (
<span key={item.key}>
<button
className={`portal-settings-nav-item${item.key === current.key ? " is-active" : ""}`}
type="button"
onClick={() => openSection(item.key)}
>
<Icon name={item.icon} size={16} strokeWidth={1.9} />
<span>{item.label}</span>
{hints[item.key] && (
<small className={hints[item.key]!.tone === "ok" ? "is-ok" : ""}>{hints[item.key]!.text}</small>
)}
</button>
{item.divider && <i className="portal-settings-nav-divider" />}
</span>
))}
<span className="portal-settings-nav-gap" />
<p>Изменения применяются к публичным страницам сразу после сохранения.</p>
</nav>
<PortalAppearanceSettings
canManage={canManage}
portal={portal}
onChanged={onChanged}
/>
<PortalProductSettings
canManage={canManage}
widgets={supportWidgets}
portal={portal}
products={products}
onChanged={onChanged}
/>
<PortalWidgetSettings
canManage={canManage}
widgets={anonymousWidgets}
portal={portal}
onChanged={onChanged}
/>
<PortalDomainSettings portal={portal} canManage={canManage} onChanged={onChanged} />
{feedback && <div className="portal-save-feedback">{feedback}</div>}
<div className="portal-settings-content">
<div className="portal-settings-inner">
<div className="portal-settings-heading">
<h3>{current.heading}</h3>
<p>{current.lead}</p>
</div>
{current.key === "basics" && (
<PortalBasicsSettings address={address} canManage={canManage} portal={portal} onChanged={onChanged} />
)}
{current.key === "domain" && (
<PortalDomainSettings canManage={canManage} portal={portal} onChanged={onChanged} />
)}
{current.key === "theme" && (
<PortalAppearanceSettings canManage={canManage} portal={portal} onChanged={onChanged} />
)}
{current.key === "widget" && (
<PortalWidgetSettings canManage={canManage} portal={portal} widgets={anonymousWidgets} onChanged={onChanged} />
)}
{current.key === "products" && (
<PortalProductSettings canManage={canManage} portal={portal} products={products} widgets={supportWidgets} onChanged={onChanged} />
)}
{current.key === "danger" && (
<PortalPublishSettings canManage={canManage} portal={portal} onChanged={onChanged} />
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { Icon } from "../../shared/icons";
/** Подвал таблиц порталов: подпись слева, страницы справа (кадры PT1/PT3).
* Один вид на оба списка раздела — у списка порталов и у библиотеки статей
* подвал в макете одинаковый; стрелки на границах гасятся (кадр PT1). */
export function PortalTableFooter({
note,
page,
pageCount,
onPageChange,
}: {
note: ReactNode;
page: number;
pageCount: number;
onPageChange: (page: number) => void;
}) {
const pages = Array.from({ length: pageCount }, (_, index) => index + 1);
return (
<div className="portal-table-footer">
<span>{note}</span>
<span className="portal-pages">
<button
aria-label="Предыдущая страница"
className="portal-page-step"
disabled={page <= 1}
type="button"
onClick={() => onPageChange(page - 1)}
>
<Icon name="chevron" size={14} strokeWidth={2} />
</button>
{pages.map((item) => (item === page
? <b key={item}>{item}</b>
: <button className="portal-page-number" key={item} type="button" onClick={() => onPageChange(item)}>{item}</button>))}
<button
aria-label="Следующая страница"
className="portal-page-step"
disabled={page >= pageCount}
type="button"
onClick={() => onPageChange(page + 1)}
>
<Icon name="chevron" size={14} strokeWidth={2} />
</button>
</span>
</div>
);
}
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { SelectField } from "../../shared/form-controls";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import {
portalErrorMessage,
@@ -30,9 +30,7 @@ export function PortalWidgetSettings({
setBusy(true);
setFeedback("");
try {
const payload = await updateSupportPortal(portal.id, {
widgetId,
});
const payload = await updateSupportPortal(portal.id, { widgetId });
onChanged(payload.portal);
setFeedback("Веб-виджет обновлён");
} catch (caught) {
@@ -43,34 +41,28 @@ export function PortalWidgetSettings({
}
return (
<section className="portal-section">
<div className="portal-section-heading">
<div>
<h2>Веб-виджет</h2>
<p>Публичный чат отображается на всех страницах портала.</p>
</div>
</div>
<div className="portal-settings-fields">
<SelectField
disabled={!canManage}
label="Веб-виджет"
value={String(widgetId ?? "")}
onChange={(value) => setWidgetId(value ? Number(value) : null)}
options={[
["", "Не показывать"],
...widgets.map((widget): [string, string] => [
String(widget.id),
widget.name,
]),
]}
/>
</div>
<div className="portal-settings-card">
<label className="portal-field is-narrow">
<span className="portal-field-label">Веб-виджет</span>
<span className="portal-select">
<select
disabled={!canManage}
value={String(widgetId ?? "")}
onChange={(event) => setWidgetId(event.target.value ? Number(event.target.value) : null)}
>
<option value="">Не показывать</option>
{widgets.map((widget) => <option key={widget.id} value={widget.id}>{widget.name}</option>)}
</select>
<Icon name="chevron" size={14} strokeWidth={2} />
</span>
<small>Виджет доступен только анонимным посетителям портала.</small>
</label>
{canManage && (
<Button variant="secondary" disabled={busy} onClick={() => void save()}>
Сохранить виджет
</Button>
<div className="portal-settings-actions">
<Button variant="primary" disabled={busy} onClick={() => void save()}>Сохранить виджет</Button>
{feedback && <span className="portal-settings-note">{feedback}</span>}
</div>
)}
{feedback && <div className="portal-save-feedback">{feedback}</div>}
</section>
</div>
);
}
@@ -1,58 +1,79 @@
import { Modal } from "antd";
import { useCallback, useEffect, useState } from "react";
import { hasCapability } from "../../auth/access";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { ErrorScreen, LoadingState, PageHeader, StatusPill } from "../../shared/ui";
import { ErrorScreen, LoadingState } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import type { Product, SessionUser } from "../../types";
import {
changePortalStatus,
listPortalArticles,
listPortalCategories,
listSupportPortals,
loadSupportPortal,
portalErrorMessage,
publishArticleRevision,
type PortalAddressConfig,
type PortalArticle,
type PortalCategory,
type PortalStatus,
type SupportPortal,
} from "./model";
import { PortalContent } from "./PortalContent";
import { PortalArticleEditor } from "./PortalArticleEditor";
import { PortalHeader } from "./PortalHeader";
import { PortalLibrary } from "./PortalLibrary";
import { PortalSettings } from "./PortalSettings";
import type { PortalSettingsSectionKey } from "./sections";
import "./styles";
// Карточка портала (дизайн-базлайн v2, кадры PT3–PT8): общая шапка с публичным
// адресом, под ней — библиотека материалов, настройки или редактор статьи.
type Publishing = { article: PortalArticle; revisionId: number };
export function SupportPortalDetailPage({
portalId,
products,
section,
user,
openPortals,
openPortalContent,
openPortalSettings,
}: {
portalId: number | null;
products: Product[];
section: PortalSettingsSectionKey | null;
user: SessionUser;
openPortals: () => void;
openPortalContent: (portalId: number) => void;
openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void;
}) {
const [portal, setPortal] = useState<SupportPortal | null>(null);
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
const [categories, setCategories] = useState<PortalCategory[]>([]);
const [articles, setArticles] = useState<PortalArticle[]>([]);
const [settingsOpen, setSettingsOpen] = useState(false);
const [editing, setEditing] = useState<PortalArticle | null | undefined>(undefined);
const [publishing, setPublishing] = useState<Publishing | null>(null);
const [pendingStatus, setPendingStatus] = useState<PortalStatus | null>(null);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);
const [pendingStatus, setPendingStatus] = useState<"DRAFT" | "PUBLISHED" | "ARCHIVED" | null>(null);
const [actionError, setActionError] = useState("");
const [error, setError] = useState("");
const canManage = hasCapability(user, "support.operate");
const load = useCallback(async () => {
if (!portalId) return;
setFailed(false);
try {
const [portalPayload, categoryPayload, articlePayload] = await Promise.all([
const [portalPayload, categoryPayload, articlePayload, listPayload] = await Promise.all([
loadSupportPortal(portalId),
listPortalCategories(portalId),
listPortalArticles(portalId),
listSupportPortals(),
]);
setPortal(portalPayload.portal);
setCategories(categoryPayload.items);
setArticles(articlePayload.items);
setAddress(listPayload.address);
} catch {
setFailed(true);
}
@@ -62,16 +83,34 @@ export function SupportPortalDetailPage({
void load();
}, [load]);
async function setStatus(status: "DRAFT" | "PUBLISHED" | "ARCHIVED") {
async function setStatus(status: PortalStatus) {
if (!portal) return;
setBusy(true);
setActionError("");
setError("");
try {
const payload = await changePortalStatus(portal.id, status);
setPortal(payload.portal);
setPendingStatus(null);
} catch (caught) {
setActionError(portalErrorMessage(caught, "Не удалось изменить статус портала"));
setError(portalErrorMessage(caught, "Не удалось изменить статус портала"));
setPendingStatus(null);
} finally {
setBusy(false);
}
}
async function publish() {
if (!publishing || !portal) return;
setBusy(true);
setError("");
try {
await publishArticleRevision(portal.id, publishing.article.id, publishing.revisionId);
setPublishing(null);
setEditing(undefined);
await load();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось опубликовать статью"));
setPublishing(null);
} finally {
setBusy(false);
}
@@ -79,56 +118,86 @@ export function SupportPortalDetailPage({
if (!portalId) return <ErrorScreen retry={openPortals} />;
if (failed) return <ErrorScreen retry={() => void load()} />;
if (!portal) return <LoadingState />;
if (!portal || !address) return <LoadingState />;
const action = portal.status === "PUBLISHED" || canManage ? (
<div className="portal-header-actions">
{portal.status === "PUBLISHED" && <a className="link has-icon" href={portal.publicUrl} target="_blank" rel="noreferrer">Открыть портал</a>}
{canManage && <Button variant="secondary" icon="settings" onClick={() => setSettingsOpen(true)}>Настройки</Button>}
{canManage && portal.status !== "PUBLISHED" && portal.status !== "ARCHIVED" && <Button variant="primary" disabled={busy} onClick={() => setPendingStatus("PUBLISHED")}>Опубликовать</Button>}
{canManage && portal.status === "ARCHIVED" && <Button variant="secondary" disabled={busy} onClick={() => void setStatus("DRAFT")}>Вернуть из архива</Button>}
{canManage && portal.status !== "ARCHIVED" && <Button variant="secondary" disabled={busy} onClick={() => setPendingStatus("ARCHIVED")}>В архив</Button>}
</div>
) : undefined;
// Редактор статьи занимает всю область раздела: шапки портала в нём нет.
if (editing !== undefined) {
return (
<>
<PortalArticleEditor
article={editing}
canManage={canManage && portal.status !== "ARCHIVED"}
categories={categories}
portal={portal}
onClose={() => setEditing(undefined)}
onPublish={(article, revisionId) => setPublishing({ article, revisionId })}
onSaved={load}
/>
<DecisionDialog
open={publishing !== null}
onClose={() => setPublishing(null)}
tone="warning"
icon="check"
title="Опубликовать выбранную версию?"
description="Эта версия станет доступна посетителям портала."
actions={<>
<Button variant="secondary" onClick={() => setPublishing(null)}>Отмена</Button>
<Button variant="primary" disabled={busy} onClick={() => void publish()}>Опубликовать</Button>
</>}
/>
</>
);
}
return (
<div className="support-portal-detail">
<PageHeader
title={portal.name}
text={<span><StatusPill status={portal.status === "PUBLISHED" ? "published" : portal.status === "ARCHIVED" ? "archived" : "draft"} /><code>{portal.publicUrl}</code></span>}
action={action}
<section className="portal-card">
<PortalHeader
canManage={canManage}
portal={portal}
settingsActive={section !== null}
onOpenPortals={openPortals}
onOpenContent={() => openPortalContent(portal.id)}
onOpenSettings={() => (section === null ? openPortalSettings(portal.id) : openPortalContent(portal.id))}
onCreateArticle={() => setEditing(null)}
onChangeStatus={(status) => (status === "ARCHIVED" ? setPendingStatus(status) : void setStatus(status))}
/>
<PortalContent
articles={articles}
canManage={canManage && portal.status !== "ARCHIVED"}
canLinkAgents={hasCapability(user, "ai.manage")}
categories={categories}
locale={portal.defaultLocale}
portalId={portal.id}
reload={load}
/>
<Modal
destroyOnHidden
footer={null}
open={settingsOpen}
title="Настройки портала"
width={960}
onCancel={() => setSettingsOpen(false)}
>
<PortalSettings portal={portal} products={products} canManage={canManage && portal.status !== "ARCHIVED"} onChanged={setPortal} />
</Modal>
{actionError && <div className="portal-form-error">{actionError}</div>}
{error && <div className="portal-form-error portal-card-error">{error}</div>}
{section === null ? (
<PortalLibrary
articles={articles}
canLinkAgents={hasCapability(user, "ai.manage")}
canManage={canManage && portal.status !== "ARCHIVED"}
categories={categories}
portalId={portal.id}
reload={load}
onEditArticle={setEditing}
/>
) : (
<PortalSettings
address={address}
canManage={canManage && portal.status !== "ARCHIVED"}
portal={portal}
products={products}
section={section}
onChanged={setPortal}
openSection={(next) => openPortalSettings(portal.id, next)}
/>
)}
<DecisionDialog
open={pendingStatus !== null}
onClose={() => setPendingStatus(null)}
tone={pendingStatus === "ARCHIVED" ? "danger" : "warning"}
icon={pendingStatus === "ARCHIVED" ? "trash" : "check"}
title={pendingStatus === "ARCHIVED" ? "Перенести портал в архив?" : "Опубликовать портал?"}
description={pendingStatus === "ARCHIVED"
? "Портал и его материалы станут недоступны посетителям до восстановления."
: "Опубликованные статьи станут доступны по публичному адресу."}
actions={<><Button variant="secondary" onClick={() => setPendingStatus(null)}>Отмена</Button><Button variant={pendingStatus === "ARCHIVED" ? "danger-outline" : "primary"} disabled={busy} onClick={() => pendingStatus && void setStatus(pendingStatus)}>{pendingStatus === "ARCHIVED" ? "В архив" : "Опубликовать"}</Button></>}
tone="danger"
icon="trash"
title="Перенести портал в архив?"
description="Портал и его материалы станут недоступны посетителям до восстановления."
actions={<>
<Button variant="secondary" onClick={() => setPendingStatus(null)}>Отмена</Button>
<Button variant="danger-outline" disabled={busy} onClick={() => pendingStatus && void setStatus(pendingStatus)}>В архив</Button>
</>}
/>
</div>
</section>
);
}
@@ -1,33 +1,71 @@
import { Dropdown } from "antd";
import { useCallback, useEffect, useMemo, useState } from "react";
import { hasCapability } from "../../auth/access";
import { ContentLibraryTable } from "../../shared/content-library/ContentLibraryTable";
import { ContentLibraryToolbar } from "../../shared/content-library/ContentLibraryToolbar";
import { Icon } from "../../shared/icons";
import { ContentState, ErrorScreen, LoadingState, PageHeader, StatusPill } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { ErrorScreen, LoadingState, StatusPill } from "../../shared/ui";
import { Button, CopyButton, FilterDropdown, SearchInput } from "../../shared/ui-controls";
import { pluralRu } from "../../shared/utils";
import type { SessionUser } from "../../types";
import {
changePortalStatus,
listSupportPortals,
portalErrorMessage,
PORTAL_STATUS_LABEL,
type PortalAddressConfig,
type PortalStatus,
type SupportPortal,
} from "./model";
import {
contentSummary,
portalSubtitle,
productsSummary,
publicHost,
updatedAt,
} from "./portalText";
import { PortalCreateDialog } from "./PortalCreateDialog";
import { PortalTableFooter } from "./PortalTableFooter";
import { DecisionDialog } from "../../shared/DecisionDialog";
import "./styles";
// Список порталов (дизайн-базлайн v2, кадры PT1/PT2). Адрес — колонка со
// ссылкой и копированием, действия строки — в меню ⋯, архивные приглушены и
// уходят в конец списка.
const PAGE_SIZE = 20;
const STATUS_FILTER: Array<{ value: string; label: string }> = [
{ value: "PUBLISHED", label: PORTAL_STATUS_LABEL.PUBLISHED },
{ value: "DRAFT", label: PORTAL_STATUS_LABEL.DRAFT },
{ value: "ARCHIVED", label: PORTAL_STATUS_LABEL.ARCHIVED },
];
function pillStatus(status: PortalStatus): "published" | "archived" | "draft" {
if (status === "PUBLISHED") return "published";
if (status === "ARCHIVED") return "archived";
return "draft";
}
export function SupportPortalsPage({
user,
openPortal,
openPortalSettings,
}: {
user: SessionUser;
openPortal: (portalId: number) => void;
openPortalSettings: (portalId: number) => void;
}) {
const [portals, setPortals] = useState<SupportPortal[] | null>(null);
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
const [failed, setFailed] = useState(false);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<string[]>([]);
const [statusOpen, setStatusOpen] = useState(false);
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
const [archiving, setArchiving] = useState<SupportPortal | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const canManage = hasCapability(user, "support.operate");
const load = useCallback(async () => {
@@ -47,76 +85,47 @@ export function SupportPortalsPage({
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return portals ?? [];
return (portals ?? []).filter((portal) => (
portal.name.toLocaleLowerCase().includes(query)
|| portal.slug.toLocaleLowerCase().includes(query)
));
}, [portals, search]);
return (portals ?? [])
.filter((portal) => (
(status.length === 0 || status.includes(portal.status))
&& (!query
|| portal.name.toLocaleLowerCase().includes(query)
|| publicHost(portal).toLocaleLowerCase().includes(query))
))
// Архивные показываются последними (подпись в подвале кадра PT1).
.sort((left, right) => Number(left.status === "ARCHIVED") - Number(right.status === "ARCHIVED"));
}, [portals, search, status]);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.min(page, pageCount);
const visible = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
async function archive(portal: SupportPortal) {
setBusy(true);
setError("");
try {
await changePortalStatus(portal.id, "ARCHIVED");
setArchiving(null);
await load();
} catch (caught) {
setError(portalErrorMessage(caught, "Не удалось перенести портал в архив"));
setArchiving(null);
} finally {
setBusy(false);
}
}
if (failed) return <ErrorScreen retry={() => void load()} />;
if (!portals || !address) return <LoadingState />;
const createAction = canManage ? (
<Button variant="primary" icon="plus" onClick={() => setCreating(true)}>
const createButton = canManage ? (
<Button variant="primary" icon="plus" iconSize={16} onClick={() => setCreating(true)}>
Создать портал
</Button>
) : undefined;
) : null;
return (
<div className="support-portals-page">
<PageHeader
title="Порталы"
text="Публичные базы знаний для клиентов"
action={createAction}
/>
{portals.length === 0 ? (
<ContentState
icon={<Icon name="folder" size={24} />}
title="Создайте первый портал поддержки"
text="Публикуйте инструкции и ответы на частые вопросы для клиентов."
action={createAction}
/>
) : (
<div className="knowledge-library-list">
<ContentLibraryToolbar
placeholder="Поиск по названию и адресу"
query={search}
onQueryChange={setSearch}
/>
<ContentLibraryTable
emptyTitle="Порталы не найдены"
error={false}
errorTitle="Не удалось загрузить порталы"
hasItems={filtered.length > 0}
loading={false}
onRetry={() => void load()}
footer={<div className="ai-table-footer"><span>{pluralRu(filtered.length, ["портал", "портала", "порталов"])}</span></div>}
>
<table className="baseline-table knowledge-table">
<thead><tr><th>ПОРТАЛ</th><th>СТАТУС</th><th>ПРОДУКТЫ</th><th /></tr></thead>
<tbody>
{filtered.map((portal) => (
<tr className="knowledge-row" key={portal.id} onClick={() => openPortal(portal.id)}>
<td>
<span className="support-portal-name">
<i><Icon name="folder" size={19} /></i>
<span>
<button className="link is-strong is-neutral" type="button" onClick={() => openPortal(portal.id)}>{portal.name}</button>
<small>{portal.publicUrl}</small>
</span>
</span>
</td>
<td><StatusPill status={portal.status === "PUBLISHED" ? "published" : portal.status === "ARCHIVED" ? "archived" : "draft"} /></td>
<td>{portal.products.length || "—"}</td>
<td className="support-portal-open"><Icon name="arrow" size={16} /></td>
</tr>
))}
</tbody>
</table>
</ContentLibraryTable>
</div>
)}
const dialogs = (
<>
{creating && (
<PortalCreateDialog
open
@@ -128,6 +137,168 @@ export function SupportPortalsPage({
}}
/>
)}
</div>
<DecisionDialog
open={archiving !== null}
onClose={() => setArchiving(null)}
tone="danger"
icon="trash"
title="Перенести портал в архив?"
description="Портал и его материалы станут недоступны посетителям до восстановления."
actions={<>
<Button variant="secondary" onClick={() => setArchiving(null)}>Отмена</Button>
<Button variant="danger-outline" disabled={busy} onClick={() => archiving && void archive(archiving)}>В архив</Button>
</>}
/>
</>
);
if (portals.length === 0) {
// Кадр PT2: создание доступно всегда, тарифных лимитов нет.
return (
<section className="portals-page is-empty">
<header className="portals-head">
<div>
<h2>Порталы</h2>
<p>Публичные базы знаний для клиентов.</p>
</div>
{createButton}
</header>
<div className="portals-empty">
<div>
<span className="portals-empty-mark"><Icon name="globe" size={27} strokeWidth={1.7} /></span>
<h3>Создайте первый портал поддержки</h3>
<p>Портал — публичная страница с инструкциями и ответами на частые вопросы. Клиент читает статью и, если не нашёл ответ, пишет в чат прямо со страницы.</p>
<div className="portals-empty-actions">
{createButton}
{/* Витрина посетителя — это и есть публичный help-контур установки:
открываем его базовый адрес, отдельного демо-экрана нет. */}
<a
className="secondary-button"
href={`${address.scheme}://${address.baseDomain}${address.port ? `:${address.port}` : ""}/`}
rel="noreferrer"
target="_blank"
>
Как это выглядит у клиента
</a>
</div>
</div>
</div>
{dialogs}
</section>
);
}
return (
<section className="portals-page">
<header className="portals-head">
<div>
<h2>Порталы</h2>
<p>Публичные базы знаний для клиентов. Каждый портал — свой адрес, оформление и материалы.</p>
</div>
{createButton}
</header>
{error && <div className="portal-form-error">{error}</div>}
<div className="portals-card">
<div className="portals-card-head">
<SearchInput
className="portals-search"
placeholder="Поиск по названию и адресу"
value={search}
onChange={(value) => { setSearch(value); setPage(1); }}
/>
<FilterDropdown
caption="Статус:"
label={status.length === 1 ? STATUS_FILTER.find((item) => item.value === status[0])!.label : "Все"}
multiple
open={statusOpen}
options={STATUS_FILTER}
selected={status}
className="portals-status-filter"
onOpenChange={setStatusOpen}
onSelect={(value) => {
setPage(1);
setStatus((current) => (current.includes(value)
? current.filter((item) => item !== value)
: [...current, value]));
}}
/>
</div>
<table className="portals-table">
<thead>
<tr>
<th>ПОРТАЛ</th>
<th>ПУБЛИЧНЫЙ АДРЕС</th>
<th>СТАТУС</th>
<th>МАТЕРИАЛЫ</th>
<th>ПРОДУКТЫ</th>
<th>ОБНОВЛЁН</th>
<th />
</tr>
</thead>
<tbody>
{visible.map((portal) => {
const host = publicHost(portal);
const ownDomain = Boolean(portal.customDomain && portal.customDomainVerifiedAt);
const archived = portal.status === "ARCHIVED";
const menuItems = [
{ key: "content", label: <button type="button" onClick={() => openPortal(portal.id)}><Icon name="folder" size={15} strokeWidth={1.9} />Открыть материалы</button> },
{ key: "settings", label: <button type="button" onClick={() => openPortalSettings(portal.id)}><Icon name="settings" size={15} strokeWidth={1.9} />Настройки портала</button> },
{ key: "divider-1", type: "divider" as const },
{ key: "copy", label: <button type="button" onClick={() => void navigator.clipboard?.writeText(portal.publicUrl)}><Icon name="copy" size={15} strokeWidth={1.9} />Копировать публичную ссылку</button> },
{ key: "open", label: <button type="button" onClick={() => window.open(portal.publicUrl, "_blank", "noreferrer")}><Icon name="external" size={15} strokeWidth={1.9} />Открыть портал</button> },
...(canManage && !archived ? [
{ key: "divider-2", type: "divider" as const },
{ key: "archive", label: <button className="danger" type="button" onClick={() => setArchiving(portal)}><Icon name="trash" size={15} strokeWidth={1.9} />Перенести в архив</button> },
] : []),
];
return (
<tr className={`portals-row${archived ? " is-archived" : ""}`} key={portal.id} onClick={() => openPortal(portal.id)}>
<td>
<span className="portals-name">
<i className={ownDomain || portal.status === "PUBLISHED" ? "is-live" : ""}><Icon name="globe" size={18} strokeWidth={1.8} /></i>
<span>
<strong>{portal.name}</strong>
<small>{portalSubtitle(portal)}</small>
</span>
</span>
</td>
<td onClick={(event) => event.stopPropagation()}>
<span className="portals-address">
<code>{host}</code>
<CopyButton className="portals-copy" label="" value={portal.publicUrl} />
{ownDomain && (
<small className="portals-domain-badge">
<Icon name="check" size={11} strokeWidth={2.6} />свой домен
</small>
)}
</span>
</td>
<td><StatusPill status={pillStatus(portal.status)} label={PORTAL_STATUS_LABEL[portal.status]} /></td>
<td className="portals-content-cell">{contentSummary(portal)}</td>
<td className="portals-products-cell">{productsSummary(portal)}</td>
<td className="portals-updated-cell">{updatedAt(portal.updatedAt)}</td>
<td className="row-actions" onClick={(event) => event.stopPropagation()}>
<Dropdown menu={{ items: menuItems }} overlayClassName="app-dropdown is-portal-menu" placement="bottomRight" trigger={["click"]}>
<button aria-label={`Действия: ${portal.name}`} className="row-menu-button" type="button"><Icon name="more" size={16} strokeWidth={2} /></button>
</Dropdown>
</td>
</tr>
);
})}
</tbody>
</table>
<PortalTableFooter
note={`${pluralRu(filtered.length, ["портал", "портала", "порталов"])} · архивные показываются последними`}
page={currentPage}
pageCount={pageCount}
onPageChange={setPage}
/>
</div>
{dialogs}
</section>
);
}
@@ -1,8 +1,9 @@
import { api } from "../../api/client";
import { api, apiUpload } from "../../api/client";
import type { ArticleImportDocument } from "./parseArticleYaml";
import type {
ArticleRevision,
PortalArticle,
PortalArticleFile,
PortalCategory,
PortalInput,
PortalStatus,
@@ -175,6 +176,40 @@ export function publishArticleRevision(
});
}
export function listArticleFiles(
portalId: number,
articleId: number,
): Promise<{ items: PortalArticleFile[] }> {
return api(`/api/v1/support/portals/${portalId}/articles/${articleId}/files/`);
}
/** Загрузка файла статьи с прогрессом: рейка редактора показывает проценты. */
export function uploadArticleFile(
portalId: number,
articleId: number,
file: File,
onProgress?: (percent: number) => void,
): Promise<{ file: PortalArticleFile }> {
const body = new FormData();
body.append("file", file);
return apiUpload(
`/api/v1/support/portals/${portalId}/articles/${articleId}/files/`,
body,
onProgress,
);
}
export function deleteArticleFile(
portalId: number,
articleId: number,
fileId: number,
): Promise<void> {
return api(
`/api/v1/support/portals/${portalId}/articles/${articleId}/files/${fileId}/`,
{ method: "DELETE" },
);
}
export function archivePortalArticle(
portalId: number,
articleId: number,
@@ -0,0 +1,134 @@
import type { Icon } from "../../shared/icons";
type IconName = Parameters<typeof Icon>[0]["name"];
export type MarkdownTool = {
key: string;
title: string;
text?: string;
icon?: IconName;
divider?: boolean;
accent?: boolean;
};
// Панель Markdown редактора статьи (дизайн-базлайн v2, кадр PT7).
export const MARKDOWN_TOOLS: MarkdownTool[] = [
{ key: "h1", title: "Заголовок 1", text: "H1" },
{ key: "h2", title: "Заголовок 2", text: "H2" },
{ key: "divider-1", title: "", divider: true },
{ key: "bold", title: "Полужирный", icon: "bold" },
{ key: "italic", title: "Курсив", icon: "italic" },
{ key: "link", title: "Ссылка", icon: "link" },
{ key: "code", title: "Код", icon: "code" },
{ key: "divider-2", title: "", divider: true },
{ key: "list", title: "Список", icon: "list" },
{ key: "numlist", title: "Нумерованный список", icon: "numlist" },
{ key: "quote", title: "Цитата", icon: "quote" },
{ key: "table", title: "Таблица", icon: "table" },
{ key: "divider-3", title: "", divider: true },
{ key: "image", title: "Вставить изображение", icon: "image", accent: true },
{ key: "attach", title: "Прикрепить файл", icon: "attach" },
];
export type MarkdownEdit = { value: string; selectionStart: number; selectionEnd: number };
const WRAPS: Record<string, [string, string]> = {
bold: ["**", "**"],
italic: ["*", "*"],
code: ["`", "`"],
};
const LINE_PREFIXES: Record<string, string> = {
h1: "# ",
h2: "## ",
list: "- ",
numlist: "1. ",
quote: "> ",
};
const TABLE_SNIPPET = "\n| Колонка | Колонка |\n| ------- | ------- |\n| Значение | Значение |\n";
/** Применение кнопки панели к тексту с учётом выделения (кадр PT7). */
export function applyMarkdownTool(
key: string,
value: string,
selectionStart: number,
selectionEnd: number,
): MarkdownEdit {
const selected = value.slice(selectionStart, selectionEnd);
const wrap = WRAPS[key];
if (wrap) {
const [open, close] = wrap;
const next = `${value.slice(0, selectionStart)}${open}${selected}${close}${value.slice(selectionEnd)}`;
return {
value: next,
selectionStart: selectionStart + open.length,
selectionEnd: selectionStart + open.length + selected.length,
};
}
const prefix = LINE_PREFIXES[key];
if (prefix) {
const lineStart = value.lastIndexOf("\n", selectionStart - 1) + 1;
const next = `${value.slice(0, lineStart)}${prefix}${value.slice(lineStart)}`;
return {
value: next,
selectionStart: selectionStart + prefix.length,
selectionEnd: selectionEnd + prefix.length,
};
}
if (key === "link") {
const label = selected || "текст ссылки";
const snippet = `[${label}](https://)`;
const next = `${value.slice(0, selectionStart)}${snippet}${value.slice(selectionEnd)}`;
const linkStart = selectionStart + snippet.length - 9;
return { value: next, selectionStart: linkStart, selectionEnd: linkStart + 8 };
}
if (key === "table") {
const next = `${value.slice(0, selectionStart)}${TABLE_SNIPPET}${value.slice(selectionEnd)}`;
const caret = selectionStart + TABLE_SNIPPET.length;
return { value: next, selectionStart: caret, selectionEnd: caret };
}
return { value, selectionStart, selectionEnd };
}
/** Вставка файла: изображение — картинкой, документ — ссылкой (кадр PT8). */
export function fileMarkdown(name: string, url: string, isImage: boolean): string {
return isImage ? `![${name}](${url})` : `[${name}](${url})`;
}
export function isImageFile(contentType: string, name: string): boolean {
return contentType.startsWith("image/") || /\.(png|jpe?g|webp|gif|svg)$/i.test(name);
}
/** «4 280 знаков · 12 абзацев» — статусная строка редактора. */
export function editorStats(value: string): string {
const characters = value.length.toLocaleString("ru-RU");
const paragraphs = value.split(/\n{2,}/).filter((block) => block.trim()).length;
const forms = paragraphs % 10 === 1 && paragraphs % 100 !== 11 ? "абзац"
: [2, 3, 4].includes(paragraphs % 10) && ![12, 13, 14].includes(paragraphs % 100) ? "абзаца"
: "абзацев";
return `${characters} знаков · ${paragraphs} ${forms}`;
}
/** «Строка 18, столбец 24» — позиция курсора в статусной строке. */
export function cursorPosition(value: string, caret: number): string {
const before = value.slice(0, caret);
const line = before.split("\n").length;
const column = caret - (before.lastIndexOf("\n") + 1) + 1;
return `Строка ${line}, столбец ${column}`;
}
/** «PNG · 240 КБ» — метаданные файла в рейке. */
export function fileMeta(name: string, size: number): string {
const extension = (name.split(".").pop() ?? "").toLocaleUpperCase();
const kilobytes = size / 1024;
const readable = kilobytes >= 1024
? `${(kilobytes / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`
: `${Math.max(1, Math.round(kilobytes)).toLocaleString("ru-RU")} КБ`;
return extension ? `${extension} · ${readable}` : readable;
}
@@ -41,11 +41,6 @@ export type SupportPortal = {
value: string;
} | null;
customDomainVerifiedAt: string | null;
customDomainVerification: {
name: string;
type: "TXT";
value: string;
} | null;
publicUrl: string;
name: string;
defaultLocale: string;
@@ -61,6 +56,9 @@ export type SupportPortal = {
products: PortalProductLink[];
createdAt: string;
updatedAt: string;
// Колонка «Материалы» списка порталов и подзаголовок карточки (кадры PT1/PT3).
categoryCount: number;
articleCount: number;
};
export type PortalCategory = {
@@ -81,6 +79,19 @@ export type ArticleRevision = {
content: string;
createdAt: string;
publishedAt: string | null;
authorName: string;
};
export type PortalArticleFile = {
id: number;
name: string;
contentType: string;
size: number;
// Относительный путь — для вставки в Markdown: страница портала отдаётся
// с CSP img-src 'self', и абсолютная ссылка была бы чужим origin.
path: string;
url: string;
createdAt: string;
};
export type PortalArticle = {
@@ -92,6 +103,10 @@ export type PortalArticle = {
publishedRevision: ArticleRevision | null;
latestRevision: Omit<ArticleRevision, "content"> | null;
revisions?: ArticleRevision[];
files?: PortalArticleFile[];
fileCount: number;
// Оценки посетителей: две кнопки под статьёй на публичном портале.
feedback: { helpful: number; unhelpful: number };
createdAt: string;
updatedAt: string;
};
@@ -124,8 +139,10 @@ export {
createPortalArticle,
createPortalCategory,
createSupportPortal,
deleteArticleFile,
deletePortalCategory,
importPortalArticles,
listArticleFiles,
listPortalArticles,
listPortalCategories,
listPortalSupportChannels,
@@ -138,6 +155,7 @@ export {
updatePortalArticle,
updatePortalCategory,
updateSupportPortal,
uploadArticleFile,
verifyPortalCustomDomain,
} from "./api";
@@ -0,0 +1,66 @@
import { pluralRu, shortDateTime } from "../../shared/utils";
import type { PortalArticle, SupportPortal } from "./model";
const LOCALE_NAMES: Record<string, string> = {
ru: "русский",
en: "английский",
};
export const LOCALE_OPTIONS: Array<[string, string]> = [
["ru", "Русский"],
["en", "English"],
];
export function localeName(locale: string): string {
return LOCALE_NAMES[locale] ?? locale;
}
/** «русский · создан 12 июля» — вторая строка названия портала (кадр PT1). */
export function portalSubtitle(portal: SupportPortal): string {
const created = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "long" })
.format(new Date(portal.createdAt));
return `${localeName(portal.defaultLocale)} · создан ${created}`;
}
/** «6 разделов · 48 статей» — колонка «Материалы» и подзаголовок карточки. */
export function contentSummary(portal: SupportPortal): string {
return [
pluralRu(portal.categoryCount, ["раздел", "раздела", "разделов"]),
pluralRu(portal.articleCount, ["статья", "статьи", "статей"]),
].join(" · ");
}
export function productsSummary(portal: SupportPortal): string {
return portal.products.length
? pluralRu(portal.products.length, ["продукт", "продукта", "продуктов"])
: "—";
}
/** Публичный адрес без схемы: в макете колонка и шапка показывают только хост. */
export function publicHost(portal: SupportPortal): string {
return portal.publicUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
export function hostedHost(portal: SupportPortal): string {
return portal.hostedDomain.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
export function updatedAt(value: string): string {
return shortDateTime(value);
}
/** «Редакция 7 · черновая 8» — колонка «Редакция» таблицы статей (кадр PT3). */
export function revisionSummary(article: PortalArticle): {
revision: string;
note: string;
} {
const published = article.publishedRevision?.revision ?? null;
const latest = article.latestRevision?.revision ?? null;
if (published === null) {
return { revision: latest === null ? "—" : String(latest), note: "не опубликована" };
}
return {
revision: String(published),
note: latest !== null && latest > published ? `черновая ${latest}` : "",
};
}
@@ -0,0 +1,76 @@
import type { Icon } from "../../shared/icons";
type IconName = Parameters<typeof Icon>[0]["name"];
export type PortalSettingsSectionKey =
| "basics"
| "domain"
| "theme"
| "widget"
| "products"
| "danger";
export type PortalSettingsSection = {
key: PortalSettingsSectionKey;
label: string;
icon: IconName;
heading: string;
lead: string;
// Разделитель под пунктом — как в макете, перед сноской субменю.
divider?: boolean;
};
// Субменю настроек портала (дизайн-базлайн v2, кадры PT4–PT6). Модалки нет:
// настройки — страница /portals/{id}/settings/{раздел}.
export const PORTAL_SETTINGS_SECTIONS: PortalSettingsSection[] = [
{
key: "basics",
label: "Основные",
icon: "settings",
heading: "Основные",
lead: "Название, публичный адрес и язык материалов портала.",
},
{
key: "domain",
label: "Адрес и домен",
icon: "globe",
heading: "Свой домен",
lead: "Портал будет открываться по адресу вашей компании. Адрес установки продолжит работать.",
},
{
key: "theme",
label: "Оформление",
icon: "paint",
heading: "Оформление",
lead: "Тема публичных страниц и цветовая схема. Применяется до первого кадра — посетитель не видит смены оформления. Список тем — из сборки установки.",
},
{
key: "widget",
label: "Веб-виджет",
icon: "widget",
heading: "Веб-виджет",
lead: "Публичный чат отображается на всех страницах портала.",
},
{
key: "products",
label: "Продукты и поддержка",
icon: "box",
heading: "Продукты и поддержка",
lead: "Выберите продукты и виджеты, через которые клиенты смогут обратиться в поддержку.",
},
{
key: "danger",
label: "Публикация и архив",
icon: "danger",
heading: "Публикация и архив",
lead: "Публикация открывает материалы посетителям, архив прячет портал целиком.",
divider: true,
},
];
export const DEFAULT_PORTAL_SETTINGS_SECTION: PortalSettingsSectionKey = "basics";
export function portalSettingsSectionKey(value: string): PortalSettingsSectionKey | null {
const found = PORTAL_SETTINGS_SECTIONS.find((section) => section.key === value);
return found ? found.key : null;
}
@@ -1,184 +1,493 @@
.support-portal-detail {
display: grid;
gap: 16px;
/* Карточка портала: шапка с публичным адресом и библиотека материалов
(дизайн-базлайн v2, кадр PT3). Метрики — из макета «Порталы Baseline». */
.portal-card {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: var(--surface-feed);
}
.support-portal-detail .page-header {
margin-bottom: 0;
.portal-card-error {
margin: 12px 28px 0;
}
.support-portal-detail .page-header p > span {
/* --- Шапка --- */
.portal-card-head {
flex: none;
padding: 18px 28px 16px;
border-bottom: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-card-head-inner {
max-width: 1500px;
}
.portal-breadcrumbs {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 9px;
color: var(--n-4);
font-size: 12.5px;
}
.portal-breadcrumbs .link {
font-size: 12.5px;
}
.portal-breadcrumbs b {
color: var(--n-2);
font-weight: 400;
}
.portal-card-head-row {
display: flex;
align-items: flex-start;
gap: 20px;
}
.portal-card-identity {
min-width: 0;
flex: 1;
}
.portal-card-title {
display: flex;
align-items: center;
gap: 11px;
margin-bottom: 10px;
}
.portal-card-title h2 {
margin: 0;
color: var(--n-1);
font-size: 21px;
font-weight: 700;
letter-spacing: -0.015em;
}
.portal-card-title > span:last-child {
color: var(--n-4);
font-size: 12.5px;
}
.portal-card .status-pill { height: 23px; }
/* Публичный адрес — постоянный элемент шапки, а не строка подзаголовка. */
.portal-address-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.portal-address-chip {
display: flex;
height: 34px;
align-items: center;
gap: 9px;
padding: 0 5px 0 11px;
border: 1px solid var(--n-7);
border-radius: 9px;
background: var(--surface-sidebar);
color: var(--n-4);
}
.portal-address-chip.is-live { color: var(--success-text); }
.portal-address-chip code {
color: var(--n-1);
font-family: var(--font-mono);
font-size: 13px;
}
.portal-domain-tag {
display: inline-flex;
height: 20px;
align-items: center;
gap: 4px;
padding: 0 7px;
border-radius: 5px;
background: var(--success-bg);
color: var(--success-text);
font-size: 10.5px;
font-weight: 600;
}
.portal-domain-tag.is-pending {
background: var(--warning-bg);
color: var(--warning-text);
}
.portal-address-copy,
.portal-address-open {
display: flex;
width: 26px;
height: 26px;
flex: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-4);
cursor: pointer;
}
.portal-address-open { color: var(--primary-text); }
.portal-address-copy:hover { background: var(--n-9); color: var(--n-2); }
.portal-address-open:hover { background: var(--primary-bg); }
.portal-hosted-note {
color: var(--n-4);
font-size: 12px;
white-space: nowrap;
}
.portal-hosted-note code {
color: var(--n-3);
font-family: var(--font-mono);
}
.portal-card-actions {
display: flex;
flex: none;
align-items: center;
gap: 8px;
padding-top: 4px;
}
.portal-settings-button {
display: flex;
height: 34px;
align-items: center;
gap: 7px;
padding: 0 13px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.portal-settings-button:hover { border-color: var(--n-6); }
.portal-settings-button.is-active {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
}
.portal-card-actions .primary-button {
height: 34px;
gap: 7px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
.portal-more-button {
display: flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-4);
cursor: pointer;
}
.portal-more-button:hover { border-color: var(--n-6); color: var(--n-2); }
/* --- Библиотека материалов --- */
.portal-library {
display: flex;
flex: 1;
min-height: 0;
}
.portal-sections {
display: flex;
width: 260px;
flex: none;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-sections > header {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 15px 14px 8px;
}
.portal-sections > header > span {
color: var(--n-4);
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.04em;
}
.portal-sections > header button {
padding: 0;
border: 0;
background: transparent;
color: var(--primary-text);
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.portal-sections > nav {
display: flex;
flex: 1;
flex-direction: column;
gap: 1px;
min-height: 0;
padding: 0 8px 12px;
overflow-y: auto;
}
.portal-section-row {
display: flex;
min-height: 32px;
align-items: center;
gap: 8px;
padding-right: 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.portal-section-row:hover { background: var(--n-9); }
.portal-section-row.is-active {
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
}
.portal-section-row > span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-section-row small {
color: var(--n-5);
font-size: 11.5px;
font-weight: 600;
}
.portal-section-row.is-active small { color: var(--primary-text); }
.portal-sections-foot {
flex: none;
padding: 10px 14px;
border-top: 1px solid var(--n-8);
}
.portal-sections-foot button {
display: flex;
width: 100%;
height: 32px;
align-items: center;
gap: 7px;
padding: 0 10px;
border: 1px dashed var(--n-6);
border-radius: 8px;
background: transparent;
color: var(--n-3);
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
}
.portal-sections-foot button:hover {
border-color: var(--primary);
color: var(--primary-text);
}
.portal-library-main {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
overflow-y: auto;
}
/* Материалы занимают всю ширину области: таблица статей широкая, и сжимать
её колонкой по центру незачем. */
.portal-library-inner {
display: flex;
width: 100%;
min-height: 100%;
flex-direction: column;
}
.portal-library-toolbar {
display: flex;
flex: none;
align-items: center;
gap: 10px;
padding: 14px 24px;
border-bottom: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-library-toolbar .ui-search-input {
width: 280px;
max-width: none;
height: 34px;
flex: none;
gap: 8px;
padding: 0 11px;
border-radius: 8px;
}
.portal-library-toolbar-gap { flex: 1; }
.portal-import-button {
height: 34px;
gap: 7px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.portal-articles-table {
background: var(--surface-card);
}
.portal-articles-table th { padding: 9px 12px; }
.portal-articles-table th:first-child { padding-left: 24px; }
.portal-articles-table th:last-child { width: 44px; }
.portal-articles-table td {
padding: 11px 12px;
border-bottom: 1px solid var(--n-8);
vertical-align: middle;
}
.portal-articles-table td:first-child { padding-left: 24px; }
.portal-article-row { cursor: pointer; }
.portal-article-row:hover > td { background: var(--n-10); }
.portal-article-row.is-archived { opacity: 0.6; }
.portal-article-name {
display: flex;
align-items: center;
gap: 10px;
}
.portal-article-name > i {
display: flex;
width: 28px;
height: 28px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 7px;
background: var(--n-9);
color: var(--n-4);
}
.portal-article-name > span {
min-width: 0;
line-height: 1.35;
}
.portal-article-name strong {
display: block;
color: var(--n-1);
font-size: 13.5px;
font-weight: 600;
}
.portal-article-name > span small {
color: var(--n-4);
font-family: var(--font-mono);
font-size: 11.5px;
}
.portal-article-files {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--n-4);
font-size: 11px;
}
.portal-article-category { color: var(--n-2); font-size: 13px; }
.portal-article-locale { color: var(--n-3); font-size: 13px; }
.portal-article-revision { color: var(--n-2); font-size: 13px; }
.portal-article-revision small {
display: block;
color: var(--warning-text);
font-size: 11px;
}
.portal-article-updated { color: var(--n-4); font-size: 12.5px; }
.portal-articles-table + .portal-table-footer {
padding: 11px 24px;
}
/* Колонка «Оценки»: два счётчика с пальцами — так же, как читатель голосует
на портале (SPEC-HUB-0028). Ширина фиксирована, чтобы цифры стояли столбиком. */
.portal-articles-table th:nth-child(5) { width: 108px; }
.portal-article-votes { width: 108px; }
.portal-votes {
display: inline-flex;
align-items: center;
gap: 10px;
}
.support-portal-detail .page-header code {
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
}
.portal-header-actions {
display: flex;
.portal-votes b {
display: inline-flex;
align-items: center;
gap: 10px;
gap: 4px;
font-size: 12.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.portal-settings-grid,
.portal-content-layout {
display: grid;
gap: 16px;
}
.portal-votes b.is-up { color: var(--success-text); }
.portal-votes b.is-down { color: var(--n-4); }
.portal-section {
padding: 22px;
border: 1px solid var(--border-default);
border-radius: var(--radius-2xl);
background: var(--surface-card);
box-shadow: var(--shadow-sm);
}
.portal-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 20px;
}
.portal-section-heading h2 {
margin: 0 0 4px;
color: var(--text-heading);
font-size: 16px;
font-weight: 700;
}
.portal-section-heading p {
margin: 0;
color: var(--text-tertiary);
.portal-votes-empty {
color: var(--n-6);
font-size: 12.5px;
}
.portal-settings-fields {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) 180px;
gap: 12px;
margin-bottom: 16px;
}
.portal-address-preview {
grid-column: 1 / -1;
margin: -4px 0 0;
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11.5px;
}
.portal-article-col-title { width: 26%; }
.portal-article-col-category { width: 18%; }
.portal-article-col-language { width: 7%; }
.portal-article-col-version { width: 7%; }
.portal-article-col-status { width: 18%; }
.portal-article-col-updated { width: 17%; }
.portal-article-col-actions { width: 48px; }
.portal-article-table th:last-child,
.portal-article-table td:last-child {
padding-right: 10px;
padding-left: 8px;
}
.portal-product-list {
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.portal-product-list > div {
display: grid;
grid-template-columns: 38px minmax(160px, 1fr) minmax(240px, .8fr);
align-items: center;
gap: 12px;
min-height: 62px;
padding: 10px 12px;
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
}
.portal-product-list > div.is-enabled {
border-color: var(--primary-border);
background: var(--primary-bg);
}
.portal-product-list strong,
.portal-product-list small {
display: block;
}
.portal-product-list small {
margin-top: 2px;
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
}
.portal-product-list .readonly-field {
margin: 0;
}
.portal-save-feedback {
color: var(--text-secondary);
font-size: 12.5px;
}
.portal-domain-verification {
margin: 0 0 16px;
padding: 14px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-lg);
background: var(--primary-bg);
}
.portal-domain-verification > strong {
font-size: 13px;
}
.portal-domain-record {
margin-top: 14px;
}
.portal-domain-record > b {
color: var(--text-heading);
font-size: 12.5px;
}
.portal-domain-verification dl {
display: grid;
gap: 8px;
margin: 8px 0 0;
}
.portal-domain-verification dl > div {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 10px;
}
.portal-domain-verification dt { color: var(--text-tertiary); }
.portal-domain-verification dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.portal-domain-actions { display: flex; gap: 8px; }
@media (max-width: 1040px) {
.portal-settings-fields {
grid-template-columns: 1fr 1fr;
}
.portal-product-list > div {
grid-template-columns: 38px 1fr;
}
.portal-product-list .readonly-field {
grid-column: 2;
}
}
@@ -0,0 +1,749 @@
/* Редактор статьи портала (дизайн-базлайн v2, кадры PT7/PT8): полноэкранный
split — текст с панелью Markdown, живой предпросмотр и рейка 300px. */
.portal-editor {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background: var(--surface-feed);
}
.portal-editor-head {
display: flex;
height: 54px;
flex: none;
align-items: center;
gap: 14px;
padding: 0 20px;
border-bottom: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-editor-back {
display: flex;
height: 30px;
align-items: center;
gap: 7px;
padding: 0 10px 0 7px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.portal-editor-back:hover { background: var(--n-9); }
.portal-editor-back svg { transform: rotate(90deg); }
.portal-editor-crumb {
color: var(--n-4);
font-size: 12.5px;
white-space: nowrap;
}
.portal-editor-gap { flex: 1; }
.portal-editor-badge {
display: inline-flex;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 9px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.portal-editor-badge i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentcolor;
}
.portal-editor-badge.is-draft { background: var(--warning-bg); color: var(--warning-text); }
.portal-editor-badge.is-live { background: var(--success-bg); color: var(--success-text); }
.portal-editor-modes.segmented {
gap: 2px;
padding: 2px;
border-radius: 8px;
background: var(--n-9);
}
.portal-editor-modes.segmented button {
display: flex;
height: 28px;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 6px;
color: var(--n-4);
font-size: 12.5px;
white-space: nowrap;
}
.portal-editor-modes.segmented button.active {
color: var(--n-1);
background: var(--surface-card);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.portal-editor-save,
.portal-editor-publish {
height: 32px;
gap: 7px;
border-radius: 8px;
font-size: 13px;
}
.portal-editor-save { padding: 0 13px; font-weight: 500; }
.portal-editor-publish { padding: 0 14px; font-weight: 600; }
.portal-editor-error { margin: 10px 20px 0; }
.portal-editor-body {
display: flex;
flex: 1;
min-height: 0;
}
/* --- Колонка текста --- */
.portal-editor-column {
position: relative;
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
border-right: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-editor-title {
flex: none;
padding: 18px 22px 10px;
}
.portal-editor-title > input {
width: 100%;
padding: 0;
border: none;
outline: none;
background: transparent;
color: var(--n-1);
font-family: inherit;
font-size: 21px;
font-weight: 700;
letter-spacing: -0.01em;
}
.portal-editor-slug {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
color: var(--n-4);
font-size: 12px;
}
.portal-editor-slug code {
color: var(--n-3);
font-family: var(--font-mono);
}
.portal-editor-slug input {
height: 24px;
padding: 0 8px;
border: 1px solid var(--primary);
border-radius: 6px;
outline: none;
background: var(--surface-card);
color: var(--n-2);
font-family: var(--font-mono);
font-size: 12px;
}
.portal-editor-slug button {
padding: 0;
border: none;
background: transparent;
color: var(--primary-text);
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.portal-md-toolbar {
display: flex;
flex: none;
align-items: center;
gap: 2px;
padding: 6px 14px;
border-top: 1px solid var(--n-8);
border-bottom: 1px solid var(--n-8);
background: var(--surface-sidebar);
}
.portal-md-toolbar > button {
display: flex;
min-width: 30px;
height: 30px;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 7px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-3);
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.portal-md-toolbar > button:hover { background: var(--n-8); }
.portal-md-toolbar > button.is-accent {
background: var(--primary-bg);
color: var(--primary-text);
}
.portal-md-divider {
width: 1px;
height: 20px;
margin: 0 5px;
background: var(--n-7);
}
.portal-md-format {
color: var(--n-4);
font-size: 11.5px;
white-space: nowrap;
}
.portal-editor-text {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.portal-editor-text textarea {
display: block;
width: 100%;
max-width: 900px;
height: 100%;
padding: 16px 22px;
border: 0;
outline: 0;
resize: none;
background: transparent;
color: var(--n-2);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.75;
}
.portal-editor-drop {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
inset: 10px;
border: 2px dashed var(--primary);
border-radius: 12px;
background: var(--primary-bg);
color: var(--primary-text);
text-align: center;
}
.portal-editor-drop strong { font-size: 15px; }
.portal-editor-drop span {
color: var(--n-3);
font-size: 12.5px;
}
.portal-editor-status {
display: flex;
flex: none;
align-items: center;
gap: 14px;
padding: 8px 22px;
overflow: hidden;
border-top: 1px solid var(--n-8);
background: var(--surface-sidebar);
color: var(--n-4);
font-size: 11.5px;
white-space: nowrap;
}
/* --- Колонка предпросмотра --- */
.portal-preview-column {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
background: var(--surface-feed);
}
.portal-preview-head {
display: flex;
height: 37px;
flex: none;
align-items: center;
gap: 10px;
padding: 0 18px;
border-bottom: 1px solid var(--n-8);
background: var(--surface-sidebar);
color: var(--n-4);
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.04em;
}
.portal-preview-head a {
display: flex;
height: 24px;
align-items: center;
gap: 6px;
padding: 0 8px;
border-radius: 6px;
color: var(--n-3);
font-size: 12px;
font-weight: 400;
letter-spacing: normal;
}
.portal-preview-head a:hover { background: var(--n-9); }
.portal-preview-body {
flex: 1;
min-height: 0;
padding: 26px 34px 40px;
overflow-y: auto;
}
.portal-preview-article {
max-width: 620px;
margin: 0 auto;
}
.portal-preview-crumbs {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 14px;
color: var(--n-4);
font-size: 12px;
}
.portal-preview-article h1 {
margin: 0 0 8px;
color: var(--n-1);
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.portal-preview-lead {
margin: 0 0 20px;
color: var(--n-3);
font-size: 14.5px;
line-height: 1.7;
}
.portal-preview-article .help-markdown p {
margin: 0 0 20px;
color: var(--n-3);
font-size: 14.5px;
line-height: 1.7;
}
.portal-preview-article .help-markdown h2 {
margin: 0 0 10px;
color: var(--n-1);
font-size: 18px;
font-weight: 700;
}
.portal-preview-article .help-markdown h3 {
margin: 0 0 8px;
color: var(--n-1);
font-size: 16px;
font-weight: 700;
}
.portal-preview-article .help-markdown ol,
.portal-preview-article .help-markdown ul {
margin: 0 0 20px;
padding-left: 22px;
color: var(--n-2);
font-size: 14.5px;
line-height: 1.75;
}
.portal-preview-article .help-markdown blockquote {
margin: 0 0 22px;
padding: 12px 16px;
border-left: 3px solid var(--primary);
border-radius: 0 8px 8px 0;
background: var(--primary-bg);
color: var(--n-2);
font-size: 14px;
line-height: 1.65;
}
.portal-preview-article .help-markdown blockquote p { margin: 0; font-size: inherit; }
.portal-preview-article .help-markdown table {
width: 100%;
margin-bottom: 22px;
border-collapse: collapse;
color: var(--n-2);
font-size: 13.5px;
}
.portal-preview-article .help-markdown th {
padding: 8px 10px;
border-bottom: 1px solid var(--n-7);
font-weight: 600;
text-align: left;
}
.portal-preview-article .help-markdown td {
padding: 8px 10px;
border-bottom: 1px solid var(--n-8);
}
.portal-preview-article .help-markdown img {
max-width: 100%;
margin-bottom: 8px;
border: 1px solid var(--n-8);
border-radius: 10px;
}
.portal-preview-article .help-markdown code {
padding: 1px 5px;
border-radius: 5px;
background: var(--n-9);
font-family: var(--font-mono);
font-size: 13px;
}
/* --- Рейка --- */
.portal-editor-rail {
display: flex;
width: 300px;
flex: none;
flex-direction: column;
min-height: 0;
overflow-y: auto;
border-left: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-rail-fields {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 16px 8px;
}
.portal-rail-fields > label { display: block; }
.portal-rail-fields > label > span:first-child {
display: block;
margin-bottom: 5px;
color: var(--n-4);
font-size: 11.5px;
font-weight: 600;
}
.portal-rail-fields > label > span:first-child small {
color: var(--n-5);
font-weight: 500;
}
.portal-rail-fields .portal-select { height: 34px; font-size: 13px; }
.portal-rail-fields textarea {
width: 100%;
/* Краткое описание видно в списке статей и в поиске портала — его пишут
на пару предложений, поэтому поле сразу высокое. */
min-height: 120px;
padding: 8px 10px;
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
resize: vertical;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 12.5px;
line-height: 1.5;
}
.portal-rail-block { padding: 6px 16px 10px; }
.portal-rail-block:last-child { padding-bottom: 20px; }
.portal-rail-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
color: var(--n-4);
font-size: 11.5px;
font-weight: 600;
}
.portal-rail-head button {
padding: 0;
border: none;
background: transparent;
color: var(--primary-text);
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.portal-rail-hint { color: var(--n-5); font-weight: 400; }
.portal-rail-drop {
padding: 12px;
border: 1px dashed var(--n-6);
border-radius: 10px;
color: var(--n-4);
text-align: center;
}
.portal-rail-drop.is-hot {
border-color: var(--primary);
background: var(--primary-bg);
color: var(--primary-text);
}
.portal-rail-drop p:first-of-type {
margin: 5px 0 2px;
color: var(--n-2);
font-size: 12.5px;
font-weight: 600;
}
.portal-rail-drop p:last-of-type {
margin: 0;
color: var(--n-4);
font-size: 11.5px;
}
.portal-rail-drop button {
padding: 0;
border: 0;
background: transparent;
color: var(--primary-text);
font-family: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
}
.portal-rail-files {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.portal-rail-file {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 9px;
border: 1px solid var(--n-8);
border-radius: 9px;
background: var(--surface-card);
}
.portal-rail-file.is-fresh {
border-color: var(--primary);
background: var(--primary-bg);
}
.portal-rail-file > i {
display: flex;
width: 28px;
height: 28px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 7px;
background: var(--n-9);
color: var(--n-3);
}
.portal-rail-file > span {
min-width: 0;
flex: 1;
line-height: 1.3;
}
.portal-rail-file strong {
display: block;
overflow: hidden;
color: var(--n-1);
font-size: 12.5px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-rail-file small {
color: var(--n-4);
font-size: 11px;
}
.portal-rail-file small.is-progress { color: var(--primary-text); }
.portal-rail-progress {
display: block;
height: 3px;
margin-top: 5px;
overflow: hidden;
border-radius: 2px;
background: var(--n-8);
}
.portal-rail-progress i {
display: block;
height: 3px;
background: var(--primary);
}
.portal-rail-file > button {
display: flex;
width: 26px;
height: 26px;
flex: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--n-4);
cursor: pointer;
}
.portal-rail-file > button:hover { background: var(--n-9); color: var(--n-2); }
.portal-rail-file-insert { color: var(--primary-text) !important; }
.portal-rail-file-insert:hover { background: var(--primary-bg) !important; }
.portal-rail-file-remove:hover { color: var(--error-text) !important; }
.portal-rail-revisions {
display: flex;
flex-direction: column;
gap: 1px;
}
.portal-rail-revision {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 9px;
border: 0;
border-radius: 8px;
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.portal-rail-revision:hover { background: var(--n-9); }
.portal-rail-revision.is-current { background: var(--n-9); }
.portal-rail-revision > i {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: var(--n-6);
}
.portal-rail-revision > i.is-published { background: var(--success-text); }
.portal-rail-revision > i.is-draft { background: var(--warning-text); }
.portal-rail-revision > span {
min-width: 0;
flex: 1;
line-height: 1.3;
}
.portal-rail-revision strong {
display: block;
color: var(--n-1);
font-size: 12.5px;
font-weight: 600;
}
.portal-rail-revision small { color: var(--n-4); font-size: 11px; }
.portal-rail-tag {
display: inline-flex;
height: 19px;
align-items: center;
padding: 0 7px;
border-radius: 5px;
font-size: 10.5px;
font-weight: 600;
}
.portal-rail-tag.is-current { background: var(--primary-bg); color: var(--primary-text) !important; }
.portal-rail-tag.is-published { background: var(--success-bg); color: var(--success-text) !important; }
.portal-rail-block > p {
margin: 9px 0 0;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.5;
}
/* Оценки читателей: под статьёй на портале две кнопки, автору нужен их итог. */
.portal-rail-votes {
display: flex;
gap: 8px;
}
.portal-rail-votes > span {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
padding: 8px 10px;
border: 1px solid var(--n-8);
border-radius: 9px;
font-size: 14px;
font-weight: 600;
}
.portal-rail-votes .is-helpful { color: var(--success-text); }
.portal-rail-votes .is-unhelpful { color: var(--n-3); }
.portal-rail-votes small {
color: var(--n-4);
font-size: 11px;
font-weight: 400;
}
@@ -1,39 +1,397 @@
.support-portal-name {
/* Раздел «Порталы» (дизайн-базлайн v2, кадры PT1/PT2). Значения — из макета
«Порталы Baseline»: карточка списка на --surface-card с рамкой n-7, шапка
таблицы 11px капсом на n-10, строки 11px, подвал на n-10. */
.hub-scroll.portals-scroll {
min-height: 0;
padding: 0;
overflow: hidden;
background: var(--surface-feed);
}
.hub-page.portals-page-shell {
width: 100%;
max-width: none;
height: 100%;
margin: 0;
}
/* --- Список --- */
/* Ширина и отступы листа — как в «Агентах», «Сотрудниках» и «Контактах»:
один ритм на все списки раздела. */
.portals-page {
width: 100%;
max-width: 1220px;
height: 100%;
margin: 0 auto;
overflow-y: auto;
padding: 26px 32px 40px;
}
.portals-page.is-empty {
display: flex;
flex-direction: column;
padding: 26px 32px 0;
overflow: hidden;
}
.portals-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
}
.portals-page.is-empty .portals-head {
margin-bottom: 0;
}
.portals-head h2 {
margin: 0 0 5px;
color: var(--n-1);
font-size: 20px;
font-weight: 700;
}
.portals-head p {
margin: 0;
color: var(--n-3);
font-size: 13px;
}
.portals-head .primary-button {
height: 36px;
flex: none;
gap: 7px;
padding: 0 15px;
border-radius: 8px;
font-size: 13.5px;
font-weight: 600;
}
/* --- Пустой список (кадр PT2) --- */
.portals-empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 40px;
}
.portals-empty > div {
width: 520px;
text-align: center;
}
.portals-empty-mark {
display: flex;
width: 56px;
height: 56px;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
border-radius: 16px;
background: var(--primary-bg);
color: var(--primary-text);
}
.portals-empty h3 {
margin: 0 0 8px;
color: var(--n-1);
font-size: 17px;
font-weight: 700;
}
.portals-empty p {
margin: 0 0 18px;
color: var(--n-3);
font-size: 13.5px;
line-height: 1.6;
}
.portals-empty-actions {
display: flex;
justify-content: center;
gap: 10px;
}
.portals-empty-actions .primary-button,
.portals-empty-actions .secondary-button {
height: 36px;
padding: 0 15px;
border-radius: 8px;
font-size: 13.5px;
}
/* --- Карточка со списком --- */
.portals-card {
overflow: hidden;
border: 1px solid var(--n-7);
border-radius: 10px;
background: var(--surface-card);
}
.portals-card-head {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--n-8);
}
.support-portal-name > i {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border-radius: var(--radius-lg);
.portals-card-head .ui-search-input {
width: 300px;
max-width: none;
height: 34px;
flex: none;
gap: 8px;
padding: 0 11px;
border-radius: 8px;
}
.portals-card-head .ui-filter-button,
.portal-library-toolbar .ui-filter-button {
padding: 0 11px;
}
.portals-table,
.portal-articles-table {
width: 100%;
border-collapse: collapse;
}
.portals-table thead tr,
.portal-articles-table thead tr {
background: var(--n-10);
}
.portals-table th,
.portal-articles-table th {
border-bottom: 1px solid var(--n-7);
color: var(--n-4);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-align: left;
}
.portals-table th { padding: 9px 12px; }
.portals-table th:first-child { padding-left: 16px; }
.portals-table th:last-child { width: 44px; }
.portals-table td {
padding: 11px 12px;
border-bottom: 1px solid var(--n-8);
vertical-align: middle;
}
.portals-table td:first-child { padding-left: 16px; }
.portals-row { cursor: pointer; }
.portals-row:hover > td { background: var(--n-10); }
.portals-row.is-archived { opacity: 0.6; }
.portals-name {
display: flex;
align-items: center;
gap: 11px;
}
.portals-name > i {
display: flex;
width: 34px;
height: 34px;
flex: none;
align-items: center;
justify-content: center;
border-radius: 9px;
background: var(--n-9);
color: var(--text-secondary);
color: var(--n-4);
}
.support-portal-name small {
.portals-name > i.is-live {
background: var(--primary-bg);
color: var(--primary-text);
}
.portals-name > span {
min-width: 0;
line-height: 1.35;
}
.portals-name strong {
display: block;
color: var(--n-1);
font-size: 13.5px;
font-weight: 600;
}
.support-portal-name .link {
margin-bottom: 3px;
font-size: 14px;
}
.support-portal-name small {
color: var(--text-tertiary);
font-family: var(--font-mono, ui-monospace, monospace);
.portals-name small {
color: var(--n-4);
font-size: 11.5px;
}
.support-portal-open {
color: var(--text-tertiary);
.portals-address {
display: flex;
align-items: center;
gap: 7px;
}
.portals-address code {
color: var(--primary-text);
font-family: var(--font-mono);
font-size: 12px;
}
.portals-copy,
.portal-dns-copy {
display: flex;
width: 24px;
height: 24px;
flex: none;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--n-4);
cursor: pointer;
}
.portals-copy:hover,
.portal-dns-copy:hover {
background: var(--n-9);
color: var(--n-2);
}
.portals-domain-badge {
display: inline-flex;
height: 20px;
align-items: center;
gap: 4px;
padding: 0 7px;
border-radius: 6px;
background: var(--success-bg);
color: var(--success-text);
font-size: 11px;
font-weight: 600;
}
.portals-content-cell { color: var(--n-2); font-size: 13px; }
.portals-products-cell { color: var(--n-3); font-size: 13px; }
.portals-updated-cell { color: var(--n-4); font-size: 12.5px; }
/* Пилюли статуса раздела мельче общих: 22px, 11.5/600, без рамки (макет). */
.portals-page .status-pill,
.portal-card .status-pill {
height: 22px;
padding: 0 9px;
border-color: transparent;
font-size: 11.5px;
font-weight: 600;
}
.portals-page .status-pill span,
.portal-card .status-pill span {
width: 6px;
height: 6px;
}
.portals-page .status-pill.draft,
.portal-card .status-pill.draft {
background: var(--warning-bg);
color: var(--warning-text);
}
/* --- Подвал таблиц раздела (кадры PT1/PT3) --- */
.portal-table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 11px 16px;
border-top: 1px solid var(--n-8);
background: var(--n-10);
color: var(--n-4);
font-size: 12.5px;
}
.portal-pages {
display: flex;
align-items: center;
gap: 6px;
}
.portal-pages b {
display: inline-flex;
min-width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 7px;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 12.5px;
font-weight: 600;
}
.portal-page-step,
.portal-page-number {
display: inline-flex;
min-width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0 8px;
border: 1px solid var(--n-7);
border-radius: 7px;
background: var(--surface-card);
color: var(--n-3);
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.portal-page-step { width: 28px; padding: 0; }
.portal-page-step:first-child svg { transform: rotate(90deg); }
.portal-page-step:last-child svg { transform: rotate(-90deg); }
.portal-page-step:disabled {
color: var(--n-6);
cursor: default;
}
.portal-page-number:hover,
.portal-page-step:not(:disabled):hover {
background: var(--n-9);
}
/* Меню строки портала — 236px по макету, кнопка ⋯ 28px. */
.app-dropdown.is-portal-menu .ant-dropdown-menu {
width: 236px;
}
.portals-table .row-menu-button,
.portal-articles-table .row-menu-button {
width: 28px;
height: 28px;
}
.portal-form-error {
padding: 9px 11px;
border-radius: var(--radius-md);
background: var(--error-bg);
color: var(--error-text);
font-size: 12.5px;
}
/* --- Диалог создания портала --- */
.portal-dialog-form {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -41,9 +399,7 @@
padding-top: 8px;
}
.portal-dialog-form > * {
grid-column: 1;
}
.portal-dialog-form > * { grid-column: 1; }
.portal-dialog-form > p {
margin: 0;
@@ -58,14 +414,6 @@
padding-top: 4px;
}
.portal-form-error {
padding: 9px 11px;
border-radius: var(--radius-md);
background: var(--error-bg);
color: var(--error-text);
font-size: 12.5px;
}
.portal-address-field {
display: grid;
gap: 6px;
@@ -86,9 +434,7 @@
background: var(--surface-card);
}
.portal-address-field > div:focus-within {
border-color: var(--primary);
}
.portal-address-field > div:focus-within { border-color: var(--primary); }
.portal-address-field input {
min-width: 0;
@@ -0,0 +1,552 @@
/* Настройки портала (дизайн-базлайн v2, кадры PT4–PT6): субменю разделов
250px и один раздел на экране. Метрики — из макета «Порталы Baseline»:
поля 36px, кнопки 30–34px, карточка на --surface-card с рамкой n-7. */
.portal-settings-layout {
display: flex;
flex: 1;
min-height: 0;
}
.portal-settings-nav {
display: flex;
width: 250px;
flex: none;
flex-direction: column;
gap: 1px;
padding: 14px 10px;
border-right: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-settings-nav > span {
display: block;
}
.portal-settings-nav-item {
display: flex;
width: 100%;
min-height: 34px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.portal-settings-nav-item:hover { background: var(--n-9); }
.portal-settings-nav-item.is-active {
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
}
.portal-settings-nav-item > span { flex: 1; }
.portal-settings-nav-item small {
color: var(--n-5);
font-size: 11px;
font-weight: 600;
}
.portal-settings-nav-item small.is-ok { color: var(--success-text); }
.portal-settings-nav-divider {
display: block;
height: 1px;
margin: 8px 6px;
background: var(--n-8);
}
.portal-settings-nav-gap { flex: 1; }
.portal-settings-nav p {
margin: 0;
padding: 0 10px;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.5;
}
.portal-settings-content {
min-width: 0;
min-height: 0;
flex: 1;
overflow-y: auto;
}
.portal-settings-inner {
max-width: 820px;
padding: 26px 34px 48px;
}
.portal-settings-heading {
margin-bottom: 20px;
}
.portal-settings-heading h3 {
margin: 0 0 4px;
color: var(--n-1);
font-size: 17px;
font-weight: 700;
}
.portal-settings-heading p {
margin: 0;
color: var(--n-3);
font-size: 13px;
}
.portal-settings-card {
display: flex;
flex-direction: column;
gap: 18px;
padding: 20px 22px;
border: 1px solid var(--n-7);
border-radius: 10px;
background: var(--surface-card);
}
/* --- Поля --- */
.portal-field { display: block; }
.portal-field.is-narrow { max-width: 320px; }
.portal-field-label {
display: block;
margin-bottom: 6px;
color: var(--n-2);
font-size: 12.5px;
font-weight: 600;
}
.portal-field > input,
.portal-field > textarea {
width: 100%;
height: 36px;
padding: 0 11px;
border: 1px solid var(--n-7);
border-radius: 8px;
outline: none;
background: var(--surface-card);
color: var(--n-1);
font-family: inherit;
font-size: 13.5px;
}
.portal-field > input.is-mono {
font-family: var(--font-mono);
font-size: 13px;
}
.portal-field > input:focus,
.portal-field textarea:focus,
.portal-select:focus-within {
border-color: var(--primary);
}
.portal-field small {
display: block;
margin-top: 6px;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.5;
}
.portal-field small b { color: var(--n-3); font-weight: 600; }
.portal-field-error { color: var(--error-text); }
.portal-key-input {
display: flex;
max-width: 520px;
align-items: stretch;
}
.portal-key-input input {
width: 180px;
height: 36px;
padding: 0 11px;
border: 1px solid var(--n-7);
border-right: none;
border-radius: 8px 0 0 8px;
outline: none;
background: var(--surface-card);
color: var(--n-1);
font-family: var(--font-mono);
font-size: 13px;
}
.portal-key-input b {
display: flex;
align-items: center;
padding: 0 11px;
border: 1px solid var(--n-7);
border-radius: 0 8px 8px 0;
background: var(--n-9);
color: var(--n-4);
font-family: var(--font-mono);
font-size: 13px;
font-weight: 400;
}
.portal-select {
position: relative;
display: flex;
height: 36px;
align-items: center;
padding: 0 11px;
border: 1px solid var(--n-7);
border-radius: 8px;
background: var(--surface-card);
color: var(--n-1);
font-size: 13.5px;
}
.portal-select select {
width: 100%;
padding: 0;
border: 0;
outline: 0;
appearance: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.portal-select > svg {
pointer-events: none;
color: var(--n-4);
}
.portal-final-link {
display: flex;
align-items: center;
gap: 11px;
padding: 12px 14px;
border: 1px solid var(--n-8);
border-radius: 9px;
background: var(--n-10);
}
.portal-final-link > span:first-child {
min-width: 0;
flex: 1;
line-height: 1.4;
}
.portal-final-link small {
display: block;
color: var(--n-4);
font-size: 11.5px;
}
.portal-final-link code {
color: var(--n-1);
font-family: var(--font-mono);
font-size: 13px;
}
.portal-inline-button {
display: inline-flex;
height: 30px;
align-items: center;
gap: 6px;
padding: 0 11px;
border: 1px solid var(--n-7);
border-radius: 7px;
background: var(--surface-card);
color: var(--n-2);
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
}
.portal-inline-button.is-accent {
color: var(--primary-text);
font-weight: 600;
}
.portal-inline-button:hover { background: var(--n-9); }
.portal-settings-actions {
display: flex;
align-items: center;
gap: 9px;
}
.portal-settings-gap { flex: 1; }
.portal-settings-actions .primary-button,
.portal-settings-actions .secondary-button,
.portal-settings-actions .danger-outline,
.portal-settings-actions a.secondary-button {
height: 34px;
gap: 7px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
}
.portal-settings-actions .secondary-button,
.portal-settings-actions .danger-outline { padding: 0 13px; font-weight: 500; }
.portal-settings-note {
color: var(--n-4);
font-size: 12px;
}
/* --- Свой домен (кадр PT5) --- */
.portal-domain-row {
display: flex;
align-items: flex-end;
gap: 10px;
}
.portal-domain-row .portal-field { flex: 1; }
.portal-domain-state {
display: inline-flex;
height: 36px;
align-items: center;
gap: 6px;
padding: 0 11px;
border-radius: 8px;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
}
.portal-domain-state.is-live { background: var(--success-bg); color: var(--success-text); }
.portal-domain-state.is-pending { background: var(--warning-bg); color: var(--warning-text); }
.portal-dns-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.portal-dns-head strong { color: var(--n-2); font-size: 12.5px; }
.portal-dns-head span { color: var(--n-4); font-size: 11.5px; }
.portal-dns-table {
width: 100%;
overflow: hidden;
border: 1px solid var(--n-8);
border-collapse: collapse;
border-radius: 8px;
}
.portal-dns-table th {
padding: 8px 12px;
background: var(--n-10);
color: var(--n-4);
font-size: 11px;
font-weight: 600;
text-align: left;
}
.portal-dns-table th:last-child { width: 40px; }
.portal-dns-table td {
padding: 9px 12px;
border-top: 1px solid var(--n-8);
}
.portal-dns-type { color: var(--n-2); font-size: 12.5px; font-weight: 600; }
.portal-dns-table code {
color: var(--n-2);
font-family: var(--font-mono);
font-size: 12.5px;
}
.portal-dns-note {
margin: 8px 0 0;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.5;
}
/* --- Оформление (кадр PT6) --- */
.portal-theme-grid {
display: grid;
max-width: 520px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.portal-theme-card {
display: block;
padding: 10px;
border: 1.5px solid var(--n-7);
border-radius: 11px;
background: var(--surface-card);
font-family: inherit;
text-align: left;
cursor: pointer;
}
.portal-theme-card:hover:not(:disabled) { border-color: var(--primary); }
.portal-theme-card.is-selected {
border-color: var(--primary);
background: var(--primary-bg);
}
.portal-theme-card.is-missing {
border-style: dashed;
cursor: default;
opacity: 0.75;
}
.portal-theme-preview {
display: block;
height: 104px;
padding: 9px;
overflow: hidden;
border: 1px solid var(--n-8);
border-radius: 8px;
}
.portal-theme-bar {
display: block;
width: 58%;
height: 9px;
margin-bottom: 7px;
border-radius: 3px;
opacity: 0.75;
}
.portal-theme-line {
display: block;
height: 5px;
margin-bottom: 4px;
border-radius: 3px;
opacity: 0.28;
}
.portal-theme-tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.portal-theme-tiles i {
display: block;
height: 26px;
opacity: 0.14;
}
.portal-theme-name {
display: flex;
align-items: center;
gap: 7px;
margin-top: 9px;
}
.portal-theme-name strong { color: var(--n-1); font-size: 13px; font-weight: 600; }
.portal-theme-name small {
display: inline-flex;
height: 18px;
align-items: center;
padding: 0 6px;
border-radius: 5px;
background: var(--primary-bg);
color: var(--primary-text);
font-size: 10.5px;
font-weight: 600;
}
.portal-theme-desc {
display: block;
margin-top: 3px;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.45;
}
/* Сегмент цветовой схемы — метрики макета: 32px, r7, подложка n-9. */
.portal-scheme-segment.segmented {
display: inline-flex;
gap: 2px;
padding: 2px;
border-radius: 9px;
background: var(--n-9);
}
.portal-scheme-segment.segmented button {
height: 32px;
padding: 0 15px;
border-radius: 7px;
color: var(--n-4);
font-size: 13px;
}
.portal-scheme-segment.segmented button.active {
background: var(--surface-card);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
/* --- Публикация и архив --- */
.portal-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.portal-status-row > span:first-child {
display: flex;
align-items: center;
gap: 10px;
}
.portal-status-row small { color: var(--n-4); font-size: 12.5px; }
/* --- Продукты (раздел субменю) --- */
.portal-product-list {
display: grid;
gap: 8px;
}
.portal-product-list > div {
display: grid;
min-height: 62px;
grid-template-columns: 38px minmax(160px, 1fr) minmax(240px, 0.8fr);
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--n-8);
border-radius: 9px;
}
.portal-product-list > div.is-enabled {
border-color: var(--primary-border);
background: var(--primary-bg);
}
.portal-product-list strong,
.portal-product-list small { display: block; }
.portal-product-list small {
margin-top: 2px;
color: var(--n-4);
font-family: var(--font-mono);
font-size: 11px;
}
@@ -1,2 +1,4 @@
import "./styles-list.css";
import "./styles-detail.css";
import "./styles-settings.css";
import "./styles-editor.css";
+10 -5
View File
@@ -3,15 +3,17 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
import { fetchWaitingCount } from "../features/conversations/model";
import { useChatScope } from "../features/chat/useChatScope";
import { isManager } from "../auth/access";
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { ShellRouteContent } from "./ShellRouteContent";
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, openChannelRoute, openSupportPortalRoute, openEmployeeRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; openEmployeeRoute: (employeeId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifOpen, setNotifOpen] = useState(false);
@@ -87,7 +89,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
openEmployeeRoute(employee.id);
}
const isSalesWorkspace = route === "chat";
const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail";
const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
const isDialogsWorkspace = route === "chat";
const isAiFullWidth = false;
const isKnowledgeLibrary = route === "aiKnowledge";
@@ -102,15 +104,18 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
const isAgents = route === "agents" || route === "agentDetail";
// «Сотрудники» — своя лента на --surface-feed (кадры E1–E4).
const isEmployees = route === "employees" || route === "employeeDetail";
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
return (
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isKnowledgeLibrary ? "ai-knowledge-library-page" : ""} ${isKnowledgeEditor ? "ai-knowledge-editor-page" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} currentEmployee={currentEmployee} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} openSupportPortal={openSupportPortalRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isPortals ? "portals-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isKnowledgeLibrary ? "ai-knowledge-library-page" : ""} ${isKnowledgeEditor ? "ai-knowledge-editor-page" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} currentEmployee={currentEmployee} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
</div>
</main>
</div>
@@ -17,6 +17,7 @@ import { SalesClientsPage } from "../features/sales/SalesClientsPage";
import { LoadingState } from "../shared/ui";
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
import type { SettingsSectionKey } from "../features/settings/sections";
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
import { hasCapability } from "../auth/access";
const SupportPortalsPage = lazy(() => import("../features/support-portals/SupportPortalsPage").then(
@@ -26,7 +27,7 @@ const SupportPortalDetailPage = lazy(() => import("../features/support-portals/S
(module) => ({ default: module.SupportPortalDetailPage }),
));
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, currentEmployee, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, openSupportPortal, openConversation, openEmployee, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; currentEmployee: Employee | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; openSupportPortal: (portalId: number) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void }) {
export function ShellRouteContent({ settingsSection, openSettings, chatScope, setChatScope, chatCounters, chatScopeSwitcher, route, data, currentEmployee, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedChannelId, openChannel, selectedSupportPortalId, portalSettingsSection, openSupportPortal, openPortalSettings, openConversation, openEmployee, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, onChannelLoaded, reload, setRoute, user, onUserUpdated, onLogout, onOpenSidebar }: { settingsSection: SettingsSectionKey | null; openSettings: (section: SettingsSectionKey | null) => void; chatScope: DialogScope; setChatScope: (scope: DialogScope) => void; chatCounters: ConversationCounters | null; chatScopeSwitcher: boolean; route: RouteKey; data: AppData; currentEmployee: Employee | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedChannelId: number | null; openChannel: (channelId: number) => void; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openSupportPortal: (portalId: number) => void; openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void; openConversation: (conversationId: number) => void; openEmployee: (employee: Employee) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; onChannelLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onOpenSidebar: () => void }) {
return (
<>
{route === "employees" && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
@@ -74,8 +75,20 @@ export function ShellRouteContent({ settingsSection, openSettings, chatScope, se
{route === "aiKnowledge" && <KnowledgePage openAgent={openAgent} openKnowledge={openKnowledge} setRoute={setRoute} user={user} />}
{route === "aiKnowledgeCreate" && <KnowledgeCreatePage openKnowledge={openKnowledge} setRoute={setRoute} />}
{route === "aiKnowledgeDetail" && <KnowledgeDetailPage agents={data.agents} canManage={hasCapability(user, "ai.manage")} knowledgeId={selectedKnowledgeId} openAgent={openAgent} setRoute={setRoute} onLoaded={onAgentLoaded} />}
{route === "supportPortals" && <Suspense fallback={<LoadingState />}><SupportPortalsPage user={user} openPortal={openSupportPortal} /></Suspense>}
{route === "supportPortalDetail" && <Suspense fallback={<LoadingState />}><SupportPortalDetailPage portalId={selectedSupportPortalId} products={data.products} user={user} openPortals={() => setRoute("supportPortals")} /></Suspense>}
{route === "supportPortals" && <Suspense fallback={<LoadingState />}><SupportPortalsPage user={user} openPortal={openSupportPortal} openPortalSettings={openPortalSettings} /></Suspense>}
{(route === "supportPortalDetail" || route === "supportPortalSettings") && (
<Suspense fallback={<LoadingState />}>
<SupportPortalDetailPage
portalId={selectedSupportPortalId}
products={data.products}
section={route === "supportPortalSettings" ? portalSettingsSection : null}
user={user}
openPortals={() => setRoute("supportPortals")}
openPortalContent={openSupportPortal}
openPortalSettings={openPortalSettings}
/>
</Suspense>
)}
</>
);
}
+1 -1
View File
@@ -133,7 +133,7 @@ export function Sidebar({
<SidebarLink activeRoutes={["salesClients", "salesClientDetail"]} icon="user" label="Контакты" route={route} routeKey="salesClients" setRoute={setRoute} />
<SidebarLink activeRoutes={["agents", "agentDetail"]} icon="robot" label="Агенты" route={route} routeKey="agents" setRoute={setRoute} />
<SidebarLink activeRoutes={["employees", "employeeDetail"]} icon="team" label="Сотрудники" route={route} routeKey="employees" setRoute={setRoute} />
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail"]} icon="globe" label="Порталы" route={route} routeKey="supportPortals" setRoute={setRoute} />
<SidebarLink activeRoutes={["supportPortals", "supportPortalDetail", "supportPortalSettings"]} icon="globe" label="Порталы" route={route} routeKey="supportPortals" setRoute={setRoute} />
<SidebarLink activeRoutes={["settings", "aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail", "administrationAudit"]} icon="settings" label="Настройки" route={route} routeKey="settings" setRoute={setRoute} />
</nav>
) : route === "profile" ? (
+56
View File
@@ -758,3 +758,59 @@
.launch-checklist-action:hover {
background: var(--primary-hover);
}
/* Полоса установки демо-данных: пока worker наполняет организацию, человек
видит причину пустых разделов, а не гадает. */
.demo-install-banner {
display: flex;
flex: none;
align-items: center;
gap: 9px;
padding: 9px 20px;
border-bottom: 1px solid var(--n-8);
background: var(--primary-bg);
color: var(--primary-text);
font-size: 13px;
font-weight: 500;
}
.demo-install-banner.is-done {
background: var(--success-bg);
color: var(--success-text);
}
.demo-install-banner > svg {
flex: none;
}
.demo-install-banner > span {
flex: 1;
min-width: 0;
}
.demo-install-banner button {
height: 26px;
padding: 0 11px;
border: 1px solid currentColor;
border-radius: 7px;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.demo-install-banner .demo-install-close {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
opacity: 0.65;
}
.demo-install-banner .demo-install-close:hover { opacity: 1; }
+4 -2
View File
@@ -1,6 +1,8 @@
import React, { lazy, Suspense, useEffect, useState } from "react";
import ReactDOM from "react-dom/client";
import { Loader } from "@chatballs/ui";
import { App } from "./App";
import { surfaceForHost, type Surface } from "./surface";
import "./styles.css";
@@ -27,10 +29,10 @@ function SurfaceApp() {
}, [surface]);
if (surface === "loading") {
return <main className="help-boot-loading"><i /><i /><i /></main>;
return <main className="help-boot-loading"><Loader size={44} /></main>;
}
return surface === "help"
? <Suspense fallback={<main className="help-boot-loading"><i /><i /><i /></main>}><HelpCenterApp /></Suspense>
? <Suspense fallback={<main className="help-boot-loading"><Loader size={44} /></main>}><HelpCenterApp /></Suspense>
: <App />;
}
+26
View File
@@ -11,6 +11,7 @@ const empty = {
clientId: null,
channelId: null,
supportPortalId: null,
portalSettingsSection: null,
settingsSection: null,
};
@@ -25,6 +26,31 @@ describe("sales detail routes", () => {
expect(pathFromRoute("supportPortalDetail", 3)).toBe("/portals/3");
expect(routeFromPath("/portals/3")).toEqual({ route: "supportPortalDetail", ...empty, supportPortalId: 3 });
});
// Настройки портала — страница с субменю разделов, а не модалка (кадры PT4–PT6).
it("parses and builds portal settings URLs", () => {
expect(routeFromPath("/portals/3/settings")).toEqual({
...empty,
route: "supportPortalSettings",
supportPortalId: 3,
portalSettingsSection: "basics",
});
expect(routeFromPath("/portals/3/settings/theme")).toEqual({
...empty,
route: "supportPortalSettings",
supportPortalId: 3,
portalSettingsSection: "theme",
});
// Неизвестный раздел открывает первый.
expect(routeFromPath("/portals/3/settings/nope")).toEqual({
...empty,
route: "supportPortalSettings",
supportPortalId: 3,
portalSettingsSection: "basics",
});
expect(pathFromRoute("supportPortalSettings", "3/domain")).toBe("/portals/3/settings/domain");
expect(pathFromRoute("supportPortalSettings", "3/")).toBe("/portals/3/settings/basics");
});
});
describe("agent routes", () => {
+26 -3
View File
@@ -1,4 +1,9 @@
import { settingsSectionKey, type SettingsSectionKey } from "./features/settings/sections";
import {
DEFAULT_PORTAL_SETTINGS_SECTION,
portalSettingsSectionKey,
type PortalSettingsSectionKey,
} from "./features/support-portals/sections";
import type { RouteKey } from "./types";
export type RouteState = {
@@ -11,6 +16,7 @@ export type RouteState = {
clientId: number | null;
channelId: number | null;
supportPortalId: number | null;
portalSettingsSection: PortalSettingsSectionKey | null;
settingsSection: SettingsSectionKey | null;
};
@@ -19,7 +25,7 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i);
const organizationPublicId = match?.[1] ?? null;
const path = match ? match[2] || "/" : normalized;
const base = { employeeId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, settingsSection: null };
const base = { employeeId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, channelId: null, supportPortalId: null, portalSettingsSection: null, settingsSection: null };
const state = { organizationPublicId, ...base };
// Chat-first (SPEC-HUB-0031): корень и устаревшие адреса командного центра и
// разделённых чатов ведут в единый «Чат».
@@ -44,8 +50,19 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
}
if (path === "/portals" || path === "/departments/support/portals") return { route: "supportPortals", ...state };
if (path.startsWith("/portals/")) {
const id = Number(path.slice("/portals/".length));
return Number.isInteger(id) && id > 0 ? { ...state, route: "supportPortalDetail", supportPortalId: id } : { route: "supportPortals", ...state };
const [, , rawId, subPath, subSection] = path.split("/");
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) return { route: "supportPortals", ...state };
// Настройки портала — страница с субменю разделов, а не модалка.
if (subPath === "settings") {
return {
...state,
route: "supportPortalSettings",
supportPortalId: id,
portalSettingsSection: portalSettingsSectionKey(subSection ?? "") ?? DEFAULT_PORTAL_SETTINGS_SECTION,
};
}
return { ...state, route: "supportPortalDetail", supportPortalId: id };
}
if (path.startsWith("/departments/support/portals/")) {
const id = Number(path.split("/")[4]);
@@ -97,6 +114,12 @@ export function pathFromRoute(route: RouteKey, entityId: number | string | null
if (route === "employeeDetail") return entityId ? `${prefix}/employees/${entityId}` : `${prefix}/employees`;
if (route === "supportPortals") return `${prefix}/portals`;
if (route === "supportPortalDetail") return entityId ? `${prefix}/portals/${entityId}` : `${prefix}/portals`;
// У настроек портала в адресе и id портала, и ключ раздела: «12/basics».
if (route === "supportPortalSettings") {
const [portalId, section] = String(entityId ?? "").split("/");
if (!portalId) return `${prefix}/portals`;
return `${prefix}/portals/${portalId}/settings/${portalSettingsSectionKey(section ?? "") ?? DEFAULT_PORTAL_SETTINGS_SECTION}`;
}
if (route === "agents") return `${prefix}/agents`;
if (route === "agentDetail") return entityId ? `${prefix}/agents/${entityId}` : `${prefix}/agents`;
if (route === "aiUsage") return `${prefix}/ai/usage`;
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more