Commit 6d8c57c4 authored by ThinhNC's avatar ThinhNC

feat(fe): standardize i18n, fix active header highlight and convert template...

feat(fe): standardize i18n, fix active header highlight and convert template select to custom dropdown
parent e204f321
......@@ -129,3 +129,18 @@ Tất cả các trang khi phát triển mới hoặc bảo trì **BẮT BUỘC P
- `/verify-email`: `Xác Thực Email | Data Crawler`
- `/forbidden`: `Truy Cập Bị Từ Chối (403) | Data Crawler`
---
## 8. Quy Chuẩn Giao Diện Dropdown & Đa Ngôn Ngữ (Dropdown & i18n Standards)
- **Quy chuẩn Custom Dropdown (ThemeToggle Pattern)**:
- **TUYỆT ĐỐI KHÔNG** dùng thẻ `<select>` mặc định của trình duyệt cho các bộ chọn tùy chỉnh trong modal/form.
- Bắt buộc thiết kế Custom Dropdown theo phong cách của `ThemeToggle`:
- **Trigger Button**: Bo góc mềm `rounded-2xl`, viền `border-border/80`, có icon phân loại, tên tùy chọn đang chọn và icon `<ChevronDown />` xoay 180° khi mở (`isDropdownOpen ? "rotate-180" : ""`).
- **Menu Popup**: `absolute``rounded-2xl border border-emerald-500/20 bg-card/95 p-1.5 shadow-xl shadow-emerald-950/10 backdrop-blur-xl z-50 animate-in fade-in-50 zoom-in-95 duration-150`.
- **Option Item**: Thẻ `button` bo góc `rounded-xl px-2.5 py-2 text-xs flex items-center justify-between`. Khi được chọn (`isSelected`), hiển thị nền xanh nhẹ `bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-semibold border border-emerald-500/20` kèm icon `<Check />`.
- **Xử lý đóng an toàn**: Sử dụng `useEffect` lắng nghe `mousedown` bên ngoài (`contains(target)`) và phím `Escape` để tự động đóng dropdown.
- **Quy chuẩn Đa Ngôn Ngữ (i18n)**:
- **Bản dịch Tiếng Việt thuần túy**: TUYỆT ĐỐI KHÔNG mở ngoặc kèm từ tiếng Anh hay ký hiệu thừa trong nhãn tiếng Việt (ví dụ: cấm `(Target URL)`, `(Depth)`, `(Normal)`, `(JSON)`, `(+)`, `(~)`, `(-)`, `(=)`).
- **Đồng bộ hóa 100% Tiếng Anh**: Mọi khóa trong `translations.vi` đều phải có khóa tương ứng trong `translations.en`.
- **Định dạng ngày tháng theo Locale**: Hàm `formatDate(date, locale)` bắt buộc phải nhận `locale` từ hook `useLanguage()`, định dạng `en-US` khi Tiếng Anh và `vi-VN` khi Tiếng Việt để tránh dính chữ tiếng Việt (như `thg`) trên giao diện tiếng Anh.
......@@ -103,6 +103,19 @@ async function handleProxy(
return NextResponse.json(data, { status: backendRes.status });
}
// Handle SSE streaming without buffering
if (responseContentType && responseContentType.includes("text/event-stream")) {
return new NextResponse(backendRes.body, {
status: backendRes.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
const data = await backendRes.arrayBuffer();
return new NextResponse(data, {
status: backendRes.status,
......
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Chi Tiết Tác Vụ Cào",
description:
"Trang giám sát chi tiết tiến độ realtime qua SSE, nhật ký crawler worker, xem trước dữ liệu trích xuất và so sánh biến động nội dung.",
};
export default function CrawlJobDetailLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
This diff is collapsed.
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Quản Lý Tác Vụ Cào",
description:
"Phân hệ quản lý toàn diện các nhiệm vụ cào dữ liệu web, phân trang server-side, bộ lọc đa tiêu chí và khởi tạo tác vụ nâng cao.",
};
export default function CrawlJobsLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
"use client";
import React, { useState } from "react";
import { CrawlerTaskTable } from "@/components/crawler/crawler-task-table";
import { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Globe2, Plus, Server } from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
export default function CrawlJobsPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const { t } = useLanguage();
return (
<div className="space-y-6 sm:space-y-8">
{/* Top Banner / Hero */}
<div className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-gradient-to-br from-card via-card/90 to-emerald-950/10 p-6 sm:p-8 shadow-sm backdrop-blur-md">
<div className="absolute right-0 top-0 -mt-10 -mr-10 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-2">
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-foreground flex items-center gap-2.5">
<Server className="h-7 w-7 text-emerald-500" />
<span>{t.table.title}</span>
</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
{t.table.subtitle}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-md shadow-emerald-600/20 px-5 py-2.5 h-auto cursor-pointer transition-all hover:scale-[1.02]">
<Plus className="mr-2 h-4 w-4" />
{t.hero.createTaskBtn}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl rounded-3xl border-border/80 bg-card text-card-foreground">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<Globe2 className="h-5 w-5 text-emerald-500" />
{t.dialog.title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t.dialog.description}
</DialogDescription>
</DialogHeader>
<CrawlerTaskForm onSuccess={() => setIsDialogOpen(false)} />
</DialogContent>
</Dialog>
</div>
</div>
</div>
{/* Main Jobs Table Component */}
<CrawlerTaskTable />
</div>
);
}
......@@ -5,7 +5,7 @@ import { useCrawlerStats } from "@/hooks/use-crawler";
import { StatCard } from "@/components/common/stat-card";
import { ServiceHealthBar } from "@/components/dashboard/service-health-bar";
import { QuotaUsageWidget } from "@/components/dashboard/quota-usage-widget";
import { CrawlerTaskTable } from "@/components/crawler/crawler-task-table";
import { RecentJobsWidget } from "@/components/dashboard/recent-jobs-widget";
import { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button";
import {
......@@ -22,12 +22,10 @@ import {
Briefcase,
CheckCircle2,
Database,
FileCode2,
Globe2,
Plus,
Radio,
RefreshCw,
Server,
} from "lucide-react";
import { useLanguage } from "@/providers/language-provider";
......@@ -197,50 +195,9 @@ export default function DashboardPage() {
{/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */}
<QuotaUsageWidget initialUsage={stats?.quotaAndUsage} />
{/* 5. Main Content: Tasks Table */}
<div className="space-y-4" id="tasks">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<Server className="h-5 w-5 text-emerald-500" />
{t.table.title}
</h2>
<p className="text-xs text-muted-foreground">
{t.table.subtitle}
</p>
</div>
</div>
<CrawlerTaskTable />
</div>
{/* 6. Architecture & Tech Stack Highlights */}
<div className="rounded-3xl border border-border/70 bg-card/60 p-6 backdrop-blur-sm transition-colors duration-200">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground mb-4">
<FileCode2 className="h-4 w-4 text-emerald-500" />
Architecture & System Highlights
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-muted-foreground">
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-emerald-600 dark:text-emerald-400">Realtime Analytics & Live APIs</span>
<p>
Đồng bộ dữ liệu thống kê từ GET /api/v1/dashboard/stats và theo dõi hạn mức GET /api/v1/auth/me/usage.
</p>
</div>
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-cyan-600 dark:text-cyan-400">Biophilic Ecosystem Health</span>
<p>
Giám sát kết nối PostgreSQL, Redis/BullMQ, chỉ số hàng đợi và Uptime theo thời gian thực.
</p>
</div>
<div className="space-y-1.5 rounded-2xl border border-border/60 bg-muted/40 p-4">
<span className="font-semibold text-teal-600 dark:text-teal-400">Resilient Server State</span>
<p>
TanStack Query v5 kết hợp Next.js BFF Proxy, xử lý 4 trạng thái UI và chuyển đổi mượt mà giữa Sáng/Tối.
</p>
</div>
</div>
{/* 5. Recent Crawl Tasks Widget */}
<div id="tasks">
<RecentJobsWidget />
</div>
</div>
);
......
......@@ -2,6 +2,7 @@
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Bot, Database, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle";
......@@ -10,6 +11,10 @@ import { useLanguage } from "@/providers/language-provider";
export function Navbar() {
const { t } = useLanguage();
const pathname = usePathname();
const isDashboard = pathname === "/";
const isCrawlJobs = pathname.startsWith("/crawl-jobs");
return (
<header className="sticky top-0 z-40 w-full border-b border-border/70 bg-card/80 backdrop-blur-md transition-colors duration-200">
......@@ -26,14 +31,22 @@ export function Navbar() {
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
<Link
href="/"
className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 hover:text-emerald-500 transition-colors font-semibold"
className={`flex items-center gap-1.5 transition-colors ${
isDashboard
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<Activity className="h-4 w-4" />
{t.nav.dashboard}
</Link>
<Link
href="#tasks"
className="flex items-center gap-1.5 hover:text-foreground transition-colors"
href="/crawl-jobs"
className={`flex items-center gap-1.5 transition-colors ${
isCrawlJobs
? "text-emerald-600 dark:text-emerald-400 font-semibold"
: "hover:text-foreground"
}`}
>
<Globe className="h-4 w-4" />
{t.nav.tasks}
......@@ -47,17 +60,8 @@ export function Navbar() {
</Link>
</nav>
{/* Right Controls: Theme Toggle, Language Toggle, Status & User Menu */}
{/* Right Controls: Theme Toggle, Language Toggle & User Menu */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Active Status Badge */}
<div className="hidden sm:flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/5 px-3 py-1 text-xs text-emerald-700 dark:text-emerald-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>{t.nav.statusActive}</span>
</div>
{/* Language Switcher */}
<LanguageToggle />
......
This diff is collapsed.
......@@ -3,20 +3,20 @@ 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",
"inline-flex items-center justify-center leading-none 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",
"border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
secondary:
"border-transparent bg-slate-800 text-slate-300",
"border-border/80 bg-muted text-foreground/80 dark:text-muted-foreground",
destructive:
"border-transparent bg-red-500/15 text-red-400 border-red-500/30",
outline: "text-slate-300 border-slate-700",
"border-rose-500/30 bg-rose-500/15 text-rose-700 dark:text-rose-300",
outline: "text-foreground border-border",
warning:
"border-amber-500/30 bg-amber-500/15 text-amber-400",
info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-400",
"border-amber-500/30 bg-amber-500/15 text-amber-700 dark:text-amber-300",
info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
},
},
defaultVariants: {
......
......@@ -13,11 +13,11 @@ const buttonVariants = cva(
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",
"border border-border bg-background hover:bg-muted hover:text-emerald-600 dark:hover:text-emerald-400 text-foreground",
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",
"bg-muted text-foreground hover:bg-muted/80 border border-border/70",
ghost: "hover:bg-muted hover:text-foreground text-muted-foreground",
link: "text-emerald-600 dark:text-emerald-400 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
......
......@@ -32,13 +32,13 @@ const DialogContent = React.forwardRef<
<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",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/80 bg-card p-6 shadow-2xl duration-200 sm:rounded-2xl text-card-foreground",
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">
<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-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Đóng</span>
</DialogPrimitive.Close>
......@@ -82,7 +82,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight text-slate-100",
"text-lg font-semibold leading-none tracking-tight text-foreground",
className
)}
{...props}
......@@ -96,7 +96,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-slate-400", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
......
......@@ -4,7 +4,7 @@ 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"
"text-xs font-semibold uppercase tracking-wider text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
......
......@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b border-slate-800", className)} {...props} />
<thead ref={ref} className={cn("[&_tr]:border-b border-border", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
......@@ -42,7 +42,7 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-slate-900/50 font-medium [&>tr]:last:border-b-0",
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
......@@ -57,7 +57,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b border-slate-800/60 transition-colors hover:bg-slate-800/40 data-[state=selected]:bg-slate-800",
"border-b border-border/60 transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted",
className
)}
{...props}
......@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
<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",
"h-10 px-4 text-left align-middle font-semibold text-xs text-muted-foreground uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
......@@ -86,7 +86,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle text-slate-200 [&:has([role=checkbox])]:pr-0", className)}
className={cn("p-4 align-middle text-foreground [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
......
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { crawlJobService } from "@/services/crawl-job.service";
import { CrawlJobQueryDto, CreateCrawlJobDto } from "@/types/crawl-job";
import { toast } from "sonner";
export const CRAWL_JOBS_QUERY_KEYS = {
all: ["crawl-jobs"] as const,
lists: () => [...CRAWL_JOBS_QUERY_KEYS.all, "list"] as const,
list: (query?: CrawlJobQueryDto) => [...CRAWL_JOBS_QUERY_KEYS.lists(), query] as const,
details: () => [...CRAWL_JOBS_QUERY_KEYS.all, "detail"] as const,
detail: (id: string) => [...CRAWL_JOBS_QUERY_KEYS.details(), id] as const,
logs: (id: string, query?: { page?: number; limit?: number }) =>
[...CRAWL_JOBS_QUERY_KEYS.detail(id), "logs", query] as const,
pages: (id: string, query?: { page?: number; limit?: number; search?: string }) =>
[...CRAWL_JOBS_QUERY_KEYS.detail(id), "pages", query] as const,
diff: (id: string, compareWithJobId?: string) =>
[...CRAWL_JOBS_QUERY_KEYS.detail(id), "diff", compareWithJobId] as const,
templates: ["extraction-templates"] as const,
};
/**
* Hook lấy danh sách Crawl Jobs hỗ trợ Server-side Pagination, Sorting và Filter
*/
export function useCrawlJobs(query?: CrawlJobQueryDto) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.list(query),
queryFn: () => crawlJobService.getJobs(query),
placeholderData: (previousData) => previousData,
staleTime: 5000,
});
}
/**
* Hook lấy chi tiết một Crawl Job
*/
export function useCrawlJob(id: string) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.detail(id),
queryFn: () => crawlJobService.getJobById(id),
enabled: Boolean(id),
staleTime: 4000,
});
}
/**
* Hook tạo tác vụ cào dữ liệu mới
*/
export function useCreateCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateCrawlJobDto) => crawlJobService.createJob(dto),
onSuccess: (newJob) => {
toast.success("Khởi tạo tác vụ cào thành công", {
description: `Tác vụ cho "${newJob.startUrl}" đã bắt đầu.`,
});
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể khởi tạo tác vụ", {
description: err.message || "Vui lòng kiểm tra lại đường dẫn và thử lại.",
});
},
});
}
/**
* Hook chạy lại tác vụ (POST /crawl-jobs/:id/rerun)
*/
export function useRerunCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.rerunJob(id),
onSuccess: (job) => {
toast.success("Đã kích hoạt chạy lại tác vụ", {
description: `Job ID: ${job.id.slice(0, 8)} đang được thực thi lại.`,
});
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.detail(job.id) });
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể chạy lại tác vụ", {
description: err.message || "Có lỗi xảy ra từ máy chủ.",
});
},
});
}
/**
* Hook hủy bỏ tác vụ (POST /crawl-jobs/:id/cancel)
*/
export function useCancelCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.cancelJob(id),
onSuccess: (job) => {
toast.info("Đã hủy tác vụ cào dữ liệu", {
description: `Job ID: ${job.id.slice(0, 8)} đã được chuyển sang trạng thái đã hủy.`,
});
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.detail(job.id) });
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể hủy tác vụ", {
description: err.message || "Không thể gửi yêu cầu hủy.",
});
},
});
}
/**
* Hook xóa tác vụ (DELETE /crawl-jobs/:id)
*/
export function useDeleteCrawlJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => crawlJobService.deleteJob(id),
onSuccess: () => {
toast.success("Đã xóa tác vụ cào dữ liệu thành công");
queryClient.invalidateQueries({ queryKey: CRAWL_JOBS_QUERY_KEYS.lists() });
queryClient.invalidateQueries({ queryKey: ["crawler", "tasks"] });
queryClient.invalidateQueries({ queryKey: ["crawler", "stats"] });
},
onError: (err: Error) => {
toast.error("Không thể xóa tác vụ", {
description: err.message || "Đã có lỗi xảy ra.",
});
},
});
}
/**
* Hook lấy nhật ký log của tác vụ (GET /crawl-jobs/:id/logs)
*/
export function useCrawlJobLogs(
id: string,
query?: { page?: number; limit?: number },
options?: { refetchInterval?: number | false }
) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.logs(id, query),
queryFn: () => crawlJobService.getJobLogs(id, query),
enabled: Boolean(id),
refetchInterval: options?.refetchInterval ?? 5000,
});
}
/**
* Hook lấy danh sách trang đã cào và preview (GET /crawl-jobs/:id/pages/preview)
*/
export function useCrawlJobPages(
id: string,
query?: { page?: number; limit?: number; search?: string }
) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.pages(id, query),
queryFn: () => crawlJobService.getPagesPreview(id, query),
enabled: Boolean(id),
staleTime: 5000,
});
}
/**
* Hook lấy báo cáo so sánh biến động dữ liệu (GET /crawl-jobs/:id/diff)
*/
export function useCrawlJobDiff(id: string, compareWithJobId?: string) {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.diff(id, compareWithJobId),
queryFn: () => crawlJobService.getDiff(id, compareWithJobId),
enabled: Boolean(id),
staleTime: 10000,
});
}
/**
* Hook lấy danh sách Extraction Templates có sẵn
*/
export function useExtractionTemplates() {
return useQuery({
queryKey: CRAWL_JOBS_QUERY_KEYS.templates,
queryFn: () => crawlJobService.getExtractionTemplates(),
staleTime: 60000,
});
}
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
export type SSEConnectionStatus = "CONNECTING" | "OPEN" | "CLOSED" | "ERROR";
export interface UseEventSourceOptions<T = unknown> {
enabled?: boolean;
withCredentials?: boolean;
onInitial?: (data: T) => void;
onProgress?: (data: T) => void;
onDone?: (data: { status?: string; message?: string }) => void;
onMessage?: (data: T, eventName: string) => void;
onError?: (error: Event) => void;
reconnectInterval?: number;
maxRetries?: number;
}
export interface UseEventSourceResult<T = unknown> {
status: SSEConnectionStatus;
data: T | null;
lastEventName: string | null;
error: Event | null;
reconnect: () => void;
close: () => void;
}
/**
* Custom hook kết nối Server-Sent Events (SSE) theo thời gian thực.
* Tận dụng endpoint SSE GET /api/v1/crawl-jobs/:id/events (qua BFF proxy /api/proxy/crawl-jobs/:id/events).
*/
export function useEventSource<T = unknown>(
url: string | null,
options: UseEventSourceOptions<T> = {}
): UseEventSourceResult<T> {
const {
enabled = true,
withCredentials = true,
onInitial,
onProgress,
onDone,
onMessage,
onError,
reconnectInterval = 4000,
maxRetries = 5,
} = options;
const [status, setStatus] = useState<SSEConnectionStatus>("CONNECTING");
const [data, setData] = useState<T | null>(null);
const [lastEventName, setLastEventName] = useState<string | null>(null);
const [error, setError] = useState<Event | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
// Safe ref callbacks to avoid unnecessary reconnections
const callbacksRef = useRef({ onInitial, onProgress, onDone, onMessage, onError });
useEffect(() => {
callbacksRef.current = { onInitial, onProgress, onDone, onMessage, onError };
}, [onInitial, onProgress, onDone, onMessage, onError]);
const cleanUp = useCallback(() => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, []);
const connect = useCallback(() => {
cleanUp();
if (!url || !enabled || typeof window === "undefined") {
setStatus("CLOSED");
return;
}
try {
setStatus("CONNECTING");
const es = new EventSource(url, { withCredentials });
eventSourceRef.current = es;
es.onopen = () => {
setStatus("OPEN");
setError(null);
retryCountRef.current = 0;
};
// Handle custom SSE event: "initial"
es.addEventListener("initial", (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as T;
setData(parsed);
setLastEventName("initial");
callbacksRef.current.onInitial?.(parsed);
callbacksRef.current.onMessage?.(parsed, "initial");
} catch {
// Ignored parse error
}
});
// Handle custom SSE event: "progress"
es.addEventListener("progress", (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as T;
setData(parsed);
setLastEventName("progress");
callbacksRef.current.onProgress?.(parsed);
callbacksRef.current.onMessage?.(parsed, "progress");
} catch {
// Ignored parse error
}
});
// Handle custom SSE event: "done"
es.addEventListener("done", (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data);
setLastEventName("done");
setStatus("CLOSED");
callbacksRef.current.onDone?.(parsed);
es.close();
} catch {
setStatus("CLOSED");
es.close();
}
});
// Generic onmessage
es.onmessage = (e: MessageEvent) => {
try {
const parsed = JSON.parse(e.data) as T;
setData(parsed);
setLastEventName("message");
callbacksRef.current.onMessage?.(parsed, "message");
} catch {
// Plain text fallback
}
};
es.onerror = (err) => {
setError(err);
callbacksRef.current.onError?.(err);
// If readyState is CLOSED or we can retry
if (es.readyState === EventSource.CLOSED) {
setStatus("CLOSED");
es.close();
if (retryCountRef.current < maxRetries) {
retryCountRef.current += 1;
setStatus("CONNECTING");
retryTimerRef.current = setTimeout(() => {
connect();
}, reconnectInterval);
} else {
setStatus("ERROR");
}
} else {
setStatus("ERROR");
}
};
} catch {
setStatus("ERROR");
}
}, [cleanUp, enabled, maxRetries, reconnectInterval, url, withCredentials]);
useEffect(() => {
connect();
return () => {
cleanUp();
};
}, [connect, cleanUp]);
const reconnect = useCallback(() => {
retryCountRef.current = 0;
connect();
}, [connect]);
const close = useCallback(() => {
cleanUp();
setStatus("CLOSED");
}, [cleanUp]);
return {
status,
data,
lastEventName,
error,
reconnect,
close,
};
}
This diff is collapsed.
......@@ -5,8 +5,8 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date | number): string {
return new Intl.DateTimeFormat("vi-VN", {
export function formatDate(date: string | Date | number, locale: string = "vi"): string {
return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", {
year: "numeric",
month: "short",
day: "numeric",
......
......@@ -18,6 +18,8 @@ export const crawlJobStatusEnum = z.enum([
"EXPIRED",
]);
export const jobPriorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
export const createCrawlJobSchema = z
.object({
startUrl: z
......@@ -26,19 +28,17 @@ export const createCrawlJobSchema = z
.url("Vui lòng nhập định dạng URL hợp lệ (vd: https://example.com)")
.optional()
.or(z.literal("")),
mode: crawlModeEnum.default("SCRAPE"),
mode: crawlModeEnum,
maxPages: z
.number()
.int("Số trang phải là số nguyên")
.min(1, "Tối thiểu phải cào 1 trang")
.max(1000, "Tối đa cào 1000 trang trong một phiên")
.default(20),
.max(1000, "Tối đa cào 1000 trang trong một phiên"),
maxDepth: z
.number()
.int("Độ sâu phải là số nguyên")
.min(1, "Độ sâu tối thiểu là 1")
.max(10, "Độ sâu tối đa là 10")
.default(1),
.max(10, "Độ sâu tối đa là 10"),
urls: z
.array(
z
......@@ -49,14 +49,16 @@ export const createCrawlJobSchema = z
.max(1000, "Danh sách tối đa 1000 URLs")
.optional()
.default([]),
priority: jobPriorityEnum,
userAgent: z.string().trim().max(500, "User-Agent tối đa 500 ký tự").optional(),
cookie: z.string().trim().max(2000, "Cookie tối đa 2000 ký tự").optional(),
templateId: z.string().optional().or(z.literal("")),
delayMs: z
.number()
.int()
.min(100, "Độ trễ tối thiểu 100ms")
.max(30000, "Độ trễ tối đa 30000ms")
.optional()
.default(1000),
respectRobotsTxt: z.boolean().default(true),
.max(30000, "Độ trễ tối đa 30000ms"),
respectRobotsTxt: z.boolean(),
scheduleId: z.string().uuid().optional(),
})
.superRefine((data, ctx) => {
......
This diff is collapsed.
This diff is collapsed.
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