update README

This commit is contained in:
Georgiy Syralev
2025-11-26 21:43:57 +03:00
parent c4c2610480
commit 753696cc93
58 changed files with 8674 additions and 3752 deletions

View File

@@ -0,0 +1,325 @@
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { API_URL } from '../config/api';
import { useToast } from '../hooks/useToast';
type StoragePlan = {
id: number;
code: string;
name: string;
price: number;
pricePerGb?: number;
bandwidthPerGb?: number;
requestsPerGb?: number;
quotaGb: number;
bandwidthGb: number;
requestLimit: string;
order: number;
isActive: boolean;
description?: string;
};
const AdminPricingTab = () => {
const { addToast } = useToast();
const [plans, setPlans] = useState<StoragePlan[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<number | null>(null);
const [editingPlan, setEditingPlan] = useState<Partial<StoragePlan> | null>(null);
const [saving, setSaving] = useState(false);
const getAuthHeaders = useCallback(() => {
const token = localStorage.getItem('access_token');
return { Authorization: `Bearer ${token}` };
}, []);
const loadPlans = useCallback(async () => {
try {
setLoading(true);
const response = await axios.get(`${API_URL}/api/storage/plans`, {
headers: getAuthHeaders(),
});
if (Array.isArray(response.data?.plans)) {
setPlans(response.data.plans);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Ошибка загрузки тарифов';
addToast(message, 'error');
} finally {
setLoading(false);
}
}, [getAuthHeaders, addToast]);
useEffect(() => {
loadPlans();
}, [loadPlans]);
const handleEdit = (plan: StoragePlan) => {
setEditing(plan.id);
setEditingPlan(JSON.parse(JSON.stringify(plan)));
};
const handleCancel = () => {
setEditing(null);
setEditingPlan(null);
};
const handleSave = async () => {
if (!editingPlan || !editing) return;
try {
setSaving(true);
const token = localStorage.getItem('access_token');
await axios.put(
`${API_URL}/api/storage/plans/${editing}`,
editingPlan,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
addToast('Тариф успешно обновлён', 'success');
setEditing(null);
setEditingPlan(null);
await loadPlans();
} catch (error) {
const message = error instanceof Error ? error.message : 'Ошибка сохранения тарифа';
addToast(message, 'error');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-blue-500 border-t-transparent" />
</div>
);
}
return (
<div className="space-y-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-semibold text-blue-900 mb-2">Управление тарифами</h3>
<p className="text-sm text-blue-800">
Здесь вы можете редактировать параметры тарифных планов, включая цены и пропорции расчётов.
</p>
</div>
<div className="space-y-4">
{plans.map((plan) => (
<div key={plan.id} className="bg-white border border-gray-200 rounded-lg p-6">
{editing === plan.id && editingPlan ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Название
</label>
<input
type="text"
value={editingPlan.name || ''}
onChange={(e) =>
setEditingPlan({ ...editingPlan, name: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Цена (базовая)
</label>
<input
type="number"
step="0.01"
value={editingPlan.price || 0}
onChange={(e) =>
setEditingPlan({
...editingPlan,
price: parseFloat(e.target.value) || 0,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{editingPlan.code === 'custom' && (
<>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Цена за GB ()
</label>
<input
type="number"
step="0.01"
value={editingPlan.pricePerGb || 0}
onChange={(e) =>
setEditingPlan({
...editingPlan,
pricePerGb: parseFloat(e.target.value) || 0,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Трафик на 1 GB (GB)
</label>
<input
type="number"
step="0.01"
value={editingPlan.bandwidthPerGb || 0}
onChange={(e) =>
setEditingPlan({
...editingPlan,
bandwidthPerGb: parseFloat(e.target.value) || 0,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Операции на 1 GB
</label>
<input
type="number"
value={editingPlan.requestsPerGb || 0}
onChange={(e) =>
setEditingPlan({
...editingPlan,
requestsPerGb: parseInt(e.target.value) || 0,
})
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<p className="text-sm text-yellow-800">
<strong>Примеры расчёта:</strong>
</p>
<p className="text-sm text-yellow-700 mt-1">
Для 100 GB: цена = 100 × {editingPlan.pricePerGb || 0} = {((editingPlan.pricePerGb || 0) * 100).toFixed(2)}
</p>
<p className="text-sm text-yellow-700">
Трафик = 100 × {editingPlan.bandwidthPerGb || 0} = {Math.ceil(((editingPlan.bandwidthPerGb || 0) * 100))} GB
</p>
<p className="text-sm text-yellow-700">
Операции = 100 × {editingPlan.requestsPerGb || 0} = {((editingPlan.requestsPerGb || 0) * 100).toLocaleString('ru-RU')}
</p>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Описание
</label>
<textarea
value={editingPlan.description || ''}
onChange={(e) =>
setEditingPlan({ ...editingPlan, description: e.target.value })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
rows={3}
/>
</div>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
>
Отмена
</button>
</div>
</div>
) : (
<div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
<h4 className="text-lg font-semibold text-gray-900">{plan.name}</h4>
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{plan.code}</span>
{!plan.isActive && (
<span className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded">
Неактивен
</span>
)}
</div>
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div>
<span className="text-gray-600">Базовая цена:</span>
<span className="font-semibold text-gray-900 ml-2">{plan.price.toLocaleString('ru-RU')}</span>
</div>
<div>
<span className="text-gray-600">Квота:</span>
<span className="font-semibold text-gray-900 ml-2">
{plan.quotaGb.toLocaleString('ru-RU')} GB
</span>
</div>
<div>
<span className="text-gray-600">Трафик:</span>
<span className="font-semibold text-gray-900 ml-2">
{plan.bandwidthGb.toLocaleString('ru-RU')} GB
</span>
</div>
<div>
<span className="text-gray-600">Операции:</span>
<span className="font-semibold text-gray-900 ml-2">{plan.requestLimit}</span>
</div>
{plan.code === 'custom' && (
<>
<div>
<span className="text-gray-600">Цена за GB:</span>
<span className="font-semibold text-gray-900 ml-2">
{(plan.pricePerGb || 0).toFixed(2)}
</span>
</div>
<div>
<span className="text-gray-600">Трафик на 1 GB:</span>
<span className="font-semibold text-gray-900 ml-2">
{(plan.bandwidthPerGb || 0).toFixed(2)} GB
</span>
</div>
<div>
<span className="text-gray-600">Операции на 1 GB:</span>
<span className="font-semibold text-gray-900 ml-2">
{(plan.requestsPerGb || 0).toLocaleString('ru-RU')}
</span>
</div>
</>
)}
</div>
</div>
<button
onClick={() => handleEdit(plan)}
className="px-4 py-2 bg-blue-50 text-blue-600 hover:bg-blue-100 rounded-lg"
>
Редактировать
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
};
export default AdminPricingTab;

View File

@@ -0,0 +1,170 @@
import { useState } from 'react';
import axios from 'axios';
import { API_URL } from '../config/api';
import { useToast } from '../hooks/useToast';
interface LogEntry {
timestamp: string;
type: 'info' | 'success' | 'error' | 'warning';
message: string;
}
export default function AdminTestingTab() {
const { addToast } = useToast();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loadingPush, setLoadingPush] = useState(false);
const [loadingEmail, setLoadingEmail] = useState(false);
const addLog = (type: LogEntry['type'], message: string) => {
const timestamp = new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
setLogs((prev) => [...prev, { timestamp, type, message }]);
};
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
const handleTestPushNotification = async () => {
setLoadingPush(true);
try {
addLog('info', 'Начинаю отправку push-уведомления...');
const response = await axios.post(
`${API_URL}/admin/test/push-notification`,
{},
{ headers: getAuthHeaders(), timeout: 15000 }
);
if (response.status === 200) {
addLog('success', 'Push-уведомление успешно отправлено');
addToast('Push-уведомление успешно отправлено', 'success');
}
} catch (error: unknown) {
let errorMessage = 'Ошибка при отправке push-уведомления';
if (axios.isAxiosError(error)) {
errorMessage = (error.response?.data as { error?: string })?.error || error.message;
} else if (error instanceof Error) {
errorMessage = error.message;
}
addLog('error', `Ошибка: ${errorMessage}`);
addToast(`Ошибка: ${errorMessage}`, 'error');
} finally {
setLoadingPush(false);
}
};;
const handleTestEmailNotification = async () => {
setLoadingEmail(true);
try {
addLog('info', 'Начинаю отправку email-уведомления...');
const response = await axios.post(
`${API_URL}/admin/test/email-notification`,
{},
{ headers: getAuthHeaders(), timeout: 15000 }
);
if (response.status === 200) {
addLog('success', 'Email-уведомление успешно отправлено');
addToast('Email-уведомление успешно отправлено', 'success');
}
} catch (error: unknown) {
let errorMessage = 'Ошибка при отправке email-уведомления';
if (axios.isAxiosError(error)) {
errorMessage = (error.response?.data as { error?: string })?.error || error.message;
} else if (error instanceof Error) {
errorMessage = error.message;
}
addLog('error', `Ошибка: ${errorMessage}`);
addToast(`Ошибка: ${errorMessage}`, 'error');
} finally {
setLoadingEmail(false);
}
};
const clearLogs = () => {
setLogs([]);
};
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-bold mb-4">Тестирование уведомлений</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<button
onClick={handleTestPushNotification}
disabled={loadingPush}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
loadingPush
? 'bg-gray-300 cursor-not-allowed text-gray-600'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
{loadingPush ? 'Отправка push...' : 'Тест Push-уведомления'}
</button>
<button
onClick={handleTestEmailNotification}
disabled={loadingEmail}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
loadingEmail
? 'bg-gray-300 cursor-not-allowed text-gray-600'
: 'bg-green-500 hover:bg-green-600 text-white'
}`}
>
{loadingEmail ? 'Отправка email...' : 'Тест Email-уведомления'}
</button>
</div>
<div className="flex justify-end">
<button
onClick={clearLogs}
className="px-4 py-2 text-sm bg-gray-300 hover:bg-gray-400 text-gray-800 rounded-lg transition-colors"
>
Очистить логи
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-bold mb-4">Логи операций</h3>
<div className="bg-gray-900 text-gray-100 rounded-lg p-4 h-96 overflow-y-auto font-mono text-sm space-y-1">
{logs.length === 0 ? (
<div className="text-gray-500 italic">Логи пусты. Нажмите кнопку теста выше.</div>
) : (
logs.map((log, idx) => (
<div
key={idx}
className={`flex gap-3 ${
log.type === 'success'
? 'text-green-400'
: log.type === 'error'
? 'text-red-400'
: log.type === 'warning'
? 'text-yellow-400'
: 'text-blue-400'
}`}
>
<span className="text-gray-500 flex-shrink-0">[{log.timestamp}]</span>
<span className="flex-shrink-0">
{log.type === 'success' && '✓'}
{log.type === 'error' && '✗'}
{log.type === 'warning' && '⚠'}
{log.type === 'info' && ''}
</span>
<span className="break-words">{log.message}</span>
</div>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -13,8 +13,8 @@ const QRLogin: React.FC<QRLoginProps> = ({ onSuccess }) => {
const { login } = useAuth();
const [qrCode, setQrCode] = useState<string>('');
const [status, setStatus] = useState<'generating' | 'waiting' | 'scanning' | 'expired' | 'error'>('generating');
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null);
const [pollingInterval, setPollingInterval] = useState<ReturnType<typeof setInterval> | null>(null);
const [refreshInterval, setRefreshInterval] = useState<ReturnType<typeof setInterval> | null>(null);
const qrLinkBase = typeof window !== 'undefined' ? window.location.origin : '';
useEffect(() => {

View File

@@ -0,0 +1,3 @@
export const VKOneTap = () => null;
export default VKOneTap;

View File

@@ -2,21 +2,23 @@
* Централизованная конфигурация API
*/
const PRODUCTION_API_ORIGIN = 'https://api.ospab.host';
const resolveDefaultApiUrl = () => {
if (typeof window === 'undefined') {
return import.meta.env.DEV ? 'http://localhost:5000' : '';
return import.meta.env.DEV ? 'http://localhost:5000' : PRODUCTION_API_ORIGIN;
}
if (import.meta.env.DEV) {
return 'http://localhost:5000';
}
return window.location.origin;
return PRODUCTION_API_ORIGIN;
};
const resolveDefaultSocketUrl = (apiUrl: string) => {
if (!apiUrl) {
return import.meta.env.DEV ? 'ws://localhost:5000/ws' : '';
return import.meta.env.DEV ? 'ws://localhost:5000/ws' : 'wss://api.ospab.host/ws';
}
try {
@@ -32,6 +34,44 @@ const resolveDefaultSocketUrl = (apiUrl: string) => {
}
};
export const API_URL = import.meta.env.VITE_API_URL || resolveDefaultApiUrl();
const normalizeSocketUrl = (value: string | undefined, fallbackApiUrl: string): string | undefined => {
if (value === undefined) return undefined;
export const SOCKET_URL = import.meta.env.VITE_SOCKET_URL || resolveDefaultSocketUrl(API_URL);
const trimmed = value.trim();
if (!trimmed) return undefined;
const lowered = trimmed.toLowerCase();
if (lowered === 'disabled' || lowered === 'none' || lowered === 'off') {
return '';
}
try {
const url = new URL(trimmed);
if (!url.pathname || url.pathname === '/') {
url.pathname = '/ws';
}
url.search = '';
url.hash = '';
return url.toString();
} catch (error) {
console.warn('[config/api] Некорректный VITE_SOCKET_URL, используем значение по умолчанию', error);
try {
const base = new URL(fallbackApiUrl);
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
base.pathname = '/ws';
base.search = '';
base.hash = '';
return base.toString();
} catch {
return undefined;
}
}
};
const RAW_API_URL = import.meta.env.VITE_API_URL;
export const API_URL = RAW_API_URL || resolveDefaultApiUrl();
const RAW_SOCKET_URL = import.meta.env.VITE_SOCKET_URL;
const defaultSocketUrl = resolveDefaultSocketUrl(API_URL);
const normalizedSocketUrl = normalizeSocketUrl(RAW_SOCKET_URL, API_URL);
export const SOCKET_URL = normalizedSocketUrl !== undefined ? normalizedSocketUrl : defaultSocketUrl;

View File

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
import AuthContext from './authcontext';
import { wsLogger } from '../utils/logger';
import { SOCKET_URL } from '../config/api';
// Типы событий (синхронизированы с backend)
type RoomType = 'notifications' | 'servers' | 'tickets' | 'balance';
@@ -16,6 +17,7 @@ type ServerToClientEvent =
| { type: 'notification:delete'; notificationId: number }
| { type: 'server:created'; server: AnyObject }
| { type: 'server:status'; serverId: number; status: string; ipAddress?: string }
| { type: 'server:stats'; serverId: number; stats: AnyObject }
| { type: 'server:deleted'; serverId: number }
| { type: 'ticket:new'; ticket: AnyObject }
| { type: 'ticket:response'; ticketId: number; response: AnyObject }
@@ -46,13 +48,13 @@ interface WebSocketProviderProps {
export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
children,
url = 'wss://ospab.host:5000/ws'
url = SOCKET_URL
}) => {
const authContext = useContext(AuthContext);
const token = authContext?.isLoggedIn ? localStorage.getItem('access_token') : null;
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handlersRef = useRef<Map<RoomType, Set<MessageHandler>>>(new Map());
const subscribedRoomsRef = useRef<Set<RoomType>>(new Set());
const reconnectAttemptsRef = useRef(0);
@@ -66,11 +68,12 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
}
handlersRef.current.get(room)?.add(handler);
// Если WebSocket подключен и комната ещё не подписана — отправляем subscribe
if (wsRef.current?.readyState === WebSocket.OPEN && !subscribedRoomsRef.current.has(room)) {
wsRef.current.send(JSON.stringify({ type: `subscribe:${room}` }));
if (!subscribedRoomsRef.current.has(room)) {
subscribedRoomsRef.current.add(room);
wsLogger.log(`Подписались на комнату: ${room}`);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: `subscribe:${room}` }));
wsLogger.log(`Подписались на комнату: ${room}`);
}
}
}, []);
@@ -83,10 +86,12 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
// Если больше нет обработчиков — отписываемся от комнаты
if (handlers.size === 0) {
handlersRef.current.delete(room);
if (wsRef.current?.readyState === WebSocket.OPEN && subscribedRoomsRef.current.has(room)) {
wsRef.current.send(JSON.stringify({ type: `unsubscribe:${room}` }));
if (subscribedRoomsRef.current.has(room)) {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: `unsubscribe:${room}` }));
wsLogger.log(`Отписались от комнаты: ${room}`);
}
subscribedRoomsRef.current.delete(room);
wsLogger.log(`Отписались от комнаты: ${room}`);
}
}
}
@@ -132,6 +137,11 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
// Подключение к WebSocket
const connect = useCallback(() => {
if (!url) {
wsLogger.log('WebSocket URL не задан, соединение отключено');
return;
}
if (!token) {
wsLogger.log('Токен отсутствует, подключение отложено');
return;
@@ -188,13 +198,14 @@ export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
// Подключение при монтировании компонента и наличии токена
useEffect(() => {
if (token) {
if (token && url) {
connect();
}
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();

View File

@@ -1,5 +1,5 @@
// /src/context/authcontext.tsx
import { createContext, useState, useEffect } from 'react';
import { createContext, useState, useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import apiClient from '../utils/apiClient';
@@ -35,6 +35,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [userData, setUserData] = useState<UserData | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const manualLogoutRef = useRef(false);
const bootstrapSession = async () => {
const token = localStorage.getItem('access_token');
@@ -69,9 +70,14 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
// Слушаем событие unauthorized из apiClient
const handleUnauthorized = () => {
if (manualLogoutRef.current) {
manualLogoutRef.current = false;
return;
}
setIsLoggedIn(false);
setUserData(null);
window.location.href = '/401';
window.location.href = '/login';
};
window.addEventListener('unauthorized', handleUnauthorized);
@@ -88,9 +94,15 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
};
const logout = () => {
manualLogoutRef.current = true;
localStorage.removeItem('access_token');
sessionStorage.clear();
setIsLoggedIn(false);
setUserData(null);
window.setTimeout(() => {
manualLogoutRef.current = false;
}, 1500);
};
const refreshUser = async () => {

View File

@@ -1,45 +1,5 @@
import { useEffect, useState } from 'react';
import io from 'socket.io-client';
import { SOCKET_URL } from '../config/api';
type Socket = SocketIOClient.Socket;
export function useSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const socketInstance = io(SOCKET_URL, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
});
socketInstance.on('connect', () => {
console.log('WebSocket connected');
setConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('WebSocket disconnected');
setConnected(false);
});
socketInstance.on('connect_error', (error: Error) => {
console.error('WebSocket connection error:', error);
});
setSocket(socketInstance);
return () => {
socketInstance.close();
};
}, []);
return { socket, connected };
}
import { useWebSocket } from './useWebSocket';
// Типы для статистики и алертов
export interface ServerStats {
@@ -55,6 +15,9 @@ export interface ServerStats {
in: number;
out: number;
};
// Дополнительные поля могут приходить из backend, поэтому допускаем произвольные ключи
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}
export interface ServerAlert {
@@ -63,46 +26,52 @@ export interface ServerAlert {
level: 'warning' | 'info' | 'critical';
}
interface ServerStatsEvent {
serverId: number;
stats: ServerStats;
}
interface ServerAlertsEvent {
serverId: number;
alerts: ServerAlert[];
}
export function useServerStats(serverId: number | null) {
const { socket, connected } = useSocket();
const { isConnected, subscribe, unsubscribe } = useWebSocket();
const [stats, setStats] = useState<ServerStats | null>(null);
const [alerts, setAlerts] = useState<ServerAlert[]>([]);
useEffect(() => {
if (!socket || !connected || !serverId) return;
if (!serverId) {
setStats(null);
setAlerts([]);
return;
}
socket.emit('subscribe-server', serverId);
// Сброс предыдущих данных при смене сервера
setStats(null);
setAlerts([]);
const handleStats = (data: ServerStatsEvent) => {
if (data.serverId === serverId) {
setStats(data.stats);
}
};
const handleAlerts = (data: ServerAlertsEvent) => {
if (data.serverId === serverId) {
setAlerts(data.alerts);
const handler: Parameters<typeof subscribe>[1] = (event) => {
switch (event.type) {
case 'server:stats':
if (event.serverId === serverId) {
setStats(event.stats as ServerStats);
}
break;
case 'server:status':
if (event.serverId === serverId) {
setStats((prev) => ({ ...(prev ?? {}), status: event.status }));
}
break;
case 'server:created':
// Если создан текущий сервер, сбрасываем статистику для повторной загрузки
if ((event.server as { id?: number })?.id === serverId) {
setStats(null);
setAlerts([]);
}
break;
default:
break;
}
};
socket.on('server-stats', handleStats);
socket.on('server-alerts', handleAlerts);
subscribe('servers', handler);
return () => {
socket.emit('unsubscribe-server', serverId);
socket.off('server-stats', handleStats);
socket.off('server-alerts', handleAlerts);
unsubscribe('servers', handler);
};
}, [socket, connected, serverId]);
}, [serverId, subscribe, unsubscribe]);
return { stats, alerts, connected };
return { stats, alerts, connected: isConnected };
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,316 +1,496 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { FiAlertCircle, FiArrowLeft, FiDatabase, FiDollarSign, FiInfo, FiShoppingCart } from 'react-icons/fi';
import {
FiAlertCircle,
FiArrowLeft,
FiClock,
FiDatabase,
FiInfo,
FiShoppingCart,
FiShield,
FiGlobe
} from 'react-icons/fi';
import apiClient from '../../utils/apiClient';
import { API_URL } from '../../config/api';
import { DEFAULT_STORAGE_PLAN_ID, STORAGE_PLAN_IDS, STORAGE_PLAN_MAP, type StoragePlanId } from '../../constants/storagePlans';
import type { StorageBucket } from './types';
// Упрощённый Checkout только для S3 Bucket
interface CheckoutProps {
onSuccess?: () => void;
}
type CheckoutPlan = {
id: number;
code: string;
name: string;
price: number;
quotaGb: number;
bandwidthGb: number;
requestLimit: string;
description: string | null;
order: number;
isActive: boolean;
};
const Checkout: React.FC<CheckoutProps> = ({ onSuccess }) => {
type CartPayload = {
cartId: string;
plan: CheckoutPlan;
price: number;
expiresAt: string;
};
const BUCKET_NAME_REGEX = /^[a-z0-9-]{3,40}$/;
type CreateBucketResponse = {
bucket?: StorageBucket;
consoleCredentials?: {
login: string;
password: string;
url?: string | null;
};
error?: string;
};
type StorageRegionOption = {
code: string;
name: string;
description: string | null;
endpoint: string | null;
isDefault: boolean;
isActive: boolean;
};
const Checkout: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [planName, setPlanName] = useState<StoragePlanId>(DEFAULT_STORAGE_PLAN_ID);
const [planPrice, setPlanPrice] = useState<number>(STORAGE_PLAN_MAP[DEFAULT_STORAGE_PLAN_ID].price);
const params = useMemo(() => new URLSearchParams(location.search), [location.search]);
const cartId = params.get('cart') ?? '';
const [cart, setCart] = useState<CartPayload | null>(null);
const [loadingCart, setLoadingCart] = useState<boolean>(true);
const [balance, setBalance] = useState<number>(0);
const [bucketName, setBucketName] = useState<string>('');
const [region, setRegion] = useState<string>('ru-central-1');
const [storageClass, setStorageClass] = useState<string>('standard');
const [region, setRegion] = useState<string>('');
const [isPublic, setIsPublic] = useState<boolean>(false);
const [versioning, setVersioning] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [submitting, setSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [regions, setRegions] = useState<StorageRegionOption[]>([]);
const [loadingRegions, setLoadingRegions] = useState<boolean>(false);
// Загружаем параметры из query (?plan=basic&price=199)
const fetchBalance = useCallback(async () => {
try {
const res = await apiClient.get(`${API_URL}/api/user/balance`);
setBalance(res.data.balance || 0);
} catch (e) {
console.error('Ошибка загрузки баланса', e);
setBalance(Number(res.data?.balance) || 0);
} catch (err) {
console.error('Ошибка загрузки баланса', err);
}
}, []);
const fetchCart = useCallback(async () => {
if (!cartId) {
setError('Не найден идентификатор корзины. Вернитесь к выбору тарифа.');
setLoadingCart(false);
return;
}
try {
setLoadingCart(true);
setError(null);
const response = await apiClient.get(`${API_URL}/api/storage/cart/${cartId}`);
setCart(response.data as CartPayload);
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось загрузить корзину';
setError(message);
} finally {
setLoadingCart(false);
}
}, [cartId]);
useEffect(() => {
fetchBalance();
}, [fetchBalance]);
useEffect(() => {
fetchCart();
}, [fetchCart]);
const fetchRegions = useCallback(async () => {
try {
setLoadingRegions(true);
const response = await apiClient.get(`${API_URL}/api/storage/regions`);
const fetchedRegions = Array.isArray(response.data?.regions)
? (response.data.regions as StorageRegionOption[])
: [];
const activeRegions = fetchedRegions.filter((item) => item?.isActive !== false);
setRegions(activeRegions);
if (activeRegions.length > 0) {
const preferred = activeRegions.find((item) => item.isDefault) ?? activeRegions[0];
setRegion((current) => (current && activeRegions.some((item) => item.code === current) ? current : preferred.code));
} else {
setRegion('');
}
} catch (err) {
console.error('Ошибка загрузки регионов', err);
setRegions([]);
setRegion('');
} finally {
setLoadingRegions(false);
}
}, []);
useEffect(() => {
const params = new URLSearchParams(location.search);
const rawPlan = params.get('plan');
const match = rawPlan ? rawPlan.toLowerCase() : '';
const planId = STORAGE_PLAN_IDS.includes(match as StoragePlanId)
? (match as StoragePlanId)
: DEFAULT_STORAGE_PLAN_ID;
setPlanName(planId);
fetchRegions();
}, [fetchRegions]);
const priceParam = params.get('price');
if (priceParam) {
const numeric = Number(priceParam);
setPlanPrice(Number.isFinite(numeric) && numeric > 0 ? numeric : STORAGE_PLAN_MAP[planId].price);
} else {
setPlanPrice(STORAGE_PLAN_MAP[planId].price);
}
const plan = cart?.plan;
const planPrice = cart?.price ?? plan?.price ?? 0;
fetchBalance();
}, [location.search, fetchBalance]);
const planHighlights = useMemo(() => {
if (!plan?.description) return [] as string[];
return plan.description
.split(/\n|\|/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 5);
}, [plan]);
const meta = STORAGE_PLAN_MAP[planName];
const expiresAtText = useMemo(() => {
if (!cart) return null;
const expires = new Date(cart.expiresAt);
return expires.toLocaleString('ru-RU');
}, [cart]);
const canCreate = () => {
if (!planPrice || !bucketName.trim() || !meta) return false;
const canCreate = useMemo(() => {
if (!cart || !plan) return false;
if (!region) return false;
if (!BUCKET_NAME_REGEX.test(bucketName.trim())) return false;
if (balance < planPrice) return false;
// Простая валидация имени (можно расширить): маленькие буквы, цифры, тире
return /^[a-z0-9-]{3,40}$/.test(bucketName.trim());
};
return true;
}, [balance, bucketName, cart, planPrice, region]);
const selectedRegion = useMemo(
() => regions.find((item) => item.code === region),
[regions, region]
);
const regionLabel = useMemo(() => {
if (selectedRegion?.name) return selectedRegion.name;
if (selectedRegion?.code) return selectedRegion.code;
if (region) return region;
return '—';
}, [selectedRegion, region]);
const balanceAfterPayment = useMemo(() => balance - planPrice, [balance, planPrice]);
const formatCurrency = useCallback((amount: number) => `${amount.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`, []);
const handleCreate = async () => {
if (!canCreate()) {
setError('Проверьте корректность данных и баланс');
return;
}
setLoading(true);
setError('');
if (!canCreate || !cart) return;
setSubmitting(true);
setError(null);
try {
// POST на будущий endpoint S3
const res = await apiClient.post(`${API_URL}/api/storage/buckets`, {
const response = await apiClient.post<CreateBucketResponse>(`${API_URL}/api/storage/buckets`, {
name: bucketName.trim(),
plan: planName,
quotaGb: meta?.quotaGb || 0,
cartId: cart.cartId,
region,
storageClass,
storageClass: 'standard',
public: isPublic,
versioning
versioning,
});
if (res.data?.error) {
setError(res.data.error);
const { bucket: createdBucket, consoleCredentials, error: apiError } = response.data ?? {};
if (apiError) {
throw new Error(apiError);
}
if (!createdBucket) {
throw new Error('Не удалось получить созданный бакет. Попробуйте ещё раз.');
}
try {
const userRes = await apiClient.get(`${API_URL}/api/auth/me`);
window.dispatchEvent(new CustomEvent('userDataUpdate', {
detail: { user: userRes.data?.user },
}));
} catch (refreshError) {
console.error('Ошибка обновления данных пользователя', refreshError);
}
if (consoleCredentials) {
navigate(`/dashboard/storage/${createdBucket.id}`, {
state: {
consoleCredentials,
bucketName: createdBucket.name,
},
});
} else {
// Обновляем пользовательские данные и баланс (если списание произошло на сервере)
try {
const userRes = await apiClient.get(`${API_URL}/api/auth/me`);
window.dispatchEvent(new CustomEvent('userDataUpdate', {
detail: { user: userRes.data.user }
}));
} catch (e) {
console.error('Ошибка обновления userData', e);
}
if (onSuccess) onSuccess();
navigate('/dashboard/storage');
}
} catch (e: unknown) {
let message = 'Ошибка создания бакета';
if (e && typeof e === 'object' && 'response' in e) {
const resp = (e as { response?: { data?: { message?: string } } }).response;
if (resp?.data?.message) message = resp.data.message;
navigate(`/dashboard/storage/${createdBucket.id}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка создания бакета';
setError(message);
console.error(e);
} finally {
setLoading(false);
setSubmitting(false);
}
};
return (
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="max-w-6xl mx-auto pb-16">
<div className="flex flex-wrap items-center justify-between gap-4 mb-8">
<button
onClick={() => navigate('/dashboard/storage')}
className="flex items-center gap-2 px-4 py-2 text-ospab-primary hover:bg-ospab-primary/5 rounded-lg transition-colors mb-4"
className="flex items-center gap-2 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<FiArrowLeft />
<span>Назад к хранилищу</span>
<span>Назад к списку бакетов</span>
</button>
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
<FiDatabase className="text-ospab-primary" /> Создание S3 Bucket
</h1>
<p className="text-gray-600 mt-1">План: {meta?.title}{planPrice ? ` • ₽${planPrice}/мес` : ''}</p>
{expiresAtText && (
<span className="inline-flex items-center gap-2 text-sm text-gray-500">
<FiClock /> Корзина действительна до {expiresAtText}
</span>
)}
</div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3 mb-4">
<FiDatabase className="text-blue-600" />
Создание S3 бакета
</h1>
<p className="text-gray-600 mb-6">
Проверяем ваш тариф, готовим бакет и резервируем средства на балансе.
</p>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 flex items-start gap-3">
<FiAlertCircle className="text-red-500 text-xl flex-shrink-0 mt-0.5" />
<div className="bg-red-50 border border-red-200 text-red-700 rounded-xl p-4 mb-6 flex items-start gap-3">
<FiAlertCircle className="text-xl" />
<div>
<p className="text-red-700 font-semibold">Ошибка</p>
<p className="text-red-600 text-sm">{error}</p>
<p className="font-semibold">Нужно внимание</p>
<p className="text-sm">{error}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left */}
<div className="lg:col-span-2 space-y-6">
{/* Bucket settings */}
<div className="bg-white rounded-xl shadow-md p-6">
<h2 className="text-xl font-bold text-gray-800 mb-4">Параметры бакета</h2>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-xl font-semibold text-gray-900">Ваш тариф</h2>
<p className="text-sm text-gray-500">Зафиксирован при создании корзины</p>
</div>
<span className="inline-flex items-center gap-2 text-sm text-gray-500">
<FiShield /> {plan?.code ?? '—'}
</span>
</div>
{loadingCart ? (
<div className="animate-pulse h-32 bg-gray-100 rounded-lg" />
) : plan ? (
<>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-6">
<div>
<h3 className="text-2xl font-bold text-gray-900">{plan.name}</h3>
<p className="text-sm text-gray-500">S3 Object Storage</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500"> в месяц</p>
<p className="text-4xl font-bold text-gray-900">{plan.price.toLocaleString('ru-RU')}</p>
</div>
</div>
<div className="grid sm:grid-cols-3 gap-3 mb-6 text-sm">
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-gray-500">Хранилище</p>
<p className="text-lg font-semibold text-gray-900">{plan.quotaGb.toLocaleString('ru-RU')} GB</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-gray-500">Исходящий трафик</p>
<p className="text-lg font-semibold text-gray-900">{plan.bandwidthGb.toLocaleString('ru-RU')} GB</p>
</div>
<div className="bg-gray-50 rounded-xl p-3">
<p className="text-gray-500">Запросы</p>
<p className="text-lg font-semibold text-gray-900">{plan.requestLimit}</p>
</div>
</div>
{planHighlights.length > 0 && (
<ul className="grid sm:grid-cols-2 gap-3 text-sm text-gray-600">
{planHighlights.map((item) => (
<li key={item} className="flex items-start gap-2">
<span className="mt-1 inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
<span>{item}</span>
</li>
))}
</ul>
)}
</>
) : (
<p className="text-sm text-gray-500">Корзина не найдена. Вернитесь на страницу тарифов.</p>
)}
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div className="flex items-center gap-3 mb-4">
<FiInfo className="text-blue-600 text-xl" />
<div>
<h2 className="text-xl font-semibold text-gray-900">Настройка бакета</h2>
<p className="text-sm text-gray-500">Базовые параметры можно изменить позже</p>
</div>
</div>
<div className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Имя бакета</label>
<input
type="text"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
onChange={(event) => setBucketName(event.target.value.toLowerCase())}
placeholder="например: media-assets"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent transition-all"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">Допустимы: a-z 0-9 - (340 символов)</p>
<p className="text-xs text-gray-500 mt-1">a-z, 0-9, дефис, 340 символов</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Регион</label>
<select
value={region}
onChange={(e) => setRegion(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
>
<option value="ru-central-1">ru-central-1</option>
<option value="eu-east-1">eu-east-1</option>
<option value="eu-west-1">eu-west-1</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Класс хранения</label>
<select
value={storageClass}
onChange={(e) => setStorageClass(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-ospab-primary focus:border-transparent"
>
<option value="standard">Standard</option>
<option value="infrequent">Infrequent</option>
<option value="archive">Archive</option>
</select>
<div className="relative">
<FiGlobe className="absolute left-3 top-3 text-gray-400" />
<select
value={region}
onChange={(event) => setRegion(event.target.value)}
disabled={loadingRegions || regions.length === 0}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-500"
>
{loadingRegions && <option value="">Загрузка...</option>}
{!loadingRegions && regions.length === 0 && <option value="">Нет доступных регионов</option>}
{regions.map((item) => (
<option key={item.code} value={item.code}>
{item.code}
</option>
))}
</select>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer select-none">
<div className="flex flex-wrap gap-4 text-sm text-gray-700">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
onChange={(event) => setIsPublic(event.target.checked)}
className="rounded"
/>
<span className="text-sm text-gray-700">Публичный доступ</span>
<span>Публичный доступ</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={versioning}
onChange={(e) => setVersioning(e.target.checked)}
onChange={(event) => setVersioning(event.target.checked)}
className="rounded"
/>
<span className="text-sm text-gray-700">Версионирование</span>
<span>Версионирование объектов</span>
</label>
</div>
</div>
</div>
{/* Plan info */}
<div className="bg-white rounded-xl shadow-md p-6">
<div className="flex items-center gap-2 mb-4">
<FiInfo className="text-ospab-primary text-xl" />
<h2 className="text-xl font-bold text-gray-800">Информация о плане</h2>
</div>
{meta ? (
<div className="space-y-3">
<p className="text-gray-700 text-sm">Включённый объём: <span className="font-semibold">{meta.quotaGb} GB</span></p>
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600">
{meta.included.slice(0, 4).map((d) => (
<li key={d} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-ospab-primary rounded-full"></span>{d}
</li>
))}
</ul>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-gray-700">
Оплата списывается помесячно при создании бакета. Использование сверх квоты будет тарифицироваться позже.
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Параметры плана не найдены. Вернитесь на страницу тарифов.</p>
)}
</div>
</div>
{/* Right */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl shadow-md p-6 sticky top-4">
<div className="flex items-center gap-2 mb-6">
<FiShoppingCart className="text-ospab-primary text-xl" />
<h2 className="text-xl font-bold text-gray-800">Итого</h2>
<aside className="space-y-6">
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">К оплате сегодня</h2>
<FiShoppingCart className="text-blue-600 text-xl" />
</div>
<div className="bg-gradient-to-br from-ospab-primary to-ospab-accent rounded-lg p-4 mb-6 text-white">
<div className="flex items-center gap-2 mb-2">
<FiDollarSign className="text-lg" />
<p className="text-white/80 text-sm">Баланс</p>
</div>
<p className="text-2xl font-bold mb-3">{balance.toFixed(2)}</p>
<div className="bg-blue-50 rounded-xl p-4 mb-4">
<p className="text-sm text-blue-600">Баланс аккаунта</p>
<p className="text-2xl font-bold text-blue-700">{balance.toFixed(2)}</p>
<button
onClick={() => navigate('/dashboard/billing')}
className="w-full bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-colors text-sm font-semibold"
>Пополнить баланс</button>
className="mt-3 w-full text-sm font-semibold text-blue-600 hover:text-blue-700"
>
Пополнить баланс
</button>
</div>
<div className="space-y-4 mb-6">
<div>
<p className="text-sm text-gray-500 mb-1">План</p>
{meta ? (
<div className="bg-gray-50 rounded-lg p-3">
<p className="font-semibold text-gray-800 mb-1">{meta.title}</p>
<p className="text-sm text-gray-600">{planPrice}/мес</p>
</div>
) : (
<p className="text-gray-400 text-sm">Не выбран</p>
)}
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Имя бакета</p>
{bucketName ? (
<div className="bg-gray-50 rounded-lg p-3">
<p className="font-semibold text-gray-800">{bucketName}</p>
</div>
) : (
<p className="text-gray-400 text-sm">Не указано</p>
)}
</div>
{planName && (
<div className="pt-4 border-t border-gray-200">
<div className="space-y-2 mb-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Стоимость:</span>
<span className="font-semibold">{planPrice}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Баланс:</span>
<span className="font-semibold">{balance.toFixed(2)}</span>
</div>
</div>
<div className="flex justify-between pt-3 border-t border-gray-200">
<span className="text-gray-800 font-semibold">Остаток:</span>
<span className={`font-bold text-lg ${balance - planPrice >= 0 ? 'text-green-600' : 'text-red-600'}`}>{(balance - planPrice).toFixed(2)}</span>
</div>
<div className="space-y-4 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-gray-500">План</p>
<p className="text-xs text-gray-400">S3 Object Storage</p>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">{plan?.name ?? '—'}</p>
<p className="text-xs text-gray-500">{plan ? formatCurrency(planPrice) : '—'}</p>
</div>
</div>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-gray-500">Регион</p>
<p className="text-xs text-gray-400">Endpoint</p>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">{regionLabel}</p>
<p className="text-xs text-gray-500">{selectedRegion?.endpoint ?? '—'}</p>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-gray-500">Баланс</p>
<p className="font-semibold text-gray-900">{formatCurrency(balance)}</p>
</div>
<div className="flex items-center justify-between gap-3 pt-4 border-t border-gray-200">
<div>
<p className="text-gray-700 font-semibold">Итог к списанию</p>
{plan && (
<p className="text-xs text-gray-500">Ежемесячный платёж тарифа</p>
)}
</div>
<p className="text-2xl font-bold text-gray-900">{plan ? formatCurrency(planPrice) : '—'}</p>
</div>
{plan && (
<p className={`text-xs ${balanceAfterPayment >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{balanceAfterPayment >= 0
? `После оплаты останется: ${formatCurrency(balanceAfterPayment)}`
: `Не хватает: ${formatCurrency(Math.abs(balanceAfterPayment))}`}
</p>
)}
</div>
<button
type="button"
onClick={handleCreate}
disabled={!canCreate() || loading}
className={`w-full py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors ${
!canCreate() || loading ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-ospab-primary text-white hover:bg-ospab-primary/90 shadow-lg hover:shadow-xl'
disabled={!canCreate || submitting || loadingCart}
className={`mt-6 w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition-colors ${
!canCreate || submitting || loadingCart
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-500'
}`}
>
{loading ? (<><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div><span>Создание...</span></>) : (<><FiShoppingCart /><span>Создать бакет</span></>)}
{submitting ? (
<>
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Создаём бакет...</span>
</>
) : (
<>
<span>Оплатить и создать</span>
<FiShoppingCart />
</>
)}
</button>
{!canCreate() && (
<p className="text-xs text-gray-500 text-center mt-3">Заполните имя бакета, выберите план и убедитесь в достаточном балансе</p>
{!canCreate && !loadingCart && (
<p className="mt-3 text-xs text-gray-500">
Проверьте имя бакета, выбранный регион и достаточный баланс для оплаты тарифа.
</p>
)}
</div>
</div>
</aside>
</div>
</div>
);

View File

@@ -7,14 +7,14 @@ import AuthContext from '../../context/authcontext';
// Импортируем компоненты для вкладок
import Summary from './summary';
import TicketsPage from './tickets';
import TicketsPage from './tickets/index';
import Billing from './billing';
import Settings from './settings';
import NotificationsPage from './notifications';
import CheckVerification from './checkverification';
import TicketResponse from './ticketresponse';
import Checkout from './checkout';
import StoragePage from './storage';
import StorageBucketPage from './storage-bucket';
import AdminPanel from './admin';
import BlogAdmin from './blogadmin';
import BlogEditor from './blogeditor';
@@ -115,7 +115,6 @@ const Dashboard = () => {
];
const adminTabs = [
{ key: 'checkverification', label: 'Проверка чеков', to: '/dashboard/checkverification' },
{ key: 'ticketresponse', label: 'Ответы на тикеты', to: '/dashboard/ticketresponse' },
];
const superAdminTabs = [
@@ -257,10 +256,11 @@ const Dashboard = () => {
<Routes>
<Route path="/" element={<Summary userData={userData ?? { user: { username: '', operator: 0 }, balance: 0, tickets: [] }} />} />
<Route path="storage" element={<StoragePage />} />
<Route path="checkout" element={<Checkout onSuccess={() => navigate('/dashboard/storage')} />} />
<Route path="storage/:bucketId" element={<StorageBucketPage />} />
<Route path="checkout" element={<Checkout />} />
{userData && (
<>
<Route path="tickets" element={<TicketsPage setUserData={setUserData} />} />
<Route path="tickets" element={<TicketsPage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="tickets/new" element={<NewTicketPage />} />
</>
@@ -273,7 +273,6 @@ const Dashboard = () => {
{isOperator && (
<>
<Route path="checkverification" element={<CheckVerification />} />
<Route path="ticketresponse" element={<TicketResponse />} />
</>
)}
{isAdmin && (

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import type { StorageBucket } from './types';
export interface StatusBadge {
label: string;
className: string;
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, index);
const digits = value >= 10 || index === 0 ? 0 : 2;
return `${value.toFixed(digits)} ${units[index]}`;
}
export function formatDate(value?: string | null, withTime = false): string {
if (!value) {
return '—';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
const options: Intl.DateTimeFormatOptions = withTime
? { dateStyle: 'short', timeStyle: 'short' }
: { dateStyle: 'short' };
return date.toLocaleString('ru-RU', options);
}
export function getUsagePercent(usedBytes: number, quotaGb: number): number {
if (!Number.isFinite(quotaGb) || quotaGb <= 0) {
return 0;
}
const quotaBytes = quotaGb * 1024 * 1024 * 1024;
if (quotaBytes <= 0) {
return 0;
}
return Math.min((usedBytes / quotaBytes) * 100, 100);
}
export function getPlanTone(plan: string): string {
if (!plan) {
return 'bg-gray-100 text-gray-700';
}
const normalized = plan.toLowerCase();
const variants: Record<string, string> = {
basic: 'bg-blue-100 text-blue-700',
standard: 'bg-green-100 text-green-700',
plus: 'bg-purple-100 text-purple-700',
pro: 'bg-orange-100 text-orange-700',
enterprise: 'bg-red-100 text-red-700',
};
return variants[normalized] ?? 'bg-gray-100 text-gray-700';
}
export function getStatusBadge(status: StorageBucket['status']): StatusBadge {
const normalized = (status ?? '').toLowerCase();
switch (normalized) {
case 'active':
return { label: 'Активен', className: 'bg-green-100 text-green-700' };
case 'creating':
return { label: 'Создаётся', className: 'bg-blue-100 text-blue-700' };
case 'suspended':
return { label: 'Приостановлен', className: 'bg-yellow-100 text-yellow-700' };
case 'error':
case 'failed':
return { label: 'Ошибка', className: 'bg-red-100 text-red-700' };
default:
return { label: status ?? 'Неизвестно', className: 'bg-gray-100 text-gray-600' };
}
}
export function formatCurrency(value: number): string {
if (!Number.isFinite(value)) {
return '—';
}
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(value);
}

View File

@@ -1,89 +1,238 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { isAxiosError } from 'axios';
import { useNavigate } from 'react-router-dom';
import { FiDatabase, FiPlus, FiInfo, FiTrash2, FiSettings, FiExternalLink } from 'react-icons/fi';
import {
FiDatabase,
FiPlus,
FiTrash2,
FiSettings,
FiExternalLink,
FiRefreshCw,
FiCheckCircle,
FiAlertTriangle,
FiInfo
} from 'react-icons/fi';
import apiClient from '../../utils/apiClient';
import { API_URL } from '../../config/api';
import { useToast } from '../../hooks/useToast';
import type { StorageBucket } from './types';
import {
formatBytes,
formatCurrency,
formatDate,
getPlanTone,
getStatusBadge,
getUsagePercent,
} from './storage-utils';
interface StorageBucket {
id: number;
name: string;
plan: string;
quotaGb: number;
usedBytes: number;
objectCount: number;
storageClass: string;
region: string;
public: boolean;
versioning: boolean;
createdAt: string;
updatedAt: string;
type StorageRegionInfo = NonNullable<StorageBucket['regionDetails']>;
type StorageClassInfo = NonNullable<StorageBucket['storageClassDetails']>;
type StoragePlanInfo = NonNullable<StorageBucket['planDetails']>;
interface StorageStatus {
minio: {
connected: boolean;
endpoint: string;
bucketPrefix: string;
availableBuckets: number;
error: string | null;
};
defaults: {
region: StorageRegionInfo | null;
storageClass: StorageClassInfo | null;
};
plans: StoragePlanInfo[];
regions: StorageRegionInfo[];
classes: StorageClassInfo[];
}
const StoragePage: React.FC = () => {
const [buckets, setBuckets] = useState<StorageBucket[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [status, setStatus] = useState<StorageStatus | null>(null);
const [loadingBuckets, setLoadingBuckets] = useState(true);
const [loadingStatus, setLoadingStatus] = useState(true);
const [error, setError] = useState<string | null>(null);
const { addToast } = useToast();
const navigate = useNavigate();
const [bucketActions, setBucketActions] = useState<Record<number, boolean>>({});
const fetchBuckets = useCallback(async (notify = false) => {
try {
setLoadingBuckets(true);
const response = await apiClient.get<{ buckets: StorageBucket[] }>('/api/storage/buckets');
setBuckets(response.data?.buckets ?? []);
setError(null);
if (notify) {
addToast('Список бакетов обновлён', 'success');
}
} catch (err) {
console.error('[Storage] Не удалось загрузить бакеты', err);
setError('Не удалось загрузить список хранилищ');
addToast('Не удалось получить список бакетов', 'error');
} finally {
setLoadingBuckets(false);
}
}, [addToast]);
const fetchStatus = useCallback(async (notify = false) => {
try {
setLoadingStatus(true);
const response = await apiClient.get<StorageStatus>('/api/storage/status');
setStatus(response.data);
if (notify && response.data.minio.connected) {
addToast('Подключение к MinIO активно', 'success');
}
} catch (err) {
console.error('[Storage] Не удалось получить статус', err);
if (notify) {
addToast('Не удалось обновить статус MinIO', 'warning');
}
} finally {
setLoadingStatus(false);
}
}, [addToast]);
const setBucketBusy = useCallback((id: number, busy: boolean) => {
setBucketActions((prev) => {
if (busy) {
return { ...prev, [id]: true };
}
if (!(id in prev)) {
return prev;
}
const next = { ...prev };
delete next[id];
return next;
});
}, []);
const isBucketBusy = useCallback((id: number) => bucketActions[id] === true, [bucketActions]);
const handleDeleteBucket = useCallback(async (bucket: StorageBucket) => {
if (!window.confirm(`Удалить бакет «${bucket.name}»?`)) {
return;
}
const deleteRequest = (force: boolean) => apiClient.delete(`/api/storage/buckets/${bucket.id}`, {
params: force ? { force: true } : undefined,
});
setBucketBusy(bucket.id, true);
try {
await deleteRequest(false);
setBuckets((prev) => prev.filter((item) => item.id !== bucket.id));
addToast(`Бакет «${bucket.name}» удалён`, 'success');
fetchStatus();
return;
} catch (error) {
let message = 'Не удалось удалить бакет';
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
message = error.response.data.error;
}
const lower = message.toLowerCase();
const requiresForce = lower.includes('непуст');
if (requiresForce) {
const confirmForce = window.confirm(`${message}. Удалить принудительно? Все объекты будут удалены без восстановления.`);
if (confirmForce) {
try {
await deleteRequest(true);
setBuckets((prev) => prev.filter((item) => item.id !== bucket.id));
addToast(`Бакет «${bucket.name}» удалён принудительно`, 'warning');
fetchStatus();
return;
} catch (forceError) {
let forceMessage = 'Не удалось удалить бакет принудительно';
if (isAxiosError(forceError) && typeof forceError.response?.data?.error === 'string') {
forceMessage = forceError.response.data.error;
}
addToast(forceMessage, 'error');
}
} else {
addToast(message, 'warning');
}
} else {
addToast(message, 'error');
}
} finally {
setBucketBusy(bucket.id, false);
}
}, [addToast, fetchStatus, setBucketBusy]);
useEffect(() => {
fetchBuckets();
}, []);
fetchStatus();
}, [fetchBuckets, fetchStatus]);
const fetchBuckets = async () => {
try {
setLoading(true);
const res = await apiClient.get(`${API_URL}/api/storage/buckets`);
setBuckets(res.data.buckets || []);
} catch (e) {
console.error('Ошибка загрузки бакетов', e);
setError('Не удалось загрузить список хранилищ');
} finally {
setLoading(false);
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const getUsagePercent = (usedBytes: number, quotaGb: number): number => {
const quotaBytes = quotaGb * 1024 * 1024 * 1024;
return quotaBytes > 0 ? Math.min((usedBytes / quotaBytes) * 100, 100) : 0;
};
const getPlanColor = (plan: string): string => {
const colors: Record<string, string> = {
basic: 'text-blue-600 bg-blue-50',
standard: 'text-green-600 bg-green-50',
plus: 'text-purple-600 bg-purple-50',
pro: 'text-orange-600 bg-orange-50',
enterprise: 'text-red-600 bg-red-50'
useEffect(() => {
const handleBucketsRefresh = () => {
fetchBuckets();
fetchStatus();
};
return colors[plan] || 'text-gray-600 bg-gray-50';
};
window.addEventListener('storageBucketsRefresh', handleBucketsRefresh);
return () => {
window.removeEventListener('storageBucketsRefresh', handleBucketsRefresh);
};
}, [fetchBuckets, fetchStatus]);
const summary = useMemo(() => {
const totalBuckets = buckets.length;
const totalUsedBytes = buckets.reduce((acc, bucket) => acc + bucket.usedBytes, 0);
const totalQuotaGb = buckets.reduce((acc, bucket) => acc + bucket.quotaGb, 0);
const autoRenewCount = buckets.reduce((acc, bucket) => acc + (bucket.autoRenew ? 1 : 0), 0);
const quotaBytes = totalQuotaGb * 1024 * 1024 * 1024;
const globalUsagePercent = quotaBytes > 0 ? Math.min((totalUsedBytes / quotaBytes) * 100, 100) : 0;
const minMonthlyPrice = buckets.reduce((min, bucket) => {
const price = bucket.planDetails?.price ?? bucket.monthlyPrice;
if (!Number.isFinite(price)) {
return min;
}
return Math.min(min, Number(price));
}, Number.POSITIVE_INFINITY);
return {
totalBuckets,
totalUsedBytes,
totalQuotaGb,
autoRenewCount,
globalUsagePercent,
lowestPrice: Number.isFinite(minMonthlyPrice) ? minMonthlyPrice : null,
};
}, [buckets]);
const handleRefreshBuckets = useCallback(() => {
fetchBuckets(true);
}, [fetchBuckets]);
const handleRefreshStatus = useCallback(() => {
fetchStatus(true);
}, [fetchStatus]);
const handleOpenBucket = useCallback((id: number) => {
navigate(`/dashboard/storage/${id}`);
}, [navigate]);
const minioStatus = status?.minio;
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6 flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-2">
<FiDatabase className="text-ospab-primary" />
S3 Хранилище
</h1>
<p className="text-gray-600 mt-1">Управление вашими объектными хранилищами</p>
<p className="text-gray-600 mt-1">Управление объектными бакетами и статус облачного хранилища</p>
</div>
<div className="flex gap-3">
<button
onClick={() => navigate('/tariffs')}
className="px-5 py-2.5 bg-white border-2 border-ospab-primary text-ospab-primary rounded-lg font-semibold hover:bg-ospab-primary hover:text-white transition-all flex items-center gap-2"
onClick={handleRefreshBuckets}
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
>
<FiInfo />
Тарифы
<FiRefreshCw className={loadingBuckets ? 'animate-spin' : ''} />
Обновить список
</button>
<button
onClick={() => navigate('/tariffs')}
@@ -96,20 +245,116 @@ const StoragePage: React.FC = () => {
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-red-700">
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6 text-red-700 flex items-center gap-2">
<FiAlertTriangle className="text-red-500" />
{error}
</div>
)}
{loading ? (
<div className="grid gap-4 mb-6">
<div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
{minioStatus?.connected ? (
<FiCheckCircle className="text-green-500 text-2xl" />
) : (
<FiAlertTriangle className="text-red-500 text-2xl" />
)}
<div>
<h2 className="text-lg font-semibold text-gray-800">Статус подключения MinIO</h2>
<p className="text-sm text-gray-500">
{minioStatus?.connected ? 'Подключение установлено' : 'Нет связи с хранилищем. Попробуйте обновить статус.'}
</p>
</div>
</div>
<button
onClick={handleRefreshStatus}
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 transition flex items-center gap-2"
>
<FiRefreshCw className={loadingStatus ? 'animate-spin' : ''} />
Проверить статус
</button>
</div>
{loadingStatus ? (
<div className="px-6 py-8 text-sm text-gray-500">Проверяем подключение к MinIO...</div>
) : status ? (
<div className="px-6 py-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-3 text-sm text-gray-600">
<div className="flex items-center gap-2">
<FiDatabase className="text-ospab-primary" />
<span>Endpoint: <span className="font-semibold text-gray-800">{minioStatus?.endpoint || '—'}</span></span>
</div>
<div className="flex items-center gap-2">
<FiInfo className="text-ospab-primary" />
<span>Префикс бакетов: <span className="font-semibold text-gray-800">{minioStatus?.bucketPrefix || '—'}</span></span>
</div>
<div className="flex items-center gap-2">
<FiInfo className="text-ospab-primary" />
<span>Всего бакетов на сервере: <span className="font-semibold text-gray-800">{minioStatus?.availableBuckets ?? '—'}</span></span>
</div>
{minioStatus?.error && !minioStatus.connected && (
<div className="flex items-center gap-2 text-red-600">
<FiAlertTriangle />
<span className="font-medium">{minioStatus.error}</span>
</div>
)}
</div>
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 space-y-3">
<div>
<p className="text-xs uppercase text-gray-500 mb-1">Регион по умолчанию</p>
<p className="text-gray-800 font-semibold">{status.defaults.region?.name ?? 'Не выбран'}</p>
<p className="text-xs text-gray-500">{status.defaults.region?.endpoint ?? status.defaults.region?.code ?? '—'}</p>
</div>
<div>
<p className="text-xs uppercase text-gray-500 mb-1">Класс хранения по умолчанию</p>
<p className="text-gray-800 font-semibold">{status.defaults.storageClass?.name ?? 'Не выбран'}</p>
<p className="text-xs text-gray-500">{status.defaults.storageClass?.description ?? status.defaults.storageClass?.code ?? '—'}</p>
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
<span>Активных тарифов: <span className="font-semibold text-gray-800">{status.plans.length}</span></span>
<span>Регионов: <span className="font-semibold text-gray-800">{status.regions.length}</span></span>
<span>Классов хранения: <span className="font-semibold text-gray-800">{status.classes.length}</span></span>
</div>
</div>
</div>
) : (
<div className="px-6 py-8 text-sm text-gray-500 flex items-center gap-2">
<FiInfo />
Нет данных о статусе хранилища. Попробуйте обновить.
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-xl shadow-md p-5">
<p className="text-xs uppercase text-gray-500 mb-2">Всего бакетов</p>
<p className="text-3xl font-bold text-gray-800">{summary.totalBuckets}</p>
<p className="text-xs text-gray-500 mt-2">Автопродление активировано: {summary.autoRenewCount}</p>
</div>
<div className="bg-white rounded-xl shadow-md p-5">
<p className="text-xs uppercase text-gray-500 mb-2">Использовано данных</p>
<p className="text-2xl font-semibold text-gray-800">{formatBytes(summary.totalUsedBytes)}</p>
<p className="text-xs text-gray-500 mt-2">Глобальная загрузка: {summary.globalUsagePercent.toFixed(1)}%</p>
</div>
<div className="bg-white rounded-xl shadow-md p-5">
<p className="text-xs uppercase text-gray-500 mb-2">Суммарная квота</p>
<p className="text-2xl font-semibold text-gray-800">{summary.totalQuotaGb} GB</p>
<p className="text-xs text-gray-500 mt-2">Мин. ежемесячный тариф: {summary.lowestPrice !== null ? formatCurrency(summary.lowestPrice) : '—'}</p>
</div>
</div>
</div>
{loadingBuckets ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-ospab-primary" />
</div>
) : buckets.length === 0 ? (
<div className="bg-white rounded-xl shadow-md p-12 text-center">
<FiDatabase className="text-6xl text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-bold text-gray-800 mb-2">Нет активных хранилищ</h3>
<p className="text-gray-600 mb-6">Создайте ваш первый S3 бакет для хранения файлов, резервных копий и медиа-контента</p>
<p className="text-gray-600 mb-6">Создайте первый S3 бакет для хранения файлов, резервных копий и медиа-контента.</p>
<button
onClick={() => navigate('/tariffs')}
className="px-6 py-3 bg-ospab-primary text-white rounded-lg font-semibold hover:bg-ospab-primary/90 shadow-lg transition-all inline-flex items-center gap-2"
@@ -122,85 +367,127 @@ const StoragePage: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{buckets.map((bucket) => {
const usagePercent = getUsagePercent(bucket.usedBytes, bucket.quotaGb);
const statusBadge = getStatusBadge(bucket.status);
const planName = bucket.planDetails?.name ?? bucket.plan;
const planTone = getPlanTone(bucket.planDetails?.code ?? bucket.plan);
const rawPrice = bucket.planDetails?.price ?? bucket.monthlyPrice;
const price = Number.isFinite(rawPrice) ? Number(rawPrice) : null;
const busy = isBucketBusy(bucket.id);
return (
<div key={bucket.id} className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow">
<div key={bucket.id} className="bg-white rounded-xl shadow-md hover:shadow-lg transition-shadow overflow-hidden">
<div className="p-6 border-b border-gray-100">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="bg-ospab-primary/10 p-3 rounded-lg">
<FiDatabase className="text-ospab-primary text-xl" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-800">{bucket.name}</h3>
<span className={`inline-block px-2 py-1 text-xs font-semibold rounded-full ${getPlanColor(bucket.plan)}`}>
{bucket.plan}
</span>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-lg font-bold text-gray-800">{bucket.name}</h3>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${planTone}`}>
{planName}
</span>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${statusBadge.className}`}>
{statusBadge.label}
</span>
</div>
<p className="text-xs text-gray-500">ID бакета: {bucket.id}</p>
</div>
</div>
<div className="flex gap-2">
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
<FiSettings />
<button
type="button"
onClick={() => handleOpenBucket(bucket.id)}
className={`p-2 rounded-lg transition-colors ${busy ? 'cursor-not-allowed text-gray-400' : 'text-gray-600 hover:bg-gray-100'}`}
title="Управление бакетом"
disabled={busy}
>
{busy ? <FiRefreshCw className="animate-spin" /> : <FiSettings />}
</button>
<button className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<FiTrash2 />
<button
type="button"
onClick={() => handleDeleteBucket(bucket)}
className={`p-2 rounded-lg transition-colors ${busy ? 'cursor-not-allowed text-red-300' : 'text-red-600 hover:bg-red-50'}`}
title="Удалить бакет"
disabled={busy}
>
{busy ? <FiRefreshCw className="animate-spin" /> : <FiTrash2 />}
</button>
</div>
</div>
{/* Usage bar */}
<div className="mb-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Использовано: {formatBytes(bucket.usedBytes)}</span>
<span>Квота: {bucket.quotaGb} GB</span>
<div className="mt-5 space-y-4 text-sm text-gray-600">
<div>
<div className="flex justify-between mb-1 text-xs uppercase text-gray-500">
<span>Использовано</span>
<span>
{formatBytes(bucket.usedBytes)} из {bucket.quotaGb} GB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
usagePercent > 90 ? 'bg-red-500' : usagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${usagePercent}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1">{usagePercent.toFixed(1)}% от квоты</p>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
usagePercent > 90 ? 'bg-red-500' : usagePercent > 70 ? 'bg-yellow-500' : 'bg-green-500'
}`}
style={{ width: `${usagePercent}%` }}
></div>
</div>
<p className="text-xs text-gray-500 mt-1">{usagePercent.toFixed(1)}% использовано</p>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center">
<p className="text-2xl font-bold text-ospab-primary">{bucket.objectCount}</p>
<p className="text-xs text-gray-500">Объектов</p>
</div>
<div className="text-center">
<p className="text-sm font-semibold text-gray-700">{bucket.region}</p>
<p className="text-xs text-gray-500">Регион</p>
</div>
<div className="text-center">
<p className="text-sm font-semibold text-gray-700">{bucket.storageClass}</p>
<p className="text-xs text-gray-500">Класс</p>
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
<span>
Объектов: <span className="font-semibold text-gray-700">{bucket.objectCount}</span>
</span>
<span>
Тариф: <span className="font-semibold text-gray-700">{planName}</span>
</span>
{price !== null ? (
<span>
Ежемесячно: <span className="font-semibold text-gray-700">{formatCurrency(price)}</span>
</span>
) : null}
<span>
Синхронизация: <span className="font-semibold text-gray-700">{formatDate(bucket.usageSyncedAt, true)}</span>
</span>
</div>
</div>
{/* Badges */}
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 flex-wrap mt-4 text-xs">
{bucket.public && (
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 text-xs font-semibold rounded-full">
Публичный
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 font-semibold rounded-full">
Публичный доступ
</span>
)}
{bucket.versioning && (
<span className="inline-flex items-center px-2 py-1 bg-purple-100 text-purple-700 text-xs font-semibold rounded-full">
<span className="inline-flex items-center px-2 py-1 bg-purple-100 text-purple-700 font-semibold rounded-full">
Версионирование
</span>
)}
{bucket.autoRenew && (
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-700 font-semibold rounded-full">
Автопродление
</span>
)}
</div>
</div>
<div className="px-6 py-4 bg-gray-50 flex justify-between items-center">
<p className="text-xs text-gray-500">
Создан: {new Date(bucket.createdAt).toLocaleDateString('ru-RU')}
</p>
<button className="text-ospab-primary hover:text-ospab-primary/80 font-semibold text-sm flex items-center gap-1">
Открыть <FiExternalLink />
<div className="px-6 py-4 bg-gray-50 flex flex-col gap-2 text-xs text-gray-500 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-4">
<span>
Создан: <span className="font-semibold text-gray-700">{formatDate(bucket.createdAt)}</span>
</span>
<span>
Следующее списание: <span className="font-semibold text-gray-700">{formatDate(bucket.nextBillingDate)}</span>
</span>
</div>
<button
onClick={() => handleOpenBucket(bucket.id)}
className="text-ospab-primary hover:text-ospab-primary/80 font-semibold text-sm flex items-center gap-1"
>
Открыть
<FiExternalLink />
</button>
</div>
</div>

View File

@@ -1,142 +0,0 @@
import React, { useEffect, useState } from 'react';
import apiClient from '../../utils/apiClient';
interface Response {
id: number;
message: string;
createdAt: string;
operator?: { username: string };
}
interface Ticket {
id: number;
title: string;
message: string;
status: string;
createdAt: string;
responses: Response[];
user?: { username: string };
}
const TicketResponse: React.FC = () => {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [responseMsg, setResponseMsg] = useState<{ [key: number]: string }>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetchTickets();
}, []);
const fetchTickets = async () => {
setError('');
try {
const res = await apiClient.get('/api/ticket');
const data = Array.isArray(res.data) ? res.data : res.data?.tickets;
setTickets(data || []);
} catch {
setError('Ошибка загрузки тикетов');
setTickets([]);
}
};
const respondTicket = async (ticketId: number) => {
setLoading(true);
setError('');
try {
await apiClient.post('/api/ticket/respond', {
ticketId,
message: responseMsg[ticketId]
});
setResponseMsg(prev => ({ ...prev, [ticketId]: '' }));
fetchTickets();
} catch {
setError('Ошибка отправки ответа');
} finally {
setLoading(false);
}
};
// Функция закрытия тикета
const closeTicket = async (ticketId: number) => {
setLoading(true);
setError('');
try {
await apiClient.post('/api/ticket/close', { ticketId });
fetchTickets();
} catch {
setError('Ошибка закрытия тикета');
} finally {
setLoading(false);
}
};
return (
<div className="p-8 bg-white rounded-3xl shadow-xl">
<h2 className="text-3xl font-bold text-gray-800 mb-6">Ответы на тикеты</h2>
{error && <div className="text-red-500 mb-4">{error}</div>}
{tickets.length === 0 ? (
<p className="text-lg text-gray-500">Нет тикетов для ответа.</p>
) : (
<div className="space-y-6">
{tickets.map(ticket => (
<div key={ticket.id} className="border rounded-xl p-4 shadow flex flex-col">
<div className="font-bold text-lg mb-1">{ticket.title}</div>
<div className="text-gray-600 mb-2">{ticket.message}</div>
<div className="text-sm text-gray-400 mb-2">Статус: {ticket.status} | Автор: {ticket.user?.username} | {new Date(ticket.createdAt).toLocaleString()}</div>
{/* Чат сообщений */}
<div className="flex flex-col gap-2 mb-4">
<div className="flex items-start gap-2">
<div className="bg-blue-100 text-blue-900 px-3 py-2 rounded-xl max-w-xl">
<span className="font-semibold">{ticket.user?.username || 'Клиент'}:</span> {ticket.message}
</div>
</div>
{(ticket.responses || []).map(r => (
<div key={r.id} className="flex items-start gap-2">
<div className="bg-green-100 text-green-900 px-3 py-2 rounded-xl max-w-xl ml-8">
<span className="font-semibold">{r.operator?.username || 'Оператор'}:</span> {r.message}
<span className="text-gray-400 ml-2 text-xs">{new Date(r.createdAt).toLocaleString()}</span>
</div>
</div>
))}
</div>
{/* Форма ответа и кнопка закрытия */}
{ticket.status !== 'closed' && (
<div className="flex flex-col md:flex-row items-center gap-2 mt-2">
<input
value={responseMsg[ticket.id] || ''}
onChange={e => setResponseMsg(prev => ({ ...prev, [ticket.id]: e.target.value }))}
placeholder="Ваш ответ..."
className="border rounded p-2 flex-1"
disabled={loading}
/>
<button
type="button"
onClick={() => respondTicket(ticket.id)}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition"
disabled={loading || !(responseMsg[ticket.id] && responseMsg[ticket.id].trim())}
>
{loading ? 'Отправка...' : 'Ответить'}
</button>
<button
type="button"
onClick={() => closeTicket(ticket.id)}
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition"
disabled={loading}
>
Закрыть тикет
</button>
</div>
)}
{ticket.status === 'closed' && (
<div className="text-red-600 font-bold mt-2">Тикет закрыт</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default TicketResponse;

View File

@@ -68,38 +68,36 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { color: string; text: string; emoji: string }> = {
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
const badges: Record<string, { color: string; text: string }> = {
open: { color: 'bg-green-100 text-green-800', text: 'Открыт' },
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе' },
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа' },
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён' },
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт' }
};
const badge = badges[status] || badges.open;
return (
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
<span>{badge.emoji}</span>
<span>{badge.text}</span>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
{badge.text}
</span>
);
};
const getPriorityBadge = (priority: string) => {
const badges: Record<string, { color: string; text: string; emoji: string }> = {
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно', emoji: '🔴' },
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий', emoji: '🟠' },
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный', emoji: '⚪' },
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий', emoji: '🟢' }
const badges: Record<string, { color: string; text: string }> = {
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно' },
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий' },
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный' },
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий' }
};
const badge = badges[priority] || badges.normal;
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
<span>{badge.emoji}</span>
<span>{badge.text}</span>
<span className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
{badge.text}
</span>
);
};
@@ -216,7 +214,6 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
{/* Tickets Grid */}
{tickets.length === 0 ? (
<div className="bg-white rounded-xl shadow-md p-12 text-center">
<div className="text-6xl mb-4">📭</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
<button
@@ -253,19 +250,10 @@ const TicketsPage: React.FC<TicketsPageProps> = () => {
{/* Footer */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<div className="flex items-center gap-4 text-sm text-gray-600">
<span className="flex items-center gap-1">
<span>🕒</span>
<span>{formatRelativeTime(ticket.updatedAt)}</span>
</span>
<span className="flex items-center gap-1">
<span>💬</span>
<span>{ticket.responses?.length || 0} ответов</span>
</span>
<span>{formatRelativeTime(ticket.updatedAt)}</span>
<span>{ticket.responses?.length || 0} ответов</span>
{ticket.closedAt && (
<span className="flex items-center gap-1">
<span>🔒</span>
<span>Закрыт</span>
</span>
<span>Закрыт</span>
)}
</div>
<span className="text-blue-500 hover:text-blue-600 font-medium">

View File

@@ -1,138 +1,237 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useContext, useEffect, useMemo, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import apiClient from '../../../utils/apiClient';
import AuthContext from '../../../context/authcontext';
import { useToast } from '../../../hooks/useToast';
interface Ticket {
interface TicketAuthor {
id: number;
username: string;
operator: boolean;
email?: string | null;
}
interface TicketAttachment {
id: number;
filename: string;
fileUrl: string;
fileSize: number;
mimeType: string;
createdAt: string;
}
interface TicketResponse {
id: number;
message: string;
isInternal: boolean;
createdAt: string;
author: TicketAuthor | null;
attachments: TicketAttachment[];
}
interface TicketDetail {
id: number;
title: string;
message: string;
status: string;
priority: string;
category: string;
user: TicketAuthor | null;
assignedTo: number | null;
assignedOperator: TicketAuthor | null;
createdAt: string;
updatedAt: string;
closedAt?: string;
assignedTo?: number;
user: {
id: number;
username: string;
};
closedAt: string | null;
responseCount: number;
lastResponseAt: string | null;
attachments: TicketAttachment[];
responses: TicketResponse[];
}
interface Response {
id: number;
message: string;
isInternal: boolean;
createdAt: string;
user: {
id: number;
username: string;
operator: boolean;
};
}
const STATUS_LABELS: Record<string, { text: string; badge: string }> = {
open: { text: 'Открыт', badge: 'bg-green-100 text-green-800' },
in_progress: { text: 'В работе', badge: 'bg-blue-100 text-blue-800' },
awaiting_reply: { text: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
resolved: { text: 'Решён', badge: 'bg-purple-100 text-purple-800' },
closed: { text: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
};
const TicketDetailPage: React.FC = () => {
const PRIORITY_LABELS: Record<string, { text: string; badge: string }> = {
urgent: { text: 'Срочно', badge: 'bg-red-100 text-red-800' },
high: { text: 'Высокий', badge: 'bg-orange-100 text-orange-800' },
normal: { text: 'Обычный', badge: 'bg-gray-100 text-gray-800' },
low: { text: 'Низкий', badge: 'bg-green-100 text-green-800' },
};
const TicketDetailPage = () => {
const { id } = useParams<{ id: string }>();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [responses, setResponses] = useState<Response[]>([]);
const [loading, setLoading] = useState(true);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
const navigate = useNavigate();
const { userData } = useContext(AuthContext);
const { addToast } = useToast();
useEffect(() => {
fetchTicket();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const isOperator = Boolean(userData?.user?.operator);
const currentUserId = userData?.user?.id ?? null;
const [ticket, setTicket] = useState<TicketDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [reply, setReply] = useState('');
const [sending, setSending] = useState(false);
const [statusProcessing, setStatusProcessing] = useState(false);
const [assigning, setAssigning] = useState(false);
const [isInternalNote, setIsInternalNote] = useState(false);
const ticketId = Number(id);
const fetchTicket = async () => {
try {
const response = await apiClient.get(`/api/ticket/${id}`);
setTicket(response.data.ticket);
setResponses(response.data.ticket.responses || []);
if (!ticketId) {
setError('Некорректный идентификатор тикета');
setLoading(false);
} catch (error) {
console.error('Ошибка загрузки тикета:', error);
return;
}
setLoading(true);
setError('');
try {
const response = await apiClient.get(`/api/ticket/${ticketId}`);
const payload: TicketDetail | null = response.data?.ticket ?? null;
setTicket(payload);
} catch (err) {
console.error('Ошибка загрузки тикета:', err);
setError('Не удалось загрузить тикет');
} finally {
setLoading(false);
}
};
const sendResponse = async () => {
if (!newMessage.trim()) return;
useEffect(() => {
fetchTicket();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ticketId]);
const formatDateTime = (value: string | null) => {
if (!value) {
return '—';
}
try {
return new Date(value).toLocaleString('ru-RU');
} catch {
return value;
}
};
const handleSendReply = async () => {
if (!ticketId || !reply.trim()) {
setReply((prev) => prev.trim());
return;
}
setSending(true);
try {
await apiClient.post('/api/ticket/respond', {
ticketId: id,
message: newMessage
ticketId,
message: reply.trim(),
...(isOperator ? { isInternal: isInternalNote } : {}),
});
setNewMessage('');
setReply('');
setIsInternalNote(false);
addToast('Ответ отправлен', 'success');
fetchTicket();
} catch (error) {
console.error('Ошибка отправки ответа:', error);
alert('Не удалось отправить ответ');
} catch (err) {
console.error('Ошибка отправки ответа:', err);
addToast('Не удалось отправить ответ', 'error');
} finally {
setSending(false);
}
};
const closeTicket = async () => {
if (!confirm('Вы уверены, что хотите закрыть этот тикет?')) return;
const handleCloseTicket = async () => {
if (!ticketId) return;
const confirmation = window.confirm('Вы уверены, что хотите закрыть тикет?');
if (!confirmation) return;
setStatusProcessing(true);
try {
await apiClient.post('/api/ticket/close', { ticketId: id });
await apiClient.post('/api/ticket/close', { ticketId });
addToast('Тикет закрыт', 'success');
fetchTicket();
alert('Тикет успешно закрыт');
} catch (error) {
console.error('Ошибка закрытия тикета:', error);
alert('Не удалось закрыть тикет');
} catch (err) {
console.error('Ошибка закрытия тикета:', err);
addToast('Не удалось закрыть тикет', 'error');
} finally {
setStatusProcessing(false);
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { color: string; text: string; emoji: string }> = {
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
};
const handleUpdateStatus = async (status: string) => {
if (!ticketId) return;
const badge = badges[status] || badges.open;
return (
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-medium ${badge.color}`}>
<span>{badge.emoji}</span>
<span>{badge.text}</span>
</span>
);
setStatusProcessing(true);
try {
await apiClient.post('/api/ticket/status', { ticketId, status });
addToast('Статус обновлён', 'success');
fetchTicket();
} catch (err) {
console.error('Ошибка изменения статуса:', err);
addToast('Не удалось изменить статус', 'error');
} finally {
setStatusProcessing(false);
}
};
const getPriorityBadge = (priority: string) => {
const badges: Record<string, { color: string; text: string }> = {
urgent: { color: 'bg-red-100 text-red-800', text: 'Срочно 🔴' },
high: { color: 'bg-orange-100 text-orange-800', text: 'Высокий 🟠' },
normal: { color: 'bg-gray-100 text-gray-800', text: 'Обычный ⚪' },
low: { color: 'bg-green-100 text-green-800', text: 'Низкий 🟢' }
};
const handleAssignToMe = async () => {
if (!ticketId || !currentUserId) return;
const badge = badges[priority] || badges.normal;
setAssigning(true);
try {
await apiClient.post('/api/ticket/assign', { ticketId, operatorId: currentUserId });
addToast('Тикет назначен на вас', 'success');
fetchTicket();
} catch (err) {
console.error('Ошибка назначения тикета:', err);
addToast('Не удалось назначить тикет', 'error');
} finally {
setAssigning(false);
}
};
const statusChip = useMemo(() => {
if (!ticket) {
return null;
}
const meta = STATUS_LABELS[ticket.status] ?? STATUS_LABELS.open;
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${badge.color}`}>
{badge.text}
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${meta.badge}`}>
<span>{meta.text}</span>
</span>
);
};
}, [ticket]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">Загрузка тикета...</p>
<div className="mx-auto h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<p className="mt-4 text-sm text-gray-600">Загрузка тикета...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="max-w-md rounded-2xl border border-red-200 bg-red-50 p-6 text-center text-red-600">
<h2 className="text-lg font-semibold">Ошибка</h2>
<p className="mt-2 text-sm">{error}</p>
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-red-700">
Вернуться к тикетам
</Link>
</div>
</div>
);
@@ -140,130 +239,212 @@ const TicketDetailPage: React.FC = () => {
if (!ticket) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Тикет не найден</h2>
<Link
to="/dashboard/tickets"
className="text-blue-500 hover:text-blue-600 font-medium"
>
Вернуться к списку тикетов
<div className="flex min-h-screen items-center justify-center px-4">
<div className="max-w-md rounded-2xl border border-gray-200 bg-white p-6 text-center shadow-sm">
<h2 className="text-lg font-semibold text-gray-900">Тикет не найден</h2>
<p className="mt-2 text-sm text-gray-600">Возможно, он был удалён или у вас нет доступа.</p>
<Link to="/dashboard/tickets" className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-blue-600">
Вернуться к списку
</Link>
</div>
</div>
);
}
const priorityMeta = PRIORITY_LABELS[ticket.priority] ?? PRIORITY_LABELS.normal;
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Back Button */}
<Link
to="/dashboard/tickets"
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-4">
<button
type="button"
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition hover:text-gray-800"
>
<span></span>
<span>Назад к тикетам</span>
</Link>
Назад
</button>
{/* Ticket Header */}
<div className="bg-white rounded-xl shadow-md p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="rounded-2xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-900 mb-2">{ticket.title}</h1>
<div className="flex items-center gap-3">
{getStatusBadge(ticket.status)}
{getPriorityBadge(ticket.priority)}
<span className="text-sm text-gray-600">
Категория: {ticket.category}
<h1 className="text-2xl font-bold text-gray-900">{ticket.title}</h1>
<div className="mt-3 flex flex-wrap items-center gap-3">
{statusChip}
<span className={`inline-flex items-center rounded-full px-3 py-1 text-sm font-medium ${priorityMeta.badge}`}>
{priorityMeta.text}
</span>
<span className="text-sm text-gray-500">Категория: {ticket.category}</span>
{ticket.assignedOperator && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
{ticket.assignedOperator.username}
</span>
)}
</div>
</div>
{ticket.status !== 'closed' && (
<div className="flex flex-col gap-2 text-sm text-gray-600">
<span>Создан: {formatDateTime(ticket.createdAt)}</span>
<span>Обновлён: {formatDateTime(ticket.updatedAt)}</span>
{ticket.closedAt && <span>Закрыт: {formatDateTime(ticket.closedAt)}</span>}
</div>
</div>
<div className="mt-6 rounded-xl border border-gray-100 bg-gray-50 p-5 text-gray-700">
<p className="whitespace-pre-wrap text-sm leading-relaxed">{ticket.message}</p>
</div>
{ticket.attachments.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-semibold text-gray-700">Вложенные файлы</h3>
<ul className="mt-2 flex flex-wrap gap-3 text-sm text-blue-600">
{ticket.attachments.map((attachment) => (
<li key={attachment.id}>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
📎 {attachment.filename}
</a>
</li>
))}
</ul>
</div>
)}
<div className="mt-6 flex flex-wrap items-center gap-3">
{isOperator && ticket.status !== 'closed' && ticket.assignedTo !== currentUserId && (
<button
onClick={closeTicket}
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium transition-colors duration-200"
type="button"
onClick={handleAssignToMe}
disabled={assigning}
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Закрыть тикет
{assigning ? 'Назначаю...' : 'Взять в работу'}
</button>
)}
{ticket.status !== 'closed' && (
<>
{isOperator && (
<button
type="button"
onClick={() => handleUpdateStatus('resolved')}
disabled={statusProcessing}
className="inline-flex items-center gap-2 rounded-xl border border-green-200 px-4 py-2 text-sm font-semibold text-green-600 transition hover:border-green-300 hover:bg-green-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{statusProcessing ? 'Сохранение...' : 'Отметить как решён'}
</button>
)}
<button
type="button"
onClick={handleCloseTicket}
disabled={statusProcessing}
className="inline-flex items-center gap-2 rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Закрыть тикет
</button>
</>
)}
{isOperator && ticket.status === 'closed' && (
<button
type="button"
onClick={() => handleUpdateStatus('in_progress')}
disabled={statusProcessing}
className="inline-flex items-center gap-2 rounded-xl border border-blue-200 px-4 py-2 text-sm font-semibold text-blue-600 transition hover:border-blue-300 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Возобновить работу
</button>
)}
</div>
</div>
<div className="border-t border-gray-200 pt-4 mt-4">
<p className="text-gray-700 whitespace-pre-wrap">{ticket.message}</p>
</div>
<div className="rounded-2xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900">История общения</h2>
<div className="mt-4 space-y-4">
{ticket.responses.length === 0 ? (
<p className="rounded-xl border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
Ответов пока нет. Напишите первое сообщение, чтобы ускорить решение.
</p>
) : (
ticket.responses.map((response) => {
const isCurrentUser = response.author?.id === currentUserId;
const isResponseOperator = Boolean(response.author?.operator);
<div className="flex items-center gap-4 mt-4 text-sm text-gray-600">
<span>Создан: {new Date(ticket.createdAt).toLocaleString('ru-RU')}</span>
{ticket.closedAt && (
<span>Закрыт: {new Date(ticket.closedAt).toLocaleString('ru-RU')}</span>
return (
<div
key={response.id}
className={`rounded-xl border border-gray-100 p-5 ${
response.isInternal ? 'bg-yellow-50 border-yellow-200' : isCurrentUser ? 'bg-blue-50 border-blue-100' : 'bg-gray-50'
}`}
>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-600">
<span className="font-semibold text-gray-900">{response.author?.username ?? 'Неизвестно'}</span>
{isResponseOperator && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
Оператор
</span>
)}
{response.isInternal && (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-700">
Внутренний комментарий
</span>
)}
<span className="text-xs text-gray-500">{formatDateTime(response.createdAt)}</span>
</div>
<p className="mt-3 whitespace-pre-wrap text-sm text-gray-800">{response.message}</p>
{response.attachments.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2 text-sm text-blue-600">
{response.attachments.map((attachment) => (
<li key={attachment.id}>
<a href={attachment.fileUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-3 py-1 text-blue-700 hover:bg-blue-100">
📎 {attachment.filename}
</a>
</li>
))}
</ul>
)}
</div>
);
})
)}
</div>
</div>
{/* Responses */}
<div className="space-y-4 mb-6">
{responses.map((response) => (
<div
key={response.id}
className={`bg-white rounded-xl shadow-md p-6 ${
response.isInternal ? 'bg-yellow-50 border-2 border-yellow-200' : ''
}`}
>
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
{response.user.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold text-gray-900">
{response.user.username}
</span>
{response.user.operator && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
Оператор
</span>
)}
{response.isInternal && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
🔒 Внутренний комментарий
</span>
)}
<span className="text-sm text-gray-600">
{new Date(response.createdAt).toLocaleString('ru-RU')}
</span>
</div>
<p className="text-gray-700 whitespace-pre-wrap">{response.message}</p>
</div>
</div>
</div>
))}
</div>
{/* New Response Form */}
{ticket.status !== 'closed' && (
<div className="bg-white rounded-xl shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Добавить ответ</h3>
<div className="rounded-2xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900">Новый ответ</h2>
<textarea
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="Введите ваш ответ..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={5}
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder="Опишите детали, приложите решение или уточнение..."
className="mt-3 w-full rounded-xl border border-gray-200 px-4 py-3 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
rows={6}
/>
<div className="flex items-center justify-end gap-3 mt-4">
{isOperator && (
<label className="mt-3 inline-flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={isInternalNote}
onChange={(event) => setIsInternalNote(event.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Внутренний комментарий (видно только операторам)
</label>
)}
<div className="mt-4 flex flex-wrap items-center justify-end gap-3">
<button
onClick={() => setNewMessage('')}
className="px-6 py-2 text-gray-700 hover:text-gray-900 font-medium transition-colors"
disabled={sending}
type="button"
onClick={() => setReply('')}
disabled={sending || reply.length === 0}
className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
Очистить
</button>
<button
onClick={sendResponse}
disabled={sending || !newMessage.trim()}
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
onClick={handleSendReply}
disabled={sending || !reply.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{sending ? 'Отправка...' : 'Отправить'}
</button>

View File

@@ -1,167 +1,250 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AuthContext from '../../../context/authcontext';
import apiClient from '../../../utils/apiClient';
import { useToast } from '../../../hooks/useToast';
interface Ticket {
interface TicketAuthor {
id: number;
username: string;
operator: boolean;
email?: string | null;
}
interface TicketAttachment {
id: number;
filename: string;
fileUrl: string;
fileSize: number;
mimeType: string;
createdAt: string;
}
interface TicketResponse {
id: number;
message: string;
isInternal: boolean;
createdAt: string;
author: TicketAuthor | null;
attachments: TicketAttachment[];
}
interface TicketItem {
id: number;
title: string;
message: string;
status: string;
priority: string;
category: string;
user: TicketAuthor | null;
assignedTo: number | null;
assignedOperator: TicketAuthor | null;
createdAt: string;
updatedAt: string;
responses: Response[];
assignedTo?: number;
closedAt?: string;
closedAt: string | null;
responseCount: number;
lastResponseAt: string | null;
attachments: TicketAttachment[];
responses: TicketResponse[];
}
interface Response {
id: number;
message: string;
isInternal: boolean;
createdAt: string;
userId: number;
user: {
username: string;
operator: boolean;
};
interface TicketListMeta {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasMore: boolean;
}
const TicketsPage: React.FC = () => {
const [tickets, setTickets] = useState<Ticket[]>([]);
interface TicketStats {
open: number;
inProgress: number;
awaitingReply: number;
resolved: number;
closed: number;
assignedToMe?: number;
unassigned?: number;
}
const STATUS_DICTIONARY: Record<string, { label: string; badge: string }> = {
open: { label: 'Открыт', badge: 'bg-green-100 text-green-800' },
in_progress: { label: 'В работе', badge: 'bg-blue-100 text-blue-800' },
awaiting_reply: { label: 'Ожидает ответа', badge: 'bg-yellow-100 text-yellow-800' },
resolved: { label: 'Решён', badge: 'bg-purple-100 text-purple-800' },
closed: { label: 'Закрыт', badge: 'bg-gray-100 text-gray-800' },
};
const PRIORITY_DICTIONARY: Record<string, { label: string; badge: string }> = {
urgent: { label: 'Срочно', badge: 'bg-red-50 text-red-700 border border-red-200' },
high: { label: 'Высокий', badge: 'bg-orange-50 text-orange-700 border border-orange-200' },
normal: { label: 'Обычный', badge: 'bg-gray-50 text-gray-700 border border-gray-200' },
low: { label: 'Низкий', badge: 'bg-green-50 text-green-700 border border-green-200' },
};
const TicketsPage = () => {
const navigate = useNavigate();
const { userData } = useContext(AuthContext);
const { addToast } = useToast();
const isOperator = Boolean(userData?.user?.operator);
const [tickets, setTickets] = useState<TicketItem[]>([]);
const [meta, setMeta] = useState<TicketListMeta>({ page: 1, pageSize: 10, total: 0, totalPages: 1, hasMore: false });
const [stats, setStats] = useState<TicketStats>({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filters, setFilters] = useState({
status: 'all',
category: 'all',
priority: 'all'
priority: 'all',
assigned: 'all',
});
const [searchInput, setSearchInput] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
// Debounce search input to avoid flooding the API while typing
const timer = window.setTimeout(() => {
setDebouncedSearch(searchInput.trim());
}, 350);
return () => window.clearTimeout(timer);
}, [searchInput]);
useEffect(() => {
setMeta((prev) => (prev.page === 1 ? prev : { ...prev, page: 1 }));
}, [filters.status, filters.category, filters.priority, filters.assigned, debouncedSearch]);
useEffect(() => {
let isMounted = true;
const fetchTickets = async () => {
setLoading(true);
setError('');
try {
const params: Record<string, string | number> = {
page: meta.page,
pageSize: meta.pageSize,
};
if (filters.status !== 'all') params.status = filters.status;
if (filters.category !== 'all') params.category = filters.category;
if (filters.priority !== 'all') params.priority = filters.priority;
if (debouncedSearch) params.search = debouncedSearch;
if (isOperator && filters.assigned !== 'all') params.assigned = filters.assigned;
const response = await apiClient.get('/api/ticket', { params });
if (!isMounted) return;
const payload = response.data ?? {};
setTickets(Array.isArray(payload.tickets) ? payload.tickets : []);
setMeta((prev) => ({
...prev,
...(payload.meta ?? {}),
}));
setStats(payload.stats ?? { open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
} catch (err) {
if (!isMounted) return;
console.error('Ошибка загрузки тикетов:', err);
setError('Не удалось загрузить тикеты');
addToast('Не удалось загрузить тикеты. Попробуйте позже.', 'error');
setTickets([]);
setMeta((prev) => ({ ...prev, page: 1, total: 0, totalPages: 1, hasMore: false }));
setStats({ open: 0, inProgress: 0, awaitingReply: 0, resolved: 0, closed: 0 });
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchTickets();
return () => {
isMounted = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters]);
}, [meta.page, meta.pageSize, filters.status, filters.category, filters.priority, filters.assigned, debouncedSearch, isOperator]);
const fetchTickets = async () => {
try {
const params: Record<string, string> = {};
if (filters.status !== 'all') params.status = filters.status;
if (filters.category !== 'all') params.category = filters.category;
if (filters.priority !== 'all') params.priority = filters.priority;
const response = await apiClient.get('/api/ticket', { params });
setTickets(response.data.tickets || []);
setLoading(false);
} catch (error) {
console.error('Ошибка загрузки тикетов:', error);
setLoading(false);
const formatRelativeTime = (dateString: string | null) => {
if (!dateString) {
return '—';
}
};
const getStatusBadge = (status: string) => {
const badges: Record<string, { color: string; text: string; emoji: string }> = {
open: { color: 'bg-green-100 text-green-800', text: 'Открыт', emoji: '🟢' },
in_progress: { color: 'bg-blue-100 text-blue-800', text: 'В работе', emoji: '🔵' },
awaiting_reply: { color: 'bg-yellow-100 text-yellow-800', text: 'Ожидает ответа', emoji: '🟡' },
resolved: { color: 'bg-purple-100 text-purple-800', text: 'Решён', emoji: '🟣' },
closed: { color: 'bg-gray-100 text-gray-800', text: 'Закрыт', emoji: '⚪' }
};
const badge = badges[status] || badges.open;
return (
<span className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium ${badge.color}`}>
<span>{badge.emoji}</span>
<span>{badge.text}</span>
</span>
);
};
const getPriorityBadge = (priority: string) => {
const badges: Record<string, { color: string; text: string; emoji: string }> = {
urgent: { color: 'bg-red-100 text-red-800 border-red-300', text: 'Срочно', emoji: '🔴' },
high: { color: 'bg-orange-100 text-orange-800 border-orange-300', text: 'Высокий', emoji: '🟠' },
normal: { color: 'bg-gray-100 text-gray-800 border-gray-300', text: 'Обычный', emoji: '⚪' },
low: { color: 'bg-green-100 text-green-800 border-green-300', text: 'Низкий', emoji: '🟢' }
};
const badge = badges[priority] || badges.normal;
return (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium border ${badge.color}`}>
<span>{badge.emoji}</span>
<span>{badge.text}</span>
</span>
);
};
const getCategoryIcon = (category: string) => {
const icons: Record<string, string> = {
general: '💬',
technical: '⚙️',
billing: '💰',
other: '📝'
};
return icons[category] || icons.general;
};
const formatRelativeTime = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин. назад`;
if (diffHours < 24) return `${diffHours} ч. назад`;
if (diffDays < 7) return `${diffDays} дн. назад`;
if (diffMinutes < 1) return 'только что';
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
if (diffHours < 24) return `${diffHours} ч назад`;
if (diffDays < 7) return `${diffDays} дн назад`;
return date.toLocaleDateString('ru-RU');
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">Загрузка тикетов...</p>
</div>
</div>
);
}
const statusCards = useMemo(() => {
if (isOperator) {
return [
{ title: 'Открытые', value: stats.open, accent: 'bg-green-50 text-green-700 border border-green-100' },
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
{ title: 'Назначены мне', value: stats.assignedToMe ?? 0, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
{ title: 'Без оператора', value: stats.unassigned ?? 0, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
];
}
return [
{ title: 'Активные', value: stats.open + stats.inProgress, accent: 'bg-blue-50 text-blue-700 border border-blue-100' },
{ title: 'Ожидают ответа', value: stats.awaitingReply, accent: 'bg-yellow-50 text-yellow-700 border border-yellow-100' },
{ title: 'Закрытые', value: stats.closed + stats.resolved, accent: 'bg-gray-50 text-gray-700 border border-gray-200' },
];
}, [isOperator, stats]);
const handleChangePage = (nextPage: number) => {
setMeta((prev) => ({ ...prev, page: nextPage }));
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl mx-auto px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Тикеты поддержки</h1>
<p className="text-gray-600">Управляйте вашими обращениями в службу поддержки</p>
<h1 className="text-3xl font-bold text-gray-900">Тикеты поддержки</h1>
<p className="text-gray-600">Создавайте обращения и следите за их обработкой в режиме реального времени.</p>
</div>
<Link
to="/dashboard/tickets/new"
className="bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200 flex items-center gap-2"
<button
onClick={() => navigate('/dashboard/tickets/new')}
className="inline-flex items-center gap-2 rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
>
<span></span>
Создать тикет
</Link>
Новый тикет
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-md p-6 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Статус</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{statusCards.map((card) => (
<div key={card.title} className={`rounded-xl p-4 shadow-sm ${card.accent}`}>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold">{card.title}</span>
</div>
<div className="mt-2 text-3xl font-bold">{card.value}</div>
</div>
))}
</div>
<div className="rounded-2xl bg-white p-6 shadow-sm">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-6">
<div className="lg:col-span-2">
<label className="mb-2 block text-sm font-medium text-gray-700">Статус</label>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(event) => setFilters((prev) => ({ ...prev, status: event.target.value }))}
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
>
<option value="all">Все статусы</option>
<option value="open">Открыт</option>
@@ -171,14 +254,12 @@ const TicketsPage: React.FC = () => {
<option value="closed">Закрыт</option>
</select>
</div>
{/* Category Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Категория</label>
<div className="lg:col-span-2">
<label className="mb-2 block text-sm font-medium text-gray-700">Категория</label>
<select
value={filters.category}
onChange={(e) => setFilters({ ...filters, category: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(event) => setFilters((prev) => ({ ...prev, category: event.target.value }))}
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
>
<option value="all">Все категории</option>
<option value="general">Общие вопросы</option>
@@ -187,14 +268,12 @@ const TicketsPage: React.FC = () => {
<option value="other">Другое</option>
</select>
</div>
{/* Priority Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Приоритет</label>
<div className="lg:col-span-2">
<label className="mb-2 block text-sm font-medium text-gray-700">Приоритет</label>
<select
value={filters.priority}
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(event) => setFilters((prev) => ({ ...prev, priority: event.target.value }))}
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
>
<option value="all">Все приоритеты</option>
<option value="urgent">Срочно</option>
@@ -203,71 +282,147 @@ const TicketsPage: React.FC = () => {
<option value="low">Низкий</option>
</select>
</div>
{isOperator && (
<div className="lg:col-span-2">
<label className="mb-2 block text-sm font-medium text-gray-700">Назначение</label>
<select
value={filters.assigned}
onChange={(event) => setFilters((prev) => ({ ...prev, assigned: event.target.value }))}
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
>
<option value="all">Все</option>
<option value="me">Мои тикеты</option>
<option value="unassigned">Без оператора</option>
<option value="others">Назначены другим</option>
</select>
</div>
)}
<div className={isOperator ? 'lg:col-span-4' : 'lg:col-span-6'}>
<label className="mb-2 block text-sm font-medium text-gray-700">Поиск</label>
<input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Поиск по теме или описанию..."
className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</div>
</div>
</div>
{/* Tickets Grid */}
{tickets.length === 0 ? (
<div className="bg-white rounded-xl shadow-md p-12 text-center">
<div className="text-6xl mb-4">📭</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Нет тикетов</h3>
<p className="text-gray-600 mb-6">У вас пока нет открытых тикетов поддержки</p>
<Link
to="/dashboard/tickets/new"
className="inline-block bg-blue-500 hover:bg-blue-600 text-white px-6 py-3 rounded-lg font-medium transition-colors duration-200"
>
Создать первый тикет
</Link>
</div>
) : (
<div className="grid grid-cols-1 gap-6">
{tickets.map((ticket) => (
<div className="rounded-2xl bg-white shadow-sm">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 py-20">
<div className="h-12 w-12 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<p className="text-sm text-gray-500">Загрузка тикетов...</p>
</div>
) : tickets.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<h3 className="text-lg font-semibold text-gray-900">Тикетов пока нет</h3>
<p className="max-w-md text-sm text-gray-500">
Создайте тикет, чтобы команда поддержки могла помочь. Мы всегда рядом.
</p>
<Link
key={ticket.id}
to={`/dashboard/tickets/${ticket.id}`}
className="bg-white rounded-xl shadow-md hover:shadow-lg transition-all duration-200 overflow-hidden"
to="/dashboard/tickets/new"
className="rounded-xl bg-blue-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
>
<div className="p-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="text-2xl">{getCategoryIcon(ticket.category)}</span>
<h3 className="text-xl font-semibold text-gray-900">{ticket.title}</h3>
{getPriorityBadge(ticket.priority)}
</div>
<p className="text-gray-600 line-clamp-2">{ticket.message.substring(0, 150)}...</p>
</div>
<div className="ml-4">
{getStatusBadge(ticket.status)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<div className="flex items-center gap-4 text-sm text-gray-600">
<span className="flex items-center gap-1">
<span>🕒</span>
<span>{formatRelativeTime(ticket.updatedAt)}</span>
</span>
<span className="flex items-center gap-1">
<span>💬</span>
<span>{ticket.responses?.length || 0} ответов</span>
</span>
{ticket.closedAt && (
<span className="flex items-center gap-1">
<span>🔒</span>
<span>Закрыт</span>
</span>
)}
</div>
<span className="text-blue-500 hover:text-blue-600 font-medium">
Открыть
</span>
</div>
</div>
Создать первый тикет
</Link>
))}
</div>
) : (
<>
<div className="hidden w-full grid-cols-[100px_1fr_160px_160px_160px] gap-4 border-b border-gray-100 px-6 py-3 text-xs font-semibold uppercase tracking-wide text-gray-500 lg:grid">
<span>ID</span>
<span>Тема</span>
<span>Статус</span>
<span>Приоритет</span>
<span>Обновлён</span>
</div>
<ul className="divide-y divide-gray-100">
{tickets.map((ticket) => {
const statusMeta = STATUS_DICTIONARY[ticket.status] ?? STATUS_DICTIONARY.open;
const priorityMeta = PRIORITY_DICTIONARY[ticket.priority] ?? PRIORITY_DICTIONARY.normal;
return (
<li key={ticket.id}>
<button
type="button"
onClick={() => navigate(`/dashboard/tickets/${ticket.id}`)}
className="w-full px-6 py-4 text-left transition hover:bg-gray-50"
>
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-[100px_1fr_160px_160px_160px] lg:items-center lg:gap-4">
<span className="text-sm font-semibold text-gray-500">#{ticket.id}</span>
<div>
<div className="flex items-center gap-2 text-base font-semibold text-gray-900">
<span className="line-clamp-1">{ticket.title}</span>
</div>
<p className="mt-1 line-clamp-2 text-sm text-gray-500">{ticket.message}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
{ticket.assignedOperator && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2.5 py-1 text-blue-700">
{ticket.assignedOperator.username}
</span>
)}
{ticket.responseCount > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-600">
{ticket.responseCount}
</span>
)}
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-gray-500">
{ticket.user?.username ?? 'Неизвестно'}
</span>
</div>
</div>
<span className={`inline-flex items-center justify-start rounded-full px-3 py-1 text-xs font-semibold ${statusMeta.badge}`}>
{statusMeta.label}
</span>
<span className={`inline-flex items-center justify-start rounded-full px-3 py-1 text-xs font-semibold ${priorityMeta.badge}`}>
{priorityMeta.label}
</span>
<div className="text-sm text-gray-500">
<div>{formatRelativeTime(ticket.updatedAt)}</div>
{ticket.lastResponseAt && (
<div className="text-xs text-gray-400">Ответ: {formatRelativeTime(ticket.lastResponseAt)}</div>
)}
</div>
</div>
</button>
</li>
);
})}
</ul>
<div className="flex flex-col items-center justify-between gap-3 border-t border-gray-100 px-6 py-4 text-sm text-gray-600 md:flex-row">
<span>
Показано {(meta.page - 1) * meta.pageSize + 1}
{Math.min(meta.page * meta.pageSize, meta.total)} из {meta.total}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleChangePage(Math.max(1, meta.page - 1))}
disabled={meta.page === 1}
className="rounded-lg border border-gray-200 px-3 py-1 font-medium text-gray-600 transition disabled:cursor-not-allowed disabled:opacity-40 hover:bg-gray-100"
>
Назад
</button>
<span className="px-2 text-sm">Стр. {meta.page} / {meta.totalPages}</span>
<button
type="button"
onClick={() => handleChangePage(meta.page + 1)}
disabled={!meta.hasMore}
className="rounded-lg border border-gray-200 px-3 py-1 font-medium text-gray-600 transition disabled:cursor-not-allowed disabled:opacity-40 hover:bg-gray-100"
>
Вперёд
</button>
</div>
</div>
</>
)}
</div>
{error && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-600">
{error}
</div>
)}
</div>

View File

@@ -1,9 +1,11 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import apiClient from '../../../utils/apiClient';
import { useToast } from '../../../hooks/useToast';
const NewTicketPage: React.FC = () => {
const navigate = useNavigate();
const { addToast } = useToast();
const [formData, setFormData] = useState({
title: '',
message: '',
@@ -28,10 +30,13 @@ const NewTicketPage: React.FC = () => {
const response = await apiClient.post('/api/ticket/create', formData);
// Перенаправляем на созданный тикет
addToast('Тикет создан и отправлен в поддержку', 'success');
navigate(`/dashboard/tickets/${response.data.ticket.id}`);
} catch (err) {
console.error('Ошибка создания тикета:', err);
setError('Не удалось создать тикет. Попробуйте ещё раз.');
addToast('Не удалось создать тикет', 'error');
} finally {
setSending(false);
}
};
@@ -86,10 +91,10 @@ const NewTicketPage: React.FC = () => {
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="general">💬 Общие вопросы</option>
<option value="technical"> Технические</option>
<option value="billing">💰 Биллинг</option>
<option value="other">📝 Другое</option>
<option value="general">Общие вопросы</option>
<option value="technical">Технические</option>
<option value="billing">Биллинг</option>
<option value="other">Другое</option>
</select>
</div>
@@ -103,10 +108,10 @@ const NewTicketPage: React.FC = () => {
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="low">🟢 Низкий</option>
<option value="normal"> Обычный</option>
<option value="high">🟠 Высокий</option>
<option value="urgent">🔴 Срочно</option>
<option value="low">Низкий</option>
<option value="normal">Обычный</option>
<option value="high">Высокий</option>
<option value="urgent">Срочно</option>
</select>
</div>
</div>

View File

@@ -30,8 +30,61 @@ export interface StorageBucket {
region: string;
public: boolean;
versioning: boolean;
status: string;
monthlyPrice: number;
autoRenew: boolean;
createdAt: string;
updatedAt: string;
nextBillingDate?: string | null;
lastBilledAt?: string | null;
usageSyncedAt?: string | null;
consoleLogin?: string | null;
planDetails?: {
id: number;
code: string;
name: string;
price: number;
quotaGb: number;
bandwidthGb: number;
requestLimit: string;
description: string | null;
order: number;
isActive: boolean;
} | null;
regionDetails?: {
code: string;
name: string;
description: string | null;
endpoint: string | null;
isDefault: boolean;
isActive: boolean;
} | null;
storageClassDetails?: {
code: string;
name: string;
description: string | null;
redundancy: string | null;
performance: string | null;
retrievalFee: string | null;
isDefault: boolean;
isActive: boolean;
} | null;
consoleUrl?: string | null;
}
export interface StorageObject {
key: string;
size: number;
etag?: string;
lastModified?: string;
}
export interface StorageAccessKey {
id: number;
accessKey: string;
label?: string | null;
createdAt: string;
lastUsedAt?: string | null;
}
export interface UserData {

View File

@@ -181,42 +181,34 @@ const LoginPage = () => {
</div>
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="mt-6 grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => handleOAuthLogin('google')}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
aria-label="Войти через Google"
>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<span className="truncate">Google</span>
<img src="/google.png" alt="Google" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => handleOAuthLogin('github')}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
aria-label="Войти через GitHub"
>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd"/>
</svg>
<span className="truncate">GitHub</span>
<img src="/github.png" alt="GitHub" className="h-6 w-6" />
</button>
<button
type="button"
onClick={() => handleOAuthLogin('yandex')}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
aria-label="Войти через Yandex"
>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" viewBox="0 0 24 24">
<path fill="#FC3F1D" d="M13.04 1.5H8.87c-4.62 0-6.9 2.07-6.9 6.28v2.6c0 2.48.68 4.16 2.04 5.18L8.73 22.5h2.84l-4.56-6.56c-1.04-.8-1.56-2.16-1.56-4.16v-2.6c0-3.04 1.44-4.36 4.42-4.36h3.17c2.98 0 4.42 1.32 4.42 4.36v1.56h2.48v-1.56c0-4.21-2.28-6.28-6.9-6.28z"/>
</svg>
<span className="truncate">Yandex</span>
<img src="/yandex.png" alt="" className="h-6 w-6" />
</button>
</div>
</div>

View File

@@ -154,12 +154,7 @@ const RegisterPage = () => {
onClick={() => handleOAuthLogin('google')}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
<img src="/google.png" alt="Google" className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" />
<span className="truncate">Google</span>
</button>
@@ -168,9 +163,7 @@ const RegisterPage = () => {
onClick={() => handleOAuthLogin('github')}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd"/>
</svg>
<img src="/github.png" alt="GitHub" className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" />
<span className="truncate">GitHub</span>
</button>
@@ -179,8 +172,12 @@ const RegisterPage = () => {
onClick={() => handleOAuthLogin('yandex')}
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" viewBox="0 0 24 24">
<path fill="#FC3F1D" d="M13.04 1.5H8.87c-4.62 0-6.9 2.07-6.9 6.28v2.6c0 2.48.68 4.16 2.04 5.18L8.73 22.5h2.84l-4.56-6.56c-1.04-.8-1.56-2.16-1.56-4.16v-2.6c0-3.04 1.44-4.36 4.42-4.36h3.17c2.98 0 4.42 1.32 4.42 4.36v1.56h2.48v-1.56c0-4.21-2.28-6.28-6.9-6.28z"/>
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="4" y="4" width="40" height="40" rx="12" fill="#000000" />
<path
d="M25.92 11.5h-5.04c-6.16 0-9.18 2.8-9.18 8.56v3.54c0 3.36.92 5.56 2.72 6.94l7.56 6.96h3.76l-6.08-8.8c-1.32-1.08-1.96-2.9-1.96-5.6v-3.54c0-4.08 1.82-5.84 5.62-5.84h4.06c3.8 0 5.62 1.76 5.62 5.84v2.1h3.16v-2.1c0-5.76-3.08-8.56-9.24-8.56z"
fill="#FFFFFF"
/>
</svg>
<span className="truncate">Yandex</span>
</button>

View File

@@ -1,320 +1,489 @@
import { Link } from 'react-router-dom';
import { FaDatabase, FaCheck, FaArrowRight, FaShieldAlt, FaBolt, FaInfinity } from 'react-icons/fa';
import { Link, useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import {
FaDatabase,
FaCheck,
FaArrowRight,
FaShieldAlt,
FaBolt,
FaInfinity,
FaCloud,
FaLock
} from 'react-icons/fa';
import apiClient from '../utils/apiClient';
import { API_URL } from '../config/api';
type StoragePlanDto = {
id: number;
code: string;
name: string;
price: number;
pricePerGb?: number;
bandwidthPerGb?: number;
requestsPerGb?: number;
quotaGb: number;
bandwidthGb: number;
requestLimit: string;
description: string | null;
order: number;
isActive: boolean;
};
type DecoratedPlan = StoragePlanDto & {
tier: string;
highlights: string[];
};
const TIER_LABELS = ['Developer', 'Team', 'Scale', 'Enterprise'];
const BASE_FEATURES = [
'S3-совместимый API и совместимость с AWS SDK',
'Развёртывание в регионе ru-central-1',
'Версионирование и presigned URL',
'Управление доступом через Access Key/Secret Key',
'Уведомления и мониторинг в панели клиента'
];
const formatMetric = (value: number, suffix: string) => `${value.toLocaleString('ru-RU')} ${suffix}`;
const S3PlansPage = () => {
const plans = [
{
name: 'Starter',
price: 99,
storage: '10 GB',
bandwidth: '50 GB',
requests: '10,000',
features: [
'S3-совместимый API',
'Публичные и приватные бакеты',
'SSL/TLS шифрование',
'Версионирование файлов',
'CDN интеграция',
'Web-интерфейс управления'
],
popular: false
},
{
name: 'Professional',
price: 299,
storage: '50 GB',
bandwidth: '250 GB',
requests: '100,000',
features: [
'Всё из Starter',
'Lifecycle политики',
'Cross-region репликация',
'Object Lock (WORM)',
'Расширенная статистика',
'Priority поддержка',
'SLA 99.9%'
],
popular: true
},
{
name: 'Business',
price: 799,
storage: '200 GB',
bandwidth: '1 TB',
requests: '500,000',
features: [
'Всё из Professional',
'Приватная сеть',
'Кастомные домены',
'Webhook уведомления',
'Audit логи',
'Deduplicate storage',
'SLA 99.95%'
],
popular: false
},
{
name: 'Enterprise',
price: 1999,
storage: '1 TB',
bandwidth: '5 TB',
requests: 'Unlimited',
features: [
'Всё из Business',
'Выделенные ресурсы',
'Geo-распределение',
'Custom retention policies',
'Персональный менеджер',
'White-label опции',
'SLA 99.99%'
],
popular: false
const navigate = useNavigate();
const [plans, setPlans] = useState<StoragePlanDto[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [selectingPlan, setSelectingPlan] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const loadPlans = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_URL}/api/storage/plans`);
if (!response.ok) {
throw new Error('Не удалось загрузить тарифы');
}
const data = await response.json();
if (!cancelled) {
setPlans(Array.isArray(data?.plans) ? data.plans : []);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Ошибка загрузки тарифов';
if (!cancelled) {
setError(message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadPlans();
return () => {
cancelled = true;
};
}, []);
const [customGbInput, setCustomGbInput] = useState<number>(100);
const orderedPlans = useMemo(() => {
return plans
.filter((plan) => plan.isActive && plan.code !== 'custom')
.sort((a, b) => a.order - b.order || a.price - b.price);
}, [plans]);
const customPlan = useMemo(() => {
return plans.find((p) => p.code === 'custom' && p.isActive);
}, [plans]);
const maxStorageGb = useMemo(() => {
return Math.max(250000, ...orderedPlans.map((p) => p.quotaGb));
}, [orderedPlans]);
const decoratedPlans = useMemo<DecoratedPlan[]>(() => {
return orderedPlans.map((plan, index) => {
const tierIndex = Math.min(TIER_LABELS.length - 1, Math.floor(index / 3));
return {
...plan,
tier: TIER_LABELS[tierIndex],
highlights: BASE_FEATURES,
};
});
}, [orderedPlans]);
const sections = useMemo(() => {
return TIER_LABELS.map((label) => ({
label,
items: decoratedPlans.filter((plan) => plan.tier === label),
})).filter((section) => section.items.length > 0);
}, [decoratedPlans]);
const customPlanCalculated = useMemo(() => {
if (!customPlan) return null;
const pricePerGb = customPlan.pricePerGb || 0.5;
const bandwidthPerGb = customPlan.bandwidthPerGb || 1.2;
const requestsPerGb = customPlan.requestsPerGb || 100000;
return {
...customPlan,
price: customGbInput * pricePerGb,
quotaGb: customGbInput,
bandwidthGb: Math.ceil(customGbInput * bandwidthPerGb),
requestLimit: (customGbInput * requestsPerGb).toLocaleString('ru-RU'),
};
}, [customPlan, customGbInput]);
const handleSelectPlan = async (plan: DecoratedPlan) => {
try {
setSelectingPlan(plan.code);
const payload: Record<string, unknown> = {
planId: plan.id,
planCode: plan.code.toLowerCase(),
};
// Если это custom план, добавляем количество GB
if (plan.code === 'custom') {
payload.customGb = customGbInput;
}
const response = await apiClient.post('/api/storage/checkout', payload);
const cartId = response.data?.cartId;
if (!cartId) {
throw new Error('Ответ сервера без идентификатора корзины');
}
navigate(`/dashboard/checkout?cart=${encodeURIComponent(cartId)}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Не удалось начать оплату';
setError(message);
} finally {
setSelectingPlan(null);
}
];
};
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
{/* Hero Section */}
<section className="pt-32 pb-20 px-8">
<section className="pt-32 pb-20 px-6 sm:px-8">
<div className="container mx-auto max-w-6xl text-center">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 rounded-full text-sm font-medium mb-6">
<FaDatabase />
<span>S3 Object Storage</span>
</div>
<h1 className="text-5xl md:text-6xl font-bold mb-6 text-gray-900">
Тарифы S3 Хранилища
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold mb-6 text-gray-900">
Прозрачные тарифы для любого объёма
</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto mb-8">
Масштабируемое объектное хранилище с S3-совместимым API.
Храните любые данные: от бэкапов до медиа-контента.
<p className="text-lg sm:text-xl text-gray-600 max-w-3xl mx-auto mb-8">
Оплачивайте только за необходимые ресурсы. 12 готовых тарифов для команд любого размера,
с включённым трафиком, запросами и приоритетной поддержкой.
</p>
<div className="flex flex-wrap justify-center gap-4 text-sm text-gray-500">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
<FaBolt className="text-blue-500" /> NVMe + 10Gb/s сеть
</span>
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
<FaLock className="text-emerald-500" /> AES-256 at-rest
</span>
<span className="inline-flex items-center gap-2 px-3 py-1 bg-white rounded-full shadow-sm">
<FaInfinity className="text-purple-500" /> S3-совместимый API
</span>
</div>
</div>
</section>
{/* Features Grid */}
<section className="py-16 px-8 bg-white">
<section className="py-16 px-6 sm:px-8 bg-white">
<div className="container mx-auto max-w-6xl">
<div className="grid md:grid-cols-3 gap-8 mb-16">
<div className="text-center p-6">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FaBolt className="text-3xl text-blue-600" />
<div className="grid md:grid-cols-3 gap-6">
<div className="p-6 bg-gray-50 rounded-xl">
<div className="w-14 h-14 bg-blue-100 rounded-full flex items-center justify-center mb-4">
<FaBolt className="text-2xl text-blue-600" />
</div>
<h3 className="text-lg font-semibold mb-2">Высокая скорость</h3>
<h3 className="text-lg font-semibold mb-2">Готовность к нагрузке</h3>
<p className="text-gray-600 text-sm">
NVMe SSD и 10Gb/s сеть для быстрого доступа к данным
Единая платформа на NVMe с автоматическим масштабированием, CDN-интеграцией и кросс-региональной репликацией.
</p>
</div>
<div className="text-center p-6">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FaShieldAlt className="text-3xl text-green-600" />
<div className="p-6 bg-gray-50 rounded-xl">
<div className="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mb-4">
<FaShieldAlt className="text-2xl text-green-600" />
</div>
<h3 className="text-lg font-semibold mb-2">Безопасность</h3>
<h3 className="text-lg font-semibold mb-2">Безопасность по умолчанию</h3>
<p className="text-gray-600 text-sm">
Шифрование at-rest и in-transit, IAM политики доступа
3 копии данных, IAM роли, шифрование in-transit и at-rest, audit логи, Object Lock и политики хранения.
</p>
</div>
<div className="text-center p-6">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FaInfinity className="text-3xl text-purple-600" />
<div className="p-6 bg-gray-50 rounded-xl">
<div className="w-14 h-14 bg-purple-100 rounded-full flex items-center justify-center mb-4">
<FaCloud className="text-2xl text-purple-600" />
</div>
<h3 className="text-lg font-semibold mb-2">Совместимость</h3>
<h3 className="text-lg font-semibold mb-2">Совместимость с AWS SDK</h3>
<p className="text-gray-600 text-sm">
S3 API - работает с AWS SDK, boto3, s3cmd и другими
Полный S3 API, поддержка AWS CLI, Terraform, rclone, s3cmd и других инструментов без изменений в коде.
</p>
</div>
</div>
</div>
</section>
{/* Pricing Plans */}
<section className="py-20 px-8 bg-gradient-to-b from-white to-gray-50">
<section className="py-20 px-6 sm:px-8 bg-gradient-to-b from-white to-gray-50">
<div className="container mx-auto max-w-7xl">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
{plans.map((plan, index) => (
<div
key={index}
className={`relative bg-white rounded-2xl shadow-lg hover:shadow-xl transition-all ${
plan.popular ? 'ring-2 ring-blue-500 scale-105' : ''
}`}
>
{plan.popular && (
<div className="absolute -top-4 left-0 right-0 flex justify-center">
<span className="bg-blue-500 text-white px-4 py-1 rounded-full text-sm font-medium">
Популярный
</span>
</div>
)}
<div className="p-8">
<h3 className="text-2xl font-bold mb-2 text-gray-900">{plan.name}</h3>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900">{plan.price}</span>
<span className="text-gray-600 ml-2">/мес</span>
</div>
{error && (
<div className="max-w-3xl mx-auto mb-8 bg-red-50 border border-red-200 text-red-700 text-sm rounded-xl p-4">
{error}
</div>
)}
<div className="space-y-3 mb-6 text-sm">
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Хранилище:</span>
<span className="font-semibold text-gray-900">{plan.storage}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Трафик:</span>
<span className="font-semibold text-gray-900">{plan.bandwidth}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-100">
<span className="text-gray-600">Запросы:</span>
<span className="font-semibold text-gray-900">{plan.requests}</span>
</div>
</div>
{loading ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-white rounded-2xl shadow animate-pulse p-8 h-72" />
))}
</div>
) : (
sections.map((section) => (
<div key={section.label} className="mb-16 last:mb-0">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-900">{section.label} Tier</h2>
<p className="text-sm text-gray-500">
Подберите план по объёму хранилища и включённому трафику
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{section.items.map((plan) => (
<div
key={plan.code}
className="relative bg-white rounded-2xl shadow-lg hover:shadow-xl transition-transform hover:-translate-y-1 border border-transparent hover:border-blue-100"
>
<div className="p-8">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-xs uppercase tracking-wide text-gray-500">{plan.tier}</p>
<h3 className="text-2xl font-bold text-gray-900">{plan.name}</h3>
</div>
<span className="text-sm font-semibold text-blue-600 bg-blue-50 px-3 py-1 rounded-full">
{plan.code}
</span>
</div>
<ul className="space-y-3 mb-8">
{plan.features.map((feature, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<FaCheck className="text-green-500 mt-1 flex-shrink-0" />
<span className="text-gray-700">{feature}</span>
</li>
))}
</ul>
<div className="mb-6">
<span className="text-4xl font-bold text-gray-900">{plan.price.toLocaleString('ru-RU')}</span>
<span className="text-gray-500 ml-2 text-sm">в месяц</span>
</div>
<Link
to={`/dashboard/checkout?plan=${plan.name.toLowerCase()}&price=${plan.price}&type=s3`}
className={`block w-full py-3 text-center rounded-lg font-medium transition-all ${
plan.popular
? 'bg-blue-500 text-white hover:bg-blue-600 shadow-md hover:shadow-lg'
: 'bg-gray-100 text-gray-900 hover:bg-gray-200'
}`}
>
Выбрать план
<FaArrowRight className="inline ml-2" />
</Link>
<div className="space-y-3 text-sm mb-6">
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<span className="text-gray-500">Хранилище</span>
<span className="font-semibold text-gray-900">{formatMetric(plan.quotaGb, 'GB')}</span>
</div>
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<span className="text-gray-500">Исходящий трафик</span>
<span className="font-semibold text-gray-900">{formatMetric(plan.bandwidthGb, 'GB')}</span>
</div>
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<span className="text-gray-500">Запросы</span>
<span className="font-semibold text-gray-900">{plan.requestLimit}</span>
</div>
</div>
{plan.highlights.length > 0 && (
<ul className="space-y-2 text-sm text-gray-600 mb-6">
{plan.highlights.map((highlight) => (
<li key={highlight} className="flex items-start gap-2">
<FaCheck className="text-green-500 mt-1" />
<span>{highlight}</span>
</li>
))}
</ul>
)}
<button
type="button"
onClick={() => handleSelectPlan(plan)}
disabled={selectingPlan === plan.code}
className={`w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition-colors ${
selectingPlan === plan.code
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-500'
}`}
>
{selectingPlan === plan.code ? (
<>
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Создание корзины...</span>
</>
) : (
<>
<span>Выбрать план</span>
<FaArrowRight />
</>
)}
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
))
)}
<div className="mt-16 text-center">
{customPlan && customPlanCalculated && (
<div className="mt-20 pt-20 border-t border-gray-200">
<div className="mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Кастомный тариф</h2>
<p className="text-gray-600">Укажите нужное количество GB и получите автоматический расчёт стоимости</p>
</div>
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-8 border border-blue-200">
<div className="grid md:grid-cols-2 gap-12 items-center">
{/* Input */}
<div>
<label className="block text-sm font-semibold text-gray-900 mb-4">
Сколько GB вам нужно?
</label>
<div className="flex items-center gap-4 mb-6">
<input
type="range"
min="1"
max={maxStorageGb}
value={customGbInput}
onChange={(e) => setCustomGbInput(Number(e.target.value))}
className="flex-1 h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div className="flex items-center gap-4">
<input
type="number"
min="1"
max={maxStorageGb}
value={customGbInput}
onChange={(e) => setCustomGbInput(Math.min(maxStorageGb, Math.max(1, Number(e.target.value))))}
className="flex-1 px-4 py-3 border border-blue-300 rounded-lg font-semibold text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-lg font-semibold text-gray-900 min-w-fit">GB</span>
</div>
<p className="text-xs text-gray-600 mt-2">
До {maxStorageGb.toLocaleString('ru-RU')} GB
</p>
</div>
{/* Calculated Plan */}
<div className="bg-white rounded-xl p-8 shadow-sm border border-blue-100">
<div className="mb-6">
<p className="text-xs uppercase tracking-wide text-gray-500 mb-2">Custom Tier</p>
<p className="text-3xl font-bold text-gray-900">
{customPlanCalculated.price.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</p>
<span className="text-gray-500 ml-2 text-sm">в месяц</span>
</div>
<div className="space-y-2 text-sm mb-6">
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<span className="text-gray-500">Хранилище</span>
<span className="font-semibold text-gray-900">
{customPlanCalculated.quotaGb.toLocaleString('ru-RU')} GB
</span>
</div>
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<span className="text-gray-500">Исходящий трафик</span>
<span className="font-semibold text-gray-900">
{customPlanCalculated.bandwidthGb.toLocaleString('ru-RU')} GB
</span>
</div>
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<span className="text-gray-500">Запросы</span>
<span className="font-semibold text-gray-900">
{customPlanCalculated.requestLimit}
</span>
</div>
</div>
<button
type="button"
onClick={() => customPlanCalculated && handleSelectPlan(customPlanCalculated as DecoratedPlan)}
disabled={selectingPlan === customPlan?.code}
className={`w-full flex items-center justify-center gap-2 py-3 rounded-lg font-semibold transition-colors ${
selectingPlan === customPlan.code
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-500'
}`}
>
{selectingPlan === customPlan.code ? (
<>
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Создание корзины...</span>
</>
) : (
<>
<span>Выбрать кастомный план</span>
<FaArrowRight />
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
<div className="mt-20 text-center">
<p className="text-gray-600 mb-4">
Нужен индивидуальный план с большими объёмами?
Нужна гибридная архитектура или больше 5 PB хранения?
</p>
<Link
to="/dashboard/tickets"
<a
href="mailto:sales@ospab.host"
className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-700 font-medium"
>
Связаться с нами
Связаться с командой продаж
<FaArrowRight />
</Link>
</a>
</div>
</div>
</section>
{/* Use Cases */}
<section className="py-20 px-8 bg-white">
<section className="py-20 px-6 sm:px-8 bg-white">
<div className="container mx-auto max-w-6xl">
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
Сценарии использования
Подходит для любых сценариев
</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="p-6 bg-gray-50 rounded-xl">
<h3 className="text-lg font-semibold mb-3">Бэкапы и Архивы</h3>
<h3 className="text-lg font-semibold mb-3">Бэкапы и DR</h3>
<p className="text-gray-600 text-sm mb-4">
Храните резервные копии баз данных, конфигураций и важных файлов.
Версионирование защитит от случайного удаления.
Репликация, Object Lock и цикл жизни объектов позволяют хранить резервные копии и быстро восстанавливаться.
</p>
<div className="flex flex-wrap gap-2">
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">Databases</span>
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">Configs</span>
<span className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded">Archives</span>
</div>
</div>
<div className="p-6 bg-gray-50 rounded-xl">
<h3 className="text-lg font-semibold mb-3">Медиа Контент</h3>
<h3 className="text-lg font-semibold mb-3">Медиа-платформы</h3>
<p className="text-gray-600 text-sm mb-4">
Храните и раздавайте изображения, видео, аудио через CDN.
Идеально для сайтов, приложений и стриминга.
CDN-интеграция, presigned URL и высокая пропускная способность для видео, изображений и аудио.
</p>
<div className="flex flex-wrap gap-2">
<span className="text-xs px-2 py-1 bg-green-100 text-green-700 rounded">Images</span>
<span className="text-xs px-2 py-1 bg-green-100 text-green-700 rounded">Videos</span>
<span className="text-xs px-2 py-1 bg-green-100 text-green-700 rounded">Audio</span>
</div>
</div>
<div className="p-6 bg-gray-50 rounded-xl">
<h3 className="text-lg font-semibold mb-3">Статические Сайты</h3>
<h3 className="text-lg font-semibold mb-3">SaaS & Data Lake</h3>
<p className="text-gray-600 text-sm mb-4">
Хостинг статических сайтов (HTML/CSS/JS) напрямую из бакета.
Кастомные домены и SSL из коробки.
</p>
<div className="flex flex-wrap gap-2">
<span className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded">React</span>
<span className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded">Vue</span>
<span className="text-xs px-2 py-1 bg-purple-100 text-purple-700 rounded">Next.js</span>
</div>
</div>
</div>
</div>
</section>
{/* FAQ */}
<section className="py-20 px-8 bg-gray-50">
<div className="container mx-auto max-w-4xl">
<h2 className="text-3xl font-bold text-center mb-12 text-gray-900">
Частые вопросы
</h2>
<div className="space-y-6">
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="font-semibold mb-2">Что такое S3-совместимое хранилище?</h3>
<p className="text-gray-600 text-sm">
Это объектное хранилище с API, совместимым с Amazon S3. Вы можете использовать
любые инструменты и библиотеки для S3 (AWS SDK, boto3, s3cmd, Cyberduck и т.д.)
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="font-semibold mb-2">Что будет при превышении лимитов?</h3>
<p className="text-gray-600 text-sm">
При превышении хранилища или трафика мы уведомим вас. Можно перейти на старший тариф
или докупить дополнительные ресурсы. Сервис не отключается мгновенно.
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="font-semibold mb-2">Как получить доступ к хранилищу?</h3>
<p className="text-gray-600 text-sm">
После оплаты тарифа вы получите Access Key и Secret Key. Используйте их для подключения
через S3 API. Endpoint: s3.ospab.host
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm">
<h3 className="font-semibold mb-2">Есть ли гарантия сохранности данных?</h3>
<p className="text-gray-600 text-sm">
Данные хранятся с репликацией на 3 узлах (3x копии). Durability 99.999999999% (11 девяток).
Версионирование и snapshot защищают от случайного удаления.
IAM, версии API и аудит логов обеспечивают безопасность и соответствие требованиям GDPR, 152-ФЗ и SOC 2.
</p>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 px-8 bg-gradient-to-br from-blue-500 to-blue-600 text-white">
<section className="py-20 px-6 sm:px-8 bg-gray-900 text-white">
<div className="container mx-auto max-w-4xl text-center">
<h2 className="text-4xl font-bold mb-6">Готовы начать?</h2>
<p className="text-xl mb-8 opacity-90">
Создайте аккаунт и получите доступ к S3 хранилищу за 2 минуты
<h2 className="text-4xl font-bold mb-6">Готовы развернуть S3 хранилище?</h2>
<p className="text-lg sm:text-xl mb-8 text-white/80">
Создайте аккаунт и получите доступ к консоли управления, API ключам и детальной аналитике использования.
</p>
<div className="flex gap-4 justify-center">
<div className="flex flex-wrap justify-center gap-4">
<Link
to="/register"
className="px-8 py-4 bg-white text-blue-600 rounded-lg font-semibold hover:bg-gray-100 transition-all"
className="px-8 py-4 bg-white text-blue-600 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
>
Зарегистрироваться
</Link>
<Link
to="/login"
className="px-8 py-4 bg-blue-400 text-white rounded-lg font-semibold hover:bg-blue-300 transition-all"
className="px-8 py-4 bg-blue-500 text-white rounded-lg font-semibold hover:bg-blue-400 transition-colors"
>
Войти
</Link>

View File

@@ -8,6 +8,7 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json',
},
timeout: 120000, // 120 seconds timeout
});
// Добавляем токен к каждому запросу

View File

@@ -0,0 +1,142 @@
// IndexedDB utilities for persistent file uploads
export interface StoredFile {
id: string;
bucketId: number;
name: string;
size: number;
type: string;
data: ArrayBuffer;
uploadPath: string;
timestamp: number;
}
const DB_NAME = 'OspabStorageUpload';
const DB_VERSION = 1;
const STORE_NAME = 'files';
let db: IDBDatabase | null = null;
export const initDB = async (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
reject(new Error('Failed to open IndexedDB'));
};
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const database = (event.target as IDBOpenDBRequest).result;
if (!database.objectStoreNames.contains(STORE_NAME)) {
const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('bucketId', 'bucketId', { unique: false });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
};
export const getDB = async (): Promise<IDBDatabase> => {
if (!db) {
db = await initDB();
}
return db;
};
export const saveFile = async (file: StoredFile): Promise<void> => {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.put(file);
request.onerror = () => {
reject(new Error('Failed to save file'));
};
request.onsuccess = () => {
resolve();
};
});
};
export const getFiles = async (bucketId: number): Promise<StoredFile[]> => {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const index = store.index('bucketId');
const request = index.getAll(bucketId);
request.onerror = () => {
reject(new Error('Failed to get files'));
};
request.onsuccess = () => {
resolve(request.result);
};
});
};
export const getFile = async (fileId: string): Promise<StoredFile | undefined> => {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(fileId);
request.onerror = () => {
reject(new Error('Failed to get file'));
};
request.onsuccess = () => {
resolve(request.result);
};
});
};
export const deleteFile = async (fileId: string): Promise<void> => {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(fileId);
request.onerror = () => {
reject(new Error('Failed to delete file'));
};
request.onsuccess = () => {
resolve();
};
});
};
export const deleteFilesByBucket = async (bucketId: number): Promise<void> => {
const files = await getFiles(bucketId);
for (const file of files) {
await deleteFile(file.id);
}
};
export const clearAllFiles = async (): Promise<void> => {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.clear();
request.onerror = () => {
reject(new Error('Failed to clear files'));
};
request.onsuccess = () => {
resolve();
};
});
};