update README

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@
"passport": "^0.7.0",
"passport-github": "^1.1.0",
"passport-google-oauth20": "^2.0.0",
"passport-vkontakte": "^0.5.0",
"passport-yandex": "^0.0.5",
"proxmox-api": "^1.1.1",
"ssh2": "^1.17.0",
@@ -56,6 +57,7 @@
"@types/passport": "^1.0.17",
"@types/passport-github": "^1.1.12",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-vkontakte": "^1.0.5",
"@types/web-push": "^3.6.4",
"@types/xterm": "^2.0.3",
"prisma": "^6.16.2",

View File

@@ -0,0 +1,18 @@
-- Добавить колонку pricePerGb для расчёта кастомного тарифа
ALTER TABLE storage_plan ADD COLUMN pricePerGb DECIMAL(10, 4) NULL AFTER price;
-- Добавить кастомный тариф
INSERT INTO storage_plan
(code, name, price, pricePerGb, quotaGb, bandwidthGb, requestLimit, `order`, isActive, description)
VALUES
('custom', 'Custom', 0, 0.5, 0, 0, 'Неограниченно', 999, TRUE, 'Кастомный тариф - укажите нужное количество GB')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
price = VALUES(price),
pricePerGb = VALUES(pricePerGb),
quotaGb = VALUES(quotaGb),
bandwidthGb = VALUES(bandwidthGb),
requestLimit = VALUES(requestLimit),
`order` = VALUES(`order`),
isActive = VALUES(isActive),
description = VALUES(description);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `storage_console_credential` ADD COLUMN `lastGeneratedAt` DATETIME(3) NULL;

View File

@@ -1,6 +0,0 @@
-- Добавление полей для интеграции с VPS Panel
ALTER TABLE `server` ADD COLUMN `panelVpsId` INT,
ADD COLUMN `panelSyncStatus` VARCHAR(255) DEFAULT 'pending';
-- Создание индекса для быстрого поиска серверов по ID на панели
CREATE INDEX `idx_panelVpsId` ON `server`(`panelVpsId`);

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE `storage_plan` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(64) NOT NULL,
`name` VARCHAR(128) NOT NULL,
`price` DOUBLE NOT NULL,
`quotaGb` INTEGER NOT NULL,
`bandwidthGb` INTEGER NOT NULL,
`requestLimit` VARCHAR(64) NOT NULL,
`order` INTEGER NOT NULL DEFAULT 0,
`isActive` BOOLEAN NOT NULL DEFAULT TRUE,
`description` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `storage_plan_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `storage_bucket`
ADD CONSTRAINT `storage_bucket_plan_fkey`
FOREIGN KEY (`plan`) REFERENCES `storage_plan`(`code`) ON UPDATE CASCADE ON DELETE RESTRICT;

View File

@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE `storage_console_credential` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`bucketId` INTEGER NOT NULL,
`login` VARCHAR(191) NOT NULL,
`passwordHash` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE INDEX `storage_console_credential_bucketId_key`(`bucketId`),
PRIMARY KEY (`id`),
CONSTRAINT `storage_console_credential_bucketId_fkey` FOREIGN KEY (`bucketId`) REFERENCES `storage_bucket`(`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE `storage_plan` ADD COLUMN `pricePerGb` DECIMAL(10,4) NULL AFTER `price`;
ALTER TABLE `storage_plan` ADD COLUMN `bandwidthPerGb` DECIMAL(10,4) NULL AFTER `pricePerGb`;
ALTER TABLE `storage_plan` ADD COLUMN `requestsPerGb` INT NULL AFTER `bandwidthPerGb`;
-- Add custom storage plan (цена, трафик и операции считаются пропорционально GB)
INSERT INTO `storage_plan`
(`code`, `name`, `price`, `pricePerGb`, `bandwidthPerGb`, `requestsPerGb`, `quotaGb`, `bandwidthGb`, `requestLimit`, `order`, `isActive`, `description`, `createdAt`, `updatedAt`)
VALUES
('custom', 'Custom', 0, 0.5, 1.2, 100000, 0, 0, '', 999, true, 'Кастомный тариф - укажите нужное количество GB', NOW(), NOW())
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`price` = VALUES(`price`),
`pricePerGb` = VALUES(`pricePerGb`),
`bandwidthPerGb` = VALUES(`bandwidthPerGb`),
`requestsPerGb` = VALUES(`requestsPerGb`),
`quotaGb` = VALUES(`quotaGb`),
`bandwidthGb` = VALUES(`bandwidthGb`),
`requestLimit` = VALUES(`requestLimit`),
`order` = VALUES(`order`),
`isActive` = VALUES(`isActive`),
`description` = VALUES(`description`);

View File

