Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
frontend-data-crawler-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
BangNSK
frontend-data-crawler-be
Commits
3c1f03f8
Commit
3c1f03f8
authored
Jul 22, 2026
by
BangNSK
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement JobDetail page to display and manage crawl job status, pages, and assets
parent
82aeed08
Changes
1
Show whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
333 additions
and
63 deletions
+333
-63
JobDetail.tsx
src/pages/JobDetail.tsx
+333
-63
No files found.
src/pages/JobDetail.tsx
View file @
3c1f03f8
...
@@ -14,6 +14,13 @@ import {
...
@@ -14,6 +14,13 @@ import {
Download
,
Download
,
Loader2
,
Loader2
,
Image
as
ImageIcon
,
Image
as
ImageIcon
,
Eye
,
Search
,
Filter
,
Copy
,
Check
,
Code
,
Sparkles
,
}
from
'lucide-react'
;
}
from
'lucide-react'
;
import
{
getJobProgress
}
from
'../utils/jobProgress'
;
import
{
getJobProgress
}
from
'../utils/jobProgress'
;
...
@@ -51,6 +58,7 @@ interface CrawlJob {
...
@@ -51,6 +58,7 @@ interface CrawlJob {
interface
CrawlPage
{
interface
CrawlPage
{
id
:
string
;
id
:
string
;
url
:
string
;
url
:
string
;
normalizedUrl
?:
string
;
title
:
string
|
null
;
title
:
string
|
null
;
description
:
string
|
null
;
description
:
string
|
null
;
status
:
string
;
status
:
string
;
...
@@ -61,6 +69,10 @@ interface CrawlPage {
...
@@ -61,6 +69,10 @@ interface CrawlPage {
warnings
:
string
[];
warnings
:
string
[];
dataQualityScore
:
number
|
null
;
dataQualityScore
:
number
|
null
;
wordCount
:
number
|
null
;
wordCount
:
number
|
null
;
contentHash
?:
string
|
null
;
rawMarkdown
?:
string
|
null
;
mainContent
?:
string
|
null
;
cleanText
?:
string
|
null
;
}
}
interface
CrawlExport
{
interface
CrawlExport
{
...
@@ -94,6 +106,16 @@ export const JobDetail: React.FC = () => {
...
@@ -94,6 +106,16 @@ export const JobDetail: React.FC = () => {
const
[
exports
,
setExports
]
=
useState
<
CrawlExport
[]
>
([]);
const
[
exports
,
setExports
]
=
useState
<
CrawlExport
[]
>
([]);
const
[
assets
,
setAssets
]
=
useState
<
CrawlAsset
[]
>
([]);
const
[
assets
,
setAssets
]
=
useState
<
CrawlAsset
[]
>
([]);
// Filter States for Pages
const
[
searchQuery
,
setSearchQuery
]
=
useState
(
''
);
const
[
pageStatusFilter
,
setPageStatusFilter
]
=
useState
(
'ALL'
);
const
[
minQualityFilter
,
setMinQualityFilter
]
=
useState
<
number
|
'ALL'
>
(
'ALL'
);
// Preview Modal States
const
[
selectedPage
,
setSelectedPage
]
=
useState
<
CrawlPage
|
null
>
(
null
);
const
[
previewTab
,
setPreviewTab
]
=
useState
<
'clean'
|
'raw'
|
'text'
>
(
'clean'
);
const
[
copied
,
setCopied
]
=
useState
(
false
);
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
[
loadingAssets
,
setLoadingAssets
]
=
useState
(
true
);
...
@@ -131,11 +153,27 @@ export const JobDetail: React.FC = () => {
...
@@ -131,11 +153,27 @@ export const JobDetail: React.FC = () => {
}
}
},
[
id
]);
},
[
id
]);
const
fetchPages
=
useCallback
(
async
(
page
=
1
,
silent
=
false
)
=>
{
const
fetchPages
=
useCallback
(
async
(
page
=
1
,
silent
=
false
,
search
=
searchQuery
,
status
=
pageStatusFilter
,
minQuality
=
minQualityFilter
)
=>
{
const
requestId
=
++
latestPagesRequestRef
.
current
;
const
requestId
=
++
latestPagesRequestRef
.
current
;
try
{
try
{
if
(
!
silent
)
setLoadingPages
(
true
);
if
(
!
silent
)
setLoadingPages
(
true
);
const
res
=
await
api
.
get
(
`/crawl-jobs/
${
id
}
/pages`
,
{
params
:
{
page
,
limit
:
20
}
});
const
params
:
Record
<
string
,
any
>
=
{
page
,
limit
:
20
,
preview
:
true
,
};
if
(
search
.
trim
())
params
.
search
=
search
.
trim
();
if
(
status
!==
'ALL'
)
params
.
status
=
status
;
if
(
minQuality
!==
'ALL'
)
params
.
minQualityScore
=
minQuality
;
const
res
=
await
api
.
get
(
`/crawl-jobs/
${
id
}
/pages`
,
{
params
});
if
(
res
.
data
.
success
&&
requestId
===
latestPagesRequestRef
.
current
)
{
if
(
res
.
data
.
success
&&
requestId
===
latestPagesRequestRef
.
current
)
{
setPages
(
res
.
data
.
data
.
items
||
[]);
setPages
(
res
.
data
.
data
.
items
||
[]);
setPagesMeta
(
res
.
data
.
data
.
meta
);
setPagesMeta
(
res
.
data
.
data
.
meta
);
...
@@ -149,7 +187,9 @@ export const JobDetail: React.FC = () => {
...
@@ -149,7 +187,9 @@ export const JobDetail: React.FC = () => {
}
finally
{
}
finally
{
if
(
!
silent
&&
requestId
===
latestPagesRequestRef
.
current
)
setLoadingPages
(
false
);
if
(
!
silent
&&
requestId
===
latestPagesRequestRef
.
current
)
setLoadingPages
(
false
);
}
}
},
[
id
]);
},
[
id
,
searchQuery
,
pageStatusFilter
,
minQualityFilter
]
);
const
fetchExports
=
useCallback
(
async
()
=>
{
const
fetchExports
=
useCallback
(
async
()
=>
{
try
{
try
{
...
@@ -162,7 +202,8 @@ export const JobDetail: React.FC = () => {
...
@@ -162,7 +202,8 @@ export const JobDetail: React.FC = () => {
}
}
},
[
id
]);
},
[
id
]);
const
fetchAssets
=
useCallback
(
async
(
typeFilter
=
selectedAssetTypeFilter
,
silent
=
false
)
=>
{
const
fetchAssets
=
useCallback
(
async
(
typeFilter
=
selectedAssetTypeFilter
,
silent
=
false
)
=>
{
const
requestId
=
++
latestAssetsRequestRef
.
current
;
const
requestId
=
++
latestAssetsRequestRef
.
current
;
try
{
try
{
if
(
!
silent
)
setLoadingAssets
(
true
);
if
(
!
silent
)
setLoadingAssets
(
true
);
...
@@ -183,13 +224,15 @@ export const JobDetail: React.FC = () => {
...
@@ -183,13 +224,15 @@ export const JobDetail: React.FC = () => {
}
finally
{
}
finally
{
if
(
!
silent
&&
requestId
===
latestAssetsRequestRef
.
current
)
setLoadingAssets
(
false
);
if
(
!
silent
&&
requestId
===
latestAssetsRequestRef
.
current
)
setLoadingAssets
(
false
);
}
}
},
[
id
,
selectedAssetTypeFilter
]);
},
[
id
,
selectedAssetTypeFilter
]
);
// Initial load
// Initial load
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
!
id
)
return
;
if
(
!
id
)
return
;
void
fetchJob
();
void
fetchJob
();
void
fetchPages
();
void
fetchPages
(
1
);
void
fetchExports
();
void
fetchExports
();
void
fetchAssets
();
void
fetchAssets
();
},
[
fetchExports
,
fetchJob
,
fetchPages
,
fetchAssets
,
id
]);
},
[
fetchExports
,
fetchJob
,
fetchPages
,
fetchAssets
,
id
]);
...
@@ -206,7 +249,7 @@ export const JobDetail: React.FC = () => {
...
@@ -206,7 +249,7 @@ export const JobDetail: React.FC = () => {
await
Promise
.
all
([
await
Promise
.
all
([
fetchJob
(),
fetchJob
(),
fetchPages
(
pagesMeta
.
page
,
true
),
fetchPages
(
pagesMeta
.
page
,
true
),
fetchAssets
(
selectedAssetTypeFilter
,
true
)
fetchAssets
(
selectedAssetTypeFilter
,
true
)
,
]);
]);
if
(
!
stopped
)
timeout
=
setTimeout
(
poll
,
POLL_INTERVAL_MS
);
if
(
!
stopped
)
timeout
=
setTimeout
(
poll
,
POLL_INTERVAL_MS
);
};
};
...
@@ -246,12 +289,8 @@ export const JobDetail: React.FC = () => {
...
@@ -246,12 +289,8 @@ export const JobDetail: React.FC = () => {
const
handleDownload
=
async
(
exportId
:
string
,
fileName
:
string
,
_mimeType
:
string
)
=>
{
const
handleDownload
=
async
(
exportId
:
string
,
fileName
:
string
,
_mimeType
:
string
)
=>
{
try
{
try
{
console
.
log
(
`Downloading export
${
exportId
}
, requesting blob...`
);
const
res
=
await
api
.
get
(
`/exports/
${
exportId
}
/download`
,
{
responseType
:
'blob'
});
const
res
=
await
api
.
get
(
`/exports/
${
exportId
}
/download`
,
{
responseType
:
'blob'
});
const
blob
=
res
.
data
;
const
blob
=
res
.
data
;
// res.data đã là Blob do responseType: 'blob'
console
.
log
(
'Downloaded blob type:'
,
blob
.
type
,
'size:'
,
blob
.
size
);
const
url
=
window
.
URL
.
createObjectURL
(
blob
);
const
url
=
window
.
URL
.
createObjectURL
(
blob
);
const
link
=
document
.
createElement
(
'a'
);
const
link
=
document
.
createElement
(
'a'
);
link
.
href
=
url
;
link
.
href
=
url
;
...
@@ -276,6 +315,12 @@ export const JobDetail: React.FC = () => {
...
@@ -276,6 +315,12 @@ export const JobDetail: React.FC = () => {
}
}
};
};
const
copyToClipboard
=
(
text
:
string
)
=>
{
void
navigator
.
clipboard
.
writeText
(
text
);
setCopied
(
true
);
setTimeout
(()
=>
setCopied
(
false
),
2000
);
};
const
getStatusConfig
=
(
status
:
string
)
=>
{
const
getStatusConfig
=
(
status
:
string
)
=>
{
switch
(
status
)
{
switch
(
status
)
{
case
'COMPLETED'
:
case
'COMPLETED'
:
...
@@ -341,7 +386,7 @@ export const JobDetail: React.FC = () => {
...
@@ -341,7 +386,7 @@ export const JobDetail: React.FC = () => {
const
statusConfig
=
getStatusConfig
(
job
.
status
);
const
statusConfig
=
getStatusConfig
(
job
.
status
);
return
(
return
(
<
div
className=
"space-y-6 max-w-
5
xl mx-auto"
>
<
div
className=
"space-y-6 max-w-
6
xl mx-auto"
>
{
/* Back + Header */
}
{
/* Back + Header */
}
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
className=
"flex items-start justify-between gap-4"
>
<
div
>
<
div
>
...
@@ -372,7 +417,11 @@ export const JobDetail: React.FC = () => {
...
@@ -372,7 +417,11 @@ export const JobDetail: React.FC = () => {
</
button
>
</
button
>
)
}
)
}
<
button
<
button
onClick=
{
()
=>
{
void
fetchJob
();
void
fetchPages
(
pagesMeta
.
page
);
void
fetchExports
();
}
}
onClick=
{
()
=>
{
void
fetchJob
();
void
fetchPages
(
pagesMeta
.
page
);
void
fetchExports
();
}
}
className=
"p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
className=
"p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
title=
"Làm mới"
title=
"Làm mới"
>
>
...
@@ -419,13 +468,11 @@ export const JobDetail: React.FC = () => {
...
@@ -419,13 +468,11 @@ export const JobDetail: React.FC = () => {
</
div
>
</
div
>
<
div
className=
"w-full bg-slate-100 rounded-full h-3 overflow-hidden"
>
<
div
className=
"w-full bg-slate-100 rounded-full h-3 overflow-hidden"
>
{
isActive
&&
!
hasProgressData
?
(
{
isActive
&&
!
hasProgressData
?
(
<
div
className=
"h-3 w-1/3 animate-pulse rounded-full bg-indigo-500"
aria
-
label=
"Đang xử lý
, chưa có số liệu tiến độ
"
/>
<
div
className=
"h-3 w-1/3 animate-pulse rounded-full bg-indigo-500"
aria
-
label=
"Đang xử lý"
/>
)
:
(
)
:
(
<
div
<
div
className=
{
`h-3 rounded-full transition-all duration-500 ${
className=
{
`h-3 rounded-full transition-all duration-500 ${
job.status === 'FAILED' ? 'bg-rose-500' :
job.status === 'FAILED' ? 'bg-rose-500' : job.status === 'CANCELED' ? 'bg-slate-400' : 'bg-indigo-500'
job.status === 'CANCELED' ? 'bg-slate-400' :
'bg-indigo-500'
} ${isActive ? 'animate-pulse' : ''}`
}
} ${isActive ? 'animate-pulse' : ''}`
}
style=
{
{
width
:
`${progress.percent}%`
}
}
style=
{
{
width
:
`${progress.percent}%`
}
}
/>
/>
...
@@ -450,7 +497,7 @@ export const JobDetail: React.FC = () => {
...
@@ -450,7 +497,7 @@ export const JobDetail: React.FC = () => {
<
div
className=
"bg-white rounded-xl shadow-sm border border-slate-200 p-6"
>
<
div
className=
"bg-white rounded-xl shadow-sm border border-slate-200 p-6"
>
<
h2
className=
"text-base font-bold text-slate-900 mb-4 flex items-center gap-2"
>
<
h2
className=
"text-base font-bold text-slate-900 mb-4 flex items-center gap-2"
>
<
FileText
className=
"h-5 w-5 text-slate-400"
/>
<
FileText
className=
"h-5 w-5 text-slate-400"
/>
Xuất kết quả dữ liệu
Xuất kết quả dữ liệu
(Data Contract v1)
</
h2
>
</
h2
>
{
isCrawler
&&
(
{
isCrawler
&&
(
...
@@ -554,6 +601,66 @@ export const JobDetail: React.FC = () => {
...
@@ -554,6 +601,66 @@ export const JobDetail: React.FC = () => {
{
activeTab
===
'pages'
?
(
{
activeTab
===
'pages'
?
(
<>
<>
{
/* Filter Bar */
}
<
div
className=
"p-4 bg-slate-50/80 border-b border-slate-100 flex flex-col md:flex-row gap-3 items-center justify-between"
>
<
div
className=
"relative w-full md:w-72"
>
<
Search
className=
"absolute left-3 top-2.5 h-4 w-4 text-slate-400"
/>
<
input
type=
"text"
placeholder=
"Tìm URL hoặc tiêu đề..."
value=
{
searchQuery
}
onChange=
{
(
e
)
=>
setSearchQuery
(
e
.
target
.
value
)
}
onKeyDown=
{
(
e
)
=>
{
if
(
e
.
key
===
'Enter'
)
void
fetchPages
(
1
);
}
}
className=
"w-full pl-9 pr-3 py-1.5 text-xs bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-2 w-full md:w-auto"
>
<
div
className=
"flex items-center gap-1.5 text-xs text-slate-500 font-medium"
>
<
Filter
className=
"h-3.5 w-3.5"
/>
Lọc:
</
div
>
<
select
value=
{
pageStatusFilter
}
onChange=
{
(
e
)
=>
{
setPageStatusFilter
(
e
.
target
.
value
);
void
fetchPages
(
1
,
false
,
searchQuery
,
e
.
target
.
value
,
minQualityFilter
);
}
}
className=
"bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-700 focus:outline-none"
>
<
option
value=
"ALL"
>
Tất cả trạng thái
</
option
>
<
option
value=
"SUCCESS"
>
Thành công (SUCCESS)
</
option
>
<
option
value=
"FAILED"
>
Thất bại (FAILED)
</
option
>
<
option
value=
"BLOCKED"
>
Bị chặn (BLOCKED)
</
option
>
<
option
value=
"PENDING"
>
Chờ (PENDING)
</
option
>
</
select
>
<
select
value=
{
minQualityFilter
}
onChange=
{
(
e
)
=>
{
const
val
=
e
.
target
.
value
===
'ALL'
?
'ALL'
:
Number
(
e
.
target
.
value
);
setMinQualityFilter
(
val
);
void
fetchPages
(
1
,
false
,
searchQuery
,
pageStatusFilter
,
val
);
}
}
className=
"bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-700 focus:outline-none"
>
<
option
value=
"ALL"
>
Tất cả chất lượng
</
option
>
<
option
value=
"70"
>
Điểm Quality
≥
70 (Tốt)
</
option
>
<
option
value=
"40"
>
Điểm Quality
≥
40 (Trung bình)
</
option
>
<
option
value=
"0"
>
Tất cả điểm quality
</
option
>
</
select
>
<
button
type=
"button"
onClick=
{
()
=>
void
fetchPages
(
1
)
}
className=
"px-3 py-1 bg-indigo-600 text-white rounded-lg text-xs font-semibold hover:bg-indigo-700 transition-colors"
>
Áp dụng
</
button
>
</
div
>
</
div
>
{
pagesError
&&
(
{
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"
>
<
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
>
<
span
>
{
pagesError
}
</
span
>
...
@@ -567,7 +674,7 @@ export const JobDetail: React.FC = () => {
...
@@ -567,7 +674,7 @@ export const JobDetail: React.FC = () => {
</
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
tìm thấy trang phù hợp
.'
}
</
div
>
</
div
>
)
:
(
)
:
(
<>
<>
...
@@ -581,7 +688,7 @@ export const JobDetail: React.FC = () => {
...
@@ -581,7 +688,7 @@ export const JobDetail: React.FC = () => {
<
th
className=
"px-6 py-3"
>
HTTP Code
</
th
>
<
th
className=
"px-6 py-3"
>
HTTP Code
</
th
>
<
th
className=
"px-6 py-3"
>
Chất lượng
</
th
>
<
th
className=
"px-6 py-3"
>
Chất lượng
</
th
>
<
th
className=
"px-6 py-3"
>
Cảnh báo
</
th
>
<
th
className=
"px-6 py-3"
>
Cảnh báo
</
th
>
<
th
className=
"px-6 py-3
"
>
Thời gian cào
</
th
>
<
th
className=
"px-6 py-3
text-right"
>
Xem trước
</
th
>
</
tr
>
</
tr
>
</
thead
>
</
thead
>
<
tbody
className=
"divide-y divide-slate-100 text-slate-700"
>
<
tbody
className=
"divide-y divide-slate-100 text-slate-700"
>
...
@@ -612,7 +719,7 @@ export const JobDetail: React.FC = () => {
...
@@ -612,7 +719,7 @@ export const JobDetail: React.FC = () => {
)
:
'–'
}
)
:
'–'
}
</
td
>
</
td
>
<
td
className=
"px-6 py-3"
>
<
td
className=
"px-6 py-3"
>
<
div
className=
"flex flex-col gap-1 items-
center
"
>
<
div
className=
"flex flex-col gap-1 items-
start
"
>
{
page
.
dataQualityScore
!==
null
&&
page
.
dataQualityScore
!==
undefined
?
(
{
page
.
dataQualityScore
!==
null
&&
page
.
dataQualityScore
!==
undefined
?
(
<
span
<
span
title=
{
`Điểm chất lượng: ${page.dataQualityScore}/100`
}
title=
{
`Điểm chất lượng: ${page.dataQualityScore}/100`
}
...
@@ -631,7 +738,7 @@ export const JobDetail: React.FC = () => {
...
@@ -631,7 +738,7 @@ export const JobDetail: React.FC = () => {
)
}
)
}
{
page
.
hasSensitiveData
&&
(
{
page
.
hasSensitiveData
&&
(
<
span
<
span
title=
"Trang này chứa dữ liệu nhạy cảm
(email/SĐT/CCCD...)
"
title=
"Trang này chứa dữ liệu nhạy cảm"
className=
"inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded"
className=
"inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded"
>
>
🔒 Nhạy cảm
🔒 Nhạy cảm
...
@@ -655,8 +762,16 @@ export const JobDetail: React.FC = () => {
...
@@ -655,8 +762,16 @@ export const JobDetail: React.FC = () => {
<
span
className=
"text-slate-300 text-xs"
>
–
</
span
>
<
span
className=
"text-slate-300 text-xs"
>
–
</
span
>
)
}
)
}
</
td
>
</
td
>
<
td
className=
"px-6 py-3 text-slate-400 text-xs"
>
<
td
className=
"px-6 py-3 text-right"
>
{
page
.
crawledAt
?
new
Date
(
page
.
crawledAt
).
toLocaleString
(
'vi-VN'
)
:
'–'
}
<
button
onClick=
{
()
=>
{
setSelectedPage
(
page
);
setPreviewTab
(
'clean'
);
}
}
className=
"inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-900 bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1.5 rounded-lg border border-indigo-100 transition-colors"
>
<
Eye
className=
"h-3.5 w-3.5"
/>
Xem
</
button
>
</
td
>
</
td
>
</
tr
>
</
tr
>
))
}
))
}
...
@@ -676,14 +791,14 @@ export const JobDetail: React.FC = () => {
...
@@ -676,14 +791,14 @@ export const JobDetail: React.FC = () => {
disabled=
{
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"
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
←
Trước
</
button
>
</
button
>
<
button
<
button
onClick=
{
()
=>
fetchPages
(
pagesMeta
.
page
+
1
)
}
onClick=
{
()
=>
fetchPages
(
pagesMeta
.
page
+
1
)
}
disabled=
{
pagesMeta
.
page
>=
pagesMeta
.
totalPages
}
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"
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
→
Tiếp
→
</
button
>
</
button
>
</
div
>
</
div
>
</
div
>
</
div
>
...
@@ -795,13 +910,13 @@ export const JobDetail: React.FC = () => {
...
@@ -795,13 +910,13 @@ export const JobDetail: React.FC = () => {
{
asset
.
sourceUrl
}
{
asset
.
sourceUrl
}
</
a
>
</
a
>
)
:
(
)
:
(
<
span
className=
"text-slate-300"
>
—
</
span
>
<
span
className=
"text-slate-300"
>
—
</
span
>
)
}
)
}
</
td
>
</
td
>
<
td
className=
"px-6 py-3 text-xs text-slate-500 max-w-xs"
>
<
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
.
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
.
mimeType
&&
<
p
className=
"font-mono text-slate-400"
><
strong
>
Mime:
</
strong
>
{
asset
.
mimeType
}
</
p
>
}
{
!
asset
.
altText
&&
!
asset
.
mimeType
&&
<
span
className=
"text-slate-300"
>
—
</
span
>
}
{
!
asset
.
altText
&&
!
asset
.
mimeType
&&
<
span
className=
"text-slate-300"
>
—
</
span
>
}
</
td
>
</
td
>
</
tr
>
</
tr
>
))
}
))
}
...
@@ -812,6 +927,161 @@ export const JobDetail: React.FC = () => {
...
@@ -812,6 +927,161 @@ export const JobDetail: React.FC = () => {
</
div
>
</
div
>
)
}
)
}
</
div
>
</
div
>
{
/* Page Preview Modal (Data Contract v1) */
}
{
selectedPage
&&
(
<
div
className=
"fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm"
>
<
div
className=
"bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden"
>
{
/* Modal Header */
}
<
div
className=
"px-6 py-4 border-b border-slate-100 flex items-start justify-between bg-slate-50/50"
>
<
div
className=
"pr-4 min-w-0"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"px-2 py-0.5 bg-indigo-50 text-indigo-700 text-xs font-bold rounded"
>
Data Contract v1 Preview
</
span
>
{
selectedPage
.
dataQualityScore
!==
null
&&
selectedPage
.
dataQualityScore
!==
undefined
&&
(
<
span
className=
"px-2 py-0.5 bg-emerald-50 text-emerald-700 text-xs font-bold rounded-full"
>
Quality:
{
selectedPage
.
dataQualityScore
}
/100
</
span
>
)
}
</
div
>
<
h3
className=
"text-lg font-bold text-slate-900 mt-1 truncate"
title=
{
selectedPage
.
title
||
selectedPage
.
url
}
>
{
selectedPage
.
title
||
selectedPage
.
url
}
</
h3
>
<
a
href=
{
selectedPage
.
url
}
target=
"_blank"
rel=
"noreferrer"
className=
"text-xs text-indigo-600 hover:underline font-mono truncate block"
>
{
selectedPage
.
url
}
</
a
>
</
div
>
<
button
onClick=
{
()
=>
setSelectedPage
(
null
)
}
className=
"text-slate-400 hover:text-slate-600 p-1"
>
<
XCircle
className=
"h-6 w-6"
/>
</
button
>
</
div
>
{
/* Content Tabs Header */
}
<
div
className=
"px-6 py-2 bg-slate-100/70 border-b border-slate-200 flex items-center justify-between"
>
<
div
className=
"flex space-x-2"
>
<
button
onClick=
{
()
=>
setPreviewTab
(
'clean'
)
}
className=
{
`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'clean'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`
}
>
<
Sparkles
className=
"h-3.5 w-3.5"
/>
Main Content (AI Clean)
</
button
>
<
button
onClick=
{
()
=>
setPreviewTab
(
'raw'
)
}
className=
{
`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'raw'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`
}
>
<
Code
className=
"h-3.5 w-3.5"
/>
Raw Markdown
</
button
>
<
button
onClick=
{
()
=>
setPreviewTab
(
'text'
)
}
className=
{
`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'text'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`
}
>
<
FileText
className=
"h-3.5 w-3.5"
/>
Clean Text
</
button
>
</
div
>
<
button
onClick=
{
()
=>
{
const
content
=
previewTab
===
'clean'
?
selectedPage
.
mainContent
:
previewTab
===
'raw'
?
selectedPage
.
rawMarkdown
:
selectedPage
.
cleanText
;
if
(
content
)
copyToClipboard
(
content
);
}
}
className=
"flex items-center gap-1 px-3 py-1 bg-white border border-slate-300 rounded-md text-xs font-medium text-slate-700 hover:bg-slate-50"
>
{
copied
?
<
Check
className=
"h-3.5 w-3.5 text-emerald-600"
/>
:
<
Copy
className=
"h-3.5 w-3.5 text-slate-400"
/>
}
{
copied
?
'Đã chép'
:
'Sao chép'
}
</
button
>
</
div
>
{
/* Modal Body */
}
<
div
className=
"p-6 overflow-y-auto flex-1 space-y-4 font-sans"
>
{
/* Meta Stats Row */
}
<
div
className=
"grid grid-cols-2 md:grid-cols-4 gap-3 p-3 bg-slate-50 rounded-xl text-xs"
>
<
div
>
<
span
className=
"text-slate-400 font-medium block"
>
Số từ (Word Count)
</
span
>
<
span
className=
"font-semibold text-slate-800"
>
{
selectedPage
.
wordCount
??
'—'
}
</
span
>
</
div
>
<
div
>
<
span
className=
"text-slate-400 font-medium block"
>
Content Hash
</
span
>
<
span
className=
"font-mono text-slate-600 truncate block"
title=
{
selectedPage
.
contentHash
||
''
}
>
{
selectedPage
.
contentHash
?
`${selectedPage.contentHash.substring(0, 12)}
...
`
:
'—'
}
</
span
>
</
div
>
<
div
>
<
span
className=
"text-slate-400 font-medium block"
>
Cảnh báo
</
span
>
<
span
className=
"font-semibold text-slate-800"
>
{
selectedPage
.
warnings
&&
selectedPage
.
warnings
.
length
>
0
?
selectedPage
.
warnings
.
join
(
', '
)
:
'Không có'
}
</
span
>
</
div
>
<
div
>
<
span
className=
"text-slate-400 font-medium block"
>
Thời gian cào
</
span
>
<
span
className=
"text-slate-700"
>
{
selectedPage
.
crawledAt
?
new
Date
(
selectedPage
.
crawledAt
).
toLocaleString
(
'vi-VN'
)
:
'—'
}
</
span
>
</
div
>
</
div
>
{
/* Main Content Area */
}
<
div
className=
"border border-slate-200 rounded-xl p-4 bg-slate-900 text-slate-100 font-mono text-xs overflow-x-auto min-h-[300px] max-h-[500px]"
>
{
previewTab
===
'clean'
&&
(
<
pre
className=
"whitespace-pre-wrap font-mono break-words leading-relaxed text-emerald-400"
>
{
selectedPage
.
mainContent
||
<
span
className=
"text-slate-500 italic"
>
Chưa có nội dung mainContent.
</
span
>
}
</
pre
>
)
}
{
previewTab
===
'raw'
&&
(
<
pre
className=
"whitespace-pre-wrap font-mono break-words leading-relaxed text-slate-200"
>
{
selectedPage
.
rawMarkdown
||
<
span
className=
"text-slate-500 italic"
>
Chưa có nội dung rawMarkdown.
</
span
>
}
</
pre
>
)
}
{
previewTab
===
'text'
&&
(
<
pre
className=
"whitespace-pre-wrap font-sans break-words leading-relaxed text-slate-300"
>
{
selectedPage
.
cleanText
||
<
span
className=
"text-slate-500 italic"
>
Chưa có nội dung cleanText.
</
span
>
}
</
pre
>
)
}
</
div
>
</
div
>
{
/* Modal Footer */
}
<
div
className=
"px-6 py-3 bg-slate-50 border-t border-slate-100 flex justify-end"
>
<
button
onClick=
{
()
=>
setSelectedPage
(
null
)
}
className=
"px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 text-xs font-semibold transition-colors"
>
Đóng
</
button
>
</
div
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
);
);
};
};
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment