Commit 67f87917 authored by ThinhNC's avatar ThinhNC

Merge branch 'fix/i18n-dialog-header-and-custom-dropdown' into 'develop'

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

See merge request !8
parents 9d403e42 6d8c57c4
...@@ -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 ...@@ -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` - `/verify-email`: `Xác Thực Email | Data Crawler`
- `/forbidden`: `Truy Cập Bị Từ Chối (403) | 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( ...@@ -103,6 +103,19 @@ async function handleProxy(
return NextResponse.json(data, { status: backendRes.status }); 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(); const data = await backendRes.arrayBuffer();
return new NextResponse(data, { return new NextResponse(data, {
status: backendRes.status, 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"; ...@@ -5,7 +5,7 @@ import { useCrawlerStats } from "@/hooks/use-crawler";
import { StatCard } from "@/components/common/stat-card"; import { StatCard } from "@/components/common/stat-card";
import { ServiceHealthBar } from "@/components/dashboard/service-health-bar"; import { ServiceHealthBar } from "@/components/dashboard/service-health-bar";
import { QuotaUsageWidget } from "@/components/dashboard/quota-usage-widget"; 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 { CrawlerTaskForm } from "@/components/crawler/crawler-task-form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -22,12 +22,10 @@ import { ...@@ -22,12 +22,10 @@ import {
Briefcase, Briefcase,
CheckCircle2, CheckCircle2,
Database, Database,
FileCode2,
Globe2, Globe2,
Plus, Plus,
Radio, Radio,
RefreshCw, RefreshCw,
Server,
} from "lucide-react"; } from "lucide-react";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
...@@ -197,50 +195,9 @@ export default function DashboardPage() { ...@@ -197,50 +195,9 @@ export default function DashboardPage() {
{/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */} {/* 4. Quota & Usage Widget (Hiển thị Hạn ngạch & Mức độ sử dụng) */}
<QuotaUsageWidget initialUsage={stats?.quotaAndUsage} /> <QuotaUsageWidget initialUsage={stats?.quotaAndUsage} />
{/* 5. Main Content: Tasks Table */} {/* 5. Recent Crawl Tasks Widget */}
<div className="space-y-4" id="tasks"> <div id="tasks">
<div className="flex items-center justify-between"> <RecentJobsWidget />
<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>
</div> </div>
</div> </div>
); );
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation";
import { Activity, Bot, Database, Globe } from "lucide-react"; import { Activity, Bot, Database, Globe } from "lucide-react";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { LanguageToggle } from "./language-toggle"; import { LanguageToggle } from "./language-toggle";
...@@ -10,6 +11,10 @@ import { useLanguage } from "@/providers/language-provider"; ...@@ -10,6 +11,10 @@ import { useLanguage } from "@/providers/language-provider";
export function Navbar() { export function Navbar() {
const { t } = useLanguage(); const { t } = useLanguage();
const pathname = usePathname();
const isDashboard = pathname === "/";
const isCrawlJobs = pathname.startsWith("/crawl-jobs");
return ( 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"> <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() { ...@@ -26,14 +31,22 @@ export function Navbar() {
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground"> <nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
<Link <Link
href="/" 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" /> <Activity className="h-4 w-4" />
{t.nav.dashboard} {t.nav.dashboard}
</Link> </Link>
<Link <Link
href="#tasks" href="/crawl-jobs"
className="flex items-center gap-1.5 hover:text-foreground transition-colors" 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" /> <Globe className="h-4 w-4" />
{t.nav.tasks} {t.nav.tasks}
...@@ -47,17 +60,8 @@ export function Navbar() { ...@@ -47,17 +60,8 @@ export function Navbar() {
</Link> </Link>
</nav> </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"> <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 */} {/* Language Switcher */}
<LanguageToggle /> <LanguageToggle />
......
This diff is collapsed.
...@@ -3,20 +3,20 @@ import { cva, type VariantProps } from "class-variance-authority"; ...@@ -3,20 +3,20 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
default: 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: secondary:
"border-transparent bg-slate-800 text-slate-300", "border-border/80 bg-muted text-foreground/80 dark:text-muted-foreground",
destructive: destructive:
"border-transparent bg-red-500/15 text-red-400 border-red-500/30", "border-rose-500/30 bg-rose-500/15 text-rose-700 dark:text-rose-300",
outline: "text-slate-300 border-slate-700", outline: "text-foreground border-border",
warning: warning:
"border-amber-500/30 bg-amber-500/15 text-amber-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-400", info: "border-cyan-500/30 bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
}, },
}, },
defaultVariants: { defaultVariants: {
......
...@@ -13,11 +13,11 @@ const buttonVariants = cva( ...@@ -13,11 +13,11 @@ const buttonVariants = cva(
destructive: destructive:
"bg-red-500 text-white shadow-sm hover:bg-red-600 active:scale-[0.98]", "bg-red-500 text-white shadow-sm hover:bg-red-600 active:scale-[0.98]",
outline: 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: secondary:
"bg-slate-800 text-slate-100 hover:bg-slate-700", "bg-muted text-foreground hover:bg-muted/80 border border-border/70",
ghost: "hover:bg-slate-800/80 hover:text-slate-100 text-slate-300", ghost: "hover:bg-muted hover:text-foreground text-muted-foreground",
link: "text-emerald-400 underline-offset-4 hover:underline", link: "text-emerald-600 dark:text-emerald-400 underline-offset-4 hover:underline",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
......
...@@ -32,13 +32,13 @@ const DialogContent = React.forwardRef< ...@@ -32,13 +32,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
> >
{children} {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" /> <X className="h-4 w-4" />
<span className="sr-only">Đóng</span> <span className="sr-only">Đóng</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
...@@ -82,7 +82,7 @@ const DialogTitle = React.forwardRef< ...@@ -82,7 +82,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight text-slate-100", "text-lg font-semibold leading-none tracking-tight text-foreground",
className className
)} )}
{...props} {...props}
...@@ -96,7 +96,7 @@ const DialogDescription = React.forwardRef< ...@@ -96,7 +96,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-slate-400", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
)); ));
......
...@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; ...@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const labelVariants = cva( 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< const Label = React.forwardRef<
......
...@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef< ...@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ 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"; TableHeader.displayName = "TableHeader";
...@@ -42,7 +42,7 @@ const TableFooter = React.forwardRef< ...@@ -42,7 +42,7 @@ const TableFooter = React.forwardRef<
<tfoot <tfoot
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -57,7 +57,7 @@ const TableRow = React.forwardRef< ...@@ -57,7 +57,7 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -72,7 +72,7 @@ const TableHead = React.forwardRef< ...@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
...@@ -86,7 +86,7 @@ const TableCell = React.forwardRef< ...@@ -86,7 +86,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} 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} {...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[]) { ...@@ -5,8 +5,8 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function formatDate(date: string | Date | number): string { export function formatDate(date: string | Date | number, locale: string = "vi"): string {
return new Intl.DateTimeFormat("vi-VN", { return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "vi-VN", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
......
...@@ -18,6 +18,8 @@ export const crawlJobStatusEnum = z.enum([ ...@@ -18,6 +18,8 @@ export const crawlJobStatusEnum = z.enum([
"EXPIRED", "EXPIRED",
]); ]);
export const jobPriorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
export const createCrawlJobSchema = z export const createCrawlJobSchema = z
.object({ .object({
startUrl: z startUrl: z
...@@ -26,19 +28,17 @@ export const createCrawlJobSchema = 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)") .url("Vui lòng nhập định dạng URL hợp lệ (vd: https://example.com)")
.optional() .optional()
.or(z.literal("")), .or(z.literal("")),
mode: crawlModeEnum.default("SCRAPE"), mode: crawlModeEnum,
maxPages: z maxPages: z
.number() .number()
.int("Số trang phải là số nguyên") .int("Số trang phải là số nguyên")
.min(1, "Tối thiểu phải cào 1 trang") .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") .max(1000, "Tối đa cào 1000 trang trong một phiên"),
.default(20),
maxDepth: z maxDepth: z
.number() .number()
.int("Độ sâu phải là số nguyên") .int("Độ sâu phải là số nguyên")
.min(1, "Độ sâu tối thiểu là 1") .min(1, "Độ sâu tối thiểu là 1")
.max(10, "Độ sâu tối đa là 10") .max(10, "Độ sâu tối đa là 10"),
.default(1),
urls: z urls: z
.array( .array(
z z
...@@ -49,14 +49,16 @@ export const createCrawlJobSchema = z ...@@ -49,14 +49,16 @@ export const createCrawlJobSchema = z
.max(1000, "Danh sách tối đa 1000 URLs") .max(1000, "Danh sách tối đa 1000 URLs")
.optional() .optional()
.default([]), .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 delayMs: z
.number() .number()
.int() .int()
.min(100, "Độ trễ tối thiểu 100ms") .min(100, "Độ trễ tối thiểu 100ms")
.max(30000, "Độ trễ tối đa 30000ms") .max(30000, "Độ trễ tối đa 30000ms"),
.optional() respectRobotsTxt: z.boolean(),
.default(1000),
respectRobotsTxt: z.boolean().default(true),
scheduleId: z.string().uuid().optional(), scheduleId: z.string().uuid().optional(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
......
This diff is collapsed.
...@@ -108,6 +108,80 @@ export interface CrawlJobLog { ...@@ -108,6 +108,80 @@ export interface CrawlJobLog {
createdAt: string; createdAt: string;
} }
export type JobPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT";
export interface DiffSummary {
totalCurrentPages: number;
totalPreviousPages: number;
newPagesCount: number;
modifiedPagesCount: number;
deletedPagesCount: number;
unchangedPagesCount: number;
changeRate: number;
}
export interface NewPageItem {
url: string;
normalizedUrl: string;
title: string | null;
contentHash: string | null;
wordCount: number;
statusCode: number | null;
crawledAt: string | null;
}
export interface ModifiedPageItem {
url: string;
normalizedUrl: string;
title: string | null;
oldTitle: string | null;
oldContentHash: string | null;
newContentHash: string | null;
oldWordCount: number;
newWordCount: number;
wordCountDiff: number;
statusCode: number | null;
crawledAt: string | null;
}
export interface DeletedPageItem {
url: string;
normalizedUrl: string;
title: string | null;
previousContentHash: string | null;
previousWordCount: number;
lastCrawledAt: string | null;
}
export interface UnchangedPageItem {
url: string;
normalizedUrl: string;
title: string | null;
contentHash: string | null;
wordCount: number;
}
export interface DiffReportEnvelope {
schemaVersion: string;
generatedAt: string;
jobId: string;
previousJobId: string | null;
scheduleId: string | null;
startUrl: string;
domain: string | null;
summary: DiffSummary;
newPages: NewPageItem[];
modifiedPages: ModifiedPageItem[];
deletedPages: DeletedPageItem[];
unchangedPages: UnchangedPageItem[];
}
export interface CrawlPagePreview extends CrawlPage {
rawMarkdown?: string | null;
mainContent?: string | null;
cleanText?: string | null;
}
export interface CreateCrawlJobDto { export interface CreateCrawlJobDto {
startUrl?: string; startUrl?: string;
mode?: CrawlMode; mode?: CrawlMode;
...@@ -115,6 +189,12 @@ export interface CreateCrawlJobDto { ...@@ -115,6 +189,12 @@ export interface CreateCrawlJobDto {
maxDepth?: number; maxDepth?: number;
urls?: string[]; urls?: string[];
scheduleId?: string; scheduleId?: string;
priority?: JobPriority;
userAgent?: string;
cookie?: string;
templateId?: string;
delayMs?: number;
respectRobotsTxt?: boolean;
} }
export interface CrawlJobQueryDto { export interface CrawlJobQueryDto {
...@@ -147,3 +227,4 @@ export interface DiffSummaryDto { ...@@ -147,3 +227,4 @@ export interface DiffSummaryDto {
removedUrls?: string[]; removedUrls?: string[];
modifiedUrls?: string[]; modifiedUrls?: string[];
} }
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