mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
Первичное требование: поднял докер — прошёл мастер — дальше всё в интерфейсе. Ни одной переменной окружения задавать не нужно и негде. Запуск и секреты: - .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>
133 lines
10 KiB
TypeScript
133 lines
10 KiB
TypeScript
import { notification as antToast } from "antd";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import type { AppData, Employee, RouteKey, SessionUser } from "../types";
|
||
import type { SettingsSectionKey } from "../features/settings/sections";
|
||
import type { PortalSettingsSectionKey } from "../features/support-portals/sections";
|
||
import { NotificationDrawer } from "../features/notifications/NotificationDrawer";
|
||
import { fetchNotifications, markAllRead, markRead, type AppNotification } from "../features/notifications/model";
|
||
import { fetchWaitingCount } from "../features/conversations/model";
|
||
import { useChatScope } from "../features/chat/useChatScope";
|
||
import { isManager } from "../auth/access";
|
||
import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
|
||
import { Sidebar } from "./Sidebar";
|
||
import { ShellRouteContent } from "./ShellRouteContent";
|
||
|
||
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
|
||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const [notifOpen, setNotifOpen] = useState(false);
|
||
const [waitingCount, setWaitingCount] = useState(0);
|
||
// Кадры S2/M1: рейка или ☰ раскрывают сайдбар поверх контента.
|
||
const [sidebarExpanded, setSidebarExpanded] = useState(false);
|
||
const prevUnread = useRef<number | null>(null);
|
||
const manager = isManager(user);
|
||
// Охват чата живёт здесь: сотрудницкий сайдбар и страница чата делят одно
|
||
// состояние (дизайн-базлайн v2 §4.1).
|
||
const chatScope = useChatScope(true);
|
||
|
||
const loadWaitingCount = useCallback(async () => {
|
||
try {
|
||
setWaitingCount(await fetchWaitingCount());
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, []);
|
||
|
||
const loadNotifications = useCallback(async () => {
|
||
try {
|
||
const payload = await fetchNotifications();
|
||
setNotifications(payload.items);
|
||
setUnreadCount(payload.unreadCount);
|
||
// Тост при появлении новых непрочитанных (после первой загрузки).
|
||
if (prevUnread.current !== null && payload.unreadCount > prevUnread.current) {
|
||
const latest = payload.items.find((item) => item.unread);
|
||
if (latest) antToast.open({ message: latest.title, description: latest.body, placement: "bottomRight" });
|
||
}
|
||
prevUnread.current = payload.unreadCount;
|
||
} catch {
|
||
/* ignore transient errors */
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const tick = () => {
|
||
void loadNotifications();
|
||
void loadWaitingCount();
|
||
};
|
||
tick();
|
||
const timer = setInterval(tick, 15000);
|
||
return () => clearInterval(timer);
|
||
}, [loadNotifications, loadWaitingCount]);
|
||
|
||
async function onNotificationClick(notification: AppNotification) {
|
||
setNotifOpen(false);
|
||
if (notification.unread) {
|
||
await markRead([notification.id]).catch(() => undefined);
|
||
void loadNotifications();
|
||
}
|
||
// «salesDialogs»/«conversations» — легаси-маршруты старых уведомлений в БД.
|
||
const targetRoute = notification.targetRoute === "salesDialogs" || notification.targetRoute === "conversations"
|
||
? "chat"
|
||
: notification.targetRoute;
|
||
if (targetRoute === "chat" && notification.targetId) {
|
||
openConversationRoute(Number(notification.targetId));
|
||
} else if (targetRoute) {
|
||
setRoute(targetRoute as RouteKey);
|
||
}
|
||
}
|
||
|
||
async function onMarkAll() {
|
||
await markAllRead().catch(() => undefined);
|
||
void loadNotifications();
|
||
}
|
||
|
||
const currentEmployee = route === "employeeDetail"
|
||
? data.employees.find((employee) => employee.id === selectedEmployeeId) ?? null
|
||
: data.employees[0] ?? null;
|
||
function openEmployee(employee: Employee) {
|
||
openEmployeeRoute(employee.id);
|
||
}
|
||
const isSalesWorkspace = route === "chat";
|
||
const isSupportWorkspace = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
|
||
const isDialogsWorkspace = route === "chat";
|
||
const isAiFullWidth = false;
|
||
const isKnowledgeLibrary = route === "aiKnowledge";
|
||
const isKnowledgeEditor = route === "aiKnowledgeCreate" || route === "aiKnowledgeDetail";
|
||
// «Настройки» занимают всю область как чат: своё субменю 240px и своя лента.
|
||
const isSettings = route === "settings";
|
||
// «Профиль» — своя лента на --surface-feed и мобильные подэкраны (кадр M).
|
||
const isProfile = route === "profile";
|
||
// «Контакты» — своя лента на --surface-feed (кадры K1–K5).
|
||
const isContacts = route === "salesClients" || route === "salesClientDetail";
|
||
// «Агенты» — своя лента на --surface-feed (кадры G1–G5, S1).
|
||
const isAgents = route === "agents" || route === "agentDetail";
|
||
// «Сотрудники» — своя лента на --surface-feed (кадры E1–E4).
|
||
const isEmployees = route === "employees" || route === "employeeDetail";
|
||
// «Порталы» — своя лента на --surface-feed и полноэкранные сплиты (кадры PT1–PT8).
|
||
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
|
||
return (
|
||
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
|
||
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
|
||
<div className="hub-main">
|
||
{manager && <DemoInstallBanner reload={reload} />}
|
||
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
|
||
«назад» живут в самой странице, уведомления — в меню профиля сайдбара. */}
|
||
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""} ${isSettings ? "settings-scroll" : ""} ${isProfile ? "profile-scroll" : ""} ${isContacts ? "contacts-scroll" : ""} ${isAgents ? "agents-scroll" : ""} ${isEmployees ? "employees-scroll" : ""} ${isPortals ? "portals-scroll" : ""}`}>
|
||
<div className={`hub-page ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""} ${isSettings ? "settings-page" : ""} ${isProfile ? "profile-page-shell" : ""} ${isContacts ? "contacts-page-shell" : ""} ${isAgents ? "agents-page-shell" : ""} ${isEmployees ? "employees-page-shell" : ""} ${isKnowledgeLibrary ? "ai-knowledge-library-page" : ""} ${isKnowledgeEditor ? "ai-knowledge-editor-page" : ""} ${isPortals ? "portals-page-shell" : ""}`}>
|
||
<ShellRouteContent settingsSection={settingsSection} openSettings={openSettingsRoute} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} chatScopeSwitcher={manager} route={route} data={data} currentEmployee={currentEmployee} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedChannelId={selectedChannelId} openChannel={openChannelRoute} selectedSupportPortalId={selectedSupportPortalId} portalSettingsSection={portalSettingsSection} openSupportPortal={openSupportPortalRoute} openPortalSettings={openPortalSettingsRoute} openConversation={openConversationRoute} openEmployee={openEmployee} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={() => undefined} onChannelLoaded={() => undefined} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} onOpenSidebar={() => setSidebarExpanded(true)} />
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<NotificationDrawer
|
||
open={notifOpen}
|
||
items={notifications}
|
||
unreadCount={unreadCount}
|
||
onClose={() => setNotifOpen(false)}
|
||
onItemClick={onNotificationClick}
|
||
onMarkAll={onMarkAll}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|