english version and minio console access
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
63
ospabhost/frontend/src/middleware/LocaleProvider.tsx
Normal file
63
ospabhost/frontend/src/middleware/LocaleProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
ospabhost/frontend/src/middleware/index.ts
Normal file
13
ospabhost/frontend/src/middleware/index.ts
Normal 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';
|
||||
15
ospabhost/frontend/src/middleware/locale.context.ts
Normal file
15
ospabhost/frontend/src/middleware/locale.context.ts
Normal 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,
|
||||
});
|
||||
20
ospabhost/frontend/src/middleware/locale.hooks.ts
Normal file
20
ospabhost/frontend/src/middleware/locale.hooks.ts
Normal 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 };
|
||||
124
ospabhost/frontend/src/middleware/locale.utils.ts
Normal file
124
ospabhost/frontend/src/middleware/locale.utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Ticket {
|
||||
export interface StorageBucket {
|
||||
id: number;
|
||||
name: string;
|
||||
physicalName?: string;
|
||||
endpoint?: string;
|
||||
plan: string;
|
||||
quotaGb: number;
|
||||
usedBytes: number;
|
||||
|
||||
Reference in New Issue
Block a user