mirror of
https://github.com/azaion/ui.git
synced 2026-06-22 09:41:11 +00:00
Refactor project structure and dependencies; rename package to azaion-ui, update version to 0.0.1, and remove unused files. Introduce new routing and authentication features in App component.
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
import type { DatasetItem, PaginatedResponse, ClassDistributionItem, AnnotationListItem, Detection, Media } from '../../types'
|
||||
import { AnnotationStatus } from '../../types'
|
||||
|
||||
type Tab = 'annotations' | 'editor' | 'distribution'
|
||||
|
||||
export default function DatasetPage() {
|
||||
const { t } = useTranslation()
|
||||
const { selectedFlight } = useFlight()
|
||||
const leftPanel = useResizablePanel(250, 200, 400)
|
||||
|
||||
const [items, setItems] = useState<DatasetItem[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [fromDate, setFromDate] = useState('')
|
||||
const [toDate, setToDate] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<AnnotationStatus | null>(null)
|
||||
const [objectsOnly, setObjectsOnly] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 400)
|
||||
const [selectedClassNum, setSelectedClassNum] = useState(0)
|
||||
const [photoMode, setPhotoMode] = useState(0)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [tab, setTab] = useState<Tab>('annotations')
|
||||
const [editorAnnotation, setEditorAnnotation] = useState<AnnotationListItem | null>(null)
|
||||
const [editorDetections, setEditorDetections] = useState<Detection[]>([])
|
||||
const [distribution, setDistribution] = useState<ClassDistributionItem[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (fromDate) params.set('fromDate', fromDate)
|
||||
if (toDate) params.set('toDate', toDate)
|
||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||
if (statusFilter !== null) params.set('status', String(statusFilter))
|
||||
if (selectedClassNum) params.set('classNum', String(selectedClassNum))
|
||||
if (objectsOnly) params.set('hasDetections', 'true')
|
||||
if (debouncedSearch) params.set('name', debouncedSearch)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
|
||||
setItems(res.items)
|
||||
setTotalCount(res.totalCount)
|
||||
} catch {}
|
||||
}, [page, pageSize, fromDate, toDate, selectedFlight, statusFilter, selectedClassNum, objectsOnly, debouncedSearch])
|
||||
|
||||
useEffect(() => { fetchItems() }, [fetchItems])
|
||||
|
||||
const handleDoubleClick = async (item: DatasetItem) => {
|
||||
try {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${item.annotationId}`)
|
||||
setEditorAnnotation(ann)
|
||||
setEditorDetections(ann.detections)
|
||||
setTab('editor')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
await api.post('/api/annotations/dataset/bulk-status', {
|
||||
annotationIds: Array.from(selectedIds),
|
||||
status: AnnotationStatus.Validated,
|
||||
})
|
||||
setSelectedIds(new Set())
|
||||
fetchItems()
|
||||
}
|
||||
|
||||
const loadDistribution = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
|
||||
setDistribution(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (tab === 'distribution') loadDistribution() }, [tab, loadDistribution])
|
||||
|
||||
const maxDistCount = Math.max(...distribution.map(d => d.count), 1)
|
||||
const totalPages = Math.ceil(totalCount / pageSize)
|
||||
|
||||
const editorMedia: Media | null = editorAnnotation ? {
|
||||
id: editorAnnotation.mediaId, name: '', path: '', mediaType: 1, mediaStatus: 0,
|
||||
duration: null, annotationCount: 0, waypointId: null, userId: '',
|
||||
} : null
|
||||
|
||||
const statusButtons = [
|
||||
{ label: 'All', value: null },
|
||||
{ label: t('dataset.status.created'), value: AnnotationStatus.Created },
|
||||
{ label: t('dataset.status.edited'), value: AnnotationStatus.Edited },
|
||||
{ label: t('dataset.status.validated'), value: AnnotationStatus.Validated },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left panel */}
|
||||
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
|
||||
<DetectionClasses
|
||||
selectedClassNum={selectedClassNum}
|
||||
onSelect={setSelectedClassNum}
|
||||
photoMode={photoMode}
|
||||
onPhotoModeChange={setPhotoMode}
|
||||
/>
|
||||
<div className="p-2 border-t border-az-border">
|
||||
<label className="flex items-center gap-1.5 text-xs text-az-text cursor-pointer">
|
||||
<input type="checkbox" checked={objectsOnly} onChange={e => setObjectsOnly(e.target.checked)} className="accent-az-orange" />
|
||||
{t('dataset.objectsOnly')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="p-2 border-t border-az-border">
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('dataset.search')}
|
||||
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-az-border bg-az-panel text-xs flex-wrap">
|
||||
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
||||
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} className="bg-az-bg border border-az-border rounded px-2 py-1 text-az-text" />
|
||||
{statusButtons.map(sb => (
|
||||
<button
|
||||
key={String(sb.value)}
|
||||
onClick={() => { setStatusFilter(sb.value); setPage(1) }}
|
||||
className={`px-2 py-0.5 rounded ${statusFilter === sb.value ? 'bg-az-orange text-white' : 'bg-az-bg text-az-muted'}`}
|
||||
>
|
||||
{sb.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{selectedIds.size > 0 && (
|
||||
<button onClick={handleValidate} className="bg-az-green text-white px-2 py-0.5 rounded">
|
||||
{t('dataset.validate')} ({selectedIds.size})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-az-border bg-az-panel">
|
||||
{(['annotations', 'editor', 'distribution'] as Tab[]).map(tb => (
|
||||
<button
|
||||
key={tb}
|
||||
onClick={() => setTab(tb)}
|
||||
className={`px-3 py-1.5 text-xs ${tab === tb ? 'bg-az-bg text-white border-b-2 border-az-orange' : 'text-az-muted'}`}
|
||||
>
|
||||
{t(`dataset.${tb === 'distribution' ? 'classDistribution' : tb}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{tab === 'annotations' && (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }}>
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.annotationId}
|
||||
onClick={e => {
|
||||
if (e.ctrlKey) {
|
||||
setSelectedIds(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(item.annotationId) ? n.delete(item.annotationId) : n.add(item.annotationId)
|
||||
return n
|
||||
})
|
||||
} else {
|
||||
setSelectedIds(new Set([item.annotationId]))
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => handleDoubleClick(item)}
|
||||
className={`bg-az-panel border rounded overflow-hidden cursor-pointer ${
|
||||
selectedIds.has(item.annotationId) ? 'border-az-orange' : 'border-az-border'
|
||||
} ${item.isSeed ? 'ring-2 ring-az-red' : ''}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/annotations/annotations/${item.annotationId}/thumbnail`}
|
||||
alt={item.imageName}
|
||||
className="w-full h-32 object-cover bg-az-bg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="p-1.5 text-xs">
|
||||
<div className="truncate text-az-text">{item.imageName}</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-az-muted">{new Date(item.createdDate).toLocaleDateString()}</span>
|
||||
<span className={`px-1 rounded ${
|
||||
item.status === AnnotationStatus.Validated ? 'bg-az-green/20 text-az-green' :
|
||||
item.status === AnnotationStatus.Edited ? 'bg-az-blue/20 text-az-blue' :
|
||||
'bg-az-muted/20 text-az-muted'
|
||||
}`}>
|
||||
{item.status === AnnotationStatus.Validated ? t('dataset.status.validated') :
|
||||
item.status === AnnotationStatus.Edited ? t('dataset.status.edited') :
|
||||
t('dataset.status.created')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 py-3">
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Prev</button>
|
||||
<span className="text-xs text-az-text py-1">{page} / {totalPages}</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages} className="text-xs text-az-muted disabled:opacity-30 px-2 py-1 bg-az-panel rounded">Next</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'editor' && editorMedia && editorAnnotation && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CanvasEditor
|
||||
media={editorMedia}
|
||||
annotation={editorAnnotation}
|
||||
detections={editorDetections}
|
||||
onDetectionsChange={setEditorDetections}
|
||||
selectedClassNum={selectedClassNum}
|
||||
currentTime={0}
|
||||
annotations={[]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'distribution' && (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1.5 max-w-2xl">
|
||||
{distribution.map(d => (
|
||||
<div key={d.classNum} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: d.color }} />
|
||||
<span className="w-40 truncate text-az-text">{d.label}</span>
|
||||
<div className="flex-1 bg-az-bg rounded h-4 overflow-hidden">
|
||||
<div className="h-full rounded" style={{ width: `${(d.count / maxDistCount) * 100}%`, backgroundColor: d.color, opacity: 0.7 }} />
|
||||
</div>
|
||||
<span className="text-az-muted w-12 text-right">{d.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user