Commit 433d5d7a authored by ThinhNC's avatar ThinhNC

Merge develop into staging: filter only exportable crawl jobs and auto-download on export creation

parents 13333557 af02b4fa
Pipeline #53759 passed with stage
in 1 minute and 21 seconds
......@@ -1311,6 +1311,8 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
onClose={() => setIsExportModalOpen(false)}
defaultJobId={jobId}
defaultJobUrl={job?.domain || job?.startUrl}
defaultSuccessPages={job?.successPages}
defaultStatus={job?.status}
/>
</div>
);
......
......@@ -12,6 +12,7 @@ import {
Check,
Globe,
Sparkles,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -19,13 +20,36 @@ import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import { useCreateCrawlExport } from "@/hooks/use-crawl-exports";
import { crawlExportService } from "@/services/crawl-export.service";
import { ExportType } from "@/types/export";
import { formatDate } from "@/lib/utils";
interface CreateExportModalProps {
export interface CreateExportModalProps {
isOpen: boolean;
onClose: () => void;
defaultJobId?: string;
defaultJobUrl?: string;
defaultSuccessPages?: number;
defaultStatus?: string;
}
/**
* Kiểm tra xem tác vụ cào có đủ điều kiện để xuất dữ liệu không:
* 1. Phải có ít nhất 1 trang thu thập thành công (successPages > 0)
* 2. Không ở trạng thái đang chạy hoặc chờ (RUNNING, PROCESSING_EXPORT, PENDING, QUEUED)
*/
export function isJobExportable(job?: {
status?: string;
successPages?: number | null;
}): boolean {
if (!job) return false;
const successPages = job.successPages ?? 0;
if (successPages <= 0) return false;
return (
job.status === "COMPLETED" ||
job.status === "CANCELED" ||
job.status === "FAILED"
);
}
const EXPORT_FORMATS: {
......@@ -77,37 +101,68 @@ export function CreateExportModal({
onClose,
defaultJobId = "",
defaultJobUrl = "",
defaultSuccessPages,
defaultStatus,
}: CreateExportModalProps) {
const { t } = useLanguage();
const { data: jobsData } = useCrawlJobs({ limit: 10 });
const { t, locale } = useLanguage();
const { data: jobsData } = useCrawlJobs({
limit: 50,
sortBy: "createdAt",
order: "desc",
});
const createExportMutation = useCreateCrawlExport();
const [userSelectedJobId, setUserSelectedJobId] = useState<string | null>(null);
const selectedJobId = userSelectedJobId ?? defaultJobId;
const setSelectedJobId = (id: string) => setUserSelectedJobId(id);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState("");
const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED");
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId);
const exportableJobs = useMemo(() => {
return jobs.filter(isJobExportable);
}, [jobs]);
const displayJobs = useMemo<
Array<{ id: string; startUrl: string; successPages?: number; status?: string }>
Array<{
id: string;
startUrl: string;
successPages?: number;
status?: string;
createdAt?: string;
}>
>(() => {
if (defaultJobId && !isDefaultInCompleted) {
return [
{
id: defaultJobId,
startUrl: defaultJobUrl || `Job: ${defaultJobId.slice(0, 16)}...`,
successPages: 0,
status: "COMPLETED",
},
...completedJobs,
];
if (defaultJobId) {
const isDefaultExportable = isJobExportable({
status: defaultStatus || "COMPLETED",
successPages: defaultSuccessPages !== undefined ? defaultSuccessPages : 1,
});
if (isDefaultExportable) {
const exists = exportableJobs.some((j) => j.id === defaultJobId);
if (!exists) {
return [
{
id: defaultJobId,
startUrl: defaultJobUrl || `Job: ${defaultJobId.slice(0, 16)}...`,
successPages: defaultSuccessPages ?? 1,
status: defaultStatus || "COMPLETED",
},
...exportableJobs,
];
}
}
}
return exportableJobs;
}, [exportableJobs, defaultJobId, defaultJobUrl, defaultSuccessPages, defaultStatus]);
const selectedJobId = useMemo(() => {
if (userSelectedJobId && displayJobs.some((j) => j.id === userSelectedJobId)) {
return userSelectedJobId;
}
if (defaultJobId && displayJobs.some((j) => j.id === defaultJobId)) {
return defaultJobId;
}
return completedJobs;
}, [completedJobs, defaultJobId, defaultJobUrl, isDefaultInCompleted]);
return displayJobs[0]?.id || "";
}, [userSelectedJobId, defaultJobId, displayJobs]);
if (!isOpen) return null;
......@@ -117,11 +172,16 @@ export function CreateExportModal({
const trimmedFileName = customFileName.trim();
setCustomFileName("");
await createExportMutation.mutateAsync({
const result = await createExportMutation.mutateAsync({
jobId: selectedJobId,
exportType: selectedFormat,
fileName: trimmedFileName ? trimmedFileName : undefined,
});
// Tự động kích hoạt tải xuống tệp dữ liệu đã xuất về máy tính
if (result?.id) {
crawlExportService.downloadExport(result.id, result.fileName);
}
onClose();
};
......@@ -155,11 +215,18 @@ export function CreateExportModal({
<form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Job Selection */}
<div className="space-y-1.5">
<Label className="text-xs font-semibold">
{t.exports.modal.selectJob}
</Label>
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold">
{t.exports.modal.selectJob}
</Label>
{displayJobs.length > 0 && (
<span className="text-[11px] text-muted-foreground font-medium">
({displayJobs.length} tác vụ có thể xuất)
</span>
)}
</div>
{displayJobs.length > 0 ? (
<div className="space-y-2 max-h-36 overflow-y-auto pr-1">
<div className="space-y-2 max-h-40 overflow-y-auto pr-1">
{displayJobs.map((job) => (
<button
key={job.id}
......@@ -172,9 +239,16 @@ export function CreateExportModal({
}`}
>
<div className="truncate min-w-0 pr-2">
<p className="font-semibold text-foreground truncate">{job.startUrl}</p>
<p className="text-[10px] text-muted-foreground font-mono truncate">
ID: {job.id.slice(0, 16)}...{job.successPages ? ` • ${job.successPages} trang` : ""}
<div className="flex items-center gap-2">
<p className="font-semibold text-foreground truncate max-w-[280px] sm:max-w-md">
{job.startUrl}
</p>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 shrink-0">
{job.successPages ?? 0} trang
</span>
</div>
<p className="text-[10px] text-muted-foreground font-mono truncate pt-0.5">
ID: {job.id.slice(0, 16)}...{job.status ? ` • ${job.status === "COMPLETED" ? "Hoàn tất" : job.status}` : ""}{job.createdAt ? ` • ${formatDate(job.createdAt, locale)}` : ""}
</p>
</div>
{selectedJobId === job.id && (
......@@ -184,12 +258,10 @@ export function CreateExportModal({
))}
</div>
) : (
<Input
placeholder="Nhập mã Crawl Job ID..."
value={selectedJobId}
onChange={(e) => setSelectedJobId(e.target.value)}
className="rounded-2xl text-xs"
/>
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2.5">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-500" />
<span>Không có tác vụ cào nào có trang thu thập thành công để xuất dữ liệu.</span>
</div>
)}
</div>
......@@ -258,7 +330,7 @@ export function CreateExportModal({
</Button>
<Button
type="submit"
disabled={!selectedJobId || createExportMutation.isPending}
disabled={!selectedJobId || displayJobs.length === 0 || createExportMutation.isPending}
className="rounded-2xl text-xs h-9 px-5 bg-emerald-600 hover:bg-emerald-700 text-white shadow-sm shadow-emerald-600/20 cursor-pointer"
>
{createExportMutation.isPending
......
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