mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09: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.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
!.env.production.example
|
!.env.production.example
|
||||||
.git
|
.git
|
||||||
.venv
|
.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).
|
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
|
||||||
# Подставляется в release bundle и монтируется в контейнер 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).
|
# Caddy admin API не публикуется наружу (default localhost:2019).
|
||||||
on_demand_tls {
|
on_demand_tls {
|
||||||
ask http://backend-platform:8000/api/v1/gateway/help-domain/
|
ask http://backend-platform:8000/api/v1/gateway/help-domain/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Published hosted and verified custom Help Center domains. Certificate
|
(surfaces) {
|
||||||
# issuance is authorized against the platform ingress directory above.
|
# Платформенная поверхность живёт на своём домене; пока он не задан,
|
||||||
|
# матчер намеренно не совпадает ни с чем.
|
||||||
|
@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:// {
|
https:// {
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
tls {
|
tls {
|
||||||
on_demand
|
on_demand
|
||||||
}
|
}
|
||||||
|
import surfaces
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,9 @@ Canonical implementation workspace for Chatballs.
|
|||||||
## Быстрый старт (одна минута)
|
## Быстрый старт (одна минута)
|
||||||
|
|
||||||
Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose
|
Нужен только Docker (Docker Desktop на Windows/macOS или Docker Engine с Compose
|
||||||
на Linux). Никаких параметров заранее задавать не нужно — всё спросит браузер.
|
на Linux). Ни одной переменной задавать не нужно и негде: у продукта нет `.env`.
|
||||||
|
Организацию, владельца, домены, интеграции, почту и хранилище человек настраивает
|
||||||
|
в интерфейсе.
|
||||||
|
|
||||||
Windows (PowerShell):
|
Windows (PowerShell):
|
||||||
|
|
||||||
@@ -23,14 +25,29 @@ cd chatballs/code/chatballs
|
|||||||
./scripts/start.sh
|
./scripts/start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт копирует `.env.example` в `.env` (если его нет), собирает образы и
|
Настраивать нечего: файла `.env` у продукта нет, секреты инстанса (ключ
|
||||||
поднимает стек. Когда в логах появится готовность, откройте
|
подписи, пароли ролей БД) генерирует сам первый старт и держит их в томе
|
||||||
|
`chatballs-secrets`.
|
||||||
|
|
||||||
|
Если положить рядом со скриптом `release.env` со страницы релиза, образы
|
||||||
|
скачаются из реестра по digest и сборки не будет — это самый быстрый путь.
|
||||||
|
Без `release.env` стек собирается из исходников: так работают те, кто правит
|
||||||
|
код. Когда в логах появится готовность, откройте
|
||||||
**http://localhost** — вместо входа система покажет **мастер первого запуска**:
|
**http://localhost** — вместо входа система покажет **мастер первого запуска**:
|
||||||
название организации, ваше имя, e-mail и пароль владельца, переключатель
|
название организации, ваше имя, e-mail и пароль владельца, переключатель
|
||||||
«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под
|
«Установить демо-данные». После кнопки «Начать» вы сразу в приложении под
|
||||||
владельцем. Мастер доступен только пока в системе нет ни одной организации;
|
владельцем. Мастер доступен только пока в системе нет ни одной организации;
|
||||||
после создания владельца он закрывается навсегда.
|
после создания владельца он закрывается навсегда.
|
||||||
|
|
||||||
|
### Доступ по http и переход на TLS
|
||||||
|
|
||||||
|
Свежая установка отвечает по обычному http — по адресу сервера, пока домена и
|
||||||
|
сертификата ещё нет. Продукт не уводит себя на https принудительно: этим
|
||||||
|
занимается шлюз, когда у него появляется настоящий домен и сертификат.
|
||||||
|
Жёсткость транспорта включается сама по факту TLS: запрос пришёл по https —
|
||||||
|
cookie получают префикс `__Host-`, флаг `Secure` и HSTS; по http — обычные
|
||||||
|
имена без `Secure`. Настраивать для этого нечего.
|
||||||
|
|
||||||
### Демо-данные
|
### Демо-данные
|
||||||
|
|
||||||
Демо — вымышленное ателье «Норд» (дизайн-базлайн v2): сотрудники и группы,
|
Демо — вымышленное ателье «Норд» (дизайн-базлайн v2): сотрудники и группы,
|
||||||
@@ -75,8 +92,8 @@ CHATBALLS_INSTANCE_DIR=/opt/chatballs/instance ./deploy/migrate/rename-to-chatba
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# dev-стек из каталога репозитория
|
# dev-стек из каталога репозитория
|
||||||
CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml --env-file .env.example" deploy/migrate/rename-to-chatballs.sh
|
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 --env-file .env.example --env-file .env up -d
|
docker compose -f compose.yaml -f compose.dev.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт останавливает старый compose-проект, переписывает `.env` (резервная
|
Скрипт останавливает старый compose-проект, переписывает `.env` (резервная
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ class KnowledgeAttachment(TenantRelationModel):
|
|||||||
def public_url(self) -> str:␍
|
def public_url(self) -> str:␍
|
||||||
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому␍
|
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому␍
|
||||||
# строится от публичного адреса Hub, а не от request.␍
|
# строится от публичного адреса Hub, а не от request.␍
|
||||||
from django.conf import settings␍
|
|
||||||
from django.urls import reverse␍
|
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})␍
|
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):␍
|
class KnowledgeFragment(TenantRelationModel):␍
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from chatballs.calls.lifecycle import transition_call
|
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.calls.tokens import issue_invite_token
|
||||||
from chatballs.conversations import transports
|
from chatballs.conversations import transports
|
||||||
from chatballs.events.handlers import register
|
from chatballs.events.handlers import register
|
||||||
|
from chatballs.identity.instance_settings import public_base_url
|
||||||
from chatballs.tenancy.context import TenantContext
|
from chatballs.tenancy.context import TenantContext
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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"])
|
invite.save(update_fields=["token_hash"])
|
||||||
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
|
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
|
||||||
invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку."
|
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(
|
sent = transports.send_call_invite(
|
||||||
call.delivery_connection,
|
call.delivery_connection,
|
||||||
chat_id=call.conversation.external_chat_id,
|
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.models import CallSession
|
||||||
from chatballs.calls.turn import turn_credentials
|
from chatballs.calls.turn import turn_credentials
|
||||||
|
from chatballs.identity.instance_settings import turn_config
|
||||||
|
|
||||||
|
|
||||||
def _iso(value):
|
def _iso(value):
|
||||||
@@ -68,11 +69,12 @@ def ice_servers_payload() -> list[dict]:
|
|||||||
servers: list[dict] = []
|
servers: list[dict] = []
|
||||||
if settings.CHATBALLS_CALL_STUN_URLS:
|
if settings.CHATBALLS_CALL_STUN_URLS:
|
||||||
servers.append({"urls": list(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:
|
turn_urls, ttl = turn_config()
|
||||||
username, credential = turn_credentials()
|
if turn_urls and settings.CHATBALLS_CALL_TURN_SECRET:
|
||||||
|
username, credential = turn_credentials(ttl_seconds=ttl)
|
||||||
servers.append(
|
servers.append(
|
||||||
{
|
{
|
||||||
"urls": list(settings.CHATBALLS_CALL_TURN_URLS),
|
"urls": turn_urls,
|
||||||
"username": username,
|
"username": username,
|
||||||
"credential": credential,
|
"credential": credential,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import time
|
|||||||
from django.conf import settings
|
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).
|
"""Краткоживущие TURN REST credentials для Coturn (SPEC-HUB-0013 §11).
|
||||||
|
|
||||||
Схема coturn `use-auth-secret`:
|
Схема coturn `use-auth-secret`:
|
||||||
@@ -20,7 +22,7 @@ def turn_credentials(*, label: str = "hub", now: int | None = None) -> tuple[str
|
|||||||
coturn и наружу не отдаётся — клиент получает только производные credentials.
|
coturn и наружу не отдаётся — клиент получает только производные credentials.
|
||||||
"""
|
"""
|
||||||
moment = int(time.time()) if now is None else int(now)
|
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}"
|
username = f"{expiry}:{label}"
|
||||||
digest = hmac.new(
|
digest = hmac.new(
|
||||||
settings.CHATBALLS_CALL_TURN_SECRET.encode("utf-8"),
|
settings.CHATBALLS_CALL_TURN_SECRET.encode("utf-8"),
|
||||||
|
|||||||
@@ -39,3 +39,61 @@ class ContentSecurityPolicyMiddleware:
|
|||||||
if policy and not response.has_header("Content-Security-Policy"):
|
if policy and not response.has_header("Content-Security-Policy"):
|
||||||
response["Content-Security-Policy"] = policy
|
response["Content-Security-Policy"] = policy
|
||||||
return response
|
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
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
|
||||||
@@ -33,3 +34,44 @@ class ContentSecurityPolicyMiddlewareTests(TestCase):
|
|||||||
response = self.client.get("/api/v1/health/live/")
|
response = self.client.get("/api/v1/health/live/")
|
||||||
|
|
||||||
self.assertFalse(response.has_header("Content-Security-Policy"))
|
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 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.integrations import feature_views
|
||||||
from chatballs.tenancy import storage_views
|
from chatballs.tenancy import storage_views
|
||||||
|
|
||||||
@@ -12,6 +17,16 @@ urlpatterns = [
|
|||||||
administration_views.OrganizationSettingsView.as_view(),
|
administration_views.OrganizationSettingsView.as_view(),
|
||||||
name="organization-settings",
|
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(
|
path(
|
||||||
"administration/logo/",
|
"administration/logo/",
|
||||||
administration_views.OrganizationLogoView.as_view(),
|
administration_views.OrganizationLogoView.as_view(),
|
||||||
|
|||||||
@@ -86,7 +86,8 @@
|
|||||||
"status": "PUBLISHED",
|
"status": "PUBLISHED",
|
||||||
"helpfulVotes": 14,
|
"helpfulVotes": 14,
|
||||||
"unhelpfulVotes": 1,
|
"unhelpfulVotes": 1,
|
||||||
"publishedDaysAgo": 27
|
"publishedDaysAgo": 27,
|
||||||
|
"files": ["katalog-tkanej-2026.pdf"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "sroki-dostavki",
|
"slug": "sroki-dostavki",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
from chatballs.identity.demo_seed import manifest
|
from chatballs.identity.demo_seed import manifest
|
||||||
from chatballs.identity.demo_seed.loaders.common import backdate, now
|
from chatballs.identity.demo_seed.loaders.common import backdate, now
|
||||||
from chatballs.identity.demo_seed.refs import DemoRefs
|
from chatballs.identity.demo_seed.refs import DemoRefs
|
||||||
@@ -21,7 +23,11 @@ from chatballs.support_portals.content_services import (
|
|||||||
publish_revision,
|
publish_revision,
|
||||||
record_feedback,
|
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 (
|
from chatballs.support_portals.portal_services import (
|
||||||
PortalInput,
|
PortalInput,
|
||||||
create_portal,
|
create_portal,
|
||||||
@@ -30,6 +36,7 @@ from chatballs.support_portals.portal_services import (
|
|||||||
)
|
)
|
||||||
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
|
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
|
||||||
from chatballs.tenancy.context import TenantContext
|
from chatballs.tenancy.context import TenantContext
|
||||||
|
from chatballs.tenancy.storage import adjust_storage_usage
|
||||||
|
|
||||||
|
|
||||||
def load(context: TenantContext, refs: DemoRefs) -> None:
|
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
|
categories[item["slug"]] = category
|
||||||
|
|
||||||
for item in portal_data.get("articles", []):
|
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)
|
target_status = portal_data.get("status", PortalStatus.PUBLISHED)
|
||||||
if portal.status != target_status:
|
if portal.status != target_status:
|
||||||
@@ -138,7 +147,14 @@ def _ensure_portal(context: TenantContext, refs: DemoRefs, portal_data: dict | N
|
|||||||
refs.portal = portal
|
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()
|
article = portal.articles.filter(slug=item["slug"]).first()
|
||||||
if article is None:
|
if article is None:
|
||||||
article = create_article(
|
article = create_article(
|
||||||
@@ -183,6 +199,7 @@ def _ensure_article(context: TenantContext, refs: DemoRefs, portal: SupportPorta
|
|||||||
if status == ArticleStatus.ARCHIVED:
|
if status == ArticleStatus.ARCHIVED:
|
||||||
archive_article(article)
|
archive_article(article)
|
||||||
refs.portal_articles[item["slug"]] = article
|
refs.portal_articles[item["slug"]] = article
|
||||||
|
return article
|
||||||
|
|
||||||
|
|
||||||
def _link_agent_articles(refs: DemoRefs) -> None:
|
def _link_agent_articles(refs: DemoRefs) -> None:
|
||||||
@@ -194,3 +211,35 @@ def _link_agent_articles(refs: DemoRefs) -> None:
|
|||||||
article = refs.portal_articles.get(slug)
|
article = refs.portal_articles.get(slug)
|
||||||
if article is not None and article.status == ArticleStatus.PUBLISHED:
|
if article is not None and article.status == ArticleStatus.PUBLISHED:
|
||||||
agent.portal_articles.add(article)
|
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.contrib.auth.tokens import default_token_generator
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.utils.encoding import force_bytes
|
from django.utils.encoding import force_bytes
|
||||||
from django.utils.http import urlsafe_base64_encode
|
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
|
from chatballs.identity.models import HumanUser
|
||||||
|
|
||||||
|
|
||||||
def _password_setup_url(user: HumanUser) -> str:
|
def _password_setup_url(user: HumanUser) -> str:
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
token = default_token_generator.make_token(user)
|
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:
|
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"
|
f"{setup_url}\n\n"
|
||||||
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
|
"Ссылка действует 30 минут. Если вы не ожидали это письмо, обратитесь к владельцу организации."
|
||||||
),
|
),
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
from_email=email_from_address(),
|
||||||
recipient_list=[user.email],
|
recipient_list=[user.email],
|
||||||
|
connection=email_connection(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@ def send_password_reset_email(user: HumanUser) -> None:
|
|||||||
f"{reset_url}\n\n"
|
f"{reset_url}\n\n"
|
||||||
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
|
"Ссылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо."
|
||||||
),
|
),
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
from_email=email_from_address(),
|
||||||
recipient_list=[user.email],
|
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"]
|
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()
|
suffix = Path(filename).suffix.lower()
|
||||||
return f"users/{instance.id}/avatar-{uuid.uuid4()}{suffix}"
|
return f"users/{instance.id}/avatar-{uuid.uuid4()}{suffix}"
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ class OrganizationStatus(models.TextChoices):
|
|||||||
PENDING_OWNER = "PENDING_OWNER", "Pending owner"
|
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()
|
suffix = Path(filename).suffix.lower()
|
||||||
return (
|
return (
|
||||||
f"organizations/{instance.public_id}/branding/"
|
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
|
# 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.
|
# 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
|
from chatballs.identity.demo_models import ( # noqa: E402, F401
|
||||||
DemoDataset,
|
DemoDataset,
|
||||||
DemoRecord,
|
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.ai.knowledge_categories import ensure_uncategorized_category
|
||||||
from chatballs.identity.audit import record_audit_event
|
from chatballs.identity.audit import record_audit_event
|
||||||
|
from chatballs.identity.instance_settings import remember_public_host
|
||||||
from chatballs.identity.models import (
|
from chatballs.identity.models import (
|
||||||
EmployeeRole,
|
EmployeeRole,
|
||||||
HumanUser,
|
HumanUser,
|
||||||
@@ -109,9 +110,15 @@ def _unique_slug(name: str) -> str:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def complete_setup(data: SetupInput) -> SetupResult:
|
def complete_setup(
|
||||||
|
data: SetupInput, public_host: str = "", public_scheme: str = "http"
|
||||||
|
) -> SetupResult:
|
||||||
clean = _clean(data)
|
clean = _clean(data)
|
||||||
with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS):
|
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:
|
with connections[INSTANCE_DB_ALIAS].cursor() as cursor:
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class SetupView(APIView):
|
|||||||
email=str(body.get("email", "")),
|
email=str(body.get("email", "")),
|
||||||
password=str(body.get("password", "")),
|
password=str(body.get("password", "")),
|
||||||
install_demo=bool(body.get("installDemo", False)),
|
install_demo=bool(body.get("installDemo", False)),
|
||||||
)
|
),
|
||||||
|
public_host=request.get_host(),
|
||||||
|
public_scheme=request.scheme,
|
||||||
)
|
)
|
||||||
except SetupAlreadyCompleted:
|
except SetupAlreadyCompleted:
|
||||||
return Response(SETUP_CLOSED, status=409)
|
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 django.test import TestCase, override_settings
|
||||||
|
|
||||||
from chatballs.identity.audit import record_audit_event
|
from chatballs.identity.audit import record_audit_event
|
||||||
|
from chatballs.identity.bootstrap import bootstrap_owner
|
||||||
from chatballs.identity.models import (
|
from chatballs.identity.models import (
|
||||||
EmployeeRole,
|
EmployeeRole,
|
||||||
HumanUser,
|
HumanUser,
|
||||||
@@ -193,3 +194,104 @@ class AdministrationApiTests(TestCase):
|
|||||||
event = response.json()["items"][0]
|
event = response.json()["items"][0]
|
||||||
self.assertEqual(event["action"], "Добавлен продукт")
|
self.assertEqual(event["action"], "Добавлен продукт")
|
||||||
self.assertNotIn("products.", 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"),
|
("identity", "demorecord"),
|
||||||
# Настройки хранилища — одна строка на инстанс, не данные организации.
|
# Настройки хранилища — одна строка на инстанс, не данные организации.
|
||||||
("tenancy", "storagesettings"),
|
("tenancy", "storagesettings"),
|
||||||
|
# Настройки установки (адрес, по которому её открывают) — тоже одна
|
||||||
|
# строка на инстанс: их пишет мастер первого запуска, а не демо.
|
||||||
|
("identity", "instancesettings"),
|
||||||
}
|
}
|
||||||
COVERAGE_EXEMPT_APPS = {"platform", "events"}
|
COVERAGE_EXEMPT_APPS = {"platform", "events"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from chatballs.testing import TenantAPIClient as APIClient
|
|
||||||
|
|
||||||
from chatballs.ai.models import KnowledgeCategory
|
from chatballs.ai.models import KnowledgeCategory
|
||||||
|
from chatballs.identity.instance_settings import (
|
||||||
|
InstanceSettings,
|
||||||
|
invalidate_cache,
|
||||||
|
public_base_url,
|
||||||
|
)
|
||||||
from chatballs.identity.models import (
|
from chatballs.identity.models import (
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
EmployeeRole,
|
EmployeeRole,
|
||||||
@@ -11,6 +15,7 @@ from chatballs.identity.models import (
|
|||||||
Organization,
|
Organization,
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
)
|
)
|
||||||
|
from chatballs.testing import TenantAPIClient as APIClient
|
||||||
|
|
||||||
VALID = {
|
VALID = {
|
||||||
"organizationName": "Ателье Норд",
|
"organizationName": "Ателье Норд",
|
||||||
@@ -27,11 +32,50 @@ class SetupWizardTests(TestCase):
|
|||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
|
|
||||||
def complete(self, **overrides):
|
def complete(self, **overrides):
|
||||||
|
extra = {key: overrides.pop(key) for key in list(overrides) if key.startswith("HTTP_")}
|
||||||
body = {**VALID, **overrides}
|
body = {**VALID, **overrides}
|
||||||
return self.client.post(
|
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:
|
def test_fresh_instance_needs_setup(self) -> None:
|
||||||
response = self.client.get("/api/v1/setup/")
|
response = self.client.get("/api/v1/setup/")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from chatballs.identity.instance_settings import public_base_url
|
||||||
from chatballs.conversations import transports
|
from chatballs.conversations import transports
|
||||||
from chatballs.identity.models import OrganizationMembership
|
from chatballs.identity.models import OrganizationMembership
|
||||||
from chatballs.notifications.models import (
|
from chatballs.notifications.models import (
|
||||||
@@ -55,7 +56,7 @@ def _message_text(notification: Notification) -> str:
|
|||||||
lines = [f"{mark} {notification.title}"]
|
lines = [f"{mark} {notification.title}"]
|
||||||
if notification.body:
|
if notification.body:
|
||||||
lines.append(notification.body)
|
lines.append(notification.body)
|
||||||
base_url = getattr(settings, "INTERNAL_UI_BASE_URL", "") or ""
|
base_url = public_base_url()
|
||||||
if base_url:
|
if base_url:
|
||||||
lines.append(base_url)
|
lines.append(base_url)
|
||||||
return "\n".join(lines)
|
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.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 import transaction
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
from chatballs.ai.indexing import reindex_portal_article
|
from chatballs.ai.indexing import reindex_portal_article
|
||||||
|
from chatballs.support_portals.content_markdown import normalize_file_links
|
||||||
from chatballs.support_portals.models import (
|
from chatballs.support_portals.models import (
|
||||||
PortalArticle,
|
PortalArticle,
|
||||||
PortalArticleFeedback,
|
PortalArticleFeedback,
|
||||||
|
PortalArticleFile,
|
||||||
PortalArticleRevision,
|
PortalArticleRevision,
|
||||||
PortalCategory,
|
PortalCategory,
|
||||||
SupportPortal,
|
SupportPortal,
|
||||||
)
|
)
|
||||||
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
|
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
|
||||||
from chatballs.tenancy.context import TenantContext
|
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(
|
def create_category(
|
||||||
@@ -97,7 +110,7 @@ def create_article(
|
|||||||
|
|
||||||
|
|
||||||
def add_revision(
|
def add_revision(
|
||||||
*, context: TenantContext, article: PortalArticle, data: dict
|
*, context: TenantContext, article: PortalArticle, data: dict, author=None
|
||||||
) -> PortalArticleRevision:
|
) -> PortalArticleRevision:
|
||||||
ensure_portal_editable(article.portal)
|
ensure_portal_editable(article.portal)
|
||||||
if article.status == ArticleStatus.ARCHIVED:
|
if article.status == ArticleStatus.ARCHIVED:
|
||||||
@@ -109,7 +122,8 @@ def add_revision(
|
|||||||
revision=last + 1,
|
revision=last + 1,
|
||||||
title=str(data.get("title", "")).strip(),
|
title=str(data.get("title", "")).strip(),
|
||||||
summary=str(data.get("summary", "")).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.full_clean()
|
||||||
revision.save()
|
revision.save()
|
||||||
@@ -186,3 +200,62 @@ def ensure_portal_editable(portal: SupportPortal) -> None:
|
|||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
{"portal": "Восстановите портал, чтобы изменить его содержимое"}
|
{"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 django.core.exceptions import ValidationError
|
||||||
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from chatballs.support_portals.api import validation_response
|
from chatballs.support_portals.api import validation_response
|
||||||
from chatballs.support_portals.content_services import (
|
from chatballs.support_portals.content_services import (
|
||||||
|
add_article_file,
|
||||||
add_revision,
|
add_revision,
|
||||||
archive_article,
|
archive_article,
|
||||||
create_article,
|
create_article,
|
||||||
create_category,
|
create_category,
|
||||||
|
delete_article_file,
|
||||||
delete_category,
|
delete_category,
|
||||||
publish_revision,
|
publish_revision,
|
||||||
update_category,
|
|
||||||
update_article,
|
update_article,
|
||||||
|
update_category,
|
||||||
)
|
)
|
||||||
from chatballs.support_portals.models import PortalArticle
|
from chatballs.support_portals.models import PortalArticle
|
||||||
from chatballs.support_portals.portal_views import PortalBaseView
|
from chatballs.support_portals.portal_views import PortalBaseView
|
||||||
|
from chatballs.support_portals.selectors import category_article_counts
|
||||||
from chatballs.support_portals.serializers import (
|
from chatballs.support_portals.serializers import (
|
||||||
|
article_file_payload,
|
||||||
article_payload,
|
article_payload,
|
||||||
category_payload,
|
category_payload,
|
||||||
revision_payload,
|
revision_payload,
|
||||||
)
|
)
|
||||||
from chatballs.support_portals.selectors import category_article_counts
|
|
||||||
|
|
||||||
|
|
||||||
def _article(portal, article_id: int) -> PortalArticle | None:
|
def _article(portal, article_id: int) -> PortalArticle | None:
|
||||||
return (
|
return (
|
||||||
portal.articles.select_related("portal", "category", "published_revision")
|
portal.articles.select_related("portal", "category", "published_revision")
|
||||||
.prefetch_related("revisions")
|
.prefetch_related("revisions", "revisions__created_by", "files", "feedback")
|
||||||
.filter(id=article_id)
|
.filter(id=article_id)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
@@ -111,7 +115,7 @@ class ArticleListView(PortalBaseView):
|
|||||||
return Response({"detail": "Портал не найден"}, status=404)
|
return Response({"detail": "Портал не найден"}, status=404)
|
||||||
articles = portal.articles.select_related(
|
articles = portal.articles.select_related(
|
||||||
"category", "published_revision"
|
"category", "published_revision"
|
||||||
).prefetch_related("revisions").all()
|
).prefetch_related("revisions", "files", "feedback").all()
|
||||||
return Response({"items": [article_payload(item) for item in articles]})
|
return Response({"items": [article_payload(item) for item in articles]})
|
||||||
|
|
||||||
def post(self, request: Request, portal_id: int) -> Response:
|
def post(self, request: Request, portal_id: int) -> Response:
|
||||||
@@ -129,7 +133,10 @@ class ArticleListView(PortalBaseView):
|
|||||||
return validation_response(error)
|
return validation_response(error)
|
||||||
return Response({"detail": "Некорректная статья"}, status=400)
|
return Response({"detail": "Некорректная статья"}, status=400)
|
||||||
article = _article(portal, article.id)
|
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):
|
class ArticleDetailView(PortalBaseView):
|
||||||
@@ -140,7 +147,7 @@ class ArticleDetailView(PortalBaseView):
|
|||||||
article = _article(portal, article_id)
|
article = _article(portal, article_id)
|
||||||
if article is None:
|
if article is None:
|
||||||
return Response({"detail": "Статья не найдена"}, status=404)
|
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:
|
def patch(self, request: Request, portal_id: int, article_id: int) -> Response:
|
||||||
portal = self.portal(request, portal_id)
|
portal = self.portal(request, portal_id)
|
||||||
@@ -156,7 +163,7 @@ class ArticleDetailView(PortalBaseView):
|
|||||||
return validation_response(error)
|
return validation_response(error)
|
||||||
return Response({"detail": "Некорректные данные статьи"}, status=400)
|
return Response({"detail": "Некорректные данные статьи"}, status=400)
|
||||||
article = _article(portal, article.id)
|
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):
|
class ArticleRevisionListView(PortalBaseView):
|
||||||
@@ -172,6 +179,7 @@ class ArticleRevisionListView(PortalBaseView):
|
|||||||
context=request.tenant_context,
|
context=request.tenant_context,
|
||||||
article=article,
|
article=article,
|
||||||
data=request.data,
|
data=request.data,
|
||||||
|
author=request.user,
|
||||||
)
|
)
|
||||||
except ValidationError as error:
|
except ValidationError as error:
|
||||||
return validation_response(error)
|
return validation_response(error)
|
||||||
@@ -196,7 +204,7 @@ class ArticlePublishView(PortalBaseView):
|
|||||||
return validation_response(error)
|
return validation_response(error)
|
||||||
return Response({"detail": "Некорректная версия"}, status=400)
|
return Response({"detail": "Некорректная версия"}, status=400)
|
||||||
article = _article(portal, article.id)
|
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):
|
class ArticleArchiveView(PortalBaseView):
|
||||||
@@ -208,4 +216,66 @@ class ArticleArchiveView(PortalBaseView):
|
|||||||
if article is None:
|
if article is None:
|
||||||
return Response({"detail": "Статья не найдена"}, status=404)
|
return Response({"detail": "Статья не найдена"}, status=404)
|
||||||
article = archive_article(article)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import dns.exception
|
import dns.exception
|
||||||
import dns.resolver
|
import dns.resolver
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -13,14 +11,6 @@ from chatballs.support_portals.models import SupportPortal
|
|||||||
from chatballs.support_portals.statuses import PortalStatus
|
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:
|
def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal:
|
||||||
if portal.status == PortalStatus.ARCHIVED:
|
if portal.status == PortalStatus.ARCHIVED:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -29,15 +19,9 @@ def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal:
|
|||||||
normalized = normalize_domain(value)
|
normalized = normalize_domain(value)
|
||||||
portal.custom_domain = validate_domain(normalized) if normalized else ""
|
portal.custom_domain = validate_domain(normalized) if normalized else ""
|
||||||
portal.custom_domain_verified_at = None
|
portal.custom_domain_verified_at = None
|
||||||
portal.custom_domain_verification_token = uuid.uuid4()
|
|
||||||
portal.full_clean()
|
portal.full_clean()
|
||||||
portal.save(
|
portal.save(
|
||||||
update_fields=[
|
update_fields=["custom_domain", "custom_domain_verified_at", "updated_at"]
|
||||||
"custom_domain",
|
|
||||||
"custom_domain_verified_at",
|
|
||||||
"custom_domain_verification_token",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
return portal
|
return portal
|
||||||
|
|
||||||
@@ -49,6 +33,10 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
|
|||||||
)
|
)
|
||||||
if not portal.custom_domain:
|
if not portal.custom_domain:
|
||||||
raise ValidationError({"customDomain": "Сначала укажите домен"})
|
raise ValidationError({"customDomain": "Сначала укажите домен"})
|
||||||
|
# Подтверждать владение доменом нечем и незачем: продукт self-hosted, домен
|
||||||
|
# и установка принадлежат одному владельцу (README дизайн-базлайна, решение
|
||||||
|
# 6). Остаётся техническая проверка «ведёт ли домен на этот сервер» — она
|
||||||
|
# нужна, чтобы выписать сертификат.
|
||||||
if settings.CHATBALLS_HELP_PUBLIC_IPV4:
|
if settings.CHATBALLS_HELP_PUBLIC_IPV4:
|
||||||
try:
|
try:
|
||||||
address_answers = dns.resolver.resolve(portal.custom_domain, "A")
|
address_answers = dns.resolver.resolve(portal.custom_domain, "A")
|
||||||
@@ -70,26 +58,6 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal:
|
|||||||
{"customDomain": "A-запись домена указывает не на сервер Chatballs"}
|
{"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.custom_domain_verified_at = timezone.now()
|
||||||
portal.full_clean()
|
portal.full_clean()
|
||||||
portal.save(update_fields=["custom_domain_verified_at", "updated_at"])
|
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:
|
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):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
@@ -19,7 +27,7 @@ class SupportPortalHostBoundaryMiddleware:
|
|||||||
def __call__(self, request: HttpRequest):
|
def __call__(self, request: HttpRequest):
|
||||||
raw_host = request.META.get("HTTP_HOST") or request.META.get("SERVER_NAME", "")
|
raw_host = request.META.get("HTTP_HOST") or request.META.get("SERVER_NAME", "")
|
||||||
host = normalize_domain(raw_host.partition(":")[0])
|
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 HttpResponseBadRequest("Invalid host")
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
@@ -31,6 +39,15 @@ class SupportPortalHostBoundaryMiddleware:
|
|||||||
or str(connections["default"].settings_dict["NAME"]).startswith("test_")
|
or str(connections["default"].settings_dict["NAME"]).startswith("test_")
|
||||||
):
|
):
|
||||||
return True
|
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:
|
for allowed in settings.CHATBALLS_APP_PRIMARY_HOSTS:
|
||||||
normalized = normalize_domain(allowed.lstrip("."))
|
normalized = normalize_domain(allowed.lstrip("."))
|
||||||
if host == normalized or (
|
if host == normalized or (
|
||||||
@@ -41,3 +58,24 @@ class SupportPortalHostBoundaryMiddleware:
|
|||||||
return support_portal_route(host) is not None
|
return support_portal_route(host) is not None
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from chatballs.tenancy.models import TenantRelationModel
|
|
||||||
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
|
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
|
||||||
from chatballs.support_portals.themes import (
|
from chatballs.support_portals.themes import (
|
||||||
DEFAULT_PORTAL_THEME,
|
DEFAULT_PORTAL_THEME,
|
||||||
PortalThemeScheme,
|
PortalThemeScheme,
|
||||||
)
|
)
|
||||||
|
from chatballs.tenancy.models import TenantRelationModel
|
||||||
|
|
||||||
|
|
||||||
class SupportPortal(TenantRelationModel):
|
class SupportPortal(TenantRelationModel):
|
||||||
@@ -20,10 +22,10 @@ class SupportPortal(TenantRelationModel):
|
|||||||
slug = models.SlugField(max_length=64, unique=True)
|
slug = models.SlugField(max_length=64, unique=True)
|
||||||
hosted_domain = models.CharField(max_length=253, unique=True)
|
hosted_domain = models.CharField(max_length=253, unique=True)
|
||||||
custom_domain = models.CharField(max_length=253, blank=True, default="")
|
custom_domain = models.CharField(max_length=253, blank=True, default="")
|
||||||
|
# Отметка технической проверки «домен ведёт на этот сервер». Подтверждения
|
||||||
|
# владения доменом нет: домен и установка у одного владельца (README
|
||||||
|
# дизайн-базлайна «Порталы», решение 6).
|
||||||
custom_domain_verified_at = models.DateTimeField(null=True, blank=True)
|
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)
|
name = models.CharField(max_length=255)
|
||||||
default_locale = models.CharField(max_length=16, default="ru")
|
default_locale = models.CharField(max_length=16, default="ru")
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
@@ -271,6 +273,14 @@ class PortalArticleRevision(TenantRelationModel):
|
|||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
summary = models.CharField(max_length=500, blank=True)
|
summary = models.CharField(max_length=500, blank=True)
|
||||||
content = models.TextField()
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
published_at = models.DateTimeField(null=True, blank=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):
|
class PortalArticleFeedback(TenantRelationModel):
|
||||||
tenant_relation_fields = ("article",)
|
tenant_relation_fields = ("article",)
|
||||||
article = models.ForeignKey(
|
article = models.ForeignKey(
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from chatballs.api.permissions import HasCapability
|
|||||||
from chatballs.identity.audit import record_audit_event
|
from chatballs.identity.audit import record_audit_event
|
||||||
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
|
from chatballs.integrations.models import IntegrationProvider, IntegrationStatus
|
||||||
from chatballs.support_portals.api import validation_response
|
from chatballs.support_portals.api import validation_response
|
||||||
from chatballs.support_portals.models import SupportPortal
|
|
||||||
from chatballs.support_portals.domain_services import (
|
from chatballs.support_portals.domain_services import (
|
||||||
set_custom_domain,
|
set_custom_domain,
|
||||||
verify_custom_domain,
|
verify_custom_domain,
|
||||||
)
|
)
|
||||||
|
from chatballs.support_portals.models import SupportPortal
|
||||||
from chatballs.support_portals.portal_services import (
|
from chatballs.support_portals.portal_services import (
|
||||||
PortalInput,
|
PortalInput,
|
||||||
create_portal,
|
create_portal,
|
||||||
@@ -20,12 +20,16 @@ from chatballs.support_portals.portal_services import (
|
|||||||
set_portal_status,
|
set_portal_status,
|
||||||
update_portal,
|
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 (
|
from chatballs.support_portals.themes import (
|
||||||
DEFAULT_PORTAL_THEME,
|
DEFAULT_PORTAL_THEME,
|
||||||
PortalThemeScheme,
|
PortalThemeScheme,
|
||||||
)
|
)
|
||||||
from chatballs.support_portals.serializers import portal_payload
|
|
||||||
from chatballs.webchat.models import (
|
from chatballs.webchat.models import (
|
||||||
WebChatWidget,
|
WebChatWidget,
|
||||||
WebChatWidgetMode,
|
WebChatWidgetMode,
|
||||||
@@ -112,11 +116,22 @@ def _input(request: Request, current: SupportPortal | None = None) -> PortalInpu
|
|||||||
class PortalListView(PortalBaseView):
|
class PortalListView(PortalBaseView):
|
||||||
def get(self, request: Request) -> Response:
|
def get(self, request: Request) -> Response:
|
||||||
portals = list(portals_for_context(request.tenant_context))
|
portals = list(portals_for_context(request.tenant_context))
|
||||||
|
counts = portal_content_counts(request.tenant_context)
|
||||||
# Тарифные лимиты порталов удалены (ADR-HUB-0042 §2): создание доступно всегда.
|
# Тарифные лимиты порталов удалены (ADR-HUB-0042 §2): создание доступно всегда.
|
||||||
active_count = sum(item.status != "ARCHIVED" for item in portals)
|
active_count = sum(item.status != "ARCHIVED" for item in portals)
|
||||||
return Response(
|
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": {
|
"creation": {
|
||||||
"available": True,
|
"available": True,
|
||||||
"canCreate": True,
|
"canCreate": True,
|
||||||
@@ -153,7 +168,8 @@ class PortalDetailView(PortalBaseView):
|
|||||||
portal = self.portal(request, portal_id)
|
portal = self.portal(request, portal_id)
|
||||||
if portal is None:
|
if portal is None:
|
||||||
return Response({"detail": "Портал не найден"}, status=404)
|
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:
|
def patch(self, request: Request, portal_id: int) -> Response:
|
||||||
portal = self.portal(request, portal_id)
|
portal = self.portal(request, portal_id)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from chatballs.support_portals import public_views
|
from chatballs.support_portals import file_views, public_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", public_views.PublicPortalDetailView.as_view(), name="public-support-portal"),
|
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/", 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>/", public_views.PublicArticleDetailView.as_view(), name="public-support-article"),
|
||||||
path("articles/<slug:article_slug>/feedback/", public_views.PublicArticleFeedbackView.as_view(), name="public-support-feedback"),
|
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 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
|
from chatballs.tenancy.context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@ def public_articles(
|
|||||||
published_revision__isnull=False,
|
published_revision__isnull=False,
|
||||||
)
|
)
|
||||||
.select_related("category", "published_revision")
|
.select_related("category", "published_revision")
|
||||||
|
.prefetch_related("files")
|
||||||
.order_by("category__sort_order", "published_revision__title")
|
.order_by("category__sort_order", "published_revision__title")
|
||||||
)
|
)
|
||||||
if category:
|
if category:
|
||||||
@@ -106,3 +111,24 @@ def category_article_counts(
|
|||||||
for category in categories:
|
for category in categories:
|
||||||
total(category.id)
|
total(category.id)
|
||||||
return totals
|
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 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 (
|
from chatballs.support_portals.models import (
|
||||||
PortalArticle,
|
PortalArticle,
|
||||||
|
PortalArticleFile,
|
||||||
PortalArticleRevision,
|
PortalArticleRevision,
|
||||||
PortalCategory,
|
PortalCategory,
|
||||||
SupportPortal,
|
SupportPortal,
|
||||||
SupportPortalProduct,
|
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:
|
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(
|
public_url = portal_public_url(
|
||||||
hosted=portal.hosted_domain,
|
hosted=portal.hosted_domain,
|
||||||
custom=portal.custom_domain,
|
custom=portal.custom_domain,
|
||||||
@@ -53,15 +51,6 @@ def portal_payload(portal: SupportPortal) -> dict:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"customDomainVerifiedAt": portal.custom_domain_verified_at,
|
"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,
|
"publicUrl": public_url,
|
||||||
"name": portal.name,
|
"name": portal.name,
|
||||||
"defaultLocale": portal.default_locale,
|
"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()],
|
"products": [product_link_payload(link) for link in portal.product_links.all()],
|
||||||
"createdAt": portal.created_at,
|
"createdAt": portal.created_at,
|
||||||
"updatedAt": portal.updated_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,
|
"summary": revision.summary,
|
||||||
"createdAt": revision.created_at,
|
"createdAt": revision.created_at,
|
||||||
"publishedAt": revision.published_at,
|
"publishedAt": revision.published_at,
|
||||||
|
"authorName": _author_name(revision.created_by),
|
||||||
}
|
}
|
||||||
if content:
|
if content:
|
||||||
payload["content"] = revision.content
|
# Ссылки на файлы приводятся к относительным и на отдаче: тексты,
|
||||||
|
# написанные до этого правила, иначе остались бы с абсолютным хостом
|
||||||
|
# и картинки резал бы CSP портала.
|
||||||
|
payload["content"] = normalize_file_links(revision.content)
|
||||||
return payload
|
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)
|
latest_revision = next(iter(article.revisions.all()), None)
|
||||||
payload = {
|
payload = {
|
||||||
"id": article.id,
|
"id": article.id,
|
||||||
@@ -131,6 +139,20 @@ def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict:
|
|||||||
"createdAt": article.created_at,
|
"createdAt": article.created_at,
|
||||||
"updatedAt": article.updated_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:
|
if revisions:
|
||||||
payload["revisions"] = [
|
payload["revisions"] = [
|
||||||
revision_payload(revision) for revision in article.revisions.all()
|
revision_payload(revision) for revision in article.revisions.all()
|
||||||
@@ -138,6 +160,29 @@ def article_payload(article: PortalArticle, *, revisions: bool = False) -> dict:
|
|||||||
return payload
|
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:
|
def public_portal_payload(portal: SupportPortal) -> dict:
|
||||||
widget_key = _public_widget_key(portal.widget, "ANONYMOUS")
|
widget_key = _public_widget_key(portal.widget, "ANONYMOUS")
|
||||||
return {
|
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:
|
def public_article_payload(article: PortalArticle, *, content: bool = True) -> dict:
|
||||||
return {
|
payload = {
|
||||||
"slug": article.slug,
|
"slug": article.slug,
|
||||||
"locale": article.locale,
|
"locale": article.locale,
|
||||||
"category": category_payload(article.category),
|
"category": category_payload(article.category),
|
||||||
"revision": revision_payload(article.published_revision, content=content),
|
"revision": revision_payload(article.published_revision, content=content),
|
||||||
"updatedAt": article.updated_at,
|
"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)
|
self.assertEqual(link.support_channel, self.channel)
|
||||||
|
|
||||||
@override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42")
|
@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"]
|
portal_id = self.create_portal().json()["portal"]["id"]
|
||||||
configured = self.client.put(
|
configured = self.client.put(
|
||||||
f"/api/v1/support/portals/{portal_id}/domain/",
|
f"/api/v1/support/portals/{portal_id}/domain/",
|
||||||
@@ -148,7 +150,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
self.assertEqual(configured.status_code, 200, configured.content)
|
self.assertEqual(configured.status_code, 200, configured.content)
|
||||||
verification = configured.json()["portal"]["customDomainVerification"]
|
self.assertNotIn("customDomainVerification", configured.json()["portal"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
configured.json()["portal"]["customDomainAddress"],
|
configured.json()["portal"]["customDomainAddress"],
|
||||||
{
|
{
|
||||||
@@ -158,13 +160,10 @@ class SupportPortalManagementTests(SupportPortalTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
address_answer = mock.Mock(address="203.0.113.42")
|
address_answer = mock.Mock(address="203.0.113.42")
|
||||||
verification_answer = mock.Mock(
|
|
||||||
strings=[verification["value"].encode("utf-8")]
|
|
||||||
)
|
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"dns.resolver.resolve",
|
"dns.resolver.resolve",
|
||||||
side_effect=[[address_answer], [verification_answer]],
|
return_value=[address_answer],
|
||||||
) as resolve:
|
) as resolve:
|
||||||
verified = self.client.post(
|
verified = self.client.post(
|
||||||
f"/api/v1/support/portals/{portal_id}/domain/verify/",
|
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.assertEqual(verified.status_code, 200, verified.content)
|
||||||
self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"])
|
self.assertIsNotNone(verified.json()["portal"]["customDomainVerifiedAt"])
|
||||||
self.assertIsNone(verified.json()["portal"]["customDomainVerification"])
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
resolve.call_args_list,
|
resolve.call_args_list,
|
||||||
[
|
[mock.call("help.customer.example", "A")],
|
||||||
mock.call("help.customer.example", "A"),
|
|
||||||
mock.call("_chatballs.help.customer.example", "TXT"),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@override_settings(CHATBALLS_HELP_PUBLIC_IPV4="203.0.113.42")
|
@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.support_portals.tests.base import SupportPortalTestCase
|
||||||
from chatballs.webchat.testing import create_web_widget
|
from chatballs.webchat.testing import create_web_widget
|
||||||
|
|
||||||
@@ -41,6 +43,129 @@ class SupportPortalContentManagementTests(SupportPortalTestCase):
|
|||||||
self.assertEqual(len(body["revisions"]), 2)
|
self.assertEqual(len(body["revisions"]), 2)
|
||||||
self.assertEqual(body["publishedRevision"]["content"], "Версия 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:
|
def test_archived_portal_rejects_every_content_mutation(self) -> None:
|
||||||
portal_id = self.create_portal().json()["portal"]["id"]
|
portal_id = self.create_portal().json()["portal"]["id"]
|
||||||
category = self.client.post(
|
category = self.client.post(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -56,6 +57,8 @@ class PublicSupportPortalTests(TestCase):
|
|||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
).json()["article"]
|
).json()["article"]
|
||||||
|
self.owner = result.owner
|
||||||
|
self.article_id = article["id"]
|
||||||
revision_id = article["revisions"][0]["id"]
|
revision_id = article["revisions"][0]["id"]
|
||||||
self.client.post(
|
self.client.post(
|
||||||
f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/",
|
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.status_code, 200, response.content)
|
||||||
self.assertEqual(response.json()["portal"]["name"], "Acme Help")
|
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")
|
@override_settings(ROOT_URLCONF="chatballs_backend.urls_platform")
|
||||||
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
|
def test_gateway_authorizes_only_published_portal_domains(self) -> None:
|
||||||
custom_domain = "help.app.example"
|
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/", 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/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>/", 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>/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>/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"),
|
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)
|
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:
|
def call_invite_route(token_hash: str) -> IngressRoute | None:
|
||||||
return _unique_route("call_invite_directory", token_hash)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import SuspiciousFileOperation
|
from django.core.exceptions import SuspiciousFileOperation
|
||||||
from django.core.files.storage import FileSystemStorage, Storage
|
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
|
from chatballs.tenancy.database import current_tenant_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _assert_tenant_key(name: str) -> None:
|
def _assert_tenant_key(name: str) -> None:
|
||||||
organization_id = current_tenant_id()
|
organization_id = current_tenant_id()
|
||||||
@@ -84,15 +88,31 @@ class _DynamicStorage(Storage):
|
|||||||
return self._s3(config)
|
return self._s3(config)
|
||||||
return None
|
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):
|
def _reader_for(self, name: str):
|
||||||
"""Хранилище, где файл реально лежит: активное, иначе второе."""
|
"""Хранилище, где файл реально лежит: активное, иначе второе."""
|
||||||
active = self._active()
|
active = self._active()
|
||||||
if active.exists(name):
|
if active.exists(name):
|
||||||
return active
|
return active
|
||||||
secondary = self._secondary()
|
return self._secondary_has(name) or active
|
||||||
if secondary is not None and secondary.exists(name):
|
|
||||||
return secondary
|
|
||||||
return active
|
|
||||||
|
|
||||||
# --- Storage API ---------------------------------------------------------
|
# --- Storage API ---------------------------------------------------------
|
||||||
def _open(self, name, mode="rb"):
|
def _open(self, name, mode="rb"):
|
||||||
@@ -111,16 +131,23 @@ class _DynamicStorage(Storage):
|
|||||||
|
|
||||||
def delete(self, name):
|
def delete(self, name):
|
||||||
self._guard(name)
|
self._guard(name)
|
||||||
for storage in (self._active(), self._secondary()):
|
active = self._active()
|
||||||
if storage is not None and storage.exists(name):
|
if active.exists(name):
|
||||||
storage.delete(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):
|
def exists(self, name):
|
||||||
self._guard(name)
|
self._guard(name)
|
||||||
if self._active().exists(name):
|
if self._active().exists(name):
|
||||||
return True
|
return True
|
||||||
secondary = self._secondary()
|
return self._secondary_has(name) is not None
|
||||||
return secondary is not None and secondary.exists(name)
|
|
||||||
|
|
||||||
def size(self, name):
|
def size(self, name):
|
||||||
self._guard(name)
|
self._guard(name)
|
||||||
|
|||||||
@@ -86,6 +86,44 @@ class StorageSettingsTests(TestCase):
|
|||||||
storage.delete(self.key)
|
storage.delete(self.key)
|
||||||
self.assertFalse(storage.exists(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:
|
def test_secrets_are_encrypted_at_rest_and_masked_in_api(self) -> None:
|
||||||
self._enable_s3()
|
self._enable_s3()
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# ruff: noqa: F403,F405
|
# ruff: noqa: F403,F405
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
from chatballs_backend.settings_base import *
|
from chatballs_backend.settings_base import *
|
||||||
|
|
||||||
CHATBALLS_RUNTIME_SURFACE = "app"
|
CHATBALLS_RUNTIME_SURFACE = "app"
|
||||||
@@ -10,9 +8,9 @@ ROOT_URLCONF = "chatballs_backend.urls_app"
|
|||||||
ASGI_APPLICATION = "chatballs_backend.asgi_app.application"
|
ASGI_APPLICATION = "chatballs_backend.asgi_app.application"
|
||||||
WSGI_APPLICATION = "chatballs_backend.wsgi_app.application"
|
WSGI_APPLICATION = "chatballs_backend.wsgi_app.application"
|
||||||
|
|
||||||
_app_hosts = os.environ.get("CHATBALLS_APP_ALLOWED_HOSTS", "")
|
# Домены установки задаются в UI (мастер первого запуска), а не переменной
|
||||||
if not DEBUG and not TESTING and not _app_hosts:
|
# окружения: коробка поднимается одной командой и до настройки отвечает на
|
||||||
raise ImproperlyConfigured("CHATBALLS_APP_ALLOWED_HOSTS is required for the app surface")
|
# локальные адреса. Проверку хоста несёт middleware/список ниже.
|
||||||
CHATBALLS_APP_PRIMARY_HOSTS = env_list(
|
CHATBALLS_APP_PRIMARY_HOSTS = env_list(
|
||||||
"CHATBALLS_APP_ALLOWED_HOSTS",
|
"CHATBALLS_APP_ALLOWED_HOSTS",
|
||||||
env_list("CHATBALLS_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]),
|
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",
|
"CHATBALLS_APP_CSRF_COOKIE_NAME",
|
||||||
"__Host-chatballs-app-csrf" if CSRF_COOKIE_SECURE else "chatballs_app_csrftoken",
|
"__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
|
SESSION_COOKIE_DOMAIN = None
|
||||||
CSRF_COOKIE_DOMAIN = None
|
CSRF_COOKIE_DOMAIN = None
|
||||||
SESSION_COOKIE_PATH = "/"
|
SESSION_COOKIE_PATH = "/"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
from chatballs_backend.settings_database import build_databases
|
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
|
from chatballs_backend.settings_storage import build_storage_settings
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -16,11 +16,16 @@ INSECURE_SECRET_KEY = "local-development-only"
|
|||||||
# Автоопределение тестового прогона, чтобы manage.py test / pytest работали
|
# Автоопределение тестового прогона, чтобы manage.py test / pytest работали
|
||||||
# без ручного выставления production-окружения.
|
# без ручного выставления production-окружения.
|
||||||
TESTING = "test" in sys.argv or "pytest" in sys.modules
|
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")
|
DEBUG = env_bool("CHATBALLS_DEBUG")
|
||||||
|
# Режим поставки — свойство установки, а не переменной окружения: коробку
|
||||||
|
# ставят self-hosted, облако выставляет режим явно.
|
||||||
_delivery_mode = os.environ.get("CHATBALLS_DELIVERY_MODE", "").strip().upper()
|
_delivery_mode = os.environ.get("CHATBALLS_DELIVERY_MODE", "").strip().upper()
|
||||||
if not _delivery_mode and (DEBUG or TESTING):
|
if not _delivery_mode:
|
||||||
_delivery_mode = "CLOUD"
|
_delivery_mode = "CLOUD" if (DEBUG or TESTING) else "SELF_HOSTED"
|
||||||
if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
|
if _delivery_mode not in {"CLOUD", "SELF_HOSTED"}:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
|
"CHATBALLS_DELIVERY_MODE must be CLOUD or SELF_HOSTED"
|
||||||
@@ -31,10 +36,12 @@ if TESTING:
|
|||||||
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
|
ALLOWED_HOSTS.extend(["testserver", ".localhost"])
|
||||||
CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", [])
|
CSRF_TRUSTED_ORIGINS = env_list("CHATBALLS_CSRF_TRUSTED_ORIGINS", [])
|
||||||
|
|
||||||
# Запрещаем запуск в production с дефолтным/пустым ключом подписи.
|
# Слабый ключ подписи в production недопустим. При пустом томе секретов это
|
||||||
|
# означает сломанную установку, а не забытую человеком переменную.
|
||||||
if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}:
|
if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}:
|
||||||
raise ImproperlyConfigured(
|
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 = [
|
INSTALLED_APPS = [
|
||||||
@@ -67,6 +74,7 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"chatballs.http.middleware.TlsAwareCookieMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"chatballs.http.middleware.ContentSecurityPolicyMiddleware",
|
"chatballs.http.middleware.ContentSecurityPolicyMiddleware",
|
||||||
"chatballs.http.middleware.LocalCorsMiddleware",
|
"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 link lifetime. UI обещает 30 минут (default_token_generator uses this setting).
|
||||||
PASSWORD_RESET_TIMEOUT = int(os.environ.get("PASSWORD_RESET_TIMEOUT", str(30 * 60)))
|
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_default = not DEBUG and not TESTING
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = os.environ.get("CHATBALLS_COOKIE_SAMESITE", "Lax")
|
SESSION_COOKIE_SAMESITE = os.environ.get("CHATBALLS_COOKIE_SAMESITE", "Lax")
|
||||||
CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE
|
CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE
|
||||||
SESSION_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", _secure_default)
|
SESSION_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", False)
|
||||||
CSRF_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", _secure_default)
|
CSRF_COOKIE_SECURE = env_bool("CHATBALLS_COOKIE_SECURE", False)
|
||||||
SECURE_SSL_REDIRECT = env_bool("CHATBALLS_SSL_REDIRECT", _secure_default)
|
SECURE_SSL_REDIRECT = env_bool("CHATBALLS_SSL_REDIRECT", False)
|
||||||
|
# HSTS Django отдаёт только на запросах, пришедших по TLS, поэтому установка
|
||||||
|
# на голом http его не получает и не «залипает» на несуществующий https.
|
||||||
SECURE_HSTS_SECONDS = int(
|
SECURE_HSTS_SECONDS = int(
|
||||||
os.environ.get("CHATBALLS_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0")
|
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", [])
|
CHATBALLS_CALL_STUN_URLS = env_list("CHATBALLS_CALL_STUN_URLS", [])
|
||||||
# TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials
|
# TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials
|
||||||
# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE.
|
# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE.
|
||||||
|
# Адреса TURN владелец задаёт в «Настройках» (там же, где адрес установки);
|
||||||
|
# переменная остаётся переопределением для установок, ведущих конфигурацию сами.
|
||||||
CHATBALLS_CALL_TURN_URLS = env_list("CHATBALLS_CALL_TURN_URLS", [])
|
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)))
|
CHATBALLS_CALL_TURN_TTL_SECONDS = int(os.environ.get("CHATBALLS_CALL_TURN_TTL_SECONDS", str(60 * 60)))
|
||||||
if CHATBALLS_CALL_TURN_TTL_SECONDS <= 0:
|
if CHATBALLS_CALL_TURN_TTL_SECONDS <= 0:
|
||||||
raise ImproperlyConfigured("CHATBALLS_CALL_TURN_TTL_SECONDS must be positive")
|
raise ImproperlyConfigured("CHATBALLS_CALL_TURN_TTL_SECONDS must be positive")
|
||||||
|
|||||||
@@ -2,28 +2,30 @@ import os
|
|||||||
␍
|
␍
|
||||||
from django.core.exceptions import ImproperlyConfigured␍
|
from django.core.exceptions import ImproperlyConfigured␍
|
||||||
␍
|
␍
|
||||||
|
from chatballs_backend.settings_env import env_secret␍
|
||||||
|
␍
|
||||||
␍
|
␍
|
||||||
def _credentials() -> tuple[dict[str, str], dict[str, str]]:␍
|
def _credentials() -> tuple[dict[str, str], dict[str, str]]:␍
|
||||||
|
# Имена ролей — константы продукта, а не настройка установки: их создаёт␍
|
||||||
|
# deploy/postgres/init-runtime-roles.sh при первом старте. Переменные␍
|
||||||
|
# окружения остаются переопределением для нестандартных установок.␍
|
||||||
users = {␍
|
users = {␍
|
||||||
"app": os.environ.get(␍
|
"app": os.environ.get("POSTGRES_APP_USER", "chatballs_app"),␍
|
||||||
"POSTGRES_APP_USER", os.environ.get("POSTGRES_USER", "chatballs")␍
|
"platform": os.environ.get("POSTGRES_PLATFORM_USER", "chatballs_platform"),␍
|
||||||
),␍
|
|
||||||
"platform": os.environ.get(␍
|
|
||||||
"POSTGRES_PLATFORM_USER", os.environ.get("POSTGRES_USER", "chatballs")␍
|
|
||||||
),␍
|
|
||||||
"migration": os.environ.get(␍
|
"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 = {␍
|
passwords = {␍
|
||||||
"app": os.environ.get(␍
|
"app": env_secret("POSTGRES_APP_PASSWORD", "postgres_app_password", fallback),␍
|
||||||
"POSTGRES_APP_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")␍
|
"platform": env_secret(␍
|
||||||
|
"POSTGRES_PLATFORM_PASSWORD", "postgres_platform_password", fallback␍
|
||||||
),␍
|
),␍
|
||||||
"platform": os.environ.get(␍
|
"migration": env_secret(␍
|
||||||
"POSTGRES_PLATFORM_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")␍
|
"POSTGRES_MIGRATION_PASSWORD", "postgres_migration_password", fallback␍
|
||||||
),␍
|
|
||||||
"migration": os.environ.get(␍
|
|
||||||
"POSTGRES_MIGRATION_PASSWORD", os.environ.get("POSTGRES_PASSWORD", "chatballs")␍
|
|
||||||
),␍
|
),␍
|
||||||
}␍
|
}␍
|
||||||
return users, passwords␍
|
return users, passwords␍
|
||||||
@@ -78,9 +80,14 @@ def build_databases(*, debug: bool, testing: bool) -> dict[str, dict]:
|
|||||||
"platform": config("platform"),␍
|
"platform": config("platform"),␍
|
||||||
}␍
|
}␍
|
||||||
if testing:␍
|
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"}␍
|
databases["platform"]["TEST"] = {"MIRROR": "default"}␍
|
||||||
return databases␍
|
return databases␍
|
||||||
@@ -1,4 +1,30 @@
|
|||||||
import os
|
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:
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# ruff: noqa: F403,F405
|
# ruff: noqa: F403,F405
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
from chatballs_backend.settings_base import *
|
from chatballs_backend.settings_base import *
|
||||||
|
|
||||||
CHATBALLS_RUNTIME_SURFACE = "platform"
|
CHATBALLS_RUNTIME_SURFACE = "platform"
|
||||||
@@ -10,11 +8,9 @@ ROOT_URLCONF = "chatballs_backend.urls_platform"
|
|||||||
ASGI_APPLICATION = "chatballs_backend.asgi_platform.application"
|
ASGI_APPLICATION = "chatballs_backend.asgi_platform.application"
|
||||||
WSGI_APPLICATION = "chatballs_backend.wsgi_platform.application"
|
WSGI_APPLICATION = "chatballs_backend.wsgi_platform.application"
|
||||||
|
|
||||||
_platform_hosts = os.environ.get("CHATBALLS_PLATFORM_ALLOWED_HOSTS", "")
|
# Домены установки задаются в UI (мастер первого запуска), а не переменной
|
||||||
if not DEBUG and not TESTING and not _platform_hosts:
|
# окружения: коробка поднимается одной командой и до настройки отвечает на
|
||||||
raise ImproperlyConfigured(
|
# локальные адреса. Проверку хоста несёт middleware/список ниже.
|
||||||
"CHATBALLS_PLATFORM_ALLOWED_HOSTS is required for the platform surface"
|
|
||||||
)
|
|
||||||
ALLOWED_HOSTS = env_list(
|
ALLOWED_HOSTS = env_list(
|
||||||
"CHATBALLS_PLATFORM_ALLOWED_HOSTS",
|
"CHATBALLS_PLATFORM_ALLOWED_HOSTS",
|
||||||
["localhost", "127.0.0.1", "platform.localhost"],
|
["localhost", "127.0.0.1", "platform.localhost"],
|
||||||
@@ -42,6 +38,14 @@ CSRF_COOKIE_NAME = os.environ.get(
|
|||||||
if CSRF_COOKIE_SECURE
|
if CSRF_COOKIE_SECURE
|
||||||
else "chatballs_platform_csrftoken",
|
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
|
SESSION_COOKIE_DOMAIN = None
|
||||||
CSRF_COOKIE_DOMAIN = None
|
CSRF_COOKIE_DOMAIN = None
|
||||||
SESSION_COOKIE_PATH = "/"
|
SESSION_COOKIE_PATH = "/"
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function App() {
|
|||||||
if (activeUser && !initialRoute.organizationPublicId) {
|
if (activeUser && !initialRoute.organizationPublicId) {
|
||||||
const nextPath = pathFromRoute(
|
const nextPath = pathFromRoute(
|
||||||
initialRoute.route,
|
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,
|
initialRoute.productCode,
|
||||||
activeUser.organizationPublicId,
|
activeUser.organizationPublicId,
|
||||||
);
|
);
|
||||||
@@ -170,7 +170,7 @@ export function App() {
|
|||||||
) : !canAccess(user, navigation.route) ? (
|
) : !canAccess(user, navigation.route) ? (
|
||||||
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
|
<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>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,13 +97,35 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multipart-загрузка (вложения знаний): Content-Type выставляет браузер (boundary).
|
// Multipart-загрузка (вложения знаний, файлы статей портала): Content-Type
|
||||||
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
|
// выставляет браузер (boundary). На XHR, а не на fetch, потому что прогресс
|
||||||
const headers = new Headers({ Accept: "application/json", "X-CSRFToken": getCookie(CSRF_COOKIE_NAME) });
|
// отправки нужен рейке файлов редактора статьи (кадр PT8) — у fetch его нет.
|
||||||
const response = await fetch(resolveApiUrl(path), { method: "POST", body: form, credentials: "include", headers });
|
export function apiUpload<T>(path: string, form: FormData, onProgress?: (percent: number) => void): Promise<T> {
|
||||||
if (!response.ok) {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" })) as ApiErrorPayload;
|
const request = new XMLHttpRequest();
|
||||||
throw new ApiError(response.status, payload);
|
request.open("POST", resolveApiUrl(path));
|
||||||
}
|
request.withCredentials = true;
|
||||||
return response.json() as Promise<T>;
|
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 { fetchHelpArticle, sendArticleFeedback } from "./api";
|
||||||
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
|
import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons";
|
||||||
import { HelpLayout } from "./HelpLayout";
|
import { HelpLayout } from "./HelpLayout";
|
||||||
|
import { HelpAttachments } from "./HelpAttachments";
|
||||||
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
|
import { MarkdownContent, parseMarkdown } from "./MarkdownContent";
|
||||||
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
|
import type { HelpArticle as HelpArticleType, HelpManifest } from "./types";
|
||||||
|
|
||||||
@@ -94,6 +95,7 @@ export function HelpArticle({
|
|||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
<MarkdownContent content={article.revision.content ?? ""} />
|
<MarkdownContent content={article.revision.content ?? ""} />
|
||||||
|
<HelpAttachments attachments={article.attachments ?? []} />
|
||||||
<section className="help-feedback">
|
<section className="help-feedback">
|
||||||
{feedback === "sent" ? (
|
{feedback === "sent" ? (
|
||||||
<div className="help-feedback-thanks">
|
<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 { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Loader } from "@chatballs/ui";
|
||||||
|
|
||||||
import { fetchHelpManifest } from "./api";
|
import { fetchHelpManifest } from "./api";
|
||||||
import { HelpArticle } from "./HelpArticle";
|
import { HelpArticle } from "./HelpArticle";
|
||||||
import { HelpHome } from "./HelpHome";
|
import { HelpHome } from "./HelpHome";
|
||||||
@@ -51,7 +53,7 @@ export function HelpCenterApp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!manifest || !themeReady) {
|
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
|
return route.articleSlug
|
||||||
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
|
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
|
||||||
|
|||||||
@@ -310,3 +310,66 @@
|
|||||||
.help-article-error a {
|
.help-article-error a {
|
||||||
color: var(--help-text);
|
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);
|
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` в манифесте** — это проверяет тест.
|
**Имя папки обязано совпадать с `id` в манифесте** — это проверяет тест.
|
||||||
2. Заполните `manifest.ts`: `name` и `description` видны владельцу портала в
|
2. Заполните `manifest.ts`: `name` и `description` видны владельцу портала в
|
||||||
настройках, `schemes` перечисляет поддерживаемые цветовые схемы, `preview` —
|
настройках, `schemes` перечисляет поддерживаемые цветовые схемы, `preview` —
|
||||||
три цвета для превью.
|
три цвета для превью (`bg`/`ink`/`accent`) и необязательный `radius`
|
||||||
|
скругления плиток на мини-превью.
|
||||||
3. Переопределите нужные токены в `theme.css`.
|
3. Переопределите нужные токены в `theme.css`.
|
||||||
4. Выберите тему в карточке портала: `Настройки → Оформление`.
|
4. Выберите тему в карточке портала: `Настройки → Оформление`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ export const manifest: PortalThemeManifest = {
|
|||||||
name: "Классическая",
|
name: "Классическая",
|
||||||
description: "Базовое оформление Help Center: светлая подложка, чернильный акцент.",
|
description: "Базовое оформление Help Center: светлая подложка, чернильный акцент.",
|
||||||
schemes: ["light", "dark"],
|
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",
|
name: "Ocean",
|
||||||
description: "Мягкое океаническое оформление: синий акцент, крупные скругления.",
|
description: "Мягкое океаническое оформление: синий акцент, крупные скругления.",
|
||||||
schemes: ["light", "dark"],
|
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[];
|
schemes: PortalThemeScheme[];
|
||||||
// Три цвета для превью в настройках портала: подложка, текст, акцент.
|
// Превью в настройках портала (кадр PT6): подложка, текст, акцент и
|
||||||
preview: { bg: string; ink: string; accent: string };
|
// скругление плиток — тем же радиусом, каким тема скругляет карточки.
|
||||||
|
preview: { bg: string; ink: string; accent: string; radius?: string };
|
||||||
author?: string;
|
author?: string;
|
||||||
};
|
};
|
||||||
@@ -29,12 +29,21 @@ export type HelpRevision = {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HelpAttachment = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type HelpArticle = {
|
export type HelpArticle = {
|
||||||
slug: string;
|
slug: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
category: HelpCategory;
|
category: HelpCategory;
|
||||||
revision: HelpRevision;
|
revision: HelpRevision;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
// Файлы статьи: картинки вставлены в текст, остальное посетитель скачивает.
|
||||||
|
attachments?: HelpAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HelpPortal = {
|
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 { useMediaQuery } from "../../shared/useMediaQuery";
|
||||||
import { DemoDataCard } from "./DemoDataCard";
|
import { DemoDataCard } from "./DemoDataCard";
|
||||||
import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
|
import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
|
||||||
|
import { InstanceAddressCard } from "./InstanceAddressCard";
|
||||||
import { StorageSettingsCard } from "./StorageSettingsCard";
|
import { StorageSettingsCard } from "./StorageSettingsCard";
|
||||||
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
import { GroupsSettingsCard } from "./GroupsSettingsCard";
|
||||||
import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections";
|
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.loading) return <LoadingState />;
|
||||||
if (!page.organization) return <EmptyState title={page.error || "Не удалось загрузить настройки"} />;
|
if (!page.organization) return <EmptyState title={page.error || "Не удалось загрузить настройки"} />;
|
||||||
return (
|
return (
|
||||||
<OrganizationSettingsForm
|
<>
|
||||||
organization={page.organization}
|
<OrganizationSettingsForm
|
||||||
canManage={canManageSettings(user)}
|
organization={page.organization}
|
||||||
saving={page.saving}
|
canManage={canManageSettings(user)}
|
||||||
message={page.message}
|
saving={page.saving}
|
||||||
error={page.error}
|
message={page.message}
|
||||||
timezones={page.timezones}
|
error={page.error}
|
||||||
onChange={page.setOrganization}
|
timezones={page.timezones}
|
||||||
onSave={() => void page.save()}
|
onChange={page.setOrganization}
|
||||||
onUploadLogo={(file) => void page.uploadLogo(file)}
|
onSave={() => void page.save()}
|
||||||
onRemoveLogo={() => void page.removeLogo()}
|
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 { 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 { Button } from "../../shared/ui-controls";
|
||||||
|
import { shortDateTime } from "../../shared/utils";
|
||||||
import {
|
import {
|
||||||
listPortalThemes,
|
listPortalThemes,
|
||||||
resolvePortalTheme,
|
resolvePortalTheme,
|
||||||
schemeOptions,
|
schemeOptions,
|
||||||
} from "../help-center/themes/registry";
|
} 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";
|
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({
|
export function PortalAppearanceSettings({
|
||||||
canManage,
|
canManage,
|
||||||
portal,
|
portal,
|
||||||
@@ -23,6 +44,7 @@ export function PortalAppearanceSettings({
|
|||||||
const [scheme, setScheme] = useState<PortalThemeSchemeSetting>(portal.themeScheme);
|
const [scheme, setScheme] = useState<PortalThemeSchemeSetting>(portal.themeScheme);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTheme(portal.theme);
|
setTheme(portal.theme);
|
||||||
@@ -35,20 +57,15 @@ export function PortalAppearanceSettings({
|
|||||||
const schemes = schemeOptions(selected);
|
const schemes = schemeOptions(selected);
|
||||||
// Схема, которую выбранная тема не поддерживает, не должна молча уехать в
|
// Схема, которую выбранная тема не поддерживает, не должна молча уехать в
|
||||||
// сохранение: показываем ближайшую поддерживаемую.
|
// сохранение: показываем ближайшую поддерживаемую.
|
||||||
const effectiveScheme = schemes.some(([value]) => value === scheme)
|
const effectiveScheme = schemes.some(([value]) => value === scheme) ? scheme : schemes[0][0];
|
||||||
? scheme
|
|
||||||
: schemes[0][0];
|
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSupportPortal(portal.id, {
|
const payload = await updateSupportPortal(portal.id, { theme, themeScheme: effectiveScheme });
|
||||||
theme,
|
|
||||||
themeScheme: effectiveScheme,
|
|
||||||
});
|
|
||||||
onChanged(payload.portal);
|
onChanged(payload.portal);
|
||||||
setFeedback("Оформление обновлено");
|
setSavedAt(payload.portal.updatedAt);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setFeedback(portalErrorMessage(caught, "Не удалось сохранить оформление"));
|
setFeedback(portalErrorMessage(caught, "Не удалось сохранить оформление"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,40 +74,56 @@ export function PortalAppearanceSettings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="portal-section">
|
<div className="portal-settings-card">
|
||||||
<div className="portal-section-heading">
|
<div>
|
||||||
<div>
|
<span className="portal-field-label">Тема</span>
|
||||||
<h2>Оформление</h2>
|
<div className="portal-theme-grid">
|
||||||
<p>{selected.description || "Тема публичных страниц портала и её цветовая схема."}</p>
|
{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>
|
</div>
|
||||||
<div className="portal-settings-fields">
|
|
||||||
<SelectField
|
<div>
|
||||||
disabled={!canManage}
|
<span className="portal-field-label">Цветовая схема</span>
|
||||||
label="Тема"
|
<Segmented
|
||||||
value={theme}
|
className="portal-scheme-segment"
|
||||||
onChange={setTheme}
|
items={schemes}
|
||||||
options={[
|
|
||||||
...themes.map((item): [string, string] => [item.id, item.name]),
|
|
||||||
// Тема, которой больше нет в сборке: показываем как есть, чтобы
|
|
||||||
// сохранение не подменило её молча.
|
|
||||||
...(known ? [] : [[theme, `${theme} — тема недоступна`] as [string, string]]),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SelectField
|
|
||||||
disabled={!canManage}
|
|
||||||
label="Цветовая схема"
|
|
||||||
value={effectiveScheme}
|
value={effectiveScheme}
|
||||||
onChange={(value) => setScheme(value as PortalThemeSchemeSetting)}
|
setValue={(value) => setScheme(value)}
|
||||||
options={schemes}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
|
||||||
<Button variant="secondary" disabled={busy} onClick={() => void save()}>
|
<div className="portal-settings-actions">
|
||||||
Сохранить оформление
|
{canManage && <Button variant="primary" disabled={busy} onClick={() => void save()}>Сохранить оформление</Button>}
|
||||||
</Button>
|
<a className="secondary-button" href={portal.publicUrl} rel="noreferrer" target="_blank">
|
||||||
)}
|
<Icon name="eye" size={15} strokeWidth={1.9} />Предпросмотр портала
|
||||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
</a>
|
||||||
</section>
|
<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 { Icon } from "../../shared/icons";
|
||||||
import { ContentEditorCard } from "../../shared/content-library/ContentEditorCard";
|
import { Segmented } from "../../shared/ui";
|
||||||
import { FormField, SelectField, TextAreaField } from "../../shared/form-controls";
|
|
||||||
import { Button } from "../../shared/ui-controls";
|
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 {
|
import {
|
||||||
addArticleRevision,
|
addArticleRevision,
|
||||||
createPortalArticle,
|
createPortalArticle,
|
||||||
|
deleteArticleFile,
|
||||||
loadPortalArticle,
|
loadPortalArticle,
|
||||||
portalErrorMessage,
|
portalErrorMessage,
|
||||||
updatePortalArticle,
|
updatePortalArticle,
|
||||||
|
uploadArticleFile,
|
||||||
type PortalArticle,
|
type PortalArticle,
|
||||||
|
type PortalArticleFile,
|
||||||
type PortalCategory,
|
type PortalCategory,
|
||||||
|
type SupportPortal,
|
||||||
} from "./model";
|
} 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({
|
export function PortalArticleEditor({
|
||||||
article,
|
article,
|
||||||
|
canManage,
|
||||||
categories,
|
categories,
|
||||||
defaultLocale,
|
portal,
|
||||||
onClose,
|
onClose,
|
||||||
onPublish,
|
onPublish,
|
||||||
onSaved,
|
onSaved,
|
||||||
portalId,
|
|
||||||
}: {
|
}: {
|
||||||
article: PortalArticle | null;
|
article: PortalArticle | null;
|
||||||
|
canManage: boolean;
|
||||||
categories: PortalCategory[];
|
categories: PortalCategory[];
|
||||||
defaultLocale: string;
|
portal: SupportPortal;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPublish: (article: PortalArticle, revisionId: number) => void;
|
onPublish: (article: PortalArticle, revisionId: number) => void;
|
||||||
onSaved: () => Promise<void>;
|
onSaved: () => Promise<void>;
|
||||||
portalId: number;
|
|
||||||
}) {
|
}) {
|
||||||
|
const portalId = portal.id;
|
||||||
const [loaded, setLoaded] = useState<PortalArticle | null>(article);
|
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 [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 [slug, setSlug] = useState(article?.slug ?? "");
|
||||||
|
const [editingSlug, setEditingSlug] = useState(!article);
|
||||||
const [selectedRevisionId, setSelectedRevisionId] = useState<number | null>(null);
|
const [selectedRevisionId, setSelectedRevisionId] = useState<number | null>(null);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = useState("");
|
||||||
const [content, setContent] = 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 [busy, setBusy] = useState(Boolean(article));
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
@@ -49,43 +97,141 @@ export function PortalArticleEditor({
|
|||||||
loadPortalArticle(portalId, article.id)
|
loadPortalArticle(portalId, article.id)
|
||||||
.then(({ article: detail }) => {
|
.then(({ article: detail }) => {
|
||||||
setLoaded(detail);
|
setLoaded(detail);
|
||||||
|
setFiles(detail.files ?? []);
|
||||||
const revision = detail.revisions?.[0] ?? detail.publishedRevision;
|
const revision = detail.revisions?.[0] ?? detail.publishedRevision;
|
||||||
setSelectedRevisionId(revision?.id ?? null);
|
setSelectedRevisionId(revision?.id ?? null);
|
||||||
setTitle(revision?.title ?? "");
|
setTitle(revision?.title ?? "");
|
||||||
setSummary(revision?.summary ?? "");
|
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, "Не удалось загрузить статью")))
|
.catch((caught) => setError(portalErrorMessage(caught, "Не удалось загрузить статью")))
|
||||||
.finally(() => setBusy(false));
|
.finally(() => setBusy(false));
|
||||||
}, [article, portalId]);
|
}, [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 revisions = useMemo(() => loaded?.revisions ?? [], [loaded]);
|
||||||
const revision = revisions.find((item) => item.id === Number(value));
|
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;
|
if (!revision) return;
|
||||||
setSelectedRevisionId(revision.id);
|
setSelectedRevisionId(revision.id);
|
||||||
setTitle(revision.title);
|
setTitle(revision.title);
|
||||||
setSummary(revision.summary);
|
setSummary(revision.summary);
|
||||||
setContent(revision.content);
|
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);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
if (article) {
|
if (loaded) {
|
||||||
await updatePortalArticle(portalId, article.id, {
|
await updatePortalArticle(portalId, loaded.id, {
|
||||||
categoryId: Number(categoryId),
|
categoryId: Number(categoryId),
|
||||||
locale,
|
locale,
|
||||||
slug,
|
slug,
|
||||||
});
|
});
|
||||||
const payload = await addArticleRevision(portalId, article.id, { title, summary, content });
|
const payload = await addArticleRevision(portalId, loaded.id, { title, summary, content });
|
||||||
const detail = await loadPortalArticle(portalId, article.id);
|
const detail = await loadPortalArticle(portalId, loaded.id);
|
||||||
setLoaded(detail.article);
|
setLoaded(detail.article);
|
||||||
|
setFiles(detail.article.files ?? []);
|
||||||
setSelectedRevisionId(payload.revision.id);
|
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 {
|
} else {
|
||||||
await createPortalArticle(portalId, {
|
const created = await createPortalArticle(portalId, {
|
||||||
categoryId: Number(categoryId),
|
categoryId: Number(categoryId),
|
||||||
slug,
|
slug,
|
||||||
locale,
|
locale,
|
||||||
@@ -93,73 +239,384 @@ export function PortalArticleEditor({
|
|||||||
summary,
|
summary,
|
||||||
content,
|
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) {
|
} catch (caught) {
|
||||||
setError(portalErrorMessage(caught, "Не удалось сохранить статью"));
|
setError(portalErrorMessage(caught, "Не удалось сохранить статью"));
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
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 (
|
return (
|
||||||
<div className="knowledge-editor-page">
|
<div className="portal-editor">
|
||||||
<ContentEditorBreadcrumb
|
<div className="portal-editor-head">
|
||||||
backLabel="Материалы"
|
<button className="portal-editor-back" type="button" onClick={onClose}>
|
||||||
category={categoryName}
|
<Icon name="chevron" size={16} strokeWidth={2} />Материалы
|
||||||
title={article?.latestRevision?.title ?? "Создание статьи"}
|
</button>
|
||||||
onBack={onClose}
|
<span className="portal-editor-crumb">{category?.name ?? ""}</span>
|
||||||
/>
|
<span className="portal-editor-gap" />
|
||||||
<div className="knowledge-editor-grid create">
|
<span className={`portal-editor-badge is-${badge.tone}`}><i />{badge.text}</span>
|
||||||
<div className="knowledge-editor-main">
|
<Segmented className="portal-editor-modes" items={MODES} value={mode} setValue={setMode} />
|
||||||
<ContentEditorCard
|
{canManage && (
|
||||||
heading={<h3>{article ? "Статья" : "Новая статья"}</h3>}
|
<Button variant="secondary" className="portal-editor-save" disabled={busy || !ready} onClick={() => void save()}>
|
||||||
meta={<><span className={`knowledge-card-status${active ? "" : " disabled"}`}><i />{active ? "Опубликована" : "Черновик"}</span>{loaded && <span className="knowledge-card-updated">обновлено {formatDate(loaded.updatedAt)}</span>}</>}
|
Сохранить черновик
|
||||||
error={error}
|
</Button>
|
||||||
actions={<>
|
)}
|
||||||
<Button variant="primary" icon={article ? "save" : "plus"} disabled={busy || !ready} onClick={() => void save()}>
|
{canManage && (
|
||||||
{article ? "Сохранить новую версию" : "Создать статью"}
|
<Button
|
||||||
</Button>
|
variant="primary"
|
||||||
{article && selectedRevisionId && (
|
className="portal-editor-publish"
|
||||||
<Button variant="secondary" disabled={busy} onClick={() => onPublish(article, selectedRevisionId)}>
|
icon="check"
|
||||||
Опубликовать версию
|
disabled={busy || !ready}
|
||||||
</Button>
|
onClick={() => void publish()}
|
||||||
)}
|
|
||||||
</>}
|
|
||||||
>
|
>
|
||||||
<div className="knowledge-editor-fields">
|
Опубликовать
|
||||||
<div className="knowledge-editor-scope-row">
|
</Button>
|
||||||
<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"]]} />
|
</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>
|
||||||
<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>
|
</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>
|
||||||
</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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { FormField } from "../../shared/form-controls";
|
import { Icon } from "../../shared/icons";
|
||||||
import { Button } from "../../shared/ui-controls";
|
import { Button, CopyButton } from "../../shared/ui-controls";
|
||||||
|
import { shortDate } from "../../shared/utils";
|
||||||
import {
|
import {
|
||||||
portalErrorMessage,
|
portalErrorMessage,
|
||||||
setPortalCustomDomain,
|
setPortalCustomDomain,
|
||||||
verifyPortalCustomDomain,
|
verifyPortalCustomDomain,
|
||||||
type SupportPortal,
|
type SupportPortal,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
|
import { hostedHost } from "./portalText";
|
||||||
|
|
||||||
|
// Кадр PT5. Подтверждения владения доменом нет: продукт self-hosted, домен и
|
||||||
|
// установка принадлежат одному владельцу. Остаётся одна A-запись на IP
|
||||||
|
// установки и техническая проверка «ведёт ли домен сюда» — она нужна для TLS.
|
||||||
|
|
||||||
export function PortalDomainSettings({
|
export function PortalDomainSettings({
|
||||||
canManage,
|
canManage,
|
||||||
@@ -21,83 +27,124 @@ export function PortalDomainSettings({
|
|||||||
const [domain, setDomain] = useState(portal.customDomain ?? "");
|
const [domain, setDomain] = useState(portal.customDomain ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [feedback, setFeedback] = useState("");
|
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);
|
setBusy(true);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
|
setFailure("");
|
||||||
try {
|
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);
|
onChanged(payload.portal);
|
||||||
setFeedback(domain.trim() ? "Домен сохранён. Добавьте показанные DNS-записи и проверьте домен." : "Свой домен отключён");
|
setFeedback(action === "verify"
|
||||||
} catch (error) {
|
? "Домен ведёт сюда"
|
||||||
setFeedback(portalErrorMessage(error, "Не удалось сохранить домен"));
|
: domain.trim() ? "Домен сохранён. Добавьте A-запись и проверьте DNS." : "Свой домен отключён");
|
||||||
|
} catch (caught) {
|
||||||
|
setFailure(portalErrorMessage(
|
||||||
|
caught,
|
||||||
|
action === "verify" ? "Не удалось проверить домен" : "Не удалось сохранить домен",
|
||||||
|
));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verify() {
|
async function disconnect() {
|
||||||
|
setDomain("");
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
|
setFailure("");
|
||||||
try {
|
try {
|
||||||
const payload = await verifyPortalCustomDomain(portal.id);
|
const payload = await setPortalCustomDomain(portal.id, "");
|
||||||
onChanged(payload.portal);
|
onChanged(payload.portal);
|
||||||
setFeedback("Домен подтверждён");
|
setFeedback("Свой домен отключён");
|
||||||
} catch (error) {
|
} catch (caught) {
|
||||||
setFeedback(portalErrorMessage(error, "Не удалось подтвердить домен"));
|
setFailure(portalErrorMessage(caught, "Не удалось отключить домен"));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="portal-section">
|
<div className="portal-settings-card">
|
||||||
<div className="portal-section-heading">
|
<div className="portal-domain-row">
|
||||||
<div><h2>Свой домен</h2><p>Подключите адрес вашей компании к опубликованному порталу.</p></div>
|
<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>
|
||||||
<div className="portal-settings-fields">
|
|
||||||
<FormField
|
{portal.customDomain && (
|
||||||
disabled={!canManage}
|
<div>
|
||||||
label="Домен"
|
<div className="portal-dns-head">
|
||||||
placeholder="help.example.com"
|
<strong>Запись у регистратора домена</strong>
|
||||||
value={domain}
|
<span>обновление DNS занимает до 24 часов</span>
|
||||||
onChange={canManage ? setDomain : undefined}
|
</div>
|
||||||
mono
|
<table className="portal-dns-table">
|
||||||
wide
|
<thead>
|
||||||
/>
|
<tr><th>ТИП</th><th>ИМЯ</th><th>ЗНАЧЕНИЕ</th><th /></tr>
|
||||||
</div>
|
</thead>
|
||||||
{(portal.customDomainAddress || portal.customDomainVerification) && (
|
<tbody>
|
||||||
<div className="portal-domain-verification">
|
{record ? (
|
||||||
<strong>DNS-записи для {portal.customDomain}</strong>
|
<tr>
|
||||||
{portal.customDomainAddress && (
|
<td className="portal-dns-type">{record.type}</td>
|
||||||
<div className="portal-domain-record">
|
<td><code>{record.name}</code></td>
|
||||||
<b>A-запись</b>
|
<td><code>{record.value} · IP этой установки</code></td>
|
||||||
<dl>
|
<td><CopyButton className="portal-dns-copy" label="" value={record.value} /></td>
|
||||||
<div><dt>Тип</dt><dd>{portal.customDomainAddress.type}</dd></div>
|
</tr>
|
||||||
<div><dt>Имя</dt><dd><code>{portal.customDomainAddress.name}</code></dd></div>
|
) : (
|
||||||
<div><dt>Значение</dt><dd><code>{portal.customDomainAddress.value}</code></dd></div>
|
<tr>
|
||||||
</dl>
|
<td className="portal-dns-type">A</td>
|
||||||
</div>
|
<td><code>{portal.customDomain}</code></td>
|
||||||
)}
|
<td><code>IP этой установки не задан администратором</code></td>
|
||||||
{portal.customDomainVerification && (
|
<td />
|
||||||
<div className="portal-domain-record">
|
</tr>
|
||||||
<b>TXT-запись</b>
|
)}
|
||||||
<dl>
|
</tbody>
|
||||||
<div><dt>Тип</dt><dd>{portal.customDomainVerification.type}</dd></div>
|
</table>
|
||||||
<div><dt>Имя</dt><dd><code>{portal.customDomainVerification.name}</code></dd></div>
|
<p className="portal-dns-note">
|
||||||
<div><dt>Значение</dt><dd><code>{portal.customDomainVerification.value}</code></dd></div>
|
Больше ничего добавлять не нужно: домен ваш, установка ваша — проверка лишь смотрит,
|
||||||
</dl>
|
ведёт ли запись на этот сервер, и выписывает сертификат.
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
<div className="portal-domain-actions">
|
{feedback && <span className="portal-settings-note">{feedback}</span>}
|
||||||
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить домен</Button>}
|
{failure && <div className="portal-form-error">{failure}</div>}
|
||||||
{canManage && portal.customDomainVerification && <Button variant="primary" disabled={busy} onClick={() => void verify()}>Проверить DNS</Button>}
|
</div>
|
||||||
</div>
|
|
||||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 (
|
return (
|
||||||
<section className="portal-section">
|
<div className="portal-settings-card">
|
||||||
<div className="portal-section-heading">
|
|
||||||
<div><h2>Продукты и поддержка</h2><p>Выберите продукты и виджеты, через которые клиенты смогут обратиться в поддержку.</p></div>
|
|
||||||
</div>
|
|
||||||
<div className="portal-product-list">
|
<div className="portal-product-list">
|
||||||
{products.map((product) => {
|
{products.map((product) => {
|
||||||
const enabled = product.id in selected;
|
const enabled = product.id in selected;
|
||||||
@@ -106,8 +103,12 @@ export function PortalProductSettings({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{canManage && <Button variant="secondary" disabled={busy} onClick={() => void save()}>Сохранить продукты</Button>}
|
{canManage && (
|
||||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
<div className="portal-settings-actions">
|
||||||
</section>
|
<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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { FormField, SelectField } from "../../shared/form-controls";
|
import { Icon } from "../../shared/icons";
|
||||||
import { Button } from "../../shared/ui-controls";
|
|
||||||
import type { Product } from "../../types";
|
import type { Product } from "../../types";
|
||||||
import {
|
import {
|
||||||
listPortalSupportChannels,
|
listPortalSupportChannels,
|
||||||
portalErrorMessage,
|
type PortalAddressConfig,
|
||||||
updateSupportPortal,
|
|
||||||
type PortalWidgetOption,
|
type PortalWidgetOption,
|
||||||
type SupportPortal,
|
type SupportPortal,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import { PortalAppearanceSettings } from "./PortalAppearanceSettings";
|
import { PortalAppearanceSettings } from "./PortalAppearanceSettings";
|
||||||
|
import { PortalBasicsSettings } from "./PortalBasicsSettings";
|
||||||
import { PortalDomainSettings } from "./PortalDomainSettings";
|
import { PortalDomainSettings } from "./PortalDomainSettings";
|
||||||
import { PortalProductSettings } from "./PortalProductSettings";
|
import { PortalProductSettings } from "./PortalProductSettings";
|
||||||
|
import { PortalPublishSettings } from "./PortalPublishSettings";
|
||||||
import { PortalWidgetSettings } from "./PortalWidgetSettings";
|
import { PortalWidgetSettings } from "./PortalWidgetSettings";
|
||||||
|
import {
|
||||||
|
PORTAL_SETTINGS_SECTIONS,
|
||||||
|
type PortalSettingsSectionKey,
|
||||||
|
} from "./sections";
|
||||||
|
|
||||||
|
// Настройки портала (дизайн-базлайн v2, кадры PT4–PT6): не модалка на 960px с
|
||||||
|
// пятью секциями подряд, а страница с субменю разделов 250px.
|
||||||
|
|
||||||
export function PortalSettings({
|
export function PortalSettings({
|
||||||
|
address,
|
||||||
|
canManage,
|
||||||
portal,
|
portal,
|
||||||
products,
|
products,
|
||||||
canManage,
|
section,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
openSection,
|
||||||
}: {
|
}: {
|
||||||
|
address: PortalAddressConfig;
|
||||||
|
canManage: boolean;
|
||||||
portal: SupportPortal;
|
portal: SupportPortal;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
canManage: boolean;
|
section: PortalSettingsSectionKey;
|
||||||
onChanged: (portal: SupportPortal) => void;
|
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 [supportWidgets, setSupportWidgets] = useState<PortalWidgetOption[]>([]);
|
||||||
const [anonymousWidgets, setAnonymousWidgets] = 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(() => {
|
useEffect(() => {
|
||||||
listPortalSupportChannels(portal.id)
|
listPortalSupportChannels(portal.id)
|
||||||
@@ -52,65 +54,64 @@ export function PortalSettings({
|
|||||||
});
|
});
|
||||||
}, [portal.id]);
|
}, [portal.id]);
|
||||||
|
|
||||||
async function saveBasics() {
|
const current = PORTAL_SETTINGS_SECTIONS.find((item) => item.key === section)
|
||||||
setBusy(true);
|
?? PORTAL_SETTINGS_SECTIONS[0];
|
||||||
setFeedback("");
|
const domainLive = Boolean(portal.customDomain && portal.customDomainVerifiedAt);
|
||||||
try {
|
const hints: Partial<Record<PortalSettingsSectionKey, { text: string; tone: "ok" | "muted" }>> = {
|
||||||
const payload = await updateSupportPortal(portal.id, {
|
domain: domainLive ? { text: "работает", tone: "ok" } : undefined,
|
||||||
name,
|
products: portal.products.length ? { text: String(portal.products.length), tone: "muted" } : undefined,
|
||||||
slug,
|
};
|
||||||
defaultLocale: locale,
|
|
||||||
});
|
|
||||||
onChanged(payload.portal);
|
|
||||||
setFeedback("Настройки сохранены");
|
|
||||||
} catch (caught) {
|
|
||||||
setFeedback(portalErrorMessage(caught, "Не удалось сохранить настройки"));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="portal-settings-grid">
|
<div className="portal-settings-layout">
|
||||||
<section className="portal-section">
|
<nav className="portal-settings-nav">
|
||||||
<div className="portal-section-heading">
|
{PORTAL_SETTINGS_SECTIONS.map((item) => (
|
||||||
<div><h2>Основные настройки</h2><p>Название, публичный адрес и язык материалов.</p></div>
|
<span key={item.key}>
|
||||||
</div>
|
<button
|
||||||
<div className="portal-settings-fields">
|
className={`portal-settings-nav-item${item.key === current.key ? " is-active" : ""}`}
|
||||||
<FormField disabled={!canManage} label="Название портала" value={name} onChange={canManage ? setName : undefined} wide />
|
type="button"
|
||||||
<FormField disabled={!canManage} label="Адрес портала" value={slug} onChange={canManage ? setSlug : undefined} mono wide />
|
onClick={() => openSection(item.key)}
|
||||||
<p className="portal-address-preview">{portal.publicUrl}</p>
|
>
|
||||||
<SelectField
|
<Icon name={item.icon} size={16} strokeWidth={1.9} />
|
||||||
disabled={!canManage}
|
<span>{item.label}</span>
|
||||||
label="Язык по умолчанию"
|
{hints[item.key] && (
|
||||||
value={locale}
|
<small className={hints[item.key]!.tone === "ok" ? "is-ok" : ""}>{hints[item.key]!.text}</small>
|
||||||
onChange={setLocale}
|
)}
|
||||||
options={[["ru", "Русский"], ["en", "English"]]}
|
</button>
|
||||||
/>
|
{item.divider && <i className="portal-settings-nav-divider" />}
|
||||||
</div>
|
</span>
|
||||||
{canManage && <Button variant="primary" disabled={busy} onClick={() => void saveBasics()}>Сохранить настройки</Button>}
|
))}
|
||||||
</section>
|
<span className="portal-settings-nav-gap" />
|
||||||
|
<p>Изменения применяются к публичным страницам сразу после сохранения.</p>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<PortalAppearanceSettings
|
<div className="portal-settings-content">
|
||||||
canManage={canManage}
|
<div className="portal-settings-inner">
|
||||||
portal={portal}
|
<div className="portal-settings-heading">
|
||||||
onChanged={onChanged}
|
<h3>{current.heading}</h3>
|
||||||
/>
|
<p>{current.lead}</p>
|
||||||
<PortalProductSettings
|
</div>
|
||||||
canManage={canManage}
|
|
||||||
widgets={supportWidgets}
|
{current.key === "basics" && (
|
||||||
portal={portal}
|
<PortalBasicsSettings address={address} canManage={canManage} portal={portal} onChanged={onChanged} />
|
||||||
products={products}
|
)}
|
||||||
onChanged={onChanged}
|
{current.key === "domain" && (
|
||||||
/>
|
<PortalDomainSettings canManage={canManage} portal={portal} onChanged={onChanged} />
|
||||||
<PortalWidgetSettings
|
)}
|
||||||
canManage={canManage}
|
{current.key === "theme" && (
|
||||||
widgets={anonymousWidgets}
|
<PortalAppearanceSettings canManage={canManage} portal={portal} onChanged={onChanged} />
|
||||||
portal={portal}
|
)}
|
||||||
onChanged={onChanged}
|
{current.key === "widget" && (
|
||||||
/>
|
<PortalWidgetSettings canManage={canManage} portal={portal} widgets={anonymousWidgets} onChanged={onChanged} />
|
||||||
<PortalDomainSettings portal={portal} canManage={canManage} onChanged={onChanged} />
|
)}
|
||||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
{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>
|
</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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { SelectField } from "../../shared/form-controls";
|
import { Icon } from "../../shared/icons";
|
||||||
import { Button } from "../../shared/ui-controls";
|
import { Button } from "../../shared/ui-controls";
|
||||||
import {
|
import {
|
||||||
portalErrorMessage,
|
portalErrorMessage,
|
||||||
@@ -30,9 +30,7 @@ export function PortalWidgetSettings({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setFeedback("");
|
setFeedback("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSupportPortal(portal.id, {
|
const payload = await updateSupportPortal(portal.id, { widgetId });
|
||||||
widgetId,
|
|
||||||
});
|
|
||||||
onChanged(payload.portal);
|
onChanged(payload.portal);
|
||||||
setFeedback("Веб-виджет обновлён");
|
setFeedback("Веб-виджет обновлён");
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
@@ -43,34 +41,28 @@ export function PortalWidgetSettings({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="portal-section">
|
<div className="portal-settings-card">
|
||||||
<div className="portal-section-heading">
|
<label className="portal-field is-narrow">
|
||||||
<div>
|
<span className="portal-field-label">Веб-виджет</span>
|
||||||
<h2>Веб-виджет</h2>
|
<span className="portal-select">
|
||||||
<p>Публичный чат отображается на всех страницах портала.</p>
|
<select
|
||||||
</div>
|
disabled={!canManage}
|
||||||
</div>
|
value={String(widgetId ?? "")}
|
||||||
<div className="portal-settings-fields">
|
onChange={(event) => setWidgetId(event.target.value ? Number(event.target.value) : null)}
|
||||||
<SelectField
|
>
|
||||||
disabled={!canManage}
|
<option value="">Не показывать</option>
|
||||||
label="Веб-виджет"
|
{widgets.map((widget) => <option key={widget.id} value={widget.id}>{widget.name}</option>)}
|
||||||
value={String(widgetId ?? "")}
|
</select>
|
||||||
onChange={(value) => setWidgetId(value ? Number(value) : null)}
|
<Icon name="chevron" size={14} strokeWidth={2} />
|
||||||
options={[
|
</span>
|
||||||
["", "Не показывать"],
|
<small>Виджет доступен только анонимным посетителям портала.</small>
|
||||||
...widgets.map((widget): [string, string] => [
|
</label>
|
||||||
String(widget.id),
|
|
||||||
widget.name,
|
|
||||||
]),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<Button variant="secondary" disabled={busy} onClick={() => void save()}>
|
<div className="portal-settings-actions">
|
||||||
Сохранить виджет
|
<Button variant="primary" disabled={busy} onClick={() => void save()}>Сохранить виджет</Button>
|
||||||
</Button>
|
{feedback && <span className="portal-settings-note">{feedback}</span>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{feedback && <div className="portal-save-feedback">{feedback}</div>}
|
</div>
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,58 +1,79 @@
|
|||||||
import { Modal } from "antd";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { hasCapability } from "../../auth/access";
|
import { hasCapability } from "../../auth/access";
|
||||||
import { DecisionDialog } from "../../shared/DecisionDialog";
|
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 { Button } from "../../shared/ui-controls";
|
||||||
import type { Product, SessionUser } from "../../types";
|
import type { Product, SessionUser } from "../../types";
|
||||||
import {
|
import {
|
||||||
changePortalStatus,
|
changePortalStatus,
|
||||||
listPortalArticles,
|
listPortalArticles,
|
||||||
listPortalCategories,
|
listPortalCategories,
|
||||||
|
listSupportPortals,
|
||||||
loadSupportPortal,
|
loadSupportPortal,
|
||||||
portalErrorMessage,
|
portalErrorMessage,
|
||||||
|
publishArticleRevision,
|
||||||
|
type PortalAddressConfig,
|
||||||
type PortalArticle,
|
type PortalArticle,
|
||||||
type PortalCategory,
|
type PortalCategory,
|
||||||
|
type PortalStatus,
|
||||||
type SupportPortal,
|
type SupportPortal,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import { PortalContent } from "./PortalContent";
|
import { PortalArticleEditor } from "./PortalArticleEditor";
|
||||||
|
import { PortalHeader } from "./PortalHeader";
|
||||||
|
import { PortalLibrary } from "./PortalLibrary";
|
||||||
import { PortalSettings } from "./PortalSettings";
|
import { PortalSettings } from "./PortalSettings";
|
||||||
|
import type { PortalSettingsSectionKey } from "./sections";
|
||||||
import "./styles";
|
import "./styles";
|
||||||
|
|
||||||
|
// Карточка портала (дизайн-базлайн v2, кадры PT3–PT8): общая шапка с публичным
|
||||||
|
// адресом, под ней — библиотека материалов, настройки или редактор статьи.
|
||||||
|
|
||||||
|
type Publishing = { article: PortalArticle; revisionId: number };
|
||||||
|
|
||||||
export function SupportPortalDetailPage({
|
export function SupportPortalDetailPage({
|
||||||
portalId,
|
portalId,
|
||||||
products,
|
products,
|
||||||
|
section,
|
||||||
user,
|
user,
|
||||||
openPortals,
|
openPortals,
|
||||||
|
openPortalContent,
|
||||||
|
openPortalSettings,
|
||||||
}: {
|
}: {
|
||||||
portalId: number | null;
|
portalId: number | null;
|
||||||
products: Product[];
|
products: Product[];
|
||||||
|
section: PortalSettingsSectionKey | null;
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
openPortals: () => void;
|
openPortals: () => void;
|
||||||
|
openPortalContent: (portalId: number) => void;
|
||||||
|
openPortalSettings: (portalId: number, section?: PortalSettingsSectionKey) => void;
|
||||||
}) {
|
}) {
|
||||||
const [portal, setPortal] = useState<SupportPortal | null>(null);
|
const [portal, setPortal] = useState<SupportPortal | null>(null);
|
||||||
|
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
|
||||||
const [categories, setCategories] = useState<PortalCategory[]>([]);
|
const [categories, setCategories] = useState<PortalCategory[]>([]);
|
||||||
const [articles, setArticles] = useState<PortalArticle[]>([]);
|
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 [failed, setFailed] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [pendingStatus, setPendingStatus] = useState<"DRAFT" | "PUBLISHED" | "ARCHIVED" | null>(null);
|
const [error, setError] = useState("");
|
||||||
const [actionError, setActionError] = useState("");
|
|
||||||
const canManage = hasCapability(user, "support.operate");
|
const canManage = hasCapability(user, "support.operate");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!portalId) return;
|
if (!portalId) return;
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
try {
|
try {
|
||||||
const [portalPayload, categoryPayload, articlePayload] = await Promise.all([
|
const [portalPayload, categoryPayload, articlePayload, listPayload] = await Promise.all([
|
||||||
loadSupportPortal(portalId),
|
loadSupportPortal(portalId),
|
||||||
listPortalCategories(portalId),
|
listPortalCategories(portalId),
|
||||||
listPortalArticles(portalId),
|
listPortalArticles(portalId),
|
||||||
|
listSupportPortals(),
|
||||||
]);
|
]);
|
||||||
setPortal(portalPayload.portal);
|
setPortal(portalPayload.portal);
|
||||||
setCategories(categoryPayload.items);
|
setCategories(categoryPayload.items);
|
||||||
setArticles(articlePayload.items);
|
setArticles(articlePayload.items);
|
||||||
|
setAddress(listPayload.address);
|
||||||
} catch {
|
} catch {
|
||||||
setFailed(true);
|
setFailed(true);
|
||||||
}
|
}
|
||||||
@@ -62,16 +83,34 @@ export function SupportPortalDetailPage({
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function setStatus(status: "DRAFT" | "PUBLISHED" | "ARCHIVED") {
|
async function setStatus(status: PortalStatus) {
|
||||||
if (!portal) return;
|
if (!portal) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setActionError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await changePortalStatus(portal.id, status);
|
const payload = await changePortalStatus(portal.id, status);
|
||||||
setPortal(payload.portal);
|
setPortal(payload.portal);
|
||||||
setPendingStatus(null);
|
setPendingStatus(null);
|
||||||
} catch (caught) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -79,56 +118,86 @@ export function SupportPortalDetailPage({
|
|||||||
|
|
||||||
if (!portalId) return <ErrorScreen retry={openPortals} />;
|
if (!portalId) return <ErrorScreen retry={openPortals} />;
|
||||||
if (failed) return <ErrorScreen retry={() => void load()} />;
|
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">
|
if (editing !== undefined) {
|
||||||
{portal.status === "PUBLISHED" && <a className="link has-icon" href={portal.publicUrl} target="_blank" rel="noreferrer">Открыть портал</a>}
|
return (
|
||||||
{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>}
|
<PortalArticleEditor
|
||||||
{canManage && portal.status === "ARCHIVED" && <Button variant="secondary" disabled={busy} onClick={() => void setStatus("DRAFT")}>Вернуть из архива</Button>}
|
article={editing}
|
||||||
{canManage && portal.status !== "ARCHIVED" && <Button variant="secondary" disabled={busy} onClick={() => setPendingStatus("ARCHIVED")}>В архив</Button>}
|
canManage={canManage && portal.status !== "ARCHIVED"}
|
||||||
</div>
|
categories={categories}
|
||||||
) : undefined;
|
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 (
|
return (
|
||||||
<div className="support-portal-detail">
|
<section className="portal-card">
|
||||||
<PageHeader
|
<PortalHeader
|
||||||
title={portal.name}
|
canManage={canManage}
|
||||||
text={<span><StatusPill status={portal.status === "PUBLISHED" ? "published" : portal.status === "ARCHIVED" ? "archived" : "draft"} /><code>{portal.publicUrl}</code></span>}
|
portal={portal}
|
||||||
action={action}
|
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}
|
{error && <div className="portal-form-error portal-card-error">{error}</div>}
|
||||||
canManage={canManage && portal.status !== "ARCHIVED"}
|
|
||||||
canLinkAgents={hasCapability(user, "ai.manage")}
|
{section === null ? (
|
||||||
categories={categories}
|
<PortalLibrary
|
||||||
locale={portal.defaultLocale}
|
articles={articles}
|
||||||
portalId={portal.id}
|
canLinkAgents={hasCapability(user, "ai.manage")}
|
||||||
reload={load}
|
canManage={canManage && portal.status !== "ARCHIVED"}
|
||||||
/>
|
categories={categories}
|
||||||
<Modal
|
portalId={portal.id}
|
||||||
destroyOnHidden
|
reload={load}
|
||||||
footer={null}
|
onEditArticle={setEditing}
|
||||||
open={settingsOpen}
|
/>
|
||||||
title="Настройки портала"
|
) : (
|
||||||
width={960}
|
<PortalSettings
|
||||||
onCancel={() => setSettingsOpen(false)}
|
address={address}
|
||||||
>
|
canManage={canManage && portal.status !== "ARCHIVED"}
|
||||||
<PortalSettings portal={portal} products={products} canManage={canManage && portal.status !== "ARCHIVED"} onChanged={setPortal} />
|
portal={portal}
|
||||||
</Modal>
|
products={products}
|
||||||
{actionError && <div className="portal-form-error">{actionError}</div>}
|
section={section}
|
||||||
|
onChanged={setPortal}
|
||||||
|
openSection={(next) => openPortalSettings(portal.id, next)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DecisionDialog
|
<DecisionDialog
|
||||||
open={pendingStatus !== null}
|
open={pendingStatus !== null}
|
||||||
onClose={() => setPendingStatus(null)}
|
onClose={() => setPendingStatus(null)}
|
||||||
tone={pendingStatus === "ARCHIVED" ? "danger" : "warning"}
|
tone="danger"
|
||||||
icon={pendingStatus === "ARCHIVED" ? "trash" : "check"}
|
icon="trash"
|
||||||
title={pendingStatus === "ARCHIVED" ? "Перенести портал в архив?" : "Опубликовать портал?"}
|
title="Перенести портал в архив?"
|
||||||
description={pendingStatus === "ARCHIVED"
|
description="Портал и его материалы станут недоступны посетителям до восстановления."
|
||||||
? "Портал и его материалы станут недоступны посетителям до восстановления."
|
actions={<>
|
||||||
: "Опубликованные статьи станут доступны по публичному адресу."}
|
<Button variant="secondary" onClick={() => setPendingStatus(null)}>Отмена</Button>
|
||||||
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></>}
|
<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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { hasCapability } from "../../auth/access";
|
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 { Icon } from "../../shared/icons";
|
||||||
import { ContentState, ErrorScreen, LoadingState, PageHeader, StatusPill } from "../../shared/ui";
|
import { ErrorScreen, LoadingState, StatusPill } from "../../shared/ui";
|
||||||
import { Button } from "../../shared/ui-controls";
|
import { Button, CopyButton, FilterDropdown, SearchInput } from "../../shared/ui-controls";
|
||||||
import { pluralRu } from "../../shared/utils";
|
import { pluralRu } from "../../shared/utils";
|
||||||
import type { SessionUser } from "../../types";
|
import type { SessionUser } from "../../types";
|
||||||
import {
|
import {
|
||||||
|
changePortalStatus,
|
||||||
listSupportPortals,
|
listSupportPortals,
|
||||||
|
portalErrorMessage,
|
||||||
|
PORTAL_STATUS_LABEL,
|
||||||
type PortalAddressConfig,
|
type PortalAddressConfig,
|
||||||
|
type PortalStatus,
|
||||||
type SupportPortal,
|
type SupportPortal,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
|
import {
|
||||||
|
contentSummary,
|
||||||
|
portalSubtitle,
|
||||||
|
productsSummary,
|
||||||
|
publicHost,
|
||||||
|
updatedAt,
|
||||||
|
} from "./portalText";
|
||||||
import { PortalCreateDialog } from "./PortalCreateDialog";
|
import { PortalCreateDialog } from "./PortalCreateDialog";
|
||||||
|
import { PortalTableFooter } from "./PortalTableFooter";
|
||||||
|
import { DecisionDialog } from "../../shared/DecisionDialog";
|
||||||
import "./styles";
|
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({
|
export function SupportPortalsPage({
|
||||||
user,
|
user,
|
||||||
openPortal,
|
openPortal,
|
||||||
|
openPortalSettings,
|
||||||
}: {
|
}: {
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
openPortal: (portalId: number) => void;
|
openPortal: (portalId: number) => void;
|
||||||
|
openPortalSettings: (portalId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [portals, setPortals] = useState<SupportPortal[] | null>(null);
|
const [portals, setPortals] = useState<SupportPortal[] | null>(null);
|
||||||
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
|
const [address, setAddress] = useState<PortalAddressConfig | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
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 [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 canManage = hasCapability(user, "support.operate");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -47,76 +85,47 @@ export function SupportPortalsPage({
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLocaleLowerCase();
|
const query = search.trim().toLocaleLowerCase();
|
||||||
if (!query) return portals ?? [];
|
return (portals ?? [])
|
||||||
return (portals ?? []).filter((portal) => (
|
.filter((portal) => (
|
||||||
portal.name.toLocaleLowerCase().includes(query)
|
(status.length === 0 || status.includes(portal.status))
|
||||||
|| portal.slug.toLocaleLowerCase().includes(query)
|
&& (!query
|
||||||
));
|
|| portal.name.toLocaleLowerCase().includes(query)
|
||||||
}, [portals, search]);
|
|| 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 (failed) return <ErrorScreen retry={() => void load()} />;
|
||||||
if (!portals || !address) return <LoadingState />;
|
if (!portals || !address) return <LoadingState />;
|
||||||
|
|
||||||
const createAction = canManage ? (
|
const createButton = canManage ? (
|
||||||
<Button variant="primary" icon="plus" onClick={() => setCreating(true)}>
|
<Button variant="primary" icon="plus" iconSize={16} onClick={() => setCreating(true)}>
|
||||||
Создать портал
|
Создать портал
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined;
|
) : null;
|
||||||
|
|
||||||
return (
|
const dialogs = (
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<PortalCreateDialog
|
<PortalCreateDialog
|
||||||
open
|
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 { ArticleImportDocument } from "./parseArticleYaml";
|
||||||
import type {
|
import type {
|
||||||
ArticleRevision,
|
ArticleRevision,
|
||||||
PortalArticle,
|
PortalArticle,
|
||||||
|
PortalArticleFile,
|
||||||
PortalCategory,
|
PortalCategory,
|
||||||
PortalInput,
|
PortalInput,
|
||||||
PortalStatus,
|
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(
|
export function archivePortalArticle(
|
||||||
portalId: number,
|
portalId: number,
|
||||||
articleId: 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;
|
value: string;
|
||||||
} | null;
|
} | null;
|
||||||
customDomainVerifiedAt: string | null;
|
customDomainVerifiedAt: string | null;
|
||||||
customDomainVerification: {
|
|
||||||
name: string;
|
|
||||||
type: "TXT";
|
|
||||||
value: string;
|
|
||||||
} | null;
|
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultLocale: string;
|
defaultLocale: string;
|
||||||
@@ -61,6 +56,9 @@ export type SupportPortal = {
|
|||||||
products: PortalProductLink[];
|
products: PortalProductLink[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
// Колонка «Материалы» списка порталов и подзаголовок карточки (кадры PT1/PT3).
|
||||||
|
categoryCount: number;
|
||||||
|
articleCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PortalCategory = {
|
export type PortalCategory = {
|
||||||
@@ -81,6 +79,19 @@ export type ArticleRevision = {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
publishedAt: string | null;
|
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 = {
|
export type PortalArticle = {
|
||||||
@@ -92,6 +103,10 @@ export type PortalArticle = {
|
|||||||
publishedRevision: ArticleRevision | null;
|
publishedRevision: ArticleRevision | null;
|
||||||
latestRevision: Omit<ArticleRevision, "content"> | null;
|
latestRevision: Omit<ArticleRevision, "content"> | null;
|
||||||
revisions?: ArticleRevision[];
|
revisions?: ArticleRevision[];
|
||||||
|
files?: PortalArticleFile[];
|
||||||
|
fileCount: number;
|
||||||
|
// Оценки посетителей: две кнопки под статьёй на публичном портале.
|
||||||
|
feedback: { helpful: number; unhelpful: number };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -124,8 +139,10 @@ export {
|
|||||||
createPortalArticle,
|
createPortalArticle,
|
||||||
createPortalCategory,
|
createPortalCategory,
|
||||||
createSupportPortal,
|
createSupportPortal,
|
||||||
|
deleteArticleFile,
|
||||||
deletePortalCategory,
|
deletePortalCategory,
|
||||||
importPortalArticles,
|
importPortalArticles,
|
||||||
|
listArticleFiles,
|
||||||
listPortalArticles,
|
listPortalArticles,
|
||||||
listPortalCategories,
|
listPortalCategories,
|
||||||
listPortalSupportChannels,
|
listPortalSupportChannels,
|
||||||
@@ -138,6 +155,7 @@ export {
|
|||||||
updatePortalArticle,
|
updatePortalArticle,
|
||||||
updatePortalCategory,
|
updatePortalCategory,
|
||||||
updateSupportPortal,
|
updateSupportPortal,
|
||||||
|
uploadArticleFile,
|
||||||
verifyPortalCustomDomain,
|
verifyPortalCustomDomain,
|
||||||
} from "./api";
|
} 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;
|
(дизайн-базлайн v2, кадр PT3). Метрики — из макета «Порталы Baseline». */
|
||||||
gap: 16px;
|
|
||||||
|
.portal-card {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface-feed);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-portal-detail .page-header {
|
.portal-card-error {
|
||||||
margin-bottom: 0;
|
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-portal-detail .page-header code {
|
.portal-votes b {
|
||||||
color: var(--text-tertiary);
|
display: inline-flex;
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-settings-grid,
|
.portal-votes b.is-up { color: var(--success-text); }
|
||||||
.portal-content-layout {
|
.portal-votes b.is-down { color: var(--n-4); }
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-section {
|
.portal-votes-empty {
|
||||||
padding: 22px;
|
color: var(--n-6);
|
||||||
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);
|
|
||||||
font-size: 12.5px;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--n-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-portal-name > i {
|
.portals-card-head .ui-search-input {
|
||||||
display: grid;
|
width: 300px;
|
||||||
flex: 0 0 38px;
|
max-width: none;
|
||||||
width: 38px;
|
height: 34px;
|
||||||
height: 38px;
|
flex: none;
|
||||||
place-items: center;
|
gap: 8px;
|
||||||
border-radius: var(--radius-lg);
|
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);
|
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;
|
display: block;
|
||||||
|
color: var(--n-1);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-portal-name .link {
|
.portals-name small {
|
||||||
margin-bottom: 3px;
|
color: var(--n-4);
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-portal-name small {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-family: var(--font-mono, ui-monospace, monospace);
|
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.support-portal-open {
|
.portals-address {
|
||||||
color: var(--text-tertiary);
|
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 {
|
.portal-dialog-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
@@ -41,9 +399,7 @@
|
|||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-dialog-form > * {
|
.portal-dialog-form > * { grid-column: 1; }
|
||||||
grid-column: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-dialog-form > p {
|
.portal-dialog-form > p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -58,14 +414,6 @@
|
|||||||
padding-top: 4px;
|
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 {
|
.portal-address-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -86,9 +434,7 @@
|
|||||||
background: var(--surface-card);
|
background: var(--surface-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-address-field > div:focus-within {
|
.portal-address-field > div:focus-within { border-color: var(--primary); }
|
||||||
border-color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.portal-address-field input {
|
.portal-address-field input {
|
||||||
min-width: 0;
|
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-list.css";
|
||||||
import "./styles-detail.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 { AppData, Employee, RouteKey, SessionUser } from "../types";
|
||||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||||
|
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
|
||||||
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
|
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
|
||||||
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
|
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
|
||||||
import { fetchWaitingCount } from "../features/conversations/model";
|
import { fetchWaitingCount } from "../features/conversations/model";
|
||||||
import { useChatScope } from "../features/chat/useChatScope";
|
import { useChatScope } from "../features/chat/useChatScope";
|
||||||
import { isManager } from "../auth/access";
|
import { isManager } from "../auth/access";
|
||||||
|
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { ShellRouteContent } from "./ShellRouteContent";
|
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 [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [notifOpen, setNotifOpen] = useState(false);
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
@@ -87,7 +89,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
|||||||
openEmployeeRoute(employee.id);
|
openEmployeeRoute(employee.id);
|
||||||
}
|
}
|
||||||
const isSalesWorkspace = route === "chat";
|
const isSalesWorkspace = route === "chat";
|
||||||
const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail";
|
const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
|
||||||
const isDialogsWorkspace = route === "chat";
|
const isDialogsWorkspace = route === "chat";
|
||||||
const isAiFullWidth = false;
|
const isAiFullWidth = false;
|
||||||
const isKnowledgeLibrary = route === "aiKnowledge";
|
const isKnowledgeLibrary = route === "aiKnowledge";
|
||||||
@@ -102,15 +104,18 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
|
|||||||
const isAgents = route === "agents" || route === "agentDetail";
|
const isAgents = route === "agents" || route === "agentDetail";
|
||||||
// «Сотрудники» — своя лента на --surface-feed (кадры E1–E4).
|
// «Сотрудники» — своя лента на --surface-feed (кадры E1–E4).
|
||||||
const isEmployees = route === "employees" || route === "employeeDetail";
|
const isEmployees = route === "employees" || route === "employeeDetail";
|
||||||
|
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
|
||||||
|
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
|
||||||
return (
|
return (
|
||||||
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
|
<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} />
|
<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">
|
<div className="hub-main">
|
||||||
|
{manager && <DemoInstallBanner reload={reload} />}
|
||||||
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
|
{/* Верхней панели нет ни у одной роли (дизайн-базлайн 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" : ""}`}>
|
<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" : ""}`}>
|
<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} 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)} />
|
<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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SalesClientsPage } from "../features/sales/SalesClientsPage";
|
|||||||
import { LoadingState } from "../shared/ui";
|
import { LoadingState } from "../shared/ui";
|
||||||
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
|
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
|
||||||
import type { SettingsSectionKey } from "../features/settings/sections";
|
import type { SettingsSectionKey } from "../features/settings/sections";
|
||||||
|
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
|
||||||
import { hasCapability } from "../auth/access";
|
import { hasCapability } from "../auth/access";
|
||||||
|
|
||||||
const SupportPortalsPage = lazy(() => import("../features/support-portals/SupportPortalsPage").then(
|
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 }),
|
(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{route === "employees" && <EmployeesPage groups={data.groups} employees={data.employees} reload={reload} openEmployee={openEmployee} setRoute={setRoute} user={user} />}
|
{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 === "aiKnowledge" && <KnowledgePage openAgent={openAgent} openKnowledge={openKnowledge} setRoute={setRoute} user={user} />}
|
||||||
{route === "aiKnowledgeCreate" && <KnowledgeCreatePage openKnowledge={openKnowledge} setRoute={setRoute} />}
|
{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 === "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 === "supportPortals" && <Suspense fallback={<LoadingState />}><SupportPortalsPage user={user} openPortal={openSupportPortal} openPortalSettings={openPortalSettings} /></Suspense>}
|
||||||
{route === "supportPortalDetail" && <Suspense fallback={<LoadingState />}><SupportPortalDetailPage portalId={selectedSupportPortalId} products={data.products} user={user} openPortals={() => setRoute("supportPortals")} /></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={["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={["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={["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} />
|
<SidebarLink activeRoutes={["settings", "aiKnowledge", "aiKnowledgeCreate", "aiKnowledgeDetail", "administrationAudit"]} icon="settings" label="Настройки" route={route} routeKey="settings" setRoute={setRoute} />
|
||||||
</nav>
|
</nav>
|
||||||
) : route === "profile" ? (
|
) : route === "profile" ? (
|
||||||
|
|||||||
@@ -758,3 +758,59 @@
|
|||||||
.launch-checklist-action:hover {
|
.launch-checklist-action:hover {
|
||||||
background: var(--primary-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 React, { lazy, Suspense, useEffect, useState } from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
|
import { Loader } from "@chatballs/ui";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { surfaceForHost, type Surface } from "./surface";
|
import { surfaceForHost, type Surface } from "./surface";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
@@ -27,10 +29,10 @@ function SurfaceApp() {
|
|||||||
}, [surface]);
|
}, [surface]);
|
||||||
|
|
||||||
if (surface === "loading") {
|
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"
|
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 />;
|
: <App />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const empty = {
|
|||||||
clientId: null,
|
clientId: null,
|
||||||
channelId: null,
|
channelId: null,
|
||||||
supportPortalId: null,
|
supportPortalId: null,
|
||||||
|
portalSettingsSection: null,
|
||||||
settingsSection: null,
|
settingsSection: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,6 +26,31 @@ describe("sales detail routes", () => {
|
|||||||
expect(pathFromRoute("supportPortalDetail", 3)).toBe("/portals/3");
|
expect(pathFromRoute("supportPortalDetail", 3)).toBe("/portals/3");
|
||||||
expect(routeFromPath("/portals/3")).toEqual({ route: "supportPortalDetail", ...empty, supportPortalId: 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", () => {
|
describe("agent routes", () => {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { settingsSectionKey, type SettingsSectionKey } from "./features/settings/sections";
|
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";
|
import type { RouteKey } from "./types";
|
||||||
|
|
||||||
export type RouteState = {
|
export type RouteState = {
|
||||||
@@ -11,6 +16,7 @@ export type RouteState = {
|
|||||||
clientId: number | null;
|
clientId: number | null;
|
||||||
channelId: number | null;
|
channelId: number | null;
|
||||||
supportPortalId: number | null;
|
supportPortalId: number | null;
|
||||||
|
portalSettingsSection: PortalSettingsSectionKey | null;
|
||||||
settingsSection: SettingsSectionKey | 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 match = normalized.match(/^\/organizations\/([0-9a-f-]{36})(\/.*)?$/i);
|
||||||
const organizationPublicId = match?.[1] ?? null;
|
const organizationPublicId = match?.[1] ?? null;
|
||||||
const path = match ? match[2] || "/" : normalized;
|
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 };
|
const state = { organizationPublicId, ...base };
|
||||||
// Chat-first (SPEC-HUB-0031): корень и устаревшие адреса командного центра и
|
// 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 === "/portals" || path === "/departments/support/portals") return { route: "supportPortals", ...state };
|
||||||
if (path.startsWith("/portals/")) {
|
if (path.startsWith("/portals/")) {
|
||||||
const id = Number(path.slice("/portals/".length));
|
const [, , rawId, subPath, subSection] = path.split("/");
|
||||||
return Number.isInteger(id) && id > 0 ? { ...state, route: "supportPortalDetail", supportPortalId: id } : { route: "supportPortals", ...state };
|
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/")) {
|
if (path.startsWith("/departments/support/portals/")) {
|
||||||
const id = Number(path.split("/")[4]);
|
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 === "employeeDetail") return entityId ? `${prefix}/employees/${entityId}` : `${prefix}/employees`;
|
||||||
if (route === "supportPortals") return `${prefix}/portals`;
|
if (route === "supportPortals") return `${prefix}/portals`;
|
||||||
if (route === "supportPortalDetail") return entityId ? `${prefix}/portals/${entityId}` : `${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 === "agents") return `${prefix}/agents`;
|
||||||
if (route === "agentDetail") return entityId ? `${prefix}/agents/${entityId}` : `${prefix}/agents`;
|
if (route === "agentDetail") return entityId ? `${prefix}/agents/${entityId}` : `${prefix}/agents`;
|
||||||
if (route === "aiUsage") return `${prefix}/ai/usage`;
|
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