update README
This commit is contained in:
File diff suppressed because it is too large
Load Diff
1139
ospabhost/README.md
1139
ospabhost/README.md
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
18
ospabhost/backend/prisma/add_custom_storage_plan.sql
Normal file
18
ospabhost/backend/prisma/add_custom_storage_plan.sql
Normal 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);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `storage_console_credential` ADD COLUMN `lastGeneratedAt` DATETIME(3) NULL;
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
@@ -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")
|
||||
}
|
||||
24
ospabhost/backend/prisma/storage_plans_seed.sql
Normal file
24
ospabhost/backend/prisma/storage_plans_seed.sql
Normal 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);
|
||||
@@ -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')}`);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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: 'Не удалось загрузить профиль. Попробуйте позже.' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 : 'Неизвестная ошибка'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
273
ospabhost/backend/src/modules/storage/fileScanner.ts
Normal file
273
ospabhost/backend/src/modules/storage/fileScanner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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: 'Ошибка закрытия тикета' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
18
ospabhost/frontend/package-lock.json
generated
18
ospabhost/frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
ospabhost/frontend/public/github.png
Normal file
BIN
ospabhost/frontend/public/github.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
ospabhost/frontend/public/google.png
Normal file
BIN
ospabhost/frontend/public/google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
ospabhost/frontend/public/yandex.png
Normal file
BIN
ospabhost/frontend/public/yandex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
325
ospabhost/frontend/src/components/AdminPricingTab.tsx
Normal file
325
ospabhost/frontend/src/components/AdminPricingTab.tsx
Normal 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;
|
||||
170
ospabhost/frontend/src/components/AdminTestingTab.tsx
Normal file
170
ospabhost/frontend/src/components/AdminTestingTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
3
ospabhost/frontend/src/components/VKOneTap.tsx
Normal file
3
ospabhost/frontend/src/components/VKOneTap.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const VKOneTap = () => null;
|
||||
|
||||
export default VKOneTap;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 - (3–40 символов)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">a-z, 0-9, дефис, 3–40 символов</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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
1720
ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx
Normal file
1720
ospabhost/frontend/src/pages/dashboard/storage-bucket.tsx
Normal file
File diff suppressed because it is too large
Load Diff
85
ospabhost/frontend/src/pages/dashboard/storage-utils.ts
Normal file
85
ospabhost/frontend/src/pages/dashboard/storage-utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,6 +8,7 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 120000, // 120 seconds timeout
|
||||
});
|
||||
|
||||
// Добавляем токен к каждому запросу
|
||||
|
||||
142
ospabhost/frontend/src/utils/uploadDB.ts
Normal file
142
ospabhost/frontend/src/utils/uploadDB.ts
Normal 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();
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -5,6 +5,7 @@
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user