add deploy stage

This commit is contained in:
2026-05-23 22:31:14 +03:00
parent f5961c5529
commit 896e3bcd35
26 changed files with 130 additions and 56 deletions
+3
View File
@@ -0,0 +1,3 @@
#VITE_API_BASE_URL=https://admin-api.eventhub.local
VITE_WS_URL=wss://admin-ws.eventhub.local
VITE_APP_TITLE=EventHub Admin
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
+38
View File
@@ -0,0 +1,38 @@
.PHONY: install dev build docker-build docker-run docker-stop docker-restart clean
IMAGE_NAME = eventhub-admin-ui
CONTAINER_NAME = admin-ui
NETWORK_NAME = eventhub_network
# Установить зависимости
install:
npm install
# Запустить dev‑сервер
dev:
npm run dev
# Собрать production‑бандл
build:
npm run build
# Собрать Docker‑образ
docker-build:
docker build -t $(IMAGE_NAME) .
# Запустить контейнер (внутри сети eventhub_network)
docker-run:
docker run -d --name $(CONTAINER_NAME) --network $(NETWORK_NAME) --restart unless-stopped $(IMAGE_NAME)
# Остановить и удалить контейнер
docker-stop:
docker stop $(CONTAINER_NAME) || true
docker rm $(CONTAINER_NAME) || true
# Перезапустить: стоп → билд → ран
docker-restart: docker-stop docker-build docker-run
# Удалить сгенерированные файлы
clean:
rm -rf node_modules dist .vite
npm cache clean --force
+29
View File
@@ -0,0 +1,29 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /v1/ {
proxy_pass http://eventhub:8445; # admin-api слушает HTTP на порту 8445
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ws {
proxy_pass http://eventhub:8446; # admin-ws слушает HTTP на порту 8446
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+3 -4
View File
@@ -20,8 +20,8 @@ export const useAdminWebSocket = () => {
const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const pingIntervalRef = useRef<ReturnType<typeof setInterval>>();
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastMessageTimestamp = useRef<number>(0);
const wsRef = useRef<WebSocket | null>(null);
@@ -32,8 +32,7 @@ export const useAdminWebSocket = () => {
const connect = () => {
const wsUrl = import.meta.env.DEV
? `ws://localhost:5173/admin/ws?token=${accessToken}`
: `${import.meta.env.VITE_WS_URL || 'wss://admin-ws.eventhub.local'}/admin/ws?token=${accessToken}`;
: `wss://${window.location.host}/admin/ws?token=${accessToken}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminsApi } from '../api/adminsApi';
import { AdminListParams } from '../types/api';
import { AdminListParams, Admin } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../api/eventsApi';
import { EventListParams } from '../types/api';
import { Event, EventListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { reviewsApi } from '../api/reviewsApi';
import { ReviewListParams } from '../types/api';
import { ReviewListParams, Review } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionsApi } from '../api/subscriptionsApi';
import { SubscriptionListParams } from '../types/api';
import { SubscriptionListParams, Subscription as SubscriptionType } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
@@ -25,7 +25,7 @@ export const useSubscription = (id: string) => {
export const useUpdateSubscription = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Subscription> }) =>
mutationFn: ({ id, data }: { id: string; data: Partial<SubscriptionType> }) =>
subscriptionsApi.updateSubscription(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../api/ticketsApi';
import { TicketListParams } from '../types/api';
import { TicketListParams, Ticket } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
+1 -1
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '../api/usersApi';
import { UserListParams } from '../types/api';
import { UserListParams, User } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
+1 -1
View File
@@ -151,7 +151,7 @@ const AdminLayout: React.FC = () => {
display: 'flex',
flexDirection: 'column',
height: '100vh',
position: 'sticky',
// position: 'sticky',
top: 0,
left: 0,
}}
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Input, Select, message } from 'antd';
import { Card, Descriptions, Spin, Button, Tag, Modal, Form, Input, Select, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useAdmin, useUpdateAdmin } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
+1 -1
View File
@@ -87,7 +87,7 @@ const AdminListPage: React.FC = () => {
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<Admin> | SorterResult<Admin>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
+2 -6
View File
@@ -5,7 +5,6 @@ import { useAudit } from '../../hooks/useAudit';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useReview } from '../../hooks/useReviews';
import { AuditRecord, AuditListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
@@ -43,7 +42,6 @@ const AuditPage: React.FC = () => {
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
const { data: review, isLoading: loadingReview } = useReview(entityType === 'review' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Spin size="small" />;
@@ -60,18 +58,16 @@ const AuditPage: React.FC = () => {
}
if (entityType === 'review') {
if (loadingReview) return <Spin size="small" />;
// Для отзыва показываем ссылку на страницу отзыва
// для review показываем ссылку без загрузки, так как нет API для загрузки названия
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
// Для остальных типов (calendar, report, ticket, subscription, admin) пока просто ID
return <span>{entityId}</span>;
};
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
+12 -5
View File
@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { Table, Button, Input, Space, Popconfirm, Tooltip, Spin } from 'antd';
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons';
import { DeleteOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord, BannedWordListParams } from '../../hooks/useBannedWords';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord } from '../../hooks/useBannedWords';
import { BannedWordListParams } from '../../api/bannedWordsApi';
import { useAdmin } from '../../hooks/useAdmins';
import { BannedWord } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
const BannedWordsPage: React.FC = () => {
@@ -23,9 +24,10 @@ const BannedWordsPage: React.FC = () => {
}
};
// Обработчик изменения таблицы (сортировка/пагинация)
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
@@ -38,6 +40,11 @@ const BannedWordsPage: React.FC = () => {
}));
};
// Обработчик поиска
const handleSearch = (value: string) => {
setParams(prev => ({ ...prev, q: value || undefined, offset: 0 }));
};
// Компонент для отображения "Кем добавлено"
const AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
@@ -108,7 +115,7 @@ const BannedWordsPage: React.FC = () => {
</Button>
<Input.Search
placeholder="Поиск по слову"
onSearch={(value) => setParams(prev => ({ ...prev, q: value || undefined, offset: 0 }))}
onSearch={handleSearch}
style={{ width: 200 }}
allowClear
/>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space } from 'antd';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { Link } from 'react-router-dom';
import { useEvent } from '../../hooks/useEvents';
import dayjs from 'dayjs';
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, message, Spin, Input, Tooltip, DatePicker, InputNumber } from 'antd';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent } from '../../hooks/useEvents';
import { Event, EventListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
@@ -106,7 +106,7 @@ const EventListPage: React.FC = () => {
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<Event> | SorterResult<Event>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
+3 -3
View File
@@ -38,7 +38,7 @@ const ProfilePage: React.FC = () => {
);
if (payload.preferences) {
try {
payload.preferences = JSON.parse(payload.preferences);
payload.preferences = JSON.parse(payload.preferences as string);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
@@ -72,7 +72,7 @@ const ProfilePage: React.FC = () => {
{!editing ? (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar size={64} icon={<UserOutlined />} src={isBadValue(user.avatar_url) ? undefined : user.avatar_url} />
<Avatar size={64} icon={<UserOutlined />} src={isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url || undefined} target="_blank" rel="noreferrer">{user.avatar_url}</a>} />
<div>
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
<Tag
@@ -115,7 +115,7 @@ const ProfilePage: React.FC = () => {
<Descriptions.Item label="Язык">{isBadValue(user.language) ? '-' : user.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(user.phone) ? '-' : user.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url || undefined} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
</Descriptions.Item>
<Descriptions.Item label="Настройки">
{isBadValue(user.preferences) ? '-' : <pre>{JSON.stringify(user.preferences, null, 2)}</pre>}
+7 -8
View File
@@ -35,7 +35,7 @@ const ReportListPage: React.FC = () => {
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<Report> | SorterResult<Report>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
@@ -60,14 +60,13 @@ const ReportListPage: React.FC = () => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{reporterId}</span>;
// Используем ту же логику, что и в renderLink
const nick = user.nickname;
const email = user.email;
const isGoodString = (val: any): val is string => !isBadValue(val);
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Select, Input, message } from 'antd';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useReview, useUpdateReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
+7 -7
View File
@@ -92,7 +92,7 @@ const ReviewListPage: React.FC = () => {
// ---- Таблица ----
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<Review> | SorterResult<Review>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
@@ -111,13 +111,13 @@ const ReviewListPage: React.FC = () => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const nick = user.nickname;
const email = user.email;
const isGoodString = (val: any): val is string => !isBadValue(val);
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
if (isGoodString(user.nickname)) {
name = user.nickname;
} else if (isGoodString(user.email)) {
name = user.email;
} else {
name = user.id;
}
@@ -72,7 +72,7 @@ const SubscriptionListPage: React.FC = () => {
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input, message } from 'antd';
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input } from 'antd';
import { Link } from 'react-router-dom';
import { useTicket, useUpdateTicket } from '../../hooks/useTickets';
import { useUser } from '../../hooks/useUsers';
@@ -123,7 +123,7 @@ const TicketDetailPage: React.FC = () => {
showSearch
optionFilterProp="label"
>
{(admins || []).map((admin) => {
{(admins?.data || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Space, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { Table, Button, Tag, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets';
@@ -29,7 +29,7 @@ const TicketListPage: React.FC = () => {
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
+2 -2
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, message, Spin, Input, Tooltip } from 'antd';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip } from 'antd';
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useUsers, useUpdateUser, useDeleteUser, useUser } from '../../hooks/useUsers';
@@ -150,7 +150,7 @@ const UserListPage: React.FC = () => {
// ==================== Таблица ====================
const handleTableChange = (
pagination: any,
filters: any,
_filters: any,
sorter: SorterResult<User> | SorterResult<User>[]
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;