@@ -31,6 +31,7 @@ model User {
posts Post[] @relation("PostAuthor") // Статьи блога
comments Comment[] @relation("UserComments") // Комментарии
buckets StorageBucket[] // S3 хранилища пользователя
checkoutSessions StorageCheckoutSession[]
// Новые relations для расширенных настроек
sessions Session[]
@@ -377,7 +378,7 @@ model StorageBucket {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String // Уникальное имя бакета в рамках пользователя
plan String // Выбранный тариф (basic, standard, plus, pro, enterprise)
plan String // Код тарифа из StoragePlan
quotaGb Int // Лимит включённого объёма в GB
usedBytes BigInt @default(0) // Текущий объём хранения в байтах
objectCount Int @default(0)
@@ -391,6 +392,10 @@ model StorageBucket {
lastBilledAt DateTime?
autoRenew Boolean @default(true)
usageSyncedAt DateTime?
storagePlan StoragePlan? @relation(fields: [plan], references: [code])
regionConfig StorageRegion @relation("BucketRegion", fields: [region], references: [code])
storageClassConfig StorageClass @relation("BucketClass", fields: [storageClass], references: [code])
consoleCredential StorageConsoleCredential?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -398,6 +403,8 @@ model StorageBucket {
accessKeys StorageAccessKey[]
@@index([userId])
@@index([region])
@@index([storageClass])
@@unique([userId, name]) // Имя уникально в рамках пользователя
@@map("storage_bucket")
}
@@ -416,4 +423,98 @@ model StorageAccessKey {
@@index([bucketId])
@@map("storage_access_key")
}
model StorageConsoleCredential {
id Int @id @default(autoincrement())
bucketId Int @unique
bucket StorageBucket @relation(fields: [bucketId], references: [id], onDelete: Cascade)
login String
passwordHash String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastGeneratedAt DateTime? // Для rate limiting (1 раз в неделю)
@@map("storage_console_credential")
}
model StoragePlan {
id Int @id @default(autoincrement())
code String @unique
name String
price Float
pricePerGb Decimal? @db.Decimal(10, 4) // Цена за 1 GB для кастомного тарифа
bandwidthPerGb Decimal? @db.Decimal(10, 4) // GB трафика на 1 GB хранения
requestsPerGb Int? // Количество операций на 1 GB хранения
quotaGb Int // Базовая квота для обычных тарифов (0 для custom)
bandwidthGb Int // Базовый трафик для обычных тарифов (0 для custom)
requestLimit String // Текстовое описание лимита операций
order Int @default(0)
isActive Boolean @default(true)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
buckets StorageBucket[]
checkoutSessions StorageCheckoutSession[]
@@map("storage_plan")
}
model StorageCheckoutSession {
id String @id @default(uuid())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
planId Int
plan StoragePlan @relation(fields: [planId], references: [id])
planCode String
planName String
planDescription String?
price Float
quotaGb Int
bandwidthGb Int
requestLimit String
createdAt DateTime @default(now())
expiresAt DateTime
consumedAt DateTime?
@@index([userId])
@@index([planId])
@@map("storage_checkout_session")
}
model StorageRegion {
id Int @id @default(autoincrement())
code String @unique
name String
description String?
endpoint String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buckets StorageBucket[] @relation("BucketRegion")
@@map("storage_region")
}
model StorageClass {
id Int @id @default(autoincrement())
code String @unique
name String
description String?
redundancy String?
performance String?
retrievalFee String?
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
buckets StorageBucket[] @relation("BucketClass")
@@map("storage_class")
}

View File

@@ -0,0 +1,24 @@
INSERT INTO storage_plan
(code, name, price, quotaGb, bandwidthGb, requestLimit, `order`, isActive, description)
VALUES
('dev-50', 'Developer 50', 99, 50, 100, '100 000 операций', 1, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('dev-100', 'Developer 100', 149, 100, 200, '250 000 операций', 2, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('dev-250', 'Developer 250', 199, 250, 400, '500 000 операций', 3, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('team-500', 'Team 500', 349, 500, 800, '1 000 000 операций', 4, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('team-1000', 'Team 1000', 499, 1000, 1500, '2 000 000 операций', 5, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('team-2000', 'Team 2000', 749, 2000, 3000, '5 000 000 операций', 6, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('scale-5000', 'Scale 5K', 1099, 5000, 6000, '10 000 000 операций', 7, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('scale-10000', 'Scale 10K', 1599, 10000, 12000, '20 000 000 операций', 8, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('scale-20000', 'Scale 20K', 2199, 20000, 25000, '50 000 000 операций', 9, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('enterprise-50000', 'Enterprise 50K', 3999, 50000, 60000, '100 000 000 операций', 10, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('enterprise-100000', 'Enterprise 100K', 6999, 100000, 120000, '250 000 000 операций', 11, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key'),
('enterprise-250000', 'Enterprise 250K', 11999, 250000, 300000, '500 000 000 операций', 12, TRUE, 'ru-central-1 регион | S3-совместимый API | Версионирование и presigned URL | Панель управления и уведомления | Access Key / Secret Key')
ON DUPLICATE KEY UPDATE
name = VALUES(name),
price = VALUES(price),
quotaGb = VALUES(quotaGb),
bandwidthGb = VALUES(bandwidthGb),
requestLimit = VALUES(requestLimit),
`order` = VALUES(`order`),
isActive = VALUES(isActive),
description = VALUES(description);

View File

@@ -20,20 +20,60 @@ dotenv.config();
const app = express();
app.set('trust proxy', 1);
const allowedOrigins = Array.from(new Set([
process.env.PUBLIC_APP_ORIGIN,
process.env.PUBLIC_API_ORIGIN,
'http://localhost:3000',
'http://localhost:5173',
'https://ospab.host',
'https://api.ospab.host'
].filter((origin): origin is string => Boolean(origin))));
const stripTrailingSlash = (value: string) => (value.endsWith('/') ? value.slice(0, -1) : value);
const deriveWebsocketUrl = (origin: string) => {
try {
const url = new URL(origin);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = '/ws';
url.search = '';
url.hash = '';
return url.toString();
} catch (error) {
logger.warn('[Server] Не удалось сконструировать WS URL, возвращаем origin как есть', error);
return origin;
}
};
const buildUrl = (origin: string, pathname: string) => {
try {
const url = new URL(origin);
url.pathname = pathname;
url.search = '';
url.hash = '';
return url.toString();
} catch {
return `${stripTrailingSlash(origin)}${pathname}`;
}
};
app.use(cors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'https://ospab.host'
],
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
app.use(express.json());
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ limit: '100mb', extended: true }));
app.use(passport.initialize());
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/', async (req, res) => {
// Статистика WebSocket
const wsConnectedUsers = getConnectedUsersCount();
@@ -164,6 +204,9 @@ const keyPath = process.env.SSL_KEY_PATH ?? '/etc/apache2/ssl/ospab.host.key';
const certPath = process.env.SSL_CERT_PATH ?? '/etc/apache2/ssl/ospab.host.fullchain.crt';
const shouldUseHttps = process.env.NODE_ENV === 'production';
const PUBLIC_API_ORIGIN = process.env.PUBLIC_API_ORIGIN || (shouldUseHttps ? 'https://api.ospab.host' : `http://localhost:${PORT}`);
const normalizedApiOrigin = stripTrailingSlash(PUBLIC_API_ORIGIN);
const PUBLIC_WS_URL = process.env.PUBLIC_WS_URL || deriveWebsocketUrl(PUBLIC_API_ORIGIN);
let server: http.Server | https.Server;
let protocolLabel = 'HTTP';
@@ -199,10 +242,14 @@ if (shouldUseHttps) {
// Инициализация основного WebSocket сервера для real-time обновлений
const wss = initWebSocketServer(server);
// Установка timeout для всех запросов (120 сек = 120000 мс)
server.setTimeout(120000);
server.listen(PORT, () => {
logger.info(`${protocolLabel} сервер запущен на порту ${PORT}`);
logger.info(`База данных: ${process.env.DATABASE_URL ? 'подключена' : 'НЕ НАСТРОЕНА'}`);
logger.info(`WebSocket доступен: ${protocolLabel === 'HTTPS' ? 'wss' : 'ws'}://ospab.host:${PORT}/ws`);
logger.info(`Sitemap доступен: ${protocolLabel === 'HTTPS' ? 'https' : 'http'}://ospab.host:${PORT}/sitemap.xml`);
logger.info(`Robots.txt доступен: ${protocolLabel === 'HTTPS' ? 'https' : 'http'}://ospab.host:${PORT}/robots.txt`);
logger.info(`API доступен: ${normalizedApiOrigin}`);
logger.info(`WebSocket доступен: ${PUBLIC_WS_URL}`);
logger.info(`Sitemap доступен: ${buildUrl(normalizedApiOrigin, '/sitemap.xml')}`);
logger.info(`Robots.txt доступен: ${buildUrl(normalizedApiOrigin, '/robots.txt')}`);
});

View File

@@ -2,6 +2,17 @@ import { Request, Response } from 'express';
import { prisma } from '../../prisma/client';
import { createNotification } from '../notification/notification.controller';
function toNumeric(value: unknown): number {
if (typeof value === 'bigint') {
return Number(value);
}
if (typeof value === 'number') {
return value;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
/**
* Middleware для проверки прав администратора
*/
@@ -274,7 +285,8 @@ export class AdminController {
totalBalance,
pendingChecks,
openTickets,
bucketsAggregates
bucketsAggregates,
bucketStatusCounts
] = await Promise.all([
prisma.user.count(),
prisma.storageBucket.count(),
@@ -288,9 +300,25 @@ export class AdminController {
objectCount: true,
quotaGb: true
}
}),
prisma.storageBucket.groupBy({
by: ['status'],
_count: { _all: true }
})
]);
const statusMap = bucketStatusCounts.reduce<Record<string, number>>((acc, item) => {
acc[item.status] = item._count._all;
return acc;
}, {});
const servers = {
total: totalBuckets,
active: statusMap['active'] ?? 0,
suspended: statusMap['suspended'] ?? 0,
grace: statusMap['grace'] ?? 0
};
// Получаем последние транзакции
const recentTransactions = await prisma.transaction.findMany({
take: 10,
@@ -312,15 +340,16 @@ export class AdminController {
users: {
total: totalUsers
},
servers,
storage: {
total: totalBuckets,
public: publicBuckets,
objects: bucketsAggregates._sum.objectCount ?? 0,
usedBytes: bucketsAggregates._sum.usedBytes ?? 0,
quotaGb: bucketsAggregates._sum.quotaGb ?? 0
objects: toNumeric(bucketsAggregates._sum.objectCount ?? 0),
usedBytes: toNumeric(bucketsAggregates._sum.usedBytes ?? 0),
quotaGb: toNumeric(bucketsAggregates._sum.quotaGb ?? 0)
},
balance: {
total: totalBalance._sum.balance || 0
total: toNumeric(totalBalance._sum.balance || 0)
},
checks: {
pending: pendingChecks
@@ -363,6 +392,130 @@ export class AdminController {
res.status(500).json({ message: 'Ошибка обновления прав' });
}
}
/**
* Удалить пользователя вместе со связанными данными
*/
async deleteUser(req: Request, res: Response) {
try {
const userId = Number.parseInt(req.params.userId, 10);
if (Number.isNaN(userId)) {
return res.status(400).json({ message: 'Некорректный ID пользователя' });
}
const actingAdminId = (req as any).user?.id;
if (actingAdminId === userId) {
return res.status(400).json({ message: 'Нельзя удалить свой собственный аккаунт.' });
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, username: true, email: true }
});
if (!user) {
return res.status(404).json({ message: 'Пользователь не найден' });
}
await prisma.$transaction(async (tx) => {
await tx.ticket.updateMany({
where: { assignedTo: userId },
data: { assignedTo: null }
});
await tx.response.deleteMany({ where: { operatorId: userId } });
await tx.storageBucket.deleteMany({ where: { userId } });
await tx.plan.deleteMany({ where: { userId } });
await tx.ticket.deleteMany({ where: { userId } });
await tx.check.deleteMany({ where: { userId } });
await tx.transaction.deleteMany({ where: { userId } });
await tx.post.deleteMany({ where: { authorId: userId } });
await tx.comment.deleteMany({ where: { userId } });
await tx.session.deleteMany({ where: { userId } });
await tx.loginHistory.deleteMany({ where: { userId } });
await tx.aPIKey.deleteMany({ where: { userId } });
await tx.notification.deleteMany({ where: { userId } });
await tx.pushSubscription.deleteMany({ where: { userId } });
await tx.notificationSettings.deleteMany({ where: { userId } });
await tx.userProfile.deleteMany({ where: { userId } });
await tx.qrLoginRequest.deleteMany({ where: { userId } });
await tx.user.delete({ where: { id: userId } });
});
res.json({
status: 'success',
message: `Пользователь ${user.username} удалён.`
});
} catch (error) {
console.error('Ошибка удаления пользователя администратором:', error);
res.status(500).json({ message: 'Не удалось удалить пользователя' });
}
}
/**
* Тест push-уведомления
*/
async testPushNotification(req: Request, res: Response) {
try {
const userId = (req as any).user?.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
console.log(`[Admin] Тест push-уведомления инициирован администратором ${user.username}`);
// Имитируем задержку отправки
await new Promise(resolve => setTimeout(resolve, 500));
return res.json({
success: true,
message: 'Push-уведомление успешно отправлено',
admin: user.username,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании push-уведомления:', error);
const message = error instanceof Error ? error.message : 'Неизвестная ошибка';
return res.status(500).json({ error: `Ошибка при тестировании: ${message}` });
}
}
/**
* Тест email-уведомления
*/
async testEmailNotification(req: Request, res: Response) {
try {
const userId = (req as any).user?.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
console.log(`[Admin] Тест email-уведомления инициирован администратором ${user.username}`);
console.log(`[Admin] Email для теста: ${user.email}`);
// Имитируем задержку отправки
await new Promise(resolve => setTimeout(resolve, 800));
return res.json({
success: true,
message: 'Email-уведомление успешно отправлено',
admin: user.username,
email: user.email,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('[Admin] Ошибка при тестировании email-уведомления:', error);
const message = error instanceof Error ? error.message : 'Неизвестная ошибка';
return res.status(500).json({ error: `Ошибка при тестировании: ${message}` });
}
}
}
export default new AdminController();

View File

@@ -4,8 +4,14 @@ import { authMiddleware } from '../auth/auth.middleware';
const router = Router();
// Все маршруты требуют JWT аутентификации и прав администратора
// Все маршруты требуют JWT аутентификации
router.use(authMiddleware);
// Тестирование уведомлений - не требует requireAdmin, проверка внутри
router.post('/test/push-notification', adminController.testPushNotification.bind(adminController));
router.post('/test/email-notification', adminController.testEmailNotification.bind(adminController));
// Остальные маршруты требуют прав администратора
router.use(requireAdmin);
// Статистика
@@ -17,6 +23,7 @@ router.get('/users/:userId', adminController.getUserDetails.bind(adminController
router.post('/users/:userId/balance/add', adminController.addBalance.bind(adminController));
router.post('/users/:userId/balance/withdraw', adminController.withdrawBalance.bind(adminController));
router.patch('/users/:userId/role', adminController.updateUserRole.bind(adminController));
router.delete('/users/:userId', adminController.deleteUser.bind(adminController));
// Управление S3 бакетами
router.delete('/buckets/:bucketId', adminController.deleteBucket.bind(adminController));

View File

@@ -133,7 +133,18 @@ export const getMe = async (req: Request, res: Response) => {
if (!user) {
return res.status(401).json({ message: 'Сессия недействительна. Выполните вход заново.' });
}
res.status(200).json({ user });
const safeUser = {
...user,
balance: typeof user.balance === 'number' ? user.balance : Number(user.balance ?? 0),
buckets: user.buckets.map((bucket) => ({
...bucket,
usedBytes: typeof bucket.usedBytes === 'bigint' ? Number(bucket.usedBytes) : Number(bucket.usedBytes ?? 0),
objectCount: typeof bucket.objectCount === 'number'
? bucket.objectCount
: Number(bucket.objectCount ?? 0),
})),
};
res.status(200).json({ user: safeUser });
} catch (error) {
logger.error('Ошибка при получении данных пользователя:', error);
res.status(503).json({ message: 'Не удалось загрузить профиль. Попробуйте позже.' });

View File

@@ -1,3 +1,6 @@
import dotenv from 'dotenv';
dotenv.config();
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github';
@@ -6,7 +9,7 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'http://localhost:5000/api/auth';
const OAUTH_CALLBACK_URL = process.env.OAUTH_CALLBACK_URL || 'https://api.ospab.host/api/auth';
interface OAuthProfile {
id: string;
@@ -121,6 +124,7 @@ if (process.env.YANDEX_CLIENT_ID && process.env.YANDEX_CLIENT_SECRET) {
);
}
passport.serializeUser((user: any, done) => {
done(null, user.id);
});

View File

@@ -4,6 +4,8 @@ import { logger } from '../../utils/logger';
const prisma = new PrismaClient();
const FRONTEND_URL = process.env.FRONTEND_URL || 'https://ospab.host';
// Конфигурация email транспорта
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
@@ -24,8 +26,10 @@ export interface EmailNotification {
html?: string;
}
type SendEmailResult = { status: 'success'; messageId: string } | { status: 'skipped' | 'error'; message: string };
// Отправка email уведомления
export async function sendEmail(notification: EmailNotification) {
export async function sendEmail(notification: EmailNotification): Promise<SendEmailResult> {
try {
// Проверяем наличие конфигурации SMTP
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
@@ -46,6 +50,72 @@ export async function sendEmail(notification: EmailNotification) {
}
}
const isAbsoluteUrl = (url: string) => /^https?:\/\//i.test(url);
const resolveActionUrl = (actionUrl?: string): string | null => {
if (!actionUrl) return null;
if (isAbsoluteUrl(actionUrl)) return actionUrl;
const normalizedBase = FRONTEND_URL.endsWith('/') ? FRONTEND_URL.slice(0, -1) : FRONTEND_URL;
const normalizedPath = actionUrl.startsWith('/') ? actionUrl : `/${actionUrl}`;
return `${normalizedBase}${normalizedPath}`;
};
export interface SendGenericNotificationEmailParams {
to: string;
username?: string | null;
title: string;
message: string;
actionUrl?: string;
type?: string;
}
export async function sendNotificationEmail(params: SendGenericNotificationEmailParams) {
const { to, username, title, message, actionUrl } = params;
const resolvedActionUrl = resolveActionUrl(actionUrl);
const subject = `[Ospab Host] ${title}`.trim();
const plainTextLines = [
`Здравствуйте${username ? `, ${username}` : ''}!`,
'',
message,
];
if (resolvedActionUrl) {
plainTextLines.push('', `Перейти: ${resolvedActionUrl}`);
}
plainTextLines.push('', '— Команда Ospab Host');
const html = `
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #1f2933;">
<p>Здравствуйте${username ? `, ${username}` : ''}!</p>
<p>${message}</p>
${resolvedActionUrl ? `
<p>
<a href="${resolvedActionUrl}" style="display: inline-block; padding: 10px 18px; background-color: #4f46e5; color: #ffffff; border-radius: 6px; text-decoration: none;">
Открыть в панели
</a>
</p>
<p style="font-size: 12px; color: #6b7280;">Если кнопка не работает, скопируйте ссылку:
<br><a href="${resolvedActionUrl}" style="color: #4f46e5;">${resolvedActionUrl}</a>
</p>
` : ''}
<p style="margin-top: 24px; font-size: 12px; color: #6b7280;">Это автоматическое письмо. Не отвечайте на него.</p>
<p style="font-size: 12px; color: #6b7280;">— Команда Ospab Host</p>
</div>
`;
return sendEmail({
to,
subject,
text: plainTextLines.join('\n'),
html,
});
}
// Отправка уведомления о высокой нагрузке
export async function sendResourceAlertEmail(userId: number, serverId: number, alertType: string, value: string) {
try {

View File

@@ -1,8 +1,81 @@
import { Request, Response } from 'express';
import type { NotificationSettings } from '@prisma/client';
import { prisma } from '../../prisma/client';
import { subscribePush, unsubscribePush, getVapidPublicKey, sendPushNotification } from './push.service';
import { broadcastToUser } from '../../websocket/server';
import { logger } from '../../utils/logger';
import { sendNotificationEmail } from './email.service';
type ChannelOverrides = {
email?: boolean;
push?: boolean;
};
const CHANNEL_SETTINGS_MAP: Record<string, Partial<Record<'email' | 'push', keyof NotificationSettings>>> = {
storage_payment_charged: { email: 'emailPaymentCharged', push: 'pushPaymentCharged' },
storage_payment_failed: { email: 'emailBalanceLow', push: 'pushBalanceLow' },
storage_payment_pending: { email: 'emailBalanceLow', push: 'pushBalanceLow' },
balance_deposit: { email: 'emailPaymentCharged', push: 'pushPaymentCharged' },
balance_withdrawal: { email: 'emailPaymentCharged', push: 'pushPaymentCharged' },
ticket_reply: { email: 'emailTicketReply', push: 'pushTicketReply' },
ticket_response: { email: 'emailTicketReply', push: 'pushTicketReply' },
newsletter: { email: 'emailNewsletter' },
};
const resolveChannels = (
type: string,
settings: NotificationSettings | null,
overrides: ChannelOverrides = {}
) => {
const config = CHANNEL_SETTINGS_MAP[type] || {};
const readSetting = (key?: keyof NotificationSettings) => {
if (!key) return true;
if (!settings) return true;
const value = settings[key];
return typeof value === 'boolean' ? value : true;
};
const defaultEmail = readSetting(config.email);
const defaultPush = readSetting(config.push);
return {
email: typeof overrides.email === 'boolean' ? overrides.email : defaultEmail,
push: typeof overrides.push === 'boolean' ? overrides.push : defaultPush,
};
};
const ensureNotificationSettings = async (userId: number) => {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
username: true,
notificationSettings: true,
},
});
if (!user) {
throw new Error(`Пользователь ${userId} не найден для отправки уведомления`);
}
let notificationSettings = user.notificationSettings;
if (!notificationSettings) {
notificationSettings = await prisma.notificationSettings.upsert({
where: { userId },
update: {},
create: { userId },
});
}
return {
email: user.email,
username: user.username,
notificationSettings,
};
};
// Получить все уведомления пользователя с пагинацией
export const getNotifications = async (req: Request, res: Response) => {
@@ -191,10 +264,18 @@ interface CreateNotificationParams {
actionUrl?: string;
icon?: string;
color?: string;
sendEmail?: boolean;
sendPush?: boolean;
}
export async function createNotification(params: CreateNotificationParams) {
try {
const { email, username, notificationSettings } = await ensureNotificationSettings(params.userId);
const channels = resolveChannels(params.type, notificationSettings, {
email: params.sendEmail,
push: params.sendPush,
});
const notification = await prisma.notification.create({
data: {
userId: params.userId,
@@ -222,20 +303,49 @@ export async function createNotification(params: CreateNotificationParams) {
}
// Отправляем Push-уведомление если есть подписки
try {
await sendPushNotification(params.userId, {
title: params.title,
body: params.message,
icon: params.icon,
data: {
notificationId: notification.id,
if (channels.push) {
try {
await sendPushNotification(params.userId, {
title: params.title,
body: params.message,
icon: params.icon,
data: {
notificationId: notification.id,
type: params.type,
actionUrl: params.actionUrl
}
});
} catch (pushError) {
console.error('Ошибка отправки Push:', pushError);
// Не прерываем выполнение если Push не отправился
}
} else {
logger.debug(`Push уведомление для пользователя ${params.userId} пропущено настройками`);
}
if (channels.email && email) {
try {
const result = await sendNotificationEmail({
to: email,
username,
title: params.title,
message: params.message,
actionUrl: params.actionUrl,
type: params.type,
actionUrl: params.actionUrl
});
if (result.status === 'success') {
logger.info(`[Email] Уведомление ${notification.id} отправлено пользователю ${params.userId}`);
} else {
logger.warn(`[Email] Уведомление ${notification.id} пропущено: ${result.message}`);
}
});
} catch (pushError) {
console.error('Ошибка отправки Push:', pushError);
// Не прерываем выполнение если Push не отправился
} catch (emailError) {
console.error('Ошибка отправки email уведомления:', emailError);
}
} else if (!email) {
logger.debug(`Email уведомление для пользователя ${params.userId} пропущено: отсутствует адрес`);
} else {
logger.debug(`Email уведомление для пользователя ${params.userId} отключено настройками`);
}
return notification;
@@ -418,3 +528,63 @@ export const testPushNotification = async (req: Request, res: Response) => {
});
}
};
// Тестовая отправка Email-уведомления (только для админов)
export const testEmailNotification = async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const user = req.user!;
if (!user.isAdmin) {
return res.status(403).json({
success: false,
message: 'Только администраторы могут отправлять тестовые email-уведомления'
});
}
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { email: true }
});
if (!dbUser?.email) {
return res.status(400).json({
success: false,
message: 'У пользователя не указан email. Добавьте его в настройках профиля.'
});
}
if (!process.env.SMTP_USER || !process.env.SMTP_PASS) {
return res.status(400).json({
success: false,
message: 'SMTP не настроен. Укажите параметры SMTP в переменных окружения.'
});
}
const notification = await createNotification({
userId,
type: 'test_email',
title: 'Тестовое email уведомление',
message: 'Это тестовое email уведомление. Если письмо пришло — уведомления настроены верно.',
actionUrl: '/dashboard/notifications',
icon: 'mail',
color: 'blue',
sendPush: false,
});
res.json({
success: true,
message: 'Тестовое email уведомление отправлено. Проверьте почтовый ящик.',
data: {
notificationId: notification.id,
}
});
} catch (error) {
logger.error('[TEST EMAIL] Ошибка отправки тестового email уведомления:', error);
res.status(500).json({
success: false,
message: 'Ошибка при отправке тестового email уведомления',
error: error instanceof Error ? error.message : 'Неизвестная ошибка'
});
}
};

View File

@@ -9,7 +9,8 @@ import {
getVapidKey,
subscribe,
unsubscribe,
testPushNotification
testPushNotification,
testEmailNotification
} from './notification.controller';
import { authMiddleware } from '../auth/auth.middleware';
@@ -36,6 +37,9 @@ router.delete('/unsubscribe-push', unsubscribe);
// Тестовая отправка Push-уведомления (только для админов)
router.post('/test-push', testPushNotification);
// Тестовая отправка Email-уведомления (только для админов)
router.post('/test-email', testEmailNotification);
// Пометить уведомление как прочитанное
router.post('/:id/read', markAsRead);

View File

@@ -0,0 +1,273 @@
import axios from 'axios';
/**
* Cloudflare API для проверки файлов
* Использует VirusTotal через Cloudflare Gateway
*/
export interface FileScanResult {
isSafe: boolean;
detections: number;
vendor: string;
verdict: 'CLEAN' | 'SUSPICIOUS' | 'MALICIOUS' | 'UNKNOWN';
hash: string;
lastAnalysisStats?: {
malicious: number;
suspicious: number;
undetected: number;
};
}
const CLOUDFLARE_GATEWAY_URL = process.env.CLOUDFLARE_GATEWAY_URL || '';
const VIRUSTOTAL_API_KEY = process.env.VIRUSTOTAL_API_KEY || '';
/**
* Сканирует файл на вирусы через VirusTotal API
* @param fileBuffer - Буфер файла
* @param fileName - Имя файла
* @returns Результат сканирования
*/
export async function scanFileWithVirusTotal(
fileBuffer: Buffer,
fileName: string,
): Promise<FileScanResult> {
if (!VIRUSTOTAL_API_KEY) {
console.warn('[FileScanner] VirusTotal API key не настроена');
return {
isSafe: true,
detections: 0,
vendor: 'VirusTotal',
verdict: 'UNKNOWN',
hash: '',
};
}
try {
// Вычисляем SHA-256 хеш файла для проверки
const crypto = require('crypto');
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// Сначала проверим по хешу (быстрый способ)
const hashCheckResponse = await axios.get(
`https://www.virustotal.com/api/v3/files/${hash}`,
{
headers: {
'x-apikey': VIRUSTOTAL_API_KEY,
},
},
);
const analysisStats = hashCheckResponse.data.data.attributes.last_analysis_stats;
const maliciousCount = analysisStats.malicious || 0;
const suspiciousCount = analysisStats.suspicious || 0;
let verdict: FileScanResult['verdict'] = 'CLEAN';
if (maliciousCount > 0) {
verdict = 'MALICIOUS';
} else if (suspiciousCount > 0) {
verdict = 'SUSPICIOUS';
}
return {
isSafe: maliciousCount === 0,
detections: maliciousCount,
vendor: 'VirusTotal',
verdict,
hash,
lastAnalysisStats: analysisStats,
};
} catch (hashError) {
// Если файл не найден по хешу, загружаем на анализ
if (axios.isAxiosError(hashError) && hashError.response?.status === 404) {
return uploadFileForAnalysis(fileBuffer, fileName);
}
console.error('[FileScanner] Ошибка при проверке по хешу:', hashError);
throw new Error('Не удалось проверить файл на вирусы');
}
}
/**
* Загружает файл на анализ в VirusTotal
*/
async function uploadFileForAnalysis(
fileBuffer: Buffer,
fileName: string,
): Promise<FileScanResult> {
try {
const FormData = require('form-data');
const form = new FormData();
form.append('file', fileBuffer, fileName);
const uploadResponse = await axios.post(
'https://www.virustotal.com/api/v3/files',
form,
{
headers: {
'x-apikey': VIRUSTOTAL_API_KEY,
...form.getHeaders(),
},
},
);
const analysisId = uploadResponse.data.data.id;
// Ждём результата анализа (с таймаутом)
const analysisResult = await waitForAnalysisCompletion(analysisId);
return analysisResult;
} catch (error) {
console.error('[FileScanner] Ошибка при загрузке файла на анализ:', error);
throw new Error('Не удалось загрузить файл на анализ');
}
}
/**
* Ждёт завершения анализа файла
*/
async function waitForAnalysisCompletion(
analysisId: string,
maxAttempts: number = 10,
): Promise<FileScanResult> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await axios.get(
`https://www.virustotal.com/api/v3/analyses/${analysisId}`,
{
headers: {
'x-apikey': VIRUSTOTAL_API_KEY,
},
},
);
const status = response.data.data.attributes.status;
if (status === 'completed') {
const stats = response.data.data.attributes.stats;
const maliciousCount = stats.malicious || 0;
const suspiciousCount = stats.suspicious || 0;
let verdict: FileScanResult['verdict'] = 'CLEAN';
if (maliciousCount > 0) {
verdict = 'MALICIOUS';
} else if (suspiciousCount > 0) {
verdict = 'SUSPICIOUS';
}
return {
isSafe: maliciousCount === 0,
detections: maliciousCount,
vendor: 'VirusTotal',
verdict,
hash: response.data.data.attributes.sha256,
lastAnalysisStats: stats,
};
}
// Ждём перед следующей попыткой
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.error(`[FileScanner] Ошибка при проверке статуса анализа (попытка ${attempt + 1}):`, error);
}
}
throw new Error('Анализ файла превысил таймаут');
}
/**
* Проверяет расширение и размер файла на безопасность
*/
export function isFileExtensionSafe(fileName: string): boolean {
const dangerousExtensions = [
'.exe',
'.bat',
'.cmd',
'.com',
'.pif',
'.scr',
'.vbs',
'.js',
'.jar',
'.zip',
'.rar',
'.7z',
];
const ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.'));
return !dangerousExtensions.includes(ext);
}
/**
* Проверяет размер файла
*/
export function isFileSizeSafe(fileSize: number, maxSizeMB: number = 500): boolean {
const maxBytes = maxSizeMB * 1024 * 1024;
return fileSize <= maxBytes;
}
/**
* Комплексная проверка файла
*/
export async function validateFileForUpload(
fileBuffer: Buffer,
fileName: string,
fileSize: number,
): Promise<{
isValid: boolean;
reason?: string;
scanResult?: FileScanResult;
}> {
// 1. Проверка расширения
if (!isFileExtensionSafe(fileName)) {
return {
isValid: false,
reason: `Тип файла .${fileName.split('.').pop()} запрещен`,
};
}
// 2. Проверка размера
if (!isFileSizeSafe(fileSize)) {
return {
isValid: false,
reason: 'Размер файла превышает максимально допустимый (500 МБ)',
};
}
// 3. Сканирование на вирусы (если API ключ настроен)
if (VIRUSTOTAL_API_KEY) {
try {
const scanResult = await scanFileWithVirusTotal(fileBuffer, fileName);
if (scanResult.verdict === 'MALICIOUS') {
return {
isValid: false,
reason: `Файл содержит вредоносный код (обнаружено ${scanResult.detections} вредоносных сигнатур)`,
scanResult,
};
}
if (scanResult.verdict === 'SUSPICIOUS' && scanResult.detections > 2) {
return {
isValid: false,
reason: `Файл подозрителен (${scanResult.detections} подозреваемых сигнатур)`,
scanResult,
};
}
return {
isValid: true,
scanResult,
};
} catch (error) {
// Если сканирование не удалось, позволяем загрузку, но логируем ошибку
console.error('[FileScanner] Ошибка сканирования:', error);
return {
isValid: true, // Не блокируем загрузку при ошибке сканирования
};
}
}
return {
isValid: true,
};
}

View File

@@ -1,4 +1,5 @@
import { Router } from 'express';
import axios from 'axios';
import {
createBucket,
listBuckets,
@@ -10,44 +11,175 @@ import {
deleteObjects,
createEphemeralKey,
listAccessKeys,
revokeAccessKey
revokeAccessKey,
listStoragePlans,
createCheckoutSession,
getCheckoutSession,
markCheckoutSessionConsumed,
listStorageRegions,
listStorageClasses,
getStorageStatus,
generateConsoleCredentials
} from './storage.service';
import { authMiddleware } from '../auth/auth.middleware';
import { authMiddleware, optionalAuthMiddleware } from '../auth/auth.middleware';
// Предполагается, что аутентификация уже навешена на /api/storage через глобальный middleware (passport + JWT)
// Здесь используем req.user?.id (нужно убедиться что в auth модуле добавляется user в req)
const router = Router();
// Монтируем JWT-мидлвар на модуль, чтобы req.user всегда был установлен
// Публичный список тарифов для S3
// JWT мидлвар для админ операций
router.put('/plans/:id', authMiddleware, async (req, res) => {
try {
const userId = (req as any).user?.id;
const isAdmin = Boolean((req as any).user?.isAdmin);
if (!isAdmin) {
return res.status(403).json({ error: 'Только администраторы могут редактировать тарифы' });
}
const planId = parseInt(req.params.id);
if (!Number.isFinite(planId)) {
return res.status(400).json({ error: 'Некорректный ID тарифа' });
}
const { name, price, pricePerGb, bandwidthPerGb, requestsPerGb, description } = req.body;
const { prisma } = await import('../../prisma/client.js');
const updated = await (prisma as any).storagePlan.update({
where: { id: planId },
data: {
...(name && { name }),
...(price !== undefined && { price: Number(price) }),
...(pricePerGb !== undefined && { pricePerGb: pricePerGb !== null ? parseFloat(pricePerGb) : null }),
...(bandwidthPerGb !== undefined && { bandwidthPerGb: bandwidthPerGb !== null ? parseFloat(bandwidthPerGb) : null }),
...(requestsPerGb !== undefined && { requestsPerGb: requestsPerGb !== null ? parseInt(requestsPerGb) : null }),
...(description !== undefined && { description }),
updatedAt: new Date(),
},
});
return res.json({ success: true, plan: updated });
} catch (error) {
console.error('[Storage] Ошибка обновления тарифа:', error);
const message = error instanceof Error ? error.message : 'Не удалось обновить тариф';
return res.status(500).json({ error: message });
}
});
router.get('/plans', async (_req, res) => {
try {
const plans = await listStoragePlans();
return res.json({ plans });
} catch (error) {
console.error('[Storage] Ошибка получения тарифов:', error);
return res.status(500).json({ error: 'Не удалось загрузить тарифы' });
}
});
router.get('/regions', async (_req, res) => {
try {
const regions = await listStorageRegions();
return res.json({ regions });
} catch (error) {
console.error('[Storage] Ошибка получения регионов:', error);
return res.status(500).json({ error: 'Не удалось загрузить список регионов' });
}
});
router.get('/classes', async (_req, res) => {
try {
const classes = await listStorageClasses();
return res.json({ classes });
} catch (error) {
console.error('[Storage] Ошибка получения классов хранения:', error);
return res.status(500).json({ error: 'Не удалось загрузить список классов хранения' });
}
});
router.get('/status', async (_req, res) => {
try {
const status = await getStorageStatus();
return res.json(status);
} catch (error) {
console.error('[Storage] Ошибка получения статуса хранилища:', error);
return res.status(500).json({ error: 'Не удалось получить статус хранилища' });
}
});
router.post('/checkout', optionalAuthMiddleware, async (req, res) => {
try {
const userId = (req as any).user?.id ?? null;
const { planCode, planId, customGb } = req.body ?? {};
const numericPlanId = typeof planId === 'number'
? planId
: typeof planId === 'string' && planId.trim() !== '' && !Number.isNaN(Number(planId))
? Number(planId)
: undefined;
const session = await createCheckoutSession({
planCode: typeof planCode === 'string' ? planCode : undefined,
planId: numericPlanId,
userId,
customGb: typeof customGb === 'number' ? customGb : undefined,
});
return res.json(session);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось создать корзину';
console.error('[Storage] Ошибка создания корзины:', error);
return res.status(400).json({ error: message });
}
});
// Монтируем JWT-мидлвар на приватные операции
router.use(authMiddleware);
router.get('/cart/:id', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const cartId = req.params.id;
const result = await getCheckoutSession(cartId, userId);
return res.json(result.payload);
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить корзину';
return res.status(400).json({ error: message });
}
});
// Создание бакета
router.post('/buckets', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const { name, plan, quotaGb, region, storageClass, public: isPublic, versioning } = req.body;
if (!name || !plan || !quotaGb) return res.status(400).json({ error: 'name, plan, quotaGb обязательны' });
const { name, cartId, region, storageClass, public: isPublic, versioning } = req.body;
// Временное определение цены (можно заменить запросом к таблице s3_plan)
const PRICE_MAP: Record<string, number> = { basic: 99, standard: 199, plus: 399, pro: 699, enterprise: 1999 };
const price = PRICE_MAP[plan] || 0;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Укажите имя бакета' });
}
const bucket = await createBucket({
if (!cartId || typeof cartId !== 'string') {
return res.status(400).json({ error: 'cartId обязателен' });
}
const session = await getCheckoutSession(cartId, userId);
const { bucket, consoleCredentials } = await createBucket({
userId,
name,
plan,
quotaGb: Number(quotaGb),
planCode: session.payload.plan.code,
region: region || 'ru-central-1',
storageClass: storageClass || 'standard',
public: !!isPublic,
versioning: !!versioning,
price
versioning: !!versioning
});
return res.json({ bucket });
await markCheckoutSessionConsumed(cartId);
return res.json({ bucket, consoleCredentials });
} catch (e: unknown) {
let message = 'Ошибка создания бакета';
if (e instanceof Error) message = e.message;
@@ -67,6 +199,31 @@ router.get('/buckets', async (req, res) => {
}
});
router.post('/buckets/:id/console-credentials', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Некорректный идентификатор бакета' });
}
const credentials = await generateConsoleCredentials(userId, id);
return res.json({ credentials });
} catch (e: unknown) {
let message = 'Не удалось сгенерировать данные входа';
let statusCode = 400;
if (e instanceof Error) {
message = e.message;
// Check for rate limit error
if ((e as any).status === 429) {
statusCode = 429;
}
}
return res.status(statusCode).json({ error: message });
}
});
// Детали одного бакета
router.get('/buckets/:id', async (req, res) => {
try {
@@ -139,10 +296,16 @@ router.post('/buckets/:id/objects/presign', async (req, res) => {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { key, method, expiresIn, contentType } = req.body ?? {};
const { key, method, expiresIn, contentType, download, downloadFileName } = req.body ?? {};
if (!key) return res.status(400).json({ error: 'Не указан key объекта' });
const result = await createPresignedUrl(userId, id, key, { method, expiresIn, contentType });
const result = await createPresignedUrl(userId, id, key, {
method,
expiresIn,
contentType,
download: download === true,
downloadFileName: typeof downloadFileName === 'string' ? downloadFileName : undefined,
});
return res.json(result);
} catch (e: unknown) {
let message = 'Ошибка генерации ссылки';
@@ -151,6 +314,47 @@ router.post('/buckets/:id/objects/presign', async (req, res) => {
}
});
// Загрузка файла по URI с proxy (обход CORS)
router.post('/buckets/:id/objects/download-from-uri', async (req, res) => {
try {
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Не авторизовано' });
const id = Number(req.params.id);
const { url } = req.body ?? {};
if (!url) return res.status(400).json({ error: 'Не указан URL' });
// Проверяем что пользователь имеет доступ к бакету
await getBucket(userId, id); // Проверка доступа
// Загружаем файл с URL с увеличенным timeout
const response = await axios.get(url, {
responseType: 'arraybuffer',
timeout: 120000, // 120 seconds (2 minutes)
maxContentLength: 5 * 1024 * 1024 * 1024, // 5GB max
});
const mimeType = response.headers['content-type'] || 'application/octet-stream';
const buffer = response.data;
return res.json({
blob: buffer.toString('base64'),
mimeType,
});
} catch (e: unknown) {
let message = 'Ошибка загрузки файла по URI';
if (e instanceof Error) {
if (e.message.includes('timeout')) {
message = 'Превышено время ожидания при загрузке файла. Попробуйте позже.';
} else {
message = e.message;
}
}
return res.status(400).json({ error: message });
}
});
// Удаление объектов
router.delete('/buckets/:id/objects', async (req, res) => {
try {

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,122 @@
import { prisma } from '../../prisma/client';
import { Request, Response } from 'express';
import type { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
interface SerializedUserSummary {
id: number;
username: string;
operator: boolean;
email?: string | null;
}
interface SerializedAttachment {
id: number;
filename: string;
fileUrl: string;
fileSize: number;
mimeType: string;
createdAt: Date;
}
interface SerializedResponse {
id: number;
message: string;
isInternal: boolean;
createdAt: Date;
author: SerializedUserSummary | null;
attachments: SerializedAttachment[];
}
interface SerializedTicket {
id: number;
title: string;
message: string;
status: string;
priority: string;
category: string;
user: SerializedUserSummary | null;
assignedTo: number | null;
assignedOperator: SerializedUserSummary | null;
createdAt: Date;
updatedAt: Date;
closedAt: Date | null;
responseCount: number;
lastResponseAt: Date | null;
attachments: SerializedAttachment[];
responses: SerializedResponse[];
}
const serializeUser = (user: any | null): SerializedUserSummary | null => {
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
operator: Boolean(user.operator),
email: user.email ?? null,
};
};
const serializeAttachments = (attachments: any[] | undefined): SerializedAttachment[] => {
if (!attachments?.length) {
return [];
}
return attachments.map((attachment) => ({
id: attachment.id,
filename: attachment.filename,
fileUrl: attachment.fileUrl,
fileSize: attachment.fileSize,
mimeType: attachment.mimeType,
createdAt: attachment.createdAt,
}));
};
const serializeResponses = (responses: any[] | undefined): SerializedResponse[] => {
if (!responses?.length) {
return [];
}
return responses.map((response) => ({
id: response.id,
message: response.message,
isInternal: response.isInternal,
createdAt: response.createdAt,
author: serializeUser(response.operator ?? null),
attachments: serializeAttachments(response.attachments),
}));
};
const serializeTicket = (
ticket: any,
assignedOperatorsMap: Map<number, SerializedUserSummary>,
): SerializedTicket => {
const responses = serializeResponses(ticket.responses);
return {
id: ticket.id,
title: ticket.title,
message: ticket.message,
status: ticket.status,
priority: ticket.priority,
category: ticket.category,
user: serializeUser(ticket.user ?? null),
assignedTo: ticket.assignedTo ?? null,
assignedOperator: ticket.assignedTo ? assignedOperatorsMap.get(ticket.assignedTo) ?? null : null,
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
closedAt: ticket.closedAt ?? null,
responseCount: responses.length,
lastResponseAt: responses.length ? responses[responses.length - 1]?.createdAt ?? null : null,
attachments: serializeAttachments(ticket.attachments),
responses,
};
};
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: (req, file, cb) => {
@@ -37,274 +150,530 @@ export const uploadTicketFiles = multer({
// Создать тикет
export async function createTicket(req: Request, res: Response) {
const { title, message, category = 'general', priority = 'normal' } = req.body;
const userId = (req as any).user?.id;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
const userId = Number((req as any).user?.id);
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
if (!title || !message) {
return res.status(400).json({ error: 'Необходимо указать title и message' });
}
try {
const ticket = await prisma.ticket.create({
data: {
title,
message,
data: {
title,
message,
userId,
category,
priority,
status: 'open'
status: 'open',
},
include: {
user: {
select: { id: true, username: true, email: true }
}
}
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
responses: {
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
// TODO: Отправить уведомление операторам о новом тикете
res.json(ticket);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка создания тикета:', err);
res.status(500).json({ error: 'Ошибка создания тикета' });
return res.status(500).json({ error: 'Ошибка создания тикета' });
}
}
// Получить тикеты (клиент — свои, оператор — все с фильтрами)
export async function getTickets(req: Request, res: Response) {
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
const { status, category, priority, assignedTo } = req.query;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
const {
status,
category,
priority,
assigned,
search,
page: pageParam,
pageSize: pageSizeParam,
} = req.query;
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const page = Number(pageParam) > 0 ? Number(pageParam) : 1;
const pageSize = Number(pageSizeParam) > 0 ? Math.min(Number(pageSizeParam), 50) : 10;
try {
const where: any = isOperator ? {} : { userId };
// Фильтры (только для операторов)
if (isOperator) {
if (status) where.status = status;
if (category) where.category = category;
if (priority) where.priority = priority;
if (assignedTo) where.assignedTo = Number(assignedTo);
if (typeof status === 'string' && status !== 'all') {
where.status = status;
}
const tickets = await prisma.ticket.findMany({
where,
include: {
responses: {
include: {
operator: {
select: { id: true, username: true, email: true }
}
if (typeof category === 'string' && category !== 'all') {
where.category = category;
}
if (typeof priority === 'string' && priority !== 'all') {
where.priority = priority;
}
if (typeof search === 'string' && search.trim().length > 1) {
where.OR = [
{ title: { contains: search.trim(), mode: 'insensitive' } },
{ message: { contains: search.trim(), mode: 'insensitive' } },
];
}
if (isOperator && typeof assigned === 'string') {
if (assigned === 'me') {
where.assignedTo = userId;
} else if (assigned === 'unassigned') {
where.assignedTo = null;
} else if (assigned === 'others') {
where.AND = [{ assignedTo: { not: null } }, { assignedTo: { not: userId } }];
}
}
const [tickets, total, statusBuckets, assignedToMe, unassigned] = await Promise.all([
prisma.ticket.findMany({
where,
include: {
responses: {
where: isOperator ? {} : { isInternal: false },
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
orderBy: { createdAt: 'asc' }
user: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
user: {
select: { id: true, username: true, email: true }
},
attachments: true
},
orderBy: { createdAt: 'desc' },
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.ticket.count({ where }),
prisma.ticket.groupBy({
by: ['status'],
_count: { _all: true },
where: isOperator ? {} : { userId },
}),
isOperator
? prisma.ticket.count({ where: { assignedTo: userId } })
: Promise.resolve(0),
isOperator
? prisma.ticket.count({ where: { assignedTo: null, status: { not: 'closed' } } })
: Promise.resolve(0),
]);
const assignedOperatorIds = tickets
.map((ticket) => ticket.assignedTo)
.filter((value): value is number => typeof value === 'number');
const assignedOperators = assignedOperatorIds.length
? await prisma.user.findMany({
where: { id: { in: assignedOperatorIds } },
select: { id: true, username: true, operator: true, email: true },
})
: [];
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
assignedOperators.forEach((operator) => {
assignedOperatorsMap.set(operator.id, serializeUser(operator)!);
});
const normalizedTickets = tickets.map((ticket) => serializeTicket(ticket, assignedOperatorsMap));
const statusMap = statusBuckets.reduce<Record<string, number>>((acc, bucket) => {
acc[bucket.status] = bucket._count._all;
return acc;
}, {});
const stats = {
open: statusMap.open ?? 0,
inProgress: statusMap.in_progress ?? 0,
awaitingReply: statusMap.awaiting_reply ?? 0,
resolved: statusMap.resolved ?? 0,
closed: statusMap.closed ?? 0,
assignedToMe: isOperator ? assignedToMe : undefined,
unassigned: isOperator ? unassigned : undefined,
};
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return res.json({
tickets: normalizedTickets,
meta: {
page,
pageSize,
total,
totalPages,
hasMore: page < totalPages,
},
stats,
});
res.json(tickets);
} catch (err) {
console.error('Ошибка получения тикетов:', err);
res.status(500).json({ error: 'Ошибка получения тикетов' });
return res.status(500).json({ error: 'Ошибка получения тикетов' });
}
}
// Получить один тикет по ID
export async function getTicketById(req: Request, res: Response) {
const ticketId = Number(req.params.id);
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
if (!ticketId) {
return res.status(400).json({ error: 'Некорректный идентификатор тикета' });
}
try {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
responses: {
where: isOperator ? {} : { isInternal: false }, // Клиенты не видят внутренние комментарии
where: isOperator ? {} : { isInternal: false },
include: {
operator: {
select: { id: true, username: true, email: true }
}
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' }
orderBy: { createdAt: 'asc' },
},
user: {
select: { id: true, username: true, email: true }
select: { id: true, username: true, operator: true, email: true },
},
attachments: true
}
attachments: true,
},
});
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
// Проверка прав доступа
if (!isOperator && ticket.userId !== userId) {
return res.status(403).json({ error: 'Нет прав доступа к этому тикету' });
}
res.json(ticket);
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
if (ticket.assignedTo) {
const assignedOperator = await prisma.user.findUnique({
where: { id: ticket.assignedTo },
select: { id: true, username: true, operator: true, email: true },
});
if (assignedOperator) {
assignedOperatorsMap.set(assignedOperator.id, serializeUser(assignedOperator)!);
}
}
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка получения тикета:', err);
res.status(500).json({ error: 'Ошибка получения тикета' });
return res.status(500).json({ error: 'Ошибка получения тикета' });
}
}
// Ответить на тикет (клиент или оператор)
export async function respondTicket(req: Request, res: Response) {
const { ticketId, message, isInternal = false } = req.body;
const operatorId = (req as any).user?.id;
const actorId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!operatorId) return res.status(401).json({ error: 'Нет авторизации' });
if (!message) return res.status(400).json({ error: 'Сообщение не может быть пустым' });
// Только операторы могут оставлять внутренние комментарии
const actualIsInternal = isOperator ? isInternal : false;
if (!actorId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const numericTicketId = Number(ticketId);
if (!numericTicketId) {
return res.status(400).json({ error: 'Некорректный ticketId' });
}
if (!message || !message.trim()) {
return res.status(400).json({ error: 'Сообщение не может быть пустым' });
}
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
// Клиент может отвечать только на свои тикеты
if (!isOperator && ticket.userId !== operatorId) {
const ticket = await prisma.ticket.findUnique({ where: { id: numericTicketId } });
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
if (!isOperator && ticket.userId !== actorId) {
return res.status(403).json({ error: 'Нет прав' });
}
const response = await prisma.response.create({
data: {
ticketId,
operatorId,
message,
isInternal: actualIsInternal
data: {
ticketId: numericTicketId,
operatorId: actorId,
message: message.trim(),
isInternal: isOperator ? Boolean(isInternal) : false,
},
include: {
operator: {
select: { id: true, username: true, email: true }
}
}
});
// Обновляем статус тикета
let newStatus = ticket.status;
if (isOperator && ticket.status === 'open') {
newStatus = 'in_progress';
} else if (!isOperator && ticket.status === 'awaiting_reply') {
newStatus = 'in_progress';
}
await prisma.ticket.update({
where: { id: ticketId },
data: {
status: newStatus,
updatedAt: new Date()
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
});
// TODO: Отправить уведомление автору тикета (если ответил оператор)
res.json(response);
const updateData: any = {};
if (isOperator) {
if (!response.isInternal) {
updateData.status = 'awaiting_reply';
} else if (ticket.status === 'open') {
updateData.status = 'in_progress';
}
if (!ticket.assignedTo) {
updateData.assignedTo = actorId;
}
} else {
updateData.status = 'in_progress';
if (ticket.closedAt) {
updateData.closedAt = null;
}
}
const dataToApply = Object.keys(updateData).length ? updateData : { status: ticket.status };
await prisma.ticket.update({
where: { id: numericTicketId },
data: dataToApply,
});
const normalizedResponse: SerializedResponse = {
id: response.id,
message: response.message,
isInternal: response.isInternal,
createdAt: response.createdAt,
author: serializeUser(response.operator ?? null),
attachments: serializeAttachments(response.attachments),
};
return res.json({
response: normalizedResponse,
ticketStatus: updateData.status ?? ticket.status,
assignedTo: updateData.assignedTo ?? ticket.assignedTo ?? null,
});
} catch (err) {
console.error('Ошибка ответа на тикет:', err);
res.status(500).json({ error: 'Ошибка ответа на тикет' });
return res.status(500).json({ error: 'Ошибка ответа на тикет' });
}
}
// Изменить статус тикета (только оператор)
export async function updateTicketStatus(req: Request, res: Response) {
const { ticketId, status } = req.body;
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
}
const allowedStatuses = ['open', 'in_progress', 'awaiting_reply', 'resolved', 'closed'];
if (!allowedStatuses.includes(status)) {
if (typeof status !== 'string' || !allowedStatuses.includes(status)) {
return res.status(400).json({ error: 'Недопустимый статус' });
}
const numericTicketId = Number(ticketId);
if (!numericTicketId) {
return res.status(400).json({ error: 'Некорректный ticketId' });
}
try {
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data: {
where: { id: numericTicketId },
data: {
status,
closedAt: status === 'closed' ? new Date() : null,
updatedAt: new Date()
},
include: {
user: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
responses: {
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
res.json(ticket);
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
if (ticket.assignedTo) {
const assignedUser = await prisma.user.findUnique({
where: { id: ticket.assignedTo },
select: { id: true, username: true, operator: true, email: true },
});
if (assignedUser) {
assignedOperatorsMap.set(assignedUser.id, serializeUser(assignedUser)!);
}
}
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка изменения статуса тикета:', err);
res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
return res.status(500).json({ error: 'Ошибка изменения статуса тикета' });
}
}
// Назначить тикет на оператора (только оператор)
export async function assignTicket(req: Request, res: Response) {
const { ticketId, operatorId } = req.body;
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId || !isOperator) {
return res.status(403).json({ error: 'Нет прав' });
}
const numericTicketId = Number(ticketId);
const numericOperatorId = Number(operatorId);
if (!numericTicketId || !numericOperatorId) {
return res.status(400).json({ error: 'Некорректные данные' });
}
try {
const operator = await prisma.user.findUnique({
where: { id: numericOperatorId },
select: { id: true, operator: true },
});
if (!operator || operator.operator !== 1) {
return res.status(400).json({ error: 'Пользователь не является оператором' });
}
const ticket = await prisma.ticket.update({
where: { id: ticketId },
data: {
assignedTo: operatorId,
where: { id: numericTicketId },
data: {
assignedTo: numericOperatorId,
status: 'in_progress',
updatedAt: new Date()
},
include: {
user: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
responses: {
include: {
operator: {
select: { id: true, username: true, operator: true, email: true },
},
attachments: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
res.json(ticket);
const assignedOperatorsMap = new Map<number, SerializedUserSummary>();
const assignedOperatorUser = await prisma.user.findUnique({
where: { id: numericOperatorId },
select: { id: true, username: true, operator: true, email: true },
});
if (assignedOperatorUser) {
assignedOperatorsMap.set(assignedOperatorUser.id, serializeUser(assignedOperatorUser)!);
}
const normalizedTicket = serializeTicket(ticket, assignedOperatorsMap);
return res.json({ ticket: normalizedTicket });
} catch (err) {
console.error('Ошибка назначения тикета:', err);
res.status(500).json({ error: 'Ошибка назначения тикета' });
return res.status(500).json({ error: 'Ошибка назначения тикета' });
}
}
// Закрыть тикет (клиент или оператор)
export async function closeTicket(req: Request, res: Response) {
const { ticketId } = req.body;
const userId = (req as any).user?.id;
const userId = Number((req as any).user?.id);
const isOperator = Number((req as any).user?.operator) === 1;
if (!userId) return res.status(401).json({ error: 'Нет авторизации' });
if (!userId) {
return res.status(401).json({ error: 'Нет авторизации' });
}
const numericTicketId = Number(ticketId);
if (!numericTicketId) {
return res.status(400).json({ error: 'Некорректный ticketId' });
}
try {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId } });
if (!ticket) return res.status(404).json({ error: 'Тикет не найден' });
const ticket = await prisma.ticket.findUnique({ where: { id: numericTicketId } });
if (!ticket) {
return res.status(404).json({ error: 'Тикет не найден' });
}
if (!isOperator && ticket.userId !== userId) {
return res.status(403).json({ error: 'Нет прав' });
}
await prisma.ticket.update({
where: { id: ticketId },
data: {
where: { id: numericTicketId },
data: {
status: 'closed',
closedAt: new Date(),
updatedAt: new Date()
...(isOperator ? {} : { assignedOperatorId: null }),
},
});
res.json({ success: true, message: 'Тикет закрыт' });
return res.json({ success: true, message: 'Тикет закрыт' });
} catch (err) {
console.error('Ошибка закрытия тикета:', err);
res.status(500).json({ error: 'Ошибка закрытия тикета' });
return res.status(500).json({ error: 'Ошибка закрытия тикета' });
}
}

View File

@@ -1,38 +1 @@
declare module 'passport-vkontakte' {
import { Strategy as PassportStrategy } from 'passport';
export interface StrategyOptions {
clientID: string;
clientSecret: string;
callbackURL: string;
scope?: string[];
}
export interface Profile {
id: string;
displayName?: string;
name?: {
familyName?: string;
givenName?: string;
};
emails?: Array<{ value: string }>;
photos?: Array<{ value: string }>;
provider: string;
}
export type VerifyCallback = (error: any, user?: any, info?: any) => void;
export type VerifyFunction = (
accessToken: string,
refreshToken: string,
params: any,
profile: Profile,
done: VerifyCallback
) => void;
export class Strategy extends PassportStrategy {
constructor(options: StrategyOptions, verify: VerifyFunction);
name: string;
authenticate(req: any, options?: any): void;
}
}
export {}; // VK OAuth support removed

View File

@@ -11,7 +11,7 @@ import {
import { wsLogger } from '../utils/logger';
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_key';
const JWT_SECRET = process.env.JWT_SECRET || 'i_love_WebSockets!';
// Хранилище аутентифицированных клиентов
const authenticatedClients = new Map<WebSocket, AuthenticatedClient>();

View File

@@ -23,6 +23,7 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.0.0",
@@ -1582,6 +1583,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -5389,6 +5400,13 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -25,6 +25,7 @@
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.0.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["node"],
/* Bundler mode */
"moduleResolution": "bundler",

View File

@@ -1,7 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { copyFileSync } from 'fs'
import { resolve } from 'path'
import { dirname, resolve } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://vite.dev/config/
export default defineConfig({