Commit 0831e1f4 authored by ThinhNC's avatar ThinhNC

feat: initialize data crawler fe with Next.js 16, Tailwind CSS, shadcn/ui,...

feat: initialize data crawler fe with Next.js 16, Tailwind CSS, shadcn/ui, TanStack Query, React Hook Form, Zod, and Axios
parent fa2d0326
# Backend API Base URL
NEXT_PUBLIC_API_URL=http://localhost:9999/api/v1
# App Settings
NEXT_PUBLIC_APP_NAME="Data Crawler System"
NEXT_PUBLIC_ENABLE_DEVTOOLS=true
......@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
......
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Data Crawler Frontend (`data-crawler-fe`)
## Getting Started
Dự án frontend chuyên dụng cho hệ thống **Data Crawler**, được xây dựng trên nền tảng **Next.js 16 (App Router)** tuân thủ cấu trúc chuẩn trong tài liệu [`project-structure.md`](./project-structure.md).
First, run the development server:
## 🚀 Tech Stack
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
| Công nghệ | Vai trò |
| :--- | :--- |
| **pnpm** | Package manager hiệu năng cao |
| **Next.js 16** | Framework React với App Router (`src/app`) |
| **TypeScript** | Type-safety toàn diện |
| **Tailwind CSS v4** | Modern utility-first CSS & theme tokens |
| **shadcn/ui** | UI Component system (Radix UI primitives) |
| **TanStack Query v5** | Quản lý Server State, cache và auto-refetching |
| **React Hook Form + Zod** | Xử lý form và schema validation |
| **Axios** | HTTP Client với Interceptors và fallback mock layer |
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
---
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## 📁 Cấu trúc thư mục (Project Structure)
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
```
data-crawler-fe/
├── .env.example # Mẫu biến môi trường
├── .env.local # Biến môi trường chạy local
├── components.json # Cấu hình shadcn/ui
├── next.config.ts # Cấu hình Next.js
├── package.json # Dependencies & scripts
├── postcss.config.mjs # PostCSS
├── project-structure.md # Tài liệu kiến trúc Next.js App Router
├── tailwind.config.ts # Cấu hình Tailwind CSS
├── tsconfig.json # Cấu hình TypeScript với alias "@/*"
└── src/
├── app/ # Next.js App Router File Conventions
│ ├── error.tsx # Error boundary UI
│ ├── globals.css # Theme CSS tokens & Tailwind import
│ ├── layout.tsx # Root Layout bọc Providers & Navbar
│ ├── loading.tsx # Loading skeleton convention
│ ├── not-found.tsx # Trang 404 tùy chỉnh
│ └── page.tsx # Dashboard tổng quan Data Crawler
├── components/ # UI Components
│ ├── common/ # Navbar, StatCard, v.v.
│ ├── crawler/ # CrawlerTaskForm (RHF+Zod), CrawlerTaskTable
│ └── ui/ # shadcn/ui (button, input, badge, dialog, table, card)
├── hooks/ # Custom hooks (useCrawlerTasks, useCreateTask,...)
├── lib/ # Core Singletons: api-client (Axios), query-client, utils
├── providers/ # Providers (QueryClientProvider, Toaster)
├── schemas/ # Zod validation schemas (crawler.schema.ts)
└── types/ # TypeScript interfaces (api.ts, crawler.ts)
```
To learn more about Next.js, take a look at the following resources:
---
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## 🛠️ Hướng dẫn cài đặt & khởi chạy
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
### 1. Cài đặt dependencies
```bash
pnpm install
```
## Deploy on Vercel
### 2. Chạy môi trường phát triển (Development)
```bash
pnpm run dev
```
Mở trình duyệt tại: [http://localhost:3000](http://localhost:3000)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
### 3. Build Production
```bash
pnpm run build
pnpm run start
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
### 4. Kiểm tra Linting & Type checking
```bash
pnpm run lint
```
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
This diff is collapsed.
This diff is collapsed.
"use client";
import React, { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw } from "lucide-react";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("App Error Boundary caught:", error);
}, [error]);
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-500/10 border border-red-500/20 text-red-400 mb-4">
<AlertTriangle className="h-8 w-8" />
</div>
<h2 className="text-xl font-bold text-slate-100">Đã xảy ra sự cố không mong muốn</h2>
<p className="mt-2 text-sm text-slate-400 max-w-md">
{error?.message || "Hệ thống gặp lỗi khi tải trang này. Bạn có thể thử khôi phục lại."}
</p>
<div className="mt-6 flex items-center gap-3">
<Button onClick={() => reset()} className="bg-emerald-600 hover:bg-emerald-500">
<RefreshCw className="mr-2 h-4 w-4" />
Thử Lại
</Button>
<Button variant="outline" onClick={() => (window.location.href = "/")}>
Về Trang Chủ
</Button>
</div>
</div>
);
}
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--background: #090d16;
--foreground: #f1f5f9;
--card: #0f172a;
--card-foreground: #f1f5f9;
--popover: #0f172a;
--popover-foreground: #f1f5f9;
--primary: #10b981;
--primary-foreground: #ffffff;
--secondary: #1e293b;
--secondary-foreground: #f8fafc;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #f8fafc;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #1e293b;
--input: #1e293b;
--ring: #10b981;
--radius: 0.75rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), monospace;
}
body {
background: var(--background);
background-color: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans);
min-height: 100vh;
background-image:
radial-gradient(at 10% 10%, rgba(16, 185, 129, 0.08) 0px, transparent 40%),
radial-gradient(at 90% 90%, rgba(6, 182, 212, 0.08) 0px, transparent 40%);
background-attachment: fixed;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #090d16;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #334155;
}
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/providers/providers";
import { Navbar } from "@/components/common/navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
......@@ -13,17 +15,31 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Data Crawler System | Nền Tảng Thu Thập Dữ Liệu Tự Động",
description:
"Hệ thống quản lý và giám sát tác vụ cào dữ liệu web realtime, tích hợp Next.js, TanStack Query, React Hook Form và Tailwind CSS.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
<html lang="vi" className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col bg-[#090d16] text-slate-100 selection:bg-emerald-500/30 selection:text-emerald-300`}
>
<body className="min-h-full flex flex-col">{children}</body>
<Providers>
<Navbar />
<main className="flex-1 container mx-auto max-w-7xl px-4 py-8 sm:px-6">
{children}
</main>
<footer className="border-t border-slate-800/60 py-6 text-center text-xs text-slate-500">
<p>Data Crawler Architecture © {new Date().getFullYear()} — Powered by Next.js & TanStack Query</p>
</footer>
</Providers>
</body>
</html>
);
}
import React from "react";
import { Loader2 } from "lucide-react";
export default function Loading() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3">
<div className="relative">
<div className="h-12 w-12 rounded-full border-2 border-emerald-500/20 border-t-emerald-500 animate-spin" />
<Loader2 className="absolute inset-0 m-auto h-5 w-5 text-emerald-400 animate-pulse" />
</div>
<p className="text-sm font-medium text-slate-400">Đang chuẩn bị dữ liệu...</p>
</div>
);
}
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Compass, Home } from "lucide-react";
export default function NotFound() {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-800/80 border border-slate-700 text-emerald-400 mb-4">
<Compass className="h-8 w-8 animate-pulse" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-white">404</h1>
<h2 className="mt-2 text-lg font-semibold text-slate-200">Không tìm thấy trang yêu cầu</h2>
<p className="mt-1 text-sm text-slate-400 max-w-sm">
Đường dẫn bạn truy cập không tồn tại hoặc đã được di chuyển sang địa chỉ khác.
</p>
<div className="mt-6">
<Button asChild className="bg-emerald-600 hover:bg-emerald-500">
<Link href="/">
<Home className="mr-2 h-4 w-4" />
Về Dashboard
</Link>
</Button>
</div>
</div>
);
}
This diff is collapsed.
"use client";
import React from "react";
import Link from "next/link";
import { Activity, Bot, Database, Globe, RefreshCw } from "lucide-react";
export function Navbar() {
return (
<header className="sticky top-0 z-40 w-full border-b border-slate-800/80 bg-slate-950/80 backdrop-blur-md">
<div className="container mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6">
{/* Brand */}
<Link href="/" className="flex items-center gap-3 transition-opacity hover:opacity-90">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-500 to-cyan-600 shadow-md shadow-emerald-500/20">
<Bot className="h-6 w-6 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-lg tracking-tight text-white">DataCrawler</span>
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 border border-emerald-500/20">
PRO v1.0
</span>
</div>
<p className="text-xs text-slate-400">Hệ thống cào và xử lý dữ liệu tự động</p>
</div>
</Link>
{/* Center Nav Links */}
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-slate-300">
<Link
href="/"
className="flex items-center gap-1.5 text-emerald-400 hover:text-emerald-300 transition-colors"
>
<Activity className="h-4 w-4" />
Dashboard
</Link>
<Link
href="#tasks"
className="flex items-center gap-1.5 hover:text-slate-100 transition-colors"
>
<Globe className="h-4 w-4" />
Tác vụ Crawl
</Link>
<Link
href="#storage"
className="flex items-center gap-1.5 hover:text-slate-100 transition-colors"
>
<Database className="h-4 w-4" />
Kho Dữ Liệu
</Link>
</nav>
{/* Right Status */}
<div className="flex items-center gap-3">
<div className="hidden sm:flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs text-slate-300">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span>
<span>Crawler Engine Active</span>
</div>
<button
onClick={() => window.location.reload()}
title="Tải lại trang"
className="rounded-lg border border-slate-800 bg-slate-900/50 p-2 text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
);
}
import React from "react";
import { Card, CardContent } from "@/components/ui/card";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatCardProps {
title: string;
value: string | number;
description?: string;
icon: LucideIcon;
trend?: {
value: string;
isPositive: boolean;
};
colorClassName?: string;
}
export function StatCard({
title,
value,
description,
icon: Icon,
trend,
colorClassName = "from-emerald-500/20 to-teal-500/5 text-emerald-400 border-emerald-500/30",
}: StatCardProps) {
return (
<Card className="relative overflow-hidden border border-slate-800/80 bg-slate-900/60 backdrop-blur-md">
{/* Subtle Glow background */}
<div
className={cn(
"absolute -right-6 -top-6 h-28 w-28 rounded-full bg-gradient-to-br opacity-20 blur-2xl",
colorClassName
)}
/>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-400">
{title}
</p>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg border bg-gradient-to-br",
colorClassName
)}
>
<Icon className="h-5 w-5" />
</div>
</div>
<div className="mt-3 flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold tracking-tight text-white">
{value}
</span>
{trend && (
<span
className={cn(
"text-xs font-semibold",
trend.isPositive ? "text-emerald-400" : "text-red-400"
)}
>
{trend.isPositive ? "+" : ""}
{trend.value}
</span>
)}
</div>
{description && (
<p className="mt-1 text-xs text-slate-400">{description}</p>
)}
</CardContent>
</Card>
);
}
"use client";
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createCrawlerTaskSchema,
CreateCrawlerTaskInput,
} from "@/schemas/crawler.schema";
import { useCreateCrawlerTask } from "@/hooks/use-crawler";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2, PlusCircle, Sparkles } from "lucide-react";
interface CrawlerTaskFormProps {
onSuccess?: () => void;
}
export function CrawlerTaskForm({ onSuccess }: CrawlerTaskFormProps) {
const { mutate: createTask, isPending } = useCreateCrawlerTask();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CreateCrawlerTaskInput>({
resolver: zodResolver(createCrawlerTaskSchema),
defaultValues: {
name: "",
targetUrl: "",
maxDepth: 2,
maxPages: 50,
rateLimit: 1000,
extractSelectors: "h1, .content, article",
},
});
const onSubmit = (data: CreateCrawlerTaskInput) => {
createTask(data, {
onSuccess: () => {
reset();
onSuccess?.();
},
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Task Name */}
<div className="space-y-1.5">
<Label htmlFor="name">Tên Tác Vụ Crawl</Label>
<Input
id="name"
placeholder="vd: Cào bài viết công nghệ hàng ngày"
{...register("name")}
disabled={isPending}
/>
{errors.name && (
<p className="text-xs text-red-400">{errors.name.message}</p>
)}
</div>
{/* Target URL */}
<div className="space-y-1.5">
<Label htmlFor="targetUrl">Địa Chỉ URL Đích (Target URL)</Label>
<Input
id="targetUrl"
type="url"
placeholder="https://example.com/news"
{...register("targetUrl")}
disabled={isPending}
/>
{errors.targetUrl && (
<p className="text-xs text-red-400">{errors.targetUrl.message}</p>
)}
</div>
{/* Grid: Max Depth & Max Pages */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="maxDepth">Độ Sâu (Depth Level)</Label>
<Input
id="maxDepth"
type="number"
min={1}
max={10}
{...register("maxDepth", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.maxDepth && (
<p className="text-xs text-red-400">{errors.maxDepth.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="maxPages">Số Trang Tối Đa</Label>
<Input
id="maxPages"
type="number"
min={1}
max={5000}
{...register("maxPages", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.maxPages && (
<p className="text-xs text-red-400">{errors.maxPages.message}</p>
)}
</div>
</div>
{/* Rate Limit */}
<div className="space-y-1.5">
<Label htmlFor="rateLimit">Độ Trễ Giữa Các Request (ms)</Label>
<Input
id="rateLimit"
type="number"
min={100}
step={100}
{...register("rateLimit", { valueAsNumber: true })}
disabled={isPending}
/>
{errors.rateLimit && (
<p className="text-xs text-red-400">{errors.rateLimit.message}</p>
)}
</div>
{/* CSS Selectors */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="extractSelectors">CSS Selectors Cần Bóc Tách</Label>
<span className="text-[11px] text-slate-400">Tùy chọn</span>
</div>
<Input
id="extractSelectors"
placeholder="h1.title, .article-content, span.price"
{...register("extractSelectors")}
disabled={isPending}
/>
</div>
{/* Submit Button */}
<div className="pt-2">
<Button
type="submit"
className="w-full bg-emerald-600 hover:bg-emerald-500 font-semibold"
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Đang khởi tạo tác vụ...
</>
) : (
<>
<PlusCircle className="mr-2 h-4 w-4" />
Khởi Chạy Tác Vụ Crawl
</>
)}
</Button>
</div>
</form>
);
}
"use client";
import React from "react";
import {
useCrawlerTasks,
useDeleteCrawlerTask,
useToggleCrawlerTask,
} from "@/hooks/use-crawler";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/utils";
import { CrawlerStatus } from "@/types/crawler";
import {
ExternalLink,
Loader2,
Pause,
Play,
Trash2,
AlertCircle,
Clock,
Layers,
} from "lucide-react";
export function CrawlerTaskTable() {
const { data, isLoading, isError, error } = useCrawlerTasks();
const { mutate: toggleStatus, isPending: isToggling } = useToggleCrawlerTask();
const { mutate: deleteTask, isPending: isDeleting } = useDeleteCrawlerTask();
const getStatusBadge = (status: CrawlerStatus) => {
switch (status) {
case "RUNNING":
return (
<Badge variant="default" className="gap-1.5 py-1">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
ĐANG CHẠY
</Badge>
);
case "COMPLETED":
return (
<Badge variant="info" className="gap-1.5 py-1">
HOÀN THÀNH
</Badge>
);
case "PAUSED":
return (
<Badge variant="warning" className="gap-1.5 py-1">
TẠM DỪNG
</Badge>
);
case "FAILED":
return (
<Badge variant="destructive" className="gap-1.5 py-1">
THẤT BẠI
</Badge>
);
case "PENDING":
default:
return (
<Badge variant="secondary" className="gap-1.5 py-1">
CHỜ XỬ LÝ
</Badge>
);
}
};
if (isLoading) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 p-8 text-center">
<Loader2 className="h-8 w-8 animate-spin text-emerald-400" />
<p className="text-sm text-slate-400">Đang tải danh sách tác vụ crawl...</p>
</div>
);
}
if (isError) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-red-900/30 bg-red-950/20 p-8 text-center text-red-300">
<AlertCircle className="h-8 w-8 text-red-400" />
<p className="text-sm font-semibold">Lỗi tải dữ liệu</p>
<p className="text-xs text-red-400/80">{(error as Error)?.message || "Vui lòng thử lại"}</p>
</div>
);
}
const tasks = data?.items || [];
if (tasks.length === 0) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-slate-800 bg-slate-900/30 p-8 text-center">
<Layers className="h-10 w-10 text-slate-600" />
<h3 className="font-semibold text-slate-300">Chưa có tác vụ crawl nào</h3>
<p className="text-xs text-slate-500 max-w-sm">
Bấm nút "Tạo Tác Vụ Mới" phía trên để bắt đầu cấu hình và thu thập dữ liệu web.
</p>
</div>
);
}
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 backdrop-blur-md overflow-hidden shadow-xl shadow-black/20">
<Table>
<TableHeader>
<TableRow className="border-slate-800 bg-slate-950/40">
<TableHead>Tên Tác Vụ & Target</TableHead>
<TableHead>Trạng Thái</TableHead>
<TableHead>Tiến Độ Cào</TableHead>
<TableHead>Dữ Liệu Bóc Tách</TableHead>
<TableHead>Thời Gian Tạo</TableHead>
<TableHead className="text-right">Hành Động</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => {
const progress = Math.min(
100,
Math.round((task.pagesCrawled / Math.max(1, task.maxPages)) * 100)
);
return (
<TableRow key={task.id} className="border-slate-800/80">
{/* Name & Target */}
<TableCell className="max-w-[280px]">
<div className="font-medium text-slate-100">{task.name}</div>
<a
href={task.targetUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-emerald-400/80 hover:text-emerald-300 hover:underline truncate max-w-full mt-0.5"
>
<span className="truncate">{task.targetUrl}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</TableCell>
{/* Status */}
<TableCell>{getStatusBadge(task.status)}</TableCell>
{/* Progress */}
<TableCell className="min-w-[160px]">
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-400">
<span>{task.pagesCrawled} / {task.maxPages} trang</span>
<span className="font-semibold text-slate-300">{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-cyan-400 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</TableCell>
{/* Extracted Count */}
<TableCell>
<span className="font-semibold text-emerald-400">
{task.itemsExtracted.toLocaleString()}
</span>{" "}
<span className="text-xs text-slate-400">mục</span>
</TableCell>
{/* Created At */}
<TableCell className="text-xs text-slate-400">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-slate-500" />
{formatDate(task.createdAt)}
</div>
</TableCell>
{/* Actions */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1.5">
{task.status === "RUNNING" ? (
<Button
size="sm"
variant="secondary"
title="Tạm dừng"
disabled={isToggling}
onClick={() => toggleStatus(task.id)}
className="h-8 px-2.5 text-amber-400 hover:text-amber-300"
>
<Pause className="h-3.5 w-3.5" />
</Button>
) : (
<Button
size="sm"
variant="secondary"
title="Tiếp tục chạy"
disabled={isToggling}
onClick={() => toggleStatus(task.id)}
className="h-8 px-2.5 text-emerald-400 hover:text-emerald-300"
>
<Play className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="sm"
variant="destructive"
title="Xóa tác vụ"
disabled={isDeleting}
onClick={() => deleteTask(task.id)}
className="h-8 px-2.5 bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
secondary:
"border-transparent bg-slate-800 text-slate-300",
destructive:
"border-transparent bg-red-500/15 text-red-400 border-red-500/30",
outline: "text-slate-300 border-slate-700",
warning:
"border-amber-500/30 bg-amber-500/15 text-amber-400",
info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-400",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer shadow-sm",
{
variants: {
variant: {
default:
"bg-emerald-600 text-white shadow hover:bg-emerald-700 active:scale-[0.98] transition-all",
destructive:
"bg-red-500 text-white shadow-sm hover:bg-red-600 active:scale-[0.98]",
outline:
"border border-slate-700 bg-slate-900/60 hover:bg-slate-800 hover:text-emerald-400 text-slate-200",
secondary:
"bg-slate-800 text-slate-100 hover:bg-slate-700",
ghost: "hover:bg-slate-800/80 hover:text-slate-100 text-slate-300",
link: "text-emerald-400 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-slate-800 bg-slate-900/60 backdrop-blur-md text-slate-100 shadow-lg shadow-black/20",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight text-slate-100", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-slate-800 bg-slate-900 p-6 shadow-2xl duration-200 sm:rounded-xl text-slate-100",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none text-slate-400 hover:text-slate-100">
<X className="h-4 w-4" />
<span className="sr-only">Đóng</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-slate-100",
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-slate-700 bg-slate-900/80 px-3 py-1 text-sm text-slate-100 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-slate-100 placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-emerald-500 focus-visible:border-emerald-500 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-xs font-semibold uppercase tracking-wider text-slate-400 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b border-slate-800", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-slate-900/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-slate-800/60 transition-colors hover:bg-slate-800/40 data-[state=selected]:bg-slate-800",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-semibold text-xs text-slate-400 uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle text-slate-200 [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
};
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlerService } from "@/services/crawler.service";
import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema";
import { toast } from "sonner";
export const CRAWLER_QUERY_KEYS = {
tasks: ["crawler", "tasks"] as const,
stats: ["crawler", "stats"] as const,
};
export function useCrawlerTasks() {
return useQuery({
queryKey: CRAWLER_QUERY_KEYS.tasks,
queryFn: () => crawlerService.getTasks(),
refetchInterval: 5000, // Cập nhật tự động mỗi 5 giây
});
}
export function useCrawlerStats() {
return useQuery({
queryKey: CRAWLER_QUERY_KEYS.stats,
queryFn: () => crawlerService.getStats(),
refetchInterval: 10000,
});
}
export function useCreateCrawlerTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateCrawlerTaskInput) => crawlerService.createTask(input),
onSuccess: (data) => {
toast.success(`Đã khởi tạo tác vụ cào dữ liệu: "${data.name}"`);
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.tasks });
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.stats });
},
onError: (error: Error) => {
toast.error(error.message || "Không thể tạo tác vụ cào dữ liệu");
},
});
}
export function useToggleCrawlerTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlerService.toggleTaskStatus(id),
onSuccess: (updatedTask) => {
toast.info(`Tác vụ "${updatedTask.name}" chuyển sang: ${updatedTask.status}`);
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.tasks });
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.stats });
},
onError: (error: Error) => {
toast.error(error.message || "Không thể thay đổi trạng thái tác vụ");
},
});
}
export function useDeleteCrawlerTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlerService.deleteTask(id),
onSuccess: () => {
toast.success("Đã xóa tác vụ thành công");
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.tasks });
queryClient.invalidateQueries({ queryKey: CRAWLER_QUERY_KEYS.stats });
},
onError: (error: Error) => {
toast.error(error.message || "Không thể xóa tác vụ");
},
});
}
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from "axios";
export interface ApiErrorResponse {
message: string;
statusCode?: number;
errors?: Record<string, string[]>;
}
export class ApiError extends Error {
statusCode?: number;
errors?: Record<string, string[]>;
constructor(message: string, statusCode?: number, errors?: Record<string, string[]>) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.errors = errors;
}
}
const baseURL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
export const apiClient: AxiosInstance = axios.create({
baseURL,
timeout: 30000,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
});
// Request Interceptor
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (typeof window !== "undefined") {
const token = localStorage.getItem("auth_token");
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor
apiClient.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiErrorResponse>) => {
const message =
error.response?.data?.message ||
error.message ||
"Đã có lỗi xảy ra khi kết nối tới máy chủ.";
const statusCode = error.response?.status;
const errors = error.response?.data?.errors;
return Promise.reject(new ApiError(message, statusCode, errors));
}
);
export default apiClient;
import { QueryClient, isServer } from "@tanstack/react-query";
function makeQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
mutations: {
retry: 0,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
export function getQueryClient(): QueryClient {
if (isServer) {
// Server: always create a new query client
return makeQueryClient();
} else {
// Browser: create once or reuse existing client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date | number): string {
return new Intl.DateTimeFormat("vi-VN", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
export function formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
"use client";
import React, { ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { getQueryClient } from "@/lib/query-client";
import { Toaster } from "sonner";
interface ProvidersProps {
children: ReactNode;
}
export function Providers({ children }: ProvidersProps) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" richColors closeButton />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
import { z } from "zod";
export const createCrawlerTaskSchema = z.object({
name: z
.string()
.min(3, "Tên tác vụ phải có ít nhất 3 ký tự")
.max(100, "Tên tác vụ không được vượt quá 100 ký tự"),
targetUrl: z
.string()
.url("Vui lòng nhập đúng định dạng URL (vd: https://example.com)"),
maxDepth: z
.number()
.int()
.min(1, "Độ sâu tối thiểu là 1")
.max(10, "Độ sâu tối đa là 10"),
maxPages: z
.number()
.int()
.min(1, "Tối thiểu phải cào 1 trang")
.max(5000, "Tối đa cào 5000 trang trong một phiên"),
rateLimit: z
.number()
.int()
.min(100, "Độ trễ tối thiểu 100ms")
.max(10000, "Độ trễ tối đa 10000ms"),
extractSelectors: z.string().optional(),
});
export type CreateCrawlerTaskInput = z.infer<typeof createCrawlerTaskSchema>;
import apiClient from "@/lib/api-client";
import { CreateCrawlerTaskInput } from "@/schemas/crawler.schema";
import { ApiResponse, PaginatedResponse } from "@/types/api";
import { CrawlerStats, CrawlerTask } from "@/types/crawler";
// Dữ liệu mẫu khởi đầu khi Backend chưa kết nối
const INITIAL_MOCK_TASKS: CrawlerTask[] = [
{
id: "task-01",
name: "Cào tin tức Công nghệ & AI",
targetUrl: "https://vnexpress.net/so-hoa/cong-nghe",
status: "RUNNING",
maxDepth: 3,
maxPages: 100,
pagesCrawled: 42,
itemsExtracted: 318,
createdAt: new Date(Date.now() - 3600000 * 2).toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date(Date.now() - 60000).toISOString(),
},
{
id: "task-02",
name: "Trích xuất giá sản phẩm Laptop",
targetUrl: "https://tiki.vn/laptop/c8095",
status: "COMPLETED",
maxDepth: 2,
maxPages: 50,
pagesCrawled: 50,
itemsExtracted: 620,
createdAt: new Date(Date.now() - 3600000 * 24).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 5).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 5).toISOString(),
},
{
id: "task-03",
name: "Thu thập danh bạ doanh nghiệp",
targetUrl: "https://yellowpages.vn/danh-ba",
status: "PAUSED",
maxDepth: 2,
maxPages: 200,
pagesCrawled: 75,
itemsExtracted: 180,
createdAt: new Date(Date.now() - 3600000 * 48).toISOString(),
updatedAt: new Date(Date.now() - 3600000 * 12).toISOString(),
lastRunAt: new Date(Date.now() - 3600000 * 12).toISOString(),
},
];
let localTasksState = [...INITIAL_MOCK_TASKS];
export const crawlerService = {
// Lấy danh sách task (kết nối API, fallback mock)
async getTasks(): Promise<PaginatedResponse<CrawlerTask>> {
try {
const response = await apiClient.get<ApiResponse<PaginatedResponse<CrawlerTask>>>("/crawler/tasks");
return response.data.data;
} catch {
// Fallback local memory state cho demo
return {
items: localTasksState,
total: localTasksState.length,
page: 1,
pageSize: 10,
totalPages: 1,
};
}
},
// Lấy thống kê tổng quan
async getStats(): Promise<CrawlerStats> {
try {
const response = await apiClient.get<ApiResponse<CrawlerStats>>("/crawler/stats");
return response.data.data;
} catch {
const totalPages = localTasksState.reduce((acc, t) => acc + t.pagesCrawled, 0);
const totalItems = localTasksState.reduce((acc, t) => acc + t.itemsExtracted, 0);
const active = localTasksState.filter((t) => t.status === "RUNNING").length;
return {
activeTasks: active,
totalTasks: localTasksState.length,
totalPagesCrawled: totalPages,
totalItemsExtracted: totalItems,
successRate: 98.4,
};
}
},
// Tạo mới một task
async createTask(input: CreateCrawlerTaskInput): Promise<CrawlerTask> {
try {
const response = await apiClient.post<ApiResponse<CrawlerTask>>("/crawler/tasks", input);
return response.data.data;
} catch {
const newTask: CrawlerTask = {
id: `task-${Date.now()}`,
name: input.name,
targetUrl: input.targetUrl,
status: "RUNNING",
maxDepth: input.maxDepth,
maxPages: input.maxPages,
pagesCrawled: 0,
itemsExtracted: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lastRunAt: new Date().toISOString(),
};
localTasksState = [newTask, ...localTasksState];
return newTask;
}
},
// Dừng / chạy tiếp task
async toggleTaskStatus(id: string): Promise<CrawlerTask> {
try {
const response = await apiClient.patch<ApiResponse<CrawlerTask>>(`/crawler/tasks/${id}/toggle`);
return response.data.data;
} catch {
const task = localTasksState.find((t) => t.id === id);
if (!task) throw new Error("Task not found");
task.status = task.status === "RUNNING" ? "PAUSED" : "RUNNING";
task.updatedAt = new Date().toISOString();
return task;
}
},
// Xóa task
async deleteTask(id: string): Promise<boolean> {
try {
await apiClient.delete(`/crawler/tasks/${id}`);
return true;
} catch {
localTasksState = localTasksState.filter((t) => t.id !== id);
return true;
}
},
};
export interface ApiResponse<T = unknown> {
success: boolean;
message?: string;
data: T;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export type CrawlerStatus =
| "PENDING"
| "RUNNING"
| "COMPLETED"
| "FAILED"
| "PAUSED";
export interface CrawlerTask {
id: string;
name: string;
targetUrl: string;
status: CrawlerStatus;
maxDepth: number;
maxPages: number;
pagesCrawled: number;
itemsExtracted: number;
createdAt: string;
updatedAt: string;
lastRunAt?: string;
errorMessage?: string;
}
export interface CrawlerStats {
activeTasks: number;
totalTasks: number;
totalPagesCrawled: number;
totalItemsExtracted: number;
successRate: number;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment