mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
✨ 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:
1 parent
3672463363
commit
41a4b0dd84
128 files changed
+7575
-1547
No files matched your search
@@ -1,6 +1,5 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.production.example
|
||||
.git
|
||||
.venv
|
||||
|
||||
@@ -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 аргумент не нужен.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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` (резервная
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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=''),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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"",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(article.status_code, 201, article.content)
|
||||
content = article.json()["article"]["revisions"][0]["content"]
|
||||
self.assertEqual(content, f"")
|
||||
|
||||
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"",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(revision.status_code, 201, revision.content)
|
||||
self.assertEqual(revision.json()["revision"]["content"], f"")
|
||||
|
||||
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": "Схема: ",
|
||||
},
|
||||
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
|
||||
|
||||
@@ -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 = "/"
|
||||
|
||||
@@ -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 = "/"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user