english version and minio console access

This commit is contained in:
Georgiy Syralev
2025-12-13 12:53:28 +03:00
parent 753696cc93
commit b799f278a4
47 changed files with 4386 additions and 1264 deletions

View File

@@ -25,6 +25,7 @@ import { AuthProvider } from './context/authcontext';
import { WebSocketProvider } from './context/WebSocketContext';
import ErrorBoundary from './components/ErrorBoundary';
import { ToastProvider } from './components/Toast';
import { LocaleProvider } from './middleware';
// SEO конфиг для всех маршрутов
const SEO_CONFIG: Record<string, {
@@ -193,46 +194,75 @@ function SEOUpdater() {
function App() {
return (
<Router>
<SEOUpdater />
<AuthProvider>
<WebSocketProvider>
<ToastProvider>
<ErrorBoundary>
<Routes>
{/* Обычные страницы с footer */}
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
<Route path="/blog" element={<Pagetempl><Blog /></Pagetempl>} />
<Route path="/blog/:url" element={<Pagetempl><BlogPost /></Pagetempl>} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
<Route path="/qr-login" element={<QRLoginPage />} />
{/* Дашборд без footer */}
<Route path="/dashboard/*" element={
<DashboardTempl>
<Privateroute>
<Dashboard />
</Privateroute>
</DashboardTempl>
} />
<LocaleProvider>
<SEOUpdater />
<AuthProvider>
<WebSocketProvider>
<ToastProvider>
<ErrorBoundary>
<Routes>
{/* Русские маршруты (без префикса) */}
<Route path="/" element={<Pagetempl><Homepage /></Pagetempl>} />
<Route path="/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
<Route path="/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
<Route path="/blog" element={<Pagetempl><Blog /></Pagetempl>} />
<Route path="/blog/:url" element={<Pagetempl><BlogPost /></Pagetempl>} />
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
<Route path="/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
<Route path="/qr-login" element={<QRLoginPage />} />
{/* Английские маршруты (с префиксом /en) */}
<Route path="/en" element={<Pagetempl><Homepage /></Pagetempl>} />
<Route path="/en/about" element={<Pagetempl><Aboutpage /></Pagetempl>} />
<Route path="/en/tariffs" element={<Pagetempl><S3PlansPage /></Pagetempl>} />
<Route path="/en/blog" element={<Pagetempl><Blog /></Pagetempl>} />
<Route path="/en/blog/:url" element={<Pagetempl><BlogPost /></Pagetempl>} />
<Route path="/en/privacy" element={<Privacy />} />
<Route path="/en/terms" element={<Terms />} />
<Route path="/en/login" element={<Pagetempl><Loginpage /></Pagetempl>} />
<Route path="/en/register" element={<Pagetempl><Registerpage /></Pagetempl>} />
<Route path="/en/qr-login" element={<QRLoginPage />} />
{/* Дашборд (русский) */}
<Route path="/dashboard/*" element={
<DashboardTempl>
<Privateroute>
<Dashboard />
</Privateroute>
</DashboardTempl>
} />
{/* Дашборд (английский) */}
<Route path="/en/dashboard/*" element={
<DashboardTempl>
<Privateroute>
<Dashboard />
</Privateroute>
</DashboardTempl>
} />
{/* Страницы ошибок */}
<Route path="/401" element={<Unauthorized />} />
<Route path="/403" element={<Forbidden />} />
<Route path="/500" element={<ServerError />} />
<Route path="/502" element={<BadGateway />} />
<Route path="/503" element={<ServiceUnavailable />} />
<Route path="/504" element={<GatewayTimeout />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ErrorBoundary>
</ToastProvider>
</WebSocketProvider>
</AuthProvider>
{/* Страницы ошибок */}
<Route path="/401" element={<Unauthorized />} />
<Route path="/403" element={<Forbidden />} />
<Route path="/500" element={<ServerError />} />
<Route path="/502" element={<BadGateway />} />
<Route path="/503" element={<ServiceUnavailable />} />
<Route path="/504" element={<GatewayTimeout />} />
<Route path="/en/401" element={<Unauthorized />} />
<Route path="/en/403" element={<Forbidden />} />
<Route path="/en/500" element={<ServerError />} />
<Route path="/en/502" element={<BadGateway />} />
<Route path="/en/503" element={<ServiceUnavailable />} />
<Route path="/en/504" element={<GatewayTimeout />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ErrorBoundary>
</ToastProvider>
</WebSocketProvider>
</AuthProvider>
</LocaleProvider>
</Router>
);
}

View File

@@ -25,7 +25,8 @@ export default function AdminTestingTab() {
};
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
// Используем тот же ключ, что и во всём проекте (обычно 'access_token')
const token = localStorage.getItem('access_token') || localStorage.getItem('token');
return token ? { Authorization: `Bearer ${token}` } : {};
};
@@ -35,7 +36,7 @@ export default function AdminTestingTab() {
addLog('info', 'Начинаю отправку push-уведомления...');
const response = await axios.post(
`${API_URL}/admin/test/push-notification`,
`${API_URL}/api/admin/test/push-notification`,
{},
{ headers: getAuthHeaders(), timeout: 15000 }
);
@@ -64,7 +65,7 @@ export default function AdminTestingTab() {
addLog('info', 'Начинаю отправку email-уведомления...');
const response = await axios.post(
`${API_URL}/admin/test/email-notification`,
`${API_URL}/api/admin/test/email-notification`,
{},
{ headers: getAuthHeaders(), timeout: 15000 }
);

View File

@@ -0,0 +1,63 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { LocaleContext } from './locale.context';
import type { Locale } from './locale.utils';
import { detectUserLocale, setUserLocale, shouldRedirect } from './locale.utils';
interface LocaleProviderProps {
children: React.ReactNode;
}
/**
* Провайдер локализации
*/
export const LocaleProvider: React.FC<LocaleProviderProps> = ({ children }) => {
const navigate = useNavigate();
const location = useLocation();
const [locale, setLocaleState] = useState<Locale>(() => {
// Если путь указывает на английский, используем его
if (location.pathname.startsWith('/en')) {
return 'en';
}
// Иначе определяем по настройкам
return detectUserLocale();
});
const setLocale = useCallback((newLocale: Locale) => {
setUserLocale(newLocale);
setLocaleState(newLocale);
// Редирект на новую локаль
const { newPath } = shouldRedirect(location.pathname, newLocale);
if (newPath !== location.pathname) {
navigate(newPath + location.search + location.hash, { replace: true });
}
}, [location.pathname, location.search, location.hash, navigate]);
// Функция перевода (заглушка - можно расширить)
const t = useCallback((key: string): string => {
// TODO: Подключить реальные переводы
return key;
}, []);
// Проверяем редирект при первой загрузке
useEffect(() => {
const { redirect, newPath } = shouldRedirect(location.pathname, locale);
if (redirect) {
navigate(newPath + location.search + location.hash, { replace: true });
}
}, [location.pathname, location.search, location.hash, locale, navigate]);
const contextValue = useMemo(() => ({
locale,
setLocale,
t,
}), [locale, setLocale, t]);
return (
<LocaleContext.Provider value={contextValue}>
{children}
</LocaleContext.Provider>
);
};

View File

@@ -0,0 +1,13 @@
// Экспорт всей системы локализации из одного места
export { LocaleProvider } from './LocaleProvider';
export { LocaleContext } from './locale.context';
export { useLocale, useLocalePath } from './locale.hooks';
export {
detectUserLocale,
setUserLocale,
getLocaleFromPath,
localePath,
RUSSIAN_COUNTRIES,
EXCLUDED_PATHS,
} from './locale.utils';
export type { Locale } from './locale.utils';

View File

@@ -0,0 +1,15 @@
import React from 'react';
import type { Locale } from './locale.utils';
// Контекст локали
interface LocaleContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
}
export const LocaleContext = React.createContext<LocaleContextValue>({
locale: 'ru',
setLocale: () => {},
t: (key: string) => key,
});

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { LocaleContext } from './locale.context';
import type { Locale } from './locale.utils';
import { localePath } from './locale.utils';
/**
* Хук для получения текущей локали и функции смены
*/
export const useLocale = () => React.useContext(LocaleContext);
/**
* Хук для получения пути с учётом текущей локали
*/
export function useLocalePath() {
const { locale } = useLocale();
return (path: string) => localePath(path, locale);
}
// Реэкспортируем типы для удобства
export type { Locale };

View File

@@ -0,0 +1,124 @@
// Поддерживаемые локали
export type Locale = 'ru' | 'en';
// Список стран СНГ где используется русский (для будущего геоопределения)
export const RUSSIAN_COUNTRIES = [
'RU', // Россия
'BY', // Беларусь
'KZ', // Казахстан
'UA', // Украина
'UZ', // Узбекистан
'TJ', // Таджикистан
'KG', // Кыргызстан
'TM', // Туркменистан
'MD', // Молдова
'AZ', // Азербайджан
'AM', // Армения
'GE', // Грузия
];
// Страницы, которые не нуждаются в локализации (API, статика и т.д.)
export const EXCLUDED_PATHS = ['/api/', '/uploads/', '/assets/', '/favicon'];
/**
* Определяет локаль пользователя
*/
export function detectUserLocale(): Locale {
// 1. Проверяем сохранённую локаль
const saved = localStorage.getItem('locale') as Locale | null;
if (saved && (saved === 'ru' || saved === 'en')) {
return saved;
}
// 2. Проверяем языковые настройки браузера
const browserLang = navigator.language || (navigator as unknown as { userLanguage?: string }).userLanguage;
if (browserLang) {
if (browserLang.startsWith('ru')) {
return 'ru';
}
}
// 3. По умолчанию - английский для остального мира
return 'en';
}
/**
* Сохраняет локаль пользователя
*/
export function setUserLocale(locale: Locale): void {
localStorage.setItem('locale', locale);
}
/**
* Определяет, нужен ли редирект для текущего пути
*/
export function shouldRedirect(pathname: string, locale: Locale): { redirect: boolean; newPath: string } {
// Исключаем служебные пути
for (const excluded of EXCLUDED_PATHS) {
if (pathname.startsWith(excluded)) {
return { redirect: false, newPath: pathname };
}
}
const pathParts = pathname.split('/').filter(Boolean);
const firstPart = pathParts[0];
// Проверяем, начинается ли путь с локали
const hasLocalePrefix = firstPart === 'en' || firstPart === 'ru';
if (locale === 'ru') {
// Русский - без префикса
if (hasLocalePrefix && firstPart === 'ru') {
// Убираем /ru/ из пути
const newPath = '/' + pathParts.slice(1).join('/');
return { redirect: true, newPath: newPath || '/' };
}
if (hasLocalePrefix && firstPart === 'en') {
// Пользователь на /en/, но локаль ru - убираем префикс
const newPath = '/' + pathParts.slice(1).join('/');
return { redirect: true, newPath: newPath || '/' };
}
// Уже на русской версии без префикса
return { redirect: false, newPath: pathname };
} else {
// Английский - с префиксом /en/
if (!hasLocalePrefix) {
// Добавляем /en/ в начало
return { redirect: true, newPath: '/en' + pathname };
}
if (firstPart === 'ru') {
// Меняем /ru/ на /en/
const newPath = '/en/' + pathParts.slice(1).join('/');
return { redirect: true, newPath };
}
// Уже на /en/
return { redirect: false, newPath: pathname };
}
}
/**
* Извлекает локаль из пути
*/
export function getLocaleFromPath(pathname: string): Locale {
const firstPart = pathname.split('/').filter(Boolean)[0];
if (firstPart === 'en') return 'en';
return 'ru';
}
/**
* Создаёт путь с учётом локали
*/
export function localePath(path: string, locale: Locale): string {
// Убираем существующий префикс локали если есть
let cleanPath = path;
if (path.startsWith('/en/') || path.startsWith('/ru/')) {
cleanPath = path.slice(3);
} else if (path === '/en' || path === '/ru') {
cleanPath = '/';
}
if (locale === 'en') {
return '/en' + (cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath);
}
return cleanPath.startsWith('/') ? cleanPath : '/' + cleanPath;
}

View File

@@ -11,6 +11,7 @@ import {
FiGlobe
} from 'react-icons/fi';
import apiClient from '../../utils/apiClient';
import useAuth from '../../context/useAuth';
import { API_URL } from '../../config/api';
import type { StorageBucket } from './types';
@@ -73,6 +74,10 @@ const Checkout: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [regions, setRegions] = useState<StorageRegionOption[]>([]);
const [loadingRegions, setLoadingRegions] = useState<boolean>(false);
const [promoCode, setPromoCode] = useState<string>('');
const [promoApplied, setPromoApplied] = useState<boolean>(false);
const [promoError, setPromoError] = useState<string | null>(null);
const { isLoggedIn } = useAuth();
const fetchBalance = useCallback(async () => {
try {
@@ -180,6 +185,19 @@ const Checkout: React.FC = () => {
const balanceAfterPayment = useMemo(() => balance - planPrice, [balance, planPrice]);
const handleApplyPromo = useCallback(async () => {
if (!cart) return;
setPromoError(null);
try {
const res = await apiClient.post(`${API_URL}/api/storage/cart/${cart.cartId}/apply-promo`, { promoCode });
const updated = res.data?.cart;
if (updated) setCart(updated as CartPayload);
setPromoApplied(true);
} catch (err) {
setPromoError(err instanceof Error ? err.message : 'Не удалось применить промокод');
}
}, [cart, promoCode]);
const formatCurrency = useCallback((amount: number) => `${amount.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -269,6 +287,16 @@ const Checkout: React.FC = () => {
</div>
)}
<div className="w-full md:w-1/3 mt-4 md:mt-0">
<label className="block text-sm font-medium text-gray-700">Промокод</label>
<div className="flex gap-2 mt-1">
<input value={promoCode} onChange={(e) => setPromoCode(e.target.value)} className="border rounded px-2 py-1 flex-1" placeholder="Введите промокод" />
<button onClick={handleApplyPromo} disabled={!isLoggedIn} className={`px-3 py-1 rounded ${isLoggedIn ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>{isLoggedIn ? 'Применить' : 'Войдите, чтобы применить'}</button>
</div>
{promoError && <div className="text-red-500 text-sm mt-1">{promoError}</div>}
{promoApplied && !promoError && <div className="text-green-600 text-sm mt-1">Промокод применён</div>}
</div>
<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">

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { isAxiosError } from 'axios';
import {
FiArrowLeft,
@@ -14,7 +14,11 @@ import {
FiHelpCircle,
FiSettings,
FiFolder,
FiFolderMinus,
FiFile,
FiBarChart2,
FiChevronRight,
FiChevronDown,
} from 'react-icons/fi';
import apiClient from '../../utils/apiClient';
import { useToast } from '../../hooks/useToast';
@@ -38,6 +42,16 @@ interface CreatedKey {
label?: string | null;
}
// Тип для дерева файлов (проводник)
interface FileTreeNode {
name: string;
path: string;
isFolder: boolean;
size: number;
lastModified?: string;
children: Record<string, FileTreeNode>;
}
interface UploadProgress {
loaded: number;
total: number;
@@ -76,15 +90,164 @@ type LoadObjectsOptions = {
prefix?: string;
};
type ConsoleCredentials = {
login: string;
password: string;
url?: string | null;
};
// Компонент для отображения дерева файлов (проводник)
interface FileTreeViewProps {
node: FileTreeNode;
depth: number;
expandedFolders: Record<string, boolean>;
toggleFolder: (path: string) => void;
selectedKeys: Record<string, boolean>;
handleToggleSelection: (key: string) => void;
handleDownloadObject: (object: StorageObject) => void;
formatBytes: (bytes: number) => string;
formatDate: (value?: string | null, withTime?: boolean) => string;
objects: StorageObject[];
}
type BucketLocationState = {
consoleCredentials?: ConsoleCredentials;
bucketName?: string;
const FileTreeView: React.FC<FileTreeViewProps> = ({
node,
depth,
expandedFolders,
toggleFolder,
selectedKeys,
handleToggleSelection,
handleDownloadObject,
formatBytes,
formatDate,
objects,
}) => {
const children = Object.values(node.children);
// Сортируем: сначала папки, потом файлы, алфавитно
const sortedChildren = children.sort((a, b) => {
if (a.isFolder && !b.isFolder) return -1;
if (!a.isFolder && b.isFolder) return 1;
return a.name.localeCompare(b.name);
});
// Для корневой ноды (depth 0) отображаем только детей
if (depth === 0) {
return (
<>
{sortedChildren.map((child) => (
<FileTreeView
key={child.path}
node={child}
depth={depth + 1}
expandedFolders={expandedFolders}
toggleFolder={toggleFolder}
selectedKeys={selectedKeys}
handleToggleSelection={handleToggleSelection}
handleDownloadObject={handleDownloadObject}
formatBytes={formatBytes}
formatDate={formatDate}
objects={objects}
/>
))}
</>
);
}
const isExpanded = expandedFolders[node.path] ?? false;
const paddingLeft = (depth - 1) * 20 + 12;
if (node.isFolder) {
// Считаем общий размер папки
const folderSize = objects
.filter((obj) => obj.key.startsWith(node.path + '/'))
.reduce((acc, obj) => acc + obj.size, 0);
// Считаем количество файлов в папке
const fileCount = objects.filter((obj) => obj.key.startsWith(node.path + '/')).length;
return (
<>
<div
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 cursor-pointer text-sm"
style={{ paddingLeft }}
onClick={() => toggleFolder(node.path)}
>
<span className="w-6 flex items-center justify-center text-gray-400">
{isExpanded ? <FiChevronDown /> : <FiChevronRight />}
</span>
<span className="text-yellow-500">
<FiFolder />
</span>
<span className="flex-1 font-medium text-gray-800">
{node.name}
<span className="ml-2 text-xs text-gray-400 font-normal">({fileCount} файл.)</span>
</span>
<span className="w-24 text-right text-xs text-gray-500">{formatBytes(folderSize)}</span>
<span className="w-40 text-right text-xs text-gray-400"></span>
<span className="w-32"></span>
</div>
{isExpanded && (
<>
{sortedChildren.map((child) => (
<FileTreeView
key={child.path}
node={child}
depth={depth + 1}
expandedFolders={expandedFolders}
toggleFolder={toggleFolder}
selectedKeys={selectedKeys}
handleToggleSelection={handleToggleSelection}
handleDownloadObject={handleDownloadObject}
formatBytes={formatBytes}
formatDate={formatDate}
objects={objects}
/>
))}
</>
)}
</>
);
}
// Файл
const storageObject = objects.find((obj) => obj.key === node.path);
return (
<div
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm"
style={{ paddingLeft }}
>
<span className="w-6 flex items-center justify-center">
<input
type="checkbox"
checked={!!selectedKeys[node.path]}
onChange={(e) => {
e.stopPropagation();
handleToggleSelection(node.path);
}}
className="w-4 h-4"
/>
</span>
<span className="text-gray-400">
<FiFile />
</span>
<span className="flex-1 font-mono text-xs text-gray-700 break-all">{node.name}</span>
<span className="w-24 text-right text-xs text-gray-600">{formatBytes(node.size)}</span>
<span className="w-40 text-right text-xs text-gray-500">
{node.lastModified ? formatDate(node.lastModified, true) : '—'}
</span>
<span className="w-32 text-right">
{storageObject && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDownloadObject(storageObject);
}}
className="inline-flex items-center gap-1 px-2 py-1 border border-gray-200 rounded text-xs text-gray-600 hover:bg-gray-100"
>
<FiDownload />
Скачать
</button>
)}
</span>
</div>
);
};
const StorageBucketPage: React.FC = () => {
@@ -92,7 +255,6 @@ const StorageBucketPage: React.FC = () => {
const bucketNumber = bucketIdParam ? Number(bucketIdParam) : NaN;
const bucketIdValid = Number.isInteger(bucketNumber) && bucketNumber > 0;
const location = useLocation();
const navigate = useNavigate();
const { addToast } = useToast();
@@ -126,6 +288,9 @@ const StorageBucketPage: React.FC = () => {
const [uriUploadLoading, setUriUploadLoading] = useState(false);
const [resumedFiles, setResumedFiles] = useState<File[]>([]);
const uploadAbortControllerRef = useRef<AbortController | null>(null);
// Для проводника - какие папки развёрнуты
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>({});
const [accessKeys, setAccessKeys] = useState<StorageAccessKey[]>([]);
const [accessKeysLoading, setAccessKeysLoading] = useState(false);
@@ -141,12 +306,70 @@ const StorageBucketPage: React.FC = () => {
const selectedCount = selectedList.length;
const allSelected = objects.length > 0 && objects.every((object) => selectedKeys[object.key]);
const [consoleCredentials, setConsoleCredentials] = useState<ConsoleCredentials | null>(() => {
const state = location.state as BucketLocationState | undefined;
return state?.consoleCredentials ?? null;
});
const [consoleCredentialsLoading, setConsoleCredentialsLoading] = useState(false);
const [consoleCredentialsError, setConsoleCredentialsError] = useState<string | null>(null);
// Строим дерево файлов для проводника
const fileTree = useMemo(() => {
const root: FileTreeNode = {
name: '',
path: '',
isFolder: true,
size: 0,
children: {},
};
for (const obj of objects) {
const parts = obj.key.split('/').filter(Boolean);
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
const currentPath = parts.slice(0, i + 1).join('/');
if (!current.children[part]) {
current.children[part] = {
name: part,
path: currentPath,
isFolder: !isLast,
size: isLast ? obj.size : 0,
lastModified: isLast ? obj.lastModified : undefined,
children: {},
};
}
if (!isLast) {
// Это промежуточная папка
current.children[part].isFolder = true;
}
current = current.children[part];
}
}
return root;
}, [objects]);
const toggleFolder = useCallback((path: string) => {
setExpandedFolders((prev) => ({
...prev,
[path]: !prev[path],
}));
}, []);
const expandAllFolders = useCallback(() => {
const allPaths: Record<string, boolean> = {};
const collectPaths = (node: FileTreeNode) => {
if (node.isFolder && node.path) {
allPaths[node.path] = true;
}
Object.values(node.children).forEach(collectPaths);
};
collectPaths(fileTree);
setExpandedFolders(allPaths);
}, [fileTree]);
const collapseAllFolders = useCallback(() => {
setExpandedFolders({});
}, []);
const dispatchBucketsRefresh = useCallback(() => {
window.dispatchEvent(new Event('storageBucketsRefresh'));
@@ -311,41 +534,6 @@ const StorageBucketPage: React.FC = () => {
loadObjects({ reset: true, prefix: '' });
}, [loadObjects]);
const handleGenerateConsoleCredentials = useCallback(async () => {
if (!bucketIdValid) {
return;
}
try {
setConsoleCredentialsLoading(true);
setConsoleCredentialsError(null);
setConsoleCredentials(null);
const { data } = await apiClient.post<{ credentials?: ConsoleCredentials }>(
`/api/storage/buckets/${bucketNumber}/console-credentials`
);
const credentials = data?.credentials;
if (!credentials) {
throw new Error('Сервер не вернул данные входа');
}
setConsoleCredentials(credentials);
addToast('Создан новый пароль для MinIO Console', 'success');
await fetchBucket({ silent: true });
} catch (error) {
let message = 'Не удалось сгенерировать данные входа';
if (isAxiosError(error) && typeof error.response?.data?.error === 'string') {
message = error.response.data.error;
} else if (error instanceof Error) {
message = error.message;
}
setConsoleCredentialsError(message);
addToast(message, 'error');
} finally {
setConsoleCredentialsLoading(false);
}
}, [addToast, bucketIdValid, bucketNumber, fetchBucket]);
const handleSelectAll = useCallback(() => {
if (allSelected) {
setSelectedKeys({});
@@ -441,18 +629,25 @@ const StorageBucketPage: React.FC = () => {
const abortController = new AbortController();
uploadAbortControllerRef.current = abortController;
setUploading(true);
// Подсчитываем общий размер для единого прогресса
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
let totalLoaded = 0;
const uploadStartTime = Date.now();
setUploadStats({ currentFile: '', completedFiles: 0, totalFiles: files.length });
const progressMap: Record<string, UploadProgress> = {};
try {
// Сохраняем файлы в IndexedDB перед загрузкой
const { saveFile } = await import('../../utils/uploadDB');
for (const file of files) {
// Используем webkitRelativePath для сохранения структуры папки
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const arrayBuffer = await file.arrayBuffer();
await saveFile({
id: `${bucketNumber}_${Date.now()}_${file.name}`,
id: `${bucketNumber}_${Date.now()}_${relativePath}`,
bucketId: bucketNumber,
name: file.name,
name: relativePath,
size: file.size,
type: file.type,
data: arrayBuffer,
@@ -470,50 +665,69 @@ const StorageBucketPage: React.FC = () => {
}
const file = files[i];
setUploadStats({ currentFile: file.name, completedFiles: i, totalFiles: files.length });
// Используем webkitRelativePath для сохранения структуры папки
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const displayName = relativePath.split('/').pop() || file.name;
progressMap[file.name] = { loaded: 0, total: file.size, speed: 0, percentage: 0 };
setUploadStats({ currentFile: displayName, completedFiles: i, totalFiles: files.length });
const key = normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name;
// Формируем ключ с учётом относительного пути (структуры папки)
let key: string;
if (relativePath && relativePath !== file.name) {
// Загрузка папки - сохраняем структуру
key = normalizedPrefix ? `${normalizedPrefix}/${relativePath}` : relativePath;
} else {
// Обычная загрузка файла
key = normalizedPrefix ? `${normalizedPrefix}/${file.name}` : file.name;
}
const { data } = await apiClient.post<PresignResponse>(`/api/storage/buckets/${bucketNumber}/objects/presign`, {
key,
method: 'PUT',
contentType: file.type || undefined,
});
const startTime = Date.now();
let fileLoaded = 0; // Отслеживаем прогресс текущего файла
const xhr = new XMLHttpRequest();
// Track upload progress
// Track upload progress - единый прогресс-бар
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const elapsed = (Date.now() - startTime) / 1000;
const speed = elapsed > 0 ? event.loaded / elapsed : 0;
const percentage = Math.round((event.loaded / event.total) * 100);
// Обновляем прогресс для этого файла
const prevFileLoaded = fileLoaded;
fileLoaded = event.loaded;
totalLoaded += (fileLoaded - prevFileLoaded);
const elapsed = (Date.now() - uploadStartTime) / 1000;
const speed = elapsed > 0 ? totalLoaded / elapsed : 0;
const percentage = totalSize > 0 ? Math.round((totalLoaded / totalSize) * 100) : 0;
progressMap[file.name] = {
loaded: event.loaded,
total: event.total,
speed,
percentage,
};
setUploadProgress({ ...progressMap });
// Единый прогресс в __total__
setUploadProgress({
__total__: {
loaded: totalLoaded,
total: totalSize,
speed,
percentage,
},
});
}
});
await new Promise<void>((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
progressMap[file.name].percentage = 100;
setUploadProgress({ ...progressMap });
// Убедимся, что файл полностью засчитан
if (fileLoaded < file.size) {
totalLoaded += (file.size - fileLoaded);
}
resolve();
} else {
reject(new Error(`Загрузка файла «${file.name}» завершилась с ошибкой (${xhr.status})`));
reject(new Error(`Загрузка файла «${displayName}» завершилась с ошибкой (${xhr.status})`));
}
});
xhr.addEventListener('error', () => {
reject(new Error(`Ошибка при загрузке файла «${file.name}»`));
reject(new Error(`Ошибка при загрузке файла «${displayName}»`));
});
xhr.open('PUT', data.url);
@@ -599,30 +813,67 @@ const StorageBucketPage: React.FC = () => {
return;
}
console.log('[URI Upload] Начало загрузки, URL:', uriUploadUrl);
setUriUploadLoading(true);
const abortController = new AbortController();
uriUploadAbortControllerRef.current = abortController;
try {
// Используем бэкенд proxy для обхода CORS с увеличенным timeout
console.log('[URI Upload] Отправляем запрос на бэкенд...');
const response = await apiClient.post(
`/api/storage/buckets/${bucketNumber}/objects/download-from-uri`,
{ url: uriUploadUrl },
{ timeout: 120000 } // 120 seconds timeout
);
console.log('[URI Upload] Ответ получен:', {
hasBlob: !!response.data?.blob,
blobLength: response.data?.blob?.length,
mimeType: response.data?.mimeType,
});
if (response.data?.blob) {
const blob = new Blob([response.data.blob], { type: response.data.mimeType || 'application/octet-stream' });
const fileName = uriUploadUrl.split('/').pop() || 'file';
// Декодируем base64 в бинарные данные
const base64 = response.data.blob;
console.log('[URI Upload] Декодируем base64, длина:', base64.length);
const binaryString = atob(base64);
console.log('[URI Upload] Бинарная строка длина:', binaryString.length);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: response.data.mimeType || 'application/octet-stream' });
console.log('[URI Upload] Blob создан, размер:', blob.size, 'тип:', blob.type);
// Извлекаем имя файла из URL
const urlObj = new URL(uriUploadUrl);
let fileName = urlObj.pathname.split('/').pop() || 'file';
// Убираем query-параметры из имени
if (fileName.includes('?')) {
fileName = fileName.split('?')[0];
}
console.log('[URI Upload] Имя файла:', fileName);
const file = new File([blob], fileName, { type: blob.type });
console.log('[URI Upload] File объект создан, размер:', file.size);
await performUpload([file]);
setUriUploadUrl('');
addToast(`Файл "${fileName}" загружен`, 'success');
} else {
console.error('[URI Upload] Ответ не содержит blob:', response.data);
addToast('Сервер не вернул данные файла', 'error');
}
} catch (error) {
console.error('[URI Upload] Ошибка:', error);
let message = 'Не удалось загрузить по URI';
if (error instanceof Error && error.message === 'canceled') {
message = 'Загрузка отменена';
} else if (isAxiosError(error) && error.response?.data?.error) {
console.error('[URI Upload] Ошибка от сервера:', error.response.data.error);
message = error.response.data.error;
} else if (error instanceof Error) {
message = error.message;
@@ -827,14 +1078,11 @@ const StorageBucketPage: React.FC = () => {
setObjectPrefix('');
objectPrefixRef.current = '';
setLastCreatedKey(null);
setConsoleCredentials((location.state as BucketLocationState | undefined)?.consoleCredentials ?? null);
setConsoleCredentialsError(null);
setConsoleCredentialsLoading(false);
fetchBucket();
loadObjects({ reset: true, prefix: '' });
fetchAccessKeys();
}, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects, location.state]);
}, [bucketIdValid, fetchAccessKeys, fetchBucket, loadObjects]);
const bucketUsagePercent = bucket ? getUsagePercent(bucket.usedBytes, bucket.quotaGb) : 0;
const bucketPlanName = bucket?.planDetails?.name ?? bucket?.plan ?? '';
@@ -844,9 +1092,6 @@ const StorageBucketPage: React.FC = () => {
const bucketPrice = typeof bucketPriceValue === 'number' && Number.isFinite(bucketPriceValue)
? formatCurrency(bucketPriceValue)
: '—';
const consoleLoginValue = consoleCredentials?.login ?? bucket?.consoleLogin ?? bucket?.name ?? '';
const consoleLoginDisplay = consoleLoginValue || '—';
const consoleUrl = consoleCredentials?.url ?? bucket?.consoleUrl ?? null;
const activeTabMeta = TAB_ITEMS.find((item) => item.key === activeTab);
@@ -1097,25 +1342,25 @@ const StorageBucketPage: React.FC = () => {
Загрузка файла {uploadStats.completedFiles + 1} из {uploadStats.totalFiles}: <span className="text-ospab-primary">{uploadStats.currentFile}</span>
</div>
)}
{Object.entries(uploadProgress).map(([fileName, progress]: [string, UploadProgress]) => {
const speedMB = (progress.speed / (1024 * 1024)).toFixed(2);
return (
<div key={fileName} className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600 truncate">{fileName}</span>
<span className="text-ospab-primary font-semibold whitespace-nowrap">
{progress.percentage}% {speedMB} MB/s
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
className="bg-ospab-primary h-full transition-all duration-300"
style={{ width: `${progress.percentage}%` }}
/>
</div>
{uploadProgress.__total__ && (
<div className="space-y-2">
<div className="flex justify-between items-center text-xs">
<span className="text-gray-600">Общий прогресс</span>
<span className="text-ospab-primary font-semibold whitespace-nowrap">
{uploadProgress.__total__.percentage}% {((uploadProgress.__total__.speed * 8) / (1024 * 1024)).toFixed(2)} Mbit/s
</span>
</div>
);
})}
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="bg-ospab-primary h-full transition-all duration-300"
style={{ width: `${uploadProgress.__total__.percentage}%` }}
/>
</div>
<div className="text-xs text-gray-500 text-center">
{formatBytes(uploadProgress.__total__.loaded)} / {formatBytes(uploadProgress.__total__.total)}
</div>
</div>
)}
<button
onClick={handleCancelUpload}
className="mt-3 w-full px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-semibold hover:bg-red-700 transition"
@@ -1216,6 +1461,24 @@ const StorageBucketPage: React.FC = () => {
<FiTrash2 />
Удалить выбранные
</button>
<button
type="button"
onClick={expandAllFolders}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
disabled={objects.length === 0}
>
<FiFolder />
Развернуть все
</button>
<button
type="button"
onClick={collapseAllFolders}
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm text-gray-600 hover:bg-gray-50 transition"
disabled={objects.length === 0}
>
<FiFolderMinus />
Свернуть все
</button>
</div>
{objectsLoading ? (
@@ -1227,44 +1490,30 @@ const StorageBucketPage: React.FC = () => {
Объекты не найдены. Попробуйте изменить фильтр или загрузить новые файлы.
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Выбор</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Ключ</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Размер</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Изменён</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{objects.map((object) => (
<tr key={object.key} className="hover:bg-gray-50">
<td className="px-4 py-2">
<input
type="checkbox"
checked={!!selectedKeys[object.key]}
onChange={() => handleToggleSelection(object.key)}
/>
</td>
<td className="px-4 py-2 font-mono text-xs text-gray-700 break-all">{object.key}</td>
<td className="px-4 py-2 text-gray-600">{formatBytes(object.size)}</td>
<td className="px-4 py-2 text-gray-600">{object.lastModified ? formatDate(object.lastModified, true) : '—'}</td>
<td className="px-4 py-2">
<button
type="button"
onClick={() => handleDownloadObject(object)}
className="inline-flex items-center gap-2 px-3 py-1.5 border border-gray-200 rounded-lg text-xs text-gray-600 hover:bg-gray-50"
>
<FiDownload />
Скачать
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="border border-gray-200 rounded-lg overflow-hidden">
{/* Заголовок проводника */}
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 flex items-center gap-4 text-xs font-semibold text-gray-500">
<span className="w-6"></span>
<span className="flex-1">Имя</span>
<span className="w-24 text-right">Размер</span>
<span className="w-40 text-right">Изменён</span>
<span className="w-32 text-right">Действия</span>
</div>
{/* Дерево файлов */}
<div className="divide-y divide-gray-100">
<FileTreeView
node={fileTree}
depth={0}
expandedFolders={expandedFolders}
toggleFolder={toggleFolder}
selectedKeys={selectedKeys}
handleToggleSelection={handleToggleSelection}
handleDownloadObject={handleDownloadObject}
formatBytes={formatBytes}
formatDate={formatDate}
objects={objects}
/>
</div>
</div>
)}
@@ -1306,95 +1555,6 @@ const StorageBucketPage: React.FC = () => {
</div>
</div>
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-800">Доступ к MinIO Console</h3>
<p className="text-xs text-gray-500 mt-1">
Здесь можно получить логин и временный пароль для панели управления объектным хранилищем.
Пароль показывается только один раз после генерации.
</p>
</div>
<button
type="button"
onClick={handleGenerateConsoleCredentials}
disabled={consoleCredentialsLoading || bucketActionPending || bucketLoading}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${
consoleCredentialsLoading || bucketActionPending || bucketLoading
? 'cursor-not-allowed bg-gray-200 text-gray-500'
: 'bg-ospab-primary text-white hover:bg-ospab-primary/90'
}`}
>
{consoleCredentialsLoading ? <FiRefreshCw className="animate-spin" /> : <FiKey />}
<span>{consoleCredentialsLoading ? 'Создаём...' : 'Сгенерировать пароль'}</span>
</button>
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs text-gray-700">Логин: {consoleLoginDisplay}</span>
{consoleLoginValue && (
<button
type="button"
onClick={() => handleCopy(consoleLoginValue, 'Логин консоли')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-gray-200 rounded-lg text-xs font-semibold text-gray-600 hover:bg-gray-50"
>
<FiCopy />
Копировать
</button>
)}
{consoleUrl && (
<a
href={consoleUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-ospab-primary hover:underline"
>
Открыть MinIO Console
</a>
)}
</div>
{consoleCredentials && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800 space-y-2">
<div className="flex items-center gap-2 font-semibold">
<FiInfo />
Новые данные входа
</div>
<p className="text-xs text-green-700">
Скопируйте пароль сейчас. После закрытия страницы он больше не отобразится.
</p>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs text-green-900">Логин: {consoleCredentials.login}</span>
<button
type="button"
onClick={() => handleCopy(consoleCredentials.login, 'Логин консоли')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-green-200 rounded-lg text-xs font-semibold text-green-700 hover:bg-green-100"
>
<FiCopy />
Копировать
</button>
</div>
<div className="flex flex-wrap gap-3 items-center">
<span className="font-mono text-xs text-green-900">Пароль: {consoleCredentials.password}</span>
<button
type="button"
onClick={() => handleCopy(consoleCredentials.password, 'Пароль консоли')}
className="inline-flex items-center gap-1 px-3 py-1.5 border border-green-200 rounded-lg text-xs font-semibold text-green-700 hover:bg-green-100"
>
<FiCopy />
Копировать
</button>
</div>
</div>
)}
{consoleCredentialsError && (
<div className="text-xs text-red-600">
{consoleCredentialsError}
</div>
)}
</div>
<div className="space-y-4">
<div className="border border-gray-200 rounded-xl p-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
@@ -1483,29 +1643,47 @@ const StorageBucketPage: React.FC = () => {
<FiKey className="text-ospab-primary text-xl" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-800">Доступ по ключам</h2>
<p className="text-sm text-gray-500">Создавайте и управляйте access/secret ключами для приложений.</p>
<h2 className="text-lg font-semibold text-gray-800">Ключ доступа S3</h2>
<p className="text-sm text-gray-500">Access Key и Secret Key для программного доступа к хранилищу (один ключ на бакет).</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={newKeyLabel}
onChange={(event) => setNewKeyLabel(event.target.value)}
placeholder="Название или назначение ключа"
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none"
/>
<button
type="button"
onClick={handleCreateAccessKey}
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg text-sm font-semibold hover:bg-ospab-primary/90 transition"
disabled={creatingKey}
>
<FiKey />
{creatingKey ? 'Создаём...' : 'Создать ключ'}
</button>
{accessKeys.length === 0 ? (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="text"
value={newKeyLabel}
onChange={(event) => setNewKeyLabel(event.target.value)}
placeholder="Название или назначение ключа"
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-ospab-primary focus:outline-none"
/>
<button
type="button"
onClick={handleCreateAccessKey}
className="inline-flex items-center gap-2 px-4 py-2 bg-ospab-primary text-white rounded-lg text-sm font-semibold hover:bg-ospab-primary/90 transition"
disabled={creatingKey}
>
<FiKey />
{creatingKey ? 'Создаём...' : 'Создать ключ'}
</button>
</div>
) : null}
{/* Информация о консоли MinIO */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-700">
<span className="font-semibold">Веб-консоль:</span> Вы также можете управлять файлами через{' '}
<a
href="https://console.s3.ospab.host"
target="_blank"
rel="noopener noreferrer"
className="text-ospab-primary hover:underline font-semibold"
>
console.s3.ospab.host
</a>
{' '} используйте Access Key и Secret Key для входа.
</p>
</div>
{lastCreatedKey && (
@@ -1536,6 +1714,14 @@ const StorageBucketPage: React.FC = () => {
Копировать
</button>
</div>
<div className="mt-3 pt-3 border-t border-blue-200">
<p className="text-xs font-semibold mb-2">Подключение к S3:</p>
<div className="space-y-1 text-xs font-mono bg-white/50 rounded p-2">
<div>Endpoint: <span className="text-blue-700">{bucket?.endpoint || 's3.ospab.host'}</span></div>
<div>Bucket: <span className="text-blue-700">{bucket?.physicalName || bucket?.name}</span></div>
<div>Region: <span className="text-blue-700">{bucket?.region || 'ru-msk-1'}</span></div>
</div>
</div>
</div>
)}

View File

@@ -22,6 +22,8 @@ export interface Ticket {
export interface StorageBucket {
id: number;
name: string;
physicalName?: string;
endpoint?: string;
plan: string;
quotaGb: number;
usedBytes: number;