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
c381d01f
Commit
c381d01f
authored
Jul 14, 2026
by
BangNSK
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: implement user management module with CRUD operations and update configuration files
parent
0c058896
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
293 additions
and
97 deletions
+293
-97
JobDetail.tsx
src/pages/JobDetail.tsx
+286
-93
Logs.tsx
src/pages/Logs.tsx
+1
-1
Users.tsx
src/pages/Users.tsx
+1
-1
vite-env.d.ts
src/vite-env.d.ts
+1
-0
tsconfig.app.json
tsconfig.app.json
+4
-2
No files found.
src/pages/JobDetail.tsx
View file @
c381d01f
...
@@ -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,19 +502,44 @@ export const JobDetail: React.FC = () => {
...
@@ -455,19 +502,44 @@ 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
>
{
activeTab
===
'pages'
?
(
<>
{
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
>
...
@@ -558,6 +630,127 @@ export const JobDetail: React.FC = () => {
...
@@ -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
>
</
div
>
</
div
>
);
);
...
...
src/pages/Logs.tsx
View file @
c381d01f
...
@@ -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
(
''
);
}
}
...
...
src/pages/Users.tsx
View file @
c381d01f
...
@@ -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
(
''
);
}
}
...
...
src/vite-env.d.ts
0 → 100644
View file @
c381d01f
/// <reference types="vite/client" />
tsconfig.app.json
View file @
c381d01f
...
@@ -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"
]
}
}
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