Commit c381d01f authored by BangNSK's avatar BangNSK

feat: implement user management module with CRUD operations and update configuration files

parent 0c058896
......@@ -13,6 +13,8 @@ import {
FileText,
Download,
Loader2,
Image as ImageIcon,
Link as LinkIcon,
} from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress';
......@@ -59,6 +61,17 @@ interface CrawlExport {
createdAt: string;
}
interface CrawlAsset {
id: string;
pageId: string | null;
assetType: 'IMAGE' | 'LINK' | 'PDF' | 'FILE' | 'VIDEO' | 'OTHER';
url: string;
sourceUrl: string | null;
altText: string | null;
mimeType: string | null;
createdAt: string;
}
export const JobDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { isCrawler } = useAuth();
......@@ -67,19 +80,25 @@ export const JobDetail: React.FC = () => {
const [pages, setPages] = useState<CrawlPage[]>([]);
const [pagesMeta, setPagesMeta] = useState({ total: 0, page: 1, totalPages: 1 });
const [exports, setExports] = useState<CrawlExport[]>([]);
const [assets, setAssets] = useState<CrawlAsset[]>([]);
const [loadingJob, setLoadingJob] = useState(true);
const [loadingPages, setLoadingPages] = useState(true);
const [loadingAssets, setLoadingAssets] = useState(true);
const [loadingExport, setLoadingExport] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [selectedExportType, setSelectedExportType] = useState('JSON');
const [exportError, setExportError] = useState('');
const [jobError, setJobError] = useState('');
const [pagesError, setPagesError] = useState('');
const [assetsError, setAssetsError] = useState('');
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null);
const [activeTab, setActiveTab] = useState<'pages' | 'assets'>('pages');
const [selectedAssetTypeFilter, setSelectedAssetTypeFilter] = useState<string>('ALL');
const latestJobRequestRef = useRef(0);
const latestPagesRequestRef = useRef(0);
const latestAssetsRequestRef = useRef(0);
const fetchJob = useCallback(async () => {
const requestId = ++latestJobRequestRef.current;
......@@ -131,13 +150,37 @@ export const JobDetail: React.FC = () => {
}
}, [id]);
const fetchAssets = useCallback(async (typeFilter = selectedAssetTypeFilter, silent = false) => {
const requestId = ++latestAssetsRequestRef.current;
try {
if (!silent) setLoadingAssets(true);
const res = await api.get(`/crawl-jobs/${id}/assets`, {
params: {
assetType: typeFilter === 'ALL' ? undefined : typeFilter,
},
});
if (res.data.success && requestId === latestAssetsRequestRef.current) {
setAssets(res.data.data || []);
setAssetsError('');
}
} catch (e) {
console.error('Failed to fetch assets', e);
if (requestId === latestAssetsRequestRef.current) {
setAssetsError('Không thể tải danh sách tài nguyên.');
}
} finally {
if (!silent && requestId === latestAssetsRequestRef.current) setLoadingAssets(false);
}
}, [id, selectedAssetTypeFilter]);
// Initial load
useEffect(() => {
if (!id) return;
void fetchJob();
void fetchPages();
void fetchExports();
}, [fetchExports, fetchJob, fetchPages, id]);
void fetchAssets();
}, [fetchExports, fetchJob, fetchPages, fetchAssets, id]);
const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false;
......@@ -148,7 +191,11 @@ export const JobDetail: React.FC = () => {
let stopped = false;
let timeout: ReturnType<typeof setTimeout>;
const poll = async () => {
await Promise.all([fetchJob(), fetchPages(pagesMeta.page, true)]);
await Promise.all([
fetchJob(),
fetchPages(pagesMeta.page, true),
fetchAssets(selectedAssetTypeFilter, true)
]);
if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
};
......@@ -157,7 +204,7 @@ export const JobDetail: React.FC = () => {
stopped = true;
clearTimeout(timeout);
};
}, [fetchJob, fetchPages, isActive, pagesMeta.page]);
}, [fetchJob, fetchPages, fetchAssets, isActive, pagesMeta.page, selectedAssetTypeFilter]);
const handleCreateExport = async () => {
setExportError('');
......@@ -455,19 +502,44 @@ export const JobDetail: React.FC = () => {
)}
</div>
{/* Crawled Pages Table */}
{/* Tab Switcher & Content Card */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-bold text-slate-900 flex items-center gap-2">
<Globe className="h-5 w-5 text-slate-400" />
Danh sách trang đã cào
<span className="text-sm font-normal text-slate-400">({pagesMeta.total} trang)</span>
</h2>
<div className="border-b border-slate-100 bg-slate-50/50 px-6 flex items-center justify-between">
<div className="flex space-x-6">
<button
onClick={() => setActiveTab('pages')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'pages'
? 'border-indigo-600 text-indigo-600 font-semibold'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center gap-1.5">
<Globe className="h-4 w-4" />
Các trang đã cào ({pagesMeta.total})
</span>
</button>
<button
onClick={() => setActiveTab('assets')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'assets'
? 'border-indigo-600 text-indigo-600 font-semibold'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
<span className="flex items-center gap-1.5">
<ImageIcon className="h-4 w-4" />
Tài nguyên cào được ({assets.length})
</span>
</button>
</div>
{isActive && (
<span className="text-xs text-blue-500 font-medium animate-pulse">Đang cập nhật...</span>
)}
</div>
{activeTab === 'pages' ? (
<>
{pagesError && (
<div className="mx-6 mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{pagesError}</span>
......@@ -558,6 +630,127 @@ export const JobDetail: React.FC = () => {
)}
</>
)}
</>
) : (
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Lọc loại tài nguyên:</label>
<select
value={selectedAssetTypeFilter}
onChange={(e) => {
const val = e.target.value;
setSelectedAssetTypeFilter(val);
void fetchAssets(val);
}}
className="rounded-lg border border-slate-300 bg-white py-1.5 px-3 text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-xs"
>
<option value="ALL">Tất cả</option>
<option value="IMAGE">Hình ảnh (IMAGE)</option>
<option value="LINK">Liên kết (LINK)</option>
<option value="PDF">Tài liệu PDF (PDF)</option>
<option value="FILE">Tập tin (FILE)</option>
<option value="VIDEO">Video (VIDEO)</option>
<option value="OTHER">Khác (OTHER)</option>
</select>
</div>
{assetsError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{assetsError}</span>
<button type="button" onClick={() => void fetchAssets(selectedAssetTypeFilter)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{loadingAssets ? (
<div className="flex justify-center py-10">
<div className="h-7 w-7 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
</div>
) : assets.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm">
Không tìm thấy tài nguyên nào.
</div>
) : (
<div className="overflow-x-auto border border-slate-100 rounded-lg">
<table className="min-w-full divide-y divide-slate-100 text-sm text-left">
<thead className="bg-slate-50 text-slate-500 text-xs font-semibold uppercase">
<tr>
<th className="px-6 py-3">Loại</th>
<th className="px-6 py-3">Xem trước / URL tài nguyên</th>
<th className="px-6 py-3">Trang chứa (Source)</th>
<th className="px-6 py-3">Thông tin chi tiết</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700 bg-white">
{assets.map((asset) => (
<tr key={asset.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-3 whitespace-nowrap">
{(() => {
let cls = 'bg-slate-100 text-slate-700';
if (asset.assetType === 'IMAGE') cls = 'bg-teal-50 text-teal-700 border-teal-100 border';
else if (asset.assetType === 'LINK') cls = 'bg-sky-50 text-sky-700 border-sky-100 border';
else if (asset.assetType === 'PDF') cls = 'bg-rose-50 text-rose-700 border-rose-100 border';
else if (asset.assetType === 'VIDEO') cls = 'bg-purple-50 text-purple-700 border-purple-100 border';
else if (asset.assetType === 'FILE') cls = 'bg-blue-50 text-blue-700 border-blue-100 border';
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${cls}`}>
{asset.assetType}
</span>
);
})()}
</td>
<td className="px-6 py-3 max-w-sm">
<div className="flex items-center gap-3">
{asset.assetType === 'IMAGE' && (
<div className="h-10 w-10 rounded border border-slate-200 bg-slate-50 flex items-center justify-center overflow-hidden flex-shrink-0">
<img
src={asset.url}
alt={asset.altText || ''}
className="h-full w-full object-cover"
onError={(e) => {
(e.target as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="text-indigo-600 hover:underline truncate block text-xs font-mono"
title={asset.url}
>
{asset.url}
</a>
</div>
</td>
<td className="px-6 py-3 max-w-xs">
{asset.sourceUrl ? (
<a
href={asset.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-slate-600 hover:text-indigo-600 hover:underline truncate block text-xs"
title={asset.sourceUrl}
>
{asset.sourceUrl}
</a>
) : (
<span className="text-slate-300"></span>
)}
</td>
<td className="px-6 py-3 text-xs text-slate-500 max-w-xs">
{asset.altText && <p className="truncate" title={`Alt: ${asset.altText}`}><strong>Alt:</strong> {asset.altText}</p>}
{asset.mimeType && <p className="font-mono text-slate-400"><strong>Mime:</strong> {asset.mimeType}</p>}
{!asset.altText && !asset.mimeType && <span className="text-slate-300"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
);
......
......@@ -59,7 +59,7 @@ export const Logs: React.FC = () => {
},
});
if (res.data.success) {
setLogs(res.data.data.logs || []);
setLogs(res.data.data.items || res.data.data.logs || []);
setMeta(res.data.data.meta);
setLoadError('');
}
......
......@@ -83,7 +83,7 @@ export const Users: React.FC = () => {
params: { page, limit: 10, search: searchQuery || undefined },
});
if (res.data.success) {
setUsers(res.data.data.users || []);
setUsers(res.data.data.items || res.data.data.users || []);
setMeta(res.data.data.meta);
setLoadError('');
}
......
/// <reference types="vite/client" />
......@@ -4,7 +4,6 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
......@@ -20,7 +19,10 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
/* Types */
"types": ["vite/client"]
},
"include": ["src"]
}
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