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 { ...@@ -13,6 +13,8 @@ import {
FileText, FileText,
Download, Download,
Loader2, Loader2,
Image as ImageIcon,
Link as LinkIcon,
} from 'lucide-react'; } from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress'; import { getJobProgress } from '../utils/jobProgress';
...@@ -59,6 +61,17 @@ interface CrawlExport { ...@@ -59,6 +61,17 @@ interface CrawlExport {
createdAt: string; 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 = () => { export const JobDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { isCrawler } = useAuth(); const { isCrawler } = useAuth();
...@@ -67,19 +80,25 @@ export const JobDetail: React.FC = () => { ...@@ -67,19 +80,25 @@ export const JobDetail: React.FC = () => {
const [pages, setPages] = useState<CrawlPage[]>([]); const [pages, setPages] = useState<CrawlPage[]>([]);
const [pagesMeta, setPagesMeta] = useState({ total: 0, page: 1, totalPages: 1 }); const [pagesMeta, setPagesMeta] = useState({ total: 0, page: 1, totalPages: 1 });
const [exports, setExports] = useState<CrawlExport[]>([]); const [exports, setExports] = useState<CrawlExport[]>([]);
const [assets, setAssets] = useState<CrawlAsset[]>([]);
const [loadingJob, setLoadingJob] = useState(true); const [loadingJob, setLoadingJob] = useState(true);
const [loadingPages, setLoadingPages] = useState(true); const [loadingPages, setLoadingPages] = useState(true);
const [loadingAssets, setLoadingAssets] = useState(true);
const [loadingExport, setLoadingExport] = useState(false); const [loadingExport, setLoadingExport] = useState(false);
const [cancelling, setCancelling] = useState(false); const [cancelling, setCancelling] = useState(false);
const [selectedExportType, setSelectedExportType] = useState('JSON'); const [selectedExportType, setSelectedExportType] = useState('JSON');
const [exportError, setExportError] = useState(''); const [exportError, setExportError] = useState('');
const [jobError, setJobError] = useState(''); const [jobError, setJobError] = useState('');
const [pagesError, setPagesError] = useState(''); const [pagesError, setPagesError] = useState('');
const [assetsError, setAssetsError] = useState('');
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null); 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 latestJobRequestRef = useRef(0);
const latestPagesRequestRef = useRef(0); const latestPagesRequestRef = useRef(0);
const latestAssetsRequestRef = useRef(0);
const fetchJob = useCallback(async () => { const fetchJob = useCallback(async () => {
const requestId = ++latestJobRequestRef.current; const requestId = ++latestJobRequestRef.current;
...@@ -131,13 +150,37 @@ export const JobDetail: React.FC = () => { ...@@ -131,13 +150,37 @@ export const JobDetail: React.FC = () => {
} }
}, [id]); }, [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 // Initial load
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
void fetchJob(); void fetchJob();
void fetchPages(); void fetchPages();
void fetchExports(); void fetchExports();
}, [fetchExports, fetchJob, fetchPages, id]); void fetchAssets();
}, [fetchExports, fetchJob, fetchPages, fetchAssets, id]);
const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false; const isActive = job ? ACTIVE_STATUSES.includes(job.status) : false;
...@@ -148,7 +191,11 @@ export const JobDetail: React.FC = () => { ...@@ -148,7 +191,11 @@ export const JobDetail: React.FC = () => {
let stopped = false; let stopped = false;
let timeout: ReturnType<typeof setTimeout>; let timeout: ReturnType<typeof setTimeout>;
const poll = async () => { 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); if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
}; };
...@@ -157,7 +204,7 @@ export const JobDetail: React.FC = () => { ...@@ -157,7 +204,7 @@ export const JobDetail: React.FC = () => {
stopped = true; stopped = true;
clearTimeout(timeout); clearTimeout(timeout);
}; };
}, [fetchJob, fetchPages, isActive, pagesMeta.page]); }, [fetchJob, fetchPages, fetchAssets, isActive, pagesMeta.page, selectedAssetTypeFilter]);
const handleCreateExport = async () => { const handleCreateExport = async () => {
setExportError(''); setExportError('');
...@@ -455,108 +502,254 @@ export const JobDetail: React.FC = () => { ...@@ -455,108 +502,254 @@ export const JobDetail: React.FC = () => {
)} )}
</div> </div>
{/* Crawled Pages Table */} {/* Tab Switcher & Content Card */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> <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"> <div className="border-b border-slate-100 bg-slate-50/50 px-6 flex items-center justify-between">
<h2 className="text-base font-bold text-slate-900 flex items-center gap-2"> <div className="flex space-x-6">
<Globe className="h-5 w-5 text-slate-400" /> <button
Danh sách trang đã cào onClick={() => setActiveTab('pages')}
<span className="text-sm font-normal text-slate-400">({pagesMeta.total} trang)</span> className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
</h2> 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 && ( {isActive && (
<span className="text-xs text-blue-500 font-medium animate-pulse">Đang cập nhật...</span> <span className="text-xs text-blue-500 font-medium animate-pulse">Đang cập nhật...</span>
)} )}
</div> </div>
{pagesError && ( {activeTab === 'pages' ? (
<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> {pagesError && (
<button type="button" onClick={() => void fetchPages(pagesMeta.page)} className="font-semibold hover:underline">Thử lại</button> <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">
</div> <span>{pagesError}</span>
)} <button type="button" onClick={() => void fetchPages(pagesMeta.page)} className="font-semibold hover:underline">Thử lại</button>
</div>
)}
{loadingPages ? ( {loadingPages ? (
<div className="flex justify-center py-10"> <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 className="h-7 w-7 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
</div> </div>
) : pages.length === 0 ? ( ) : pages.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm"> <div className="text-center py-10 text-slate-400 text-sm">
{isActive ? 'Đang chờ Worker bắt đầu cào...' : 'Không có trang nào.'} {isActive ? 'Đang chờ Worker bắt đầu cào...' : 'Không có trang nào.'}
</div> </div>
) : (
<>
<div className="overflow-x-auto">
<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">Trang URL</th>
<th className="px-6 py-3">Tiêu đề</th>
<th className="px-6 py-3">Trạng thái</th>
<th className="px-6 py-3">HTTP Code</th>
<th className="px-6 py-3">Thời gian cào</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-slate-700">
{pages.map((page) => (
<tr key={page.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-3 max-w-xs">
<a
href={page.url}
target="_blank"
rel="noreferrer"
className="text-indigo-600 hover:underline truncate block max-w-xs"
title={page.url}
>
{page.url}
</a>
</td>
<td className="px-6 py-3 max-w-xs">
<span className="truncate block max-w-xs text-slate-600" title={page.title || ''}>
{page.title || <span className="text-slate-300 italic">Không có tiêu đề</span>}
</span>
</td>
<td className="px-6 py-3">{getPageStatusBadge(page.status)}</td>
<td className="px-6 py-3">
{page.statusCode ? (
<span className={`font-mono text-xs px-2 py-0.5 rounded ${page.statusCode < 400 ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'}`}>
{page.statusCode}
</span>
) : '–'}
</td>
<td className="px-6 py-3 text-slate-400 text-xs">
{page.crawledAt ? new Date(page.crawledAt).toLocaleString('vi-VN') : '–'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{pagesMeta.totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-slate-100 bg-slate-50 text-sm">
<span className="text-slate-500">
Trang {pagesMeta.page} / {pagesMeta.totalPages}
</span>
<div className="flex gap-2">
<button
onClick={() => fetchPages(pagesMeta.page - 1)}
disabled={pagesMeta.page <= 1}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
← Trước
</button>
<button
onClick={() => fetchPages(pagesMeta.page + 1)}
disabled={pagesMeta.page >= pagesMeta.totalPages}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
Tiếp →
</button>
</div>
</div>
)}
</>
)}
</>
) : ( ) : (
<> <div className="p-6 space-y-4">
<div className="overflow-x-auto"> <div className="flex items-center gap-3">
<table className="min-w-full divide-y divide-slate-100 text-sm text-left"> <label className="text-xs font-semibold text-slate-500 uppercase tracking-wide">Lọc loại tài nguyên:</label>
<thead className="bg-slate-50 text-slate-500 text-xs font-semibold uppercase"> <select
<tr> value={selectedAssetTypeFilter}
<th className="px-6 py-3">Trang URL</th> onChange={(e) => {
<th className="px-6 py-3">Tiêu đề</th> const val = e.target.value;
<th className="px-6 py-3">Trạng thái</th> setSelectedAssetTypeFilter(val);
<th className="px-6 py-3">HTTP Code</th> void fetchAssets(val);
<th className="px-6 py-3">Thời gian cào</th> }}
</tr> 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"
</thead> >
<tbody className="divide-y divide-slate-100 text-slate-700"> <option value="ALL">Tất cả</option>
{pages.map((page) => ( <option value="IMAGE">Hình ảnh (IMAGE)</option>
<tr key={page.id} className="hover:bg-slate-50/50 transition-colors"> <option value="LINK">Liên kết (LINK)</option>
<td className="px-6 py-3 max-w-xs"> <option value="PDF">Tài liệu PDF (PDF)</option>
<a <option value="FILE">Tập tin (FILE)</option>
href={page.url} <option value="VIDEO">Video (VIDEO)</option>
target="_blank" <option value="OTHER">Khác (OTHER)</option>
rel="noreferrer" </select>
className="text-indigo-600 hover:underline truncate block max-w-xs"
title={page.url}
>
{page.url}
</a>
</td>
<td className="px-6 py-3 max-w-xs">
<span className="truncate block max-w-xs text-slate-600" title={page.title || ''}>
{page.title || <span className="text-slate-300 italic">Không có tiêu đề</span>}
</span>
</td>
<td className="px-6 py-3">{getPageStatusBadge(page.status)}</td>
<td className="px-6 py-3">
{page.statusCode ? (
<span className={`font-mono text-xs px-2 py-0.5 rounded ${page.statusCode < 400 ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'}`}>
{page.statusCode}
</span>
) : '–'}
</td>
<td className="px-6 py-3 text-slate-400 text-xs">
{page.crawledAt ? new Date(page.crawledAt).toLocaleString('vi-VN') : '–'}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
{/* Pagination */} {assetsError && (
{pagesMeta.totalPages > 1 && ( <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">
<div className="flex items-center justify-between px-6 py-3 border-t border-slate-100 bg-slate-50 text-sm"> <span>{assetsError}</span>
<span className="text-slate-500"> <button type="button" onClick={() => void fetchAssets(selectedAssetTypeFilter)} className="font-semibold hover:underline">Thử lại</button>
Trang {pagesMeta.page} / {pagesMeta.totalPages}
</span>
<div className="flex gap-2">
<button
onClick={() => fetchPages(pagesMeta.page - 1)}
disabled={pagesMeta.page <= 1}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
← Trước
</button>
<button
onClick={() => fetchPages(pagesMeta.page + 1)}
disabled={pagesMeta.page >= pagesMeta.totalPages}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
>
Tiếp →
</button>
</div>
</div> </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>
</div> </div>
......
...@@ -59,7 +59,7 @@ export const Logs: React.FC = () => { ...@@ -59,7 +59,7 @@ export const Logs: React.FC = () => {
}, },
}); });
if (res.data.success) { if (res.data.success) {
setLogs(res.data.data.logs || []); setLogs(res.data.data.items || res.data.data.logs || []);
setMeta(res.data.data.meta); setMeta(res.data.data.meta);
setLoadError(''); setLoadError('');
} }
......
...@@ -83,7 +83,7 @@ export const Users: React.FC = () => { ...@@ -83,7 +83,7 @@ export const Users: React.FC = () => {
params: { page, limit: 10, search: searchQuery || undefined }, params: { page, limit: 10, search: searchQuery || undefined },
}); });
if (res.data.success) { if (res.data.success) {
setUsers(res.data.data.users || []); setUsers(res.data.data.items || res.data.data.users || []);
setMeta(res.data.data.meta); setMeta(res.data.data.meta);
setLoadError(''); setLoadError('');
} }
......
/// <reference types="vite/client" />
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
"target": "es2023", "target": "es2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "esnext", "module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true, "allowArbitraryExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
...@@ -20,7 +19,10 @@ ...@@ -20,7 +19,10 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
/* Types */
"types": ["vite/client"]
}, },
"include": ["src"] "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