528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
"use client";
|
||
|
||
// ============================================================================
|
||
// External Channels Import Page
|
||
// ============================================================================
|
||
|
||
import React, { useState, useEffect } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { externalChannelsApi, channelsApi } from "@/lib/api";
|
||
import {
|
||
parseChannelsFile,
|
||
downloadTemplate,
|
||
type ParsedChannel,
|
||
} from "@/lib/utils/file-parser";
|
||
import {
|
||
FileUp,
|
||
AlertCircle,
|
||
CheckCircle,
|
||
Loader2,
|
||
Download,
|
||
ArrowLeft,
|
||
X,
|
||
FileSpreadsheet,
|
||
} from "lucide-react";
|
||
import type { TargetChannel } from "@/lib/types/api";
|
||
|
||
interface ImportResult {
|
||
total: number;
|
||
successful: number;
|
||
failed: number;
|
||
errors: string[];
|
||
}
|
||
|
||
export default function ImportExternalChannelsPage() {
|
||
const router = useRouter();
|
||
const [file, setFile] = useState<File | null>(null);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
|
||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||
const [progress, setProgress] = useState(0);
|
||
const [currentChannel, setCurrentChannel] = useState<string>("");
|
||
const [targetChannels, setTargetChannels] = useState<TargetChannel[]>([]);
|
||
const [selectedTargetChannel, setSelectedTargetChannel] =
|
||
useState<string>("");
|
||
|
||
useEffect(() => {
|
||
loadTargetChannels();
|
||
}, []);
|
||
|
||
const loadTargetChannels = async () => {
|
||
try {
|
||
const response = await channelsApi.list();
|
||
setTargetChannels(response.target_channels);
|
||
if (response.target_channels.length > 0) {
|
||
setSelectedTargetChannel(response.target_channels[0].id);
|
||
}
|
||
} catch (err) {
|
||
console.error("Error loading target channels:", err);
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(true);
|
||
};
|
||
|
||
const handleDragLeave = () => {
|
||
setIsDragging(false);
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragging(false);
|
||
|
||
const droppedFile = e.dataTransfer.files[0];
|
||
if (droppedFile) {
|
||
handleFileSelect(droppedFile);
|
||
}
|
||
};
|
||
|
||
const handleFileSelect = async (selectedFile: File) => {
|
||
if (!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||
setParseErrors(["Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"]);
|
||
return;
|
||
}
|
||
|
||
setFile(selectedFile);
|
||
setParseErrors([]);
|
||
setParsedChannels([]);
|
||
setImportResult(null);
|
||
|
||
// Парсим файл
|
||
try {
|
||
const result = await parseChannelsFile(selectedFile);
|
||
setParsedChannels(result.channels);
|
||
setParseErrors(result.errors);
|
||
} catch (err) {
|
||
setParseErrors([
|
||
`Ошибка парсинга файла: ${
|
||
err instanceof Error ? err.message : "неизвестная ошибка"
|
||
}`,
|
||
]);
|
||
}
|
||
};
|
||
|
||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const selectedFile = e.target.files?.[0];
|
||
if (selectedFile) {
|
||
handleFileSelect(selectedFile);
|
||
}
|
||
};
|
||
|
||
const delay = (ms: number) =>
|
||
new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
const handleImport = async () => {
|
||
if (!selectedTargetChannel || parsedChannels.length === 0) return;
|
||
|
||
setIsUploading(true);
|
||
setImportResult(null);
|
||
setProgress(0);
|
||
setCurrentChannel("");
|
||
|
||
const result: ImportResult = {
|
||
total: parsedChannels.length,
|
||
successful: 0,
|
||
failed: 0,
|
||
errors: [],
|
||
};
|
||
|
||
for (let i = 0; i < parsedChannels.length; i++) {
|
||
const channel = parsedChannels[i];
|
||
setCurrentChannel(channel.title);
|
||
setProgress(((i + 1) / parsedChannels.length) * 100);
|
||
|
||
try {
|
||
await externalChannelsApi.create({
|
||
telegram_id: channel.telegram_id || 0,
|
||
title: channel.title,
|
||
username: channel.username || null,
|
||
description: channel.description || null,
|
||
subscribers_count: channel.subscribers_count || null,
|
||
target_channel_ids: [selectedTargetChannel],
|
||
});
|
||
|
||
result.successful++;
|
||
} catch (err: any) {
|
||
result.failed++;
|
||
const errorMsg =
|
||
err?.detail || err?.error?.message || "Неизвестная ошибка";
|
||
result.errors.push(
|
||
`Строка ${channel.row} (${channel.title}): ${errorMsg}`
|
||
);
|
||
}
|
||
|
||
// Задержка между запросами (300ms)
|
||
if (i < parsedChannels.length - 1) {
|
||
await delay(300);
|
||
}
|
||
}
|
||
|
||
setImportResult(result);
|
||
setIsUploading(false);
|
||
setCurrentChannel("");
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setFile(null);
|
||
setParsedChannels([]);
|
||
setParseErrors([]);
|
||
setImportResult(null);
|
||
setProgress(0);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<DashboardHeader
|
||
breadcrumbs={[
|
||
{ label: "Главная", href: "/" },
|
||
{ label: "Внешние каналы", href: "/external-channels" },
|
||
{ label: "Импорт" },
|
||
]}
|
||
/>
|
||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold tracking-tight">
|
||
Импорт внешних каналов
|
||
</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
Загрузите файл Excel или CSV с данными каналов
|
||
</p>
|
||
</div>
|
||
<Button asChild variant="outline">
|
||
<Link href="/external-channels">
|
||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
Назад
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Выбор целевого канала */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Целевой канал</CardTitle>
|
||
<CardDescription>
|
||
Выберите целевой канал для привязки импортируемых каналов
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Select
|
||
value={selectedTargetChannel}
|
||
onValueChange={setSelectedTargetChannel}
|
||
disabled={isUploading || targetChannels.length === 0}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="Выберите целевой канал" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{targetChannels.map((channel) => (
|
||
<SelectItem key={channel.id} value={channel.id}>
|
||
{channel.title}
|
||
{channel.username && ` (@${channel.username})`}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Шаблоны */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Скачать шаблон</CardTitle>
|
||
<CardDescription>
|
||
Используйте шаблон для правильного заполнения данных
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="flex gap-2">
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => downloadTemplate("xlsx")}
|
||
disabled={isUploading}
|
||
>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
Шаблон Excel
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => downloadTemplate("csv")}
|
||
disabled={isUploading}
|
||
>
|
||
<Download className="mr-2 h-4 w-4" />
|
||
Шаблон CSV
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Загрузка файла */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Загрузка файла</CardTitle>
|
||
<CardDescription>
|
||
Перетащите файл или нажмите для выбора
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div
|
||
className={`relative border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
|
||
isDragging
|
||
? "border-primary bg-primary/5"
|
||
: "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||
} ${isUploading ? "opacity-50 pointer-events-none" : ""}`}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
onDrop={handleDrop}
|
||
>
|
||
<input
|
||
type="file"
|
||
id="file-upload"
|
||
className="hidden"
|
||
accept=".xlsx,.xls,.csv"
|
||
onChange={handleFileInputChange}
|
||
disabled={isUploading}
|
||
/>
|
||
<label
|
||
htmlFor="file-upload"
|
||
className="cursor-pointer flex flex-col items-center"
|
||
>
|
||
<FileUp className="h-12 w-12 text-muted-foreground mb-4" />
|
||
{file ? (
|
||
<div className="space-y-2">
|
||
<p className="text-sm font-medium">{file.name}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{(file.size / 1024).toFixed(2)} KB
|
||
</p>
|
||
{!isUploading && (
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
handleReset();
|
||
}}
|
||
>
|
||
<X className="h-4 w-4 mr-1" />
|
||
Удалить
|
||
</Button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<p className="text-sm text-muted-foreground">
|
||
Перетащите файл сюда или нажмите для выбора
|
||
</p>
|
||
<p className="text-xs text-muted-foreground mt-1">
|
||
Поддерживаются форматы: .xlsx, .xls, .csv
|
||
</p>
|
||
</>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Результаты парсинга */}
|
||
{parsedChannels.length > 0 && !importResult && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Результаты парсинга</CardTitle>
|
||
<CardDescription>
|
||
Найдено каналов: {parsedChannels.length}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{parseErrors.length > 0 && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
<strong>Ошибки при парсинге:</strong>
|
||
<ul className="list-disc list-inside mt-2">
|
||
{parseErrors.slice(0, 5).map((error, i) => (
|
||
<li key={i} className="text-sm">
|
||
{error}
|
||
</li>
|
||
))}
|
||
{parseErrors.length > 5 && (
|
||
<li className="text-sm">
|
||
...и еще {parseErrors.length - 5}
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={handleImport}
|
||
disabled={
|
||
isUploading ||
|
||
!selectedTargetChannel ||
|
||
parsedChannels.length === 0
|
||
}
|
||
className="flex-1"
|
||
>
|
||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||
Импортировать {parsedChannels.length} каналов
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
onClick={handleReset}
|
||
disabled={isUploading}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Прогресс импорта */}
|
||
{isUploading && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Импорт в процессе</CardTitle>
|
||
<CardDescription>
|
||
{currentChannel && `Импортируется: ${currentChannel}`}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<Progress value={progress} className="w-full" />
|
||
<p className="text-sm text-center text-muted-foreground">
|
||
{Math.round(progress)}% завершено
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Результаты импорта */}
|
||
{importResult && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Результаты импорта</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid grid-cols-3 gap-4 text-center">
|
||
<div className="p-4 border rounded-lg">
|
||
<div className="text-2xl font-bold">{importResult.total}</div>
|
||
<div className="text-xs text-muted-foreground">Всего</div>
|
||
</div>
|
||
<div className="p-4 border rounded-lg border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950">
|
||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||
{importResult.successful}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">Успешно</div>
|
||
</div>
|
||
<div className="p-4 border rounded-lg border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950">
|
||
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||
{importResult.failed}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">Ошибок</div>
|
||
</div>
|
||
</div>
|
||
|
||
{importResult.errors.length > 0 && (
|
||
<Alert variant="destructive">
|
||
<AlertCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
<strong>Ошибки импорта:</strong>
|
||
<ul className="list-disc list-inside mt-2 max-h-60 overflow-y-auto">
|
||
{importResult.errors.map((error, i) => (
|
||
<li key={i} className="text-sm">
|
||
{error}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{importResult.successful > 0 && (
|
||
<Alert>
|
||
<CheckCircle className="h-4 w-4" />
|
||
<AlertDescription>
|
||
Успешно импортировано каналов: {importResult.successful}
|
||
</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<Button
|
||
onClick={() => router.push("/external-channels")}
|
||
className="flex-1"
|
||
>
|
||
Перейти к списку каналов
|
||
</Button>
|
||
<Button variant="outline" onClick={handleReset}>
|
||
Импортировать еще
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Инструкция */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Формат файла</CardTitle>
|
||
<CardDescription>Требования к структуре файла</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2 text-sm">
|
||
<p>
|
||
<strong>Колонки (в порядке):</strong>
|
||
</p>
|
||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||
<li>
|
||
<strong>Название канала*</strong> - обязательное поле
|
||
</li>
|
||
<li>
|
||
<strong>Username</strong> - без символа @ (опционально)
|
||
</li>
|
||
<li>
|
||
<strong>Telegram ID</strong> - числовой ID канала
|
||
(опционально)
|
||
</li>
|
||
<li>
|
||
<strong>Подписчиков</strong> - количество подписчиков
|
||
(опционально)
|
||
</li>
|
||
<li>
|
||
<strong>Описание</strong> - описание канала (опционально)
|
||
</li>
|
||
</ol>
|
||
<p className="text-muted-foreground mt-4">
|
||
* Первая строка файла должна содержать заголовки и будет
|
||
пропущена при импорте
|
||
</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|