Commit af02b4fa authored by ThinhNC's avatar ThinhNC

Merge fix/export-custom-filename: filter only exportable crawl jobs and...

Merge fix/export-custom-filename: filter only exportable crawl jobs and auto-download on export creation
parents 6420b58b 175c2a63
Pipeline #53758 failed with stage
in 20 seconds
...@@ -1311,6 +1311,8 @@ export default function CrawlJobDetailPage({ params }: PageProps) { ...@@ -1311,6 +1311,8 @@ export default function CrawlJobDetailPage({ params }: PageProps) {
onClose={() => setIsExportModalOpen(false)} onClose={() => setIsExportModalOpen(false)}
defaultJobId={jobId} defaultJobId={jobId}
defaultJobUrl={job?.domain || job?.startUrl} defaultJobUrl={job?.domain || job?.startUrl}
defaultSuccessPages={job?.successPages}
defaultStatus={job?.status}
/> />
</div> </div>
); );
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
Check, Check,
Globe, Globe,
Sparkles, Sparkles,
AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
...@@ -19,13 +20,36 @@ import { Label } from "@/components/ui/label"; ...@@ -19,13 +20,36 @@ import { Label } from "@/components/ui/label";
import { useLanguage } from "@/providers/language-provider"; import { useLanguage } from "@/providers/language-provider";
import { useCrawlJobs } from "@/hooks/use-crawl-jobs"; import { useCrawlJobs } from "@/hooks/use-crawl-jobs";
import { useCreateCrawlExport } from "@/hooks/use-crawl-exports"; import { useCreateCrawlExport } from "@/hooks/use-crawl-exports";
import { crawlExportService } from "@/services/crawl-export.service";
import { ExportType } from "@/types/export"; import { ExportType } from "@/types/export";
import { formatDate } from "@/lib/utils";
interface CreateExportModalProps { export interface CreateExportModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
defaultJobId?: string; defaultJobId?: string;
defaultJobUrl?: 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: { const EXPORT_FORMATS: {
...@@ -77,37 +101,68 @@ export function CreateExportModal({ ...@@ -77,37 +101,68 @@ export function CreateExportModal({
onClose, onClose,
defaultJobId = "", defaultJobId = "",
defaultJobUrl = "", defaultJobUrl = "",
defaultSuccessPages,
defaultStatus,
}: CreateExportModalProps) { }: CreateExportModalProps) {
const { t } = useLanguage(); const { t, locale } = useLanguage();
const { data: jobsData } = useCrawlJobs({ limit: 10 }); const { data: jobsData } = useCrawlJobs({
limit: 50,
sortBy: "createdAt",
order: "desc",
});
const createExportMutation = useCreateCrawlExport(); const createExportMutation = useCreateCrawlExport();
const [userSelectedJobId, setUserSelectedJobId] = useState<string | null>(null); const [userSelectedJobId, setUserSelectedJobId] = useState<string | null>(null);
const selectedJobId = userSelectedJobId ?? defaultJobId;
const setSelectedJobId = (id: string) => setUserSelectedJobId(id);
const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV"); const [selectedFormat, setSelectedFormat] = useState<ExportType>("CSV");
const [customFileName, setCustomFileName] = useState(""); const [customFileName, setCustomFileName] = useState("");
const jobs = jobsData?.items || []; const jobs = jobsData?.items || [];
const completedJobs = jobs.filter((j) => j.status === "COMPLETED"); const exportableJobs = useMemo(() => {
const isDefaultInCompleted = completedJobs.some((j) => j.id === defaultJobId); return jobs.filter(isJobExportable);
}, [jobs]);
const displayJobs = useMemo< 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) { if (defaultJobId) {
return [ const isDefaultExportable = isJobExportable({
{ status: defaultStatus || "COMPLETED",
id: defaultJobId, successPages: defaultSuccessPages !== undefined ? defaultSuccessPages : 1,
startUrl: defaultJobUrl || `Job: ${defaultJobId.slice(0, 16)}...`, });
successPages: 0,
status: "COMPLETED", if (isDefaultExportable) {
}, const exists = exportableJobs.some((j) => j.id === defaultJobId);
...completedJobs, 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; return displayJobs[0]?.id || "";
}, [completedJobs, defaultJobId, defaultJobUrl, isDefaultInCompleted]); }, [userSelectedJobId, defaultJobId, displayJobs]);
if (!isOpen) return null; if (!isOpen) return null;
...@@ -117,11 +172,16 @@ export function CreateExportModal({ ...@@ -117,11 +172,16 @@ export function CreateExportModal({
const trimmedFileName = customFileName.trim(); const trimmedFileName = customFileName.trim();
setCustomFileName(""); setCustomFileName("");
await createExportMutation.mutateAsync({ const result = await createExportMutation.mutateAsync({
jobId: selectedJobId, jobId: selectedJobId,
exportType: selectedFormat, exportType: selectedFormat,
fileName: trimmedFileName ? trimmedFileName : undefined, 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(); onClose();
}; };
...@@ -155,11 +215,18 @@ export function CreateExportModal({ ...@@ -155,11 +215,18 @@ export function CreateExportModal({
<form onSubmit={handleSubmit} className="py-4 space-y-4"> <form onSubmit={handleSubmit} className="py-4 space-y-4">
{/* Job Selection */} {/* Job Selection */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-semibold"> <div className="flex items-center justify-between">
{t.exports.modal.selectJob} <Label className="text-xs font-semibold">
</Label> {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 ? ( {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) => ( {displayJobs.map((job) => (
<button <button
key={job.id} key={job.id}
...@@ -172,9 +239,16 @@ export function CreateExportModal({ ...@@ -172,9 +239,16 @@ export function CreateExportModal({
}`} }`}
> >
<div className="truncate min-w-0 pr-2"> <div className="truncate min-w-0 pr-2">
<p className="font-semibold text-foreground truncate">{job.startUrl}</p> <div className="flex items-center gap-2">
<p className="text-[10px] text-muted-foreground font-mono truncate"> <p className="font-semibold text-foreground truncate max-w-[280px] sm:max-w-md">
ID: {job.id.slice(0, 16)}...{job.successPages ? ` • ${job.successPages} trang` : ""} {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> </p>
</div> </div>
{selectedJobId === job.id && ( {selectedJobId === job.id && (
...@@ -184,12 +258,10 @@ export function CreateExportModal({ ...@@ -184,12 +258,10 @@ export function CreateExportModal({
))} ))}
</div> </div>
) : ( ) : (
<Input <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">
placeholder="Nhập mã Crawl Job ID..." <AlertCircle className="h-4 w-4 shrink-0 text-amber-500" />
value={selectedJobId} <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>
onChange={(e) => setSelectedJobId(e.target.value)} </div>
className="rounded-2xl text-xs"
/>
)} )}
</div> </div>
...@@ -258,7 +330,7 @@ export function CreateExportModal({ ...@@ -258,7 +330,7 @@ export function CreateExportModal({
</Button> </Button>
<Button <Button
type="submit" 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" 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 {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