feat(frontend): 워크플로우 연결 Step 3 — VesselDetail 강화 + DarkVessel prediction 전환
VesselDetail: - iran proxy → prediction 직접 API 전환 (getAnalysisLatest/getAnalysisHistory) - dark 패턴 시각화: dark_tier Badge, 의심점수 바, dark_patterns 태그, 7일 반복 횟수 - 환적 의심 분석 섹션 추가 (transship_tier, transship_score) - 24h AIS 수신 이력 타임라인 그래프 (시간대별 수신/소실 막대) - 단속 이력 탭 신설 (GET /api/enforcement/records?vesselMmsi) - 지도 중심좌표를 분석 결과의 lat/lon으로 자동 설정 - 위험도 점수 표시 0~100 직접 사용 (iran proxy의 0~1 변환 제거) DarkVesselDetection: - iran proxy → getDarkVessels() 직접 API 전환 - derivePattern() 제거 → features.dark_tier/dark_suspicion_score/dark_patterns 직접 표시 - tier 기반 KPI 카드 (CRITICAL/HIGH/WATCH) + 클릭 필터 - 의심 점수 내림차순 정렬 (가장 의심스러운 순) - tier별 필터 셀렉트 추가 - 지도 범례: tier 기반 색상 enforcement.ts: getEnforcementRecords에 vesselMmsi 파라미터 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
0679c04bfe
커밋
1940caf73b
@ -3,44 +3,33 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
import { EyeOff, AlertTriangle, Radio, Tag, Loader2 } from 'lucide-react';
|
||||
import { EyeOff, AlertTriangle, Loader2, Filter } from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createMarkerLayer, createRadiusLayer, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import type { MarkerData } from '@lib/map';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
filterDarkVessels,
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { getDarkVessels, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getDarkVesselPatternIntent, getDarkVesselPatternLabel, getDarkVesselPatternMeta } from '@shared/constants/darkVesselPatterns';
|
||||
import { getVesselSurveillanceIntent, getVesselSurveillanceLabel } from '@shared/constants/vesselAnalysisStatuses';
|
||||
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/* SFR-09: 불법 어선(AIS 조작·위장·Dark Vessel) 패턴 탐지 */
|
||||
/* SFR-09: Dark Vessel 탐지 — prediction 직접 API 기반 */
|
||||
|
||||
interface Suspect { id: string; mmsi: string; name: string; flag: string; pattern: string; risk: number; lastAIS: string; status: string; label: string; lat: number; lng: number; [key: string]: unknown; }
|
||||
|
||||
const GAP_FULL_BLOCK_MIN = 1440;
|
||||
const GAP_LONG_LOSS_MIN = 60;
|
||||
const SPOOFING_THRESHOLD = 0.7;
|
||||
|
||||
function derivePattern(item: VesselAnalysisItem): string {
|
||||
const { gapDurationMin } = item.algorithms.darkVessel;
|
||||
const { spoofingScore } = item.algorithms.gpsSpoofing;
|
||||
if (gapDurationMin > GAP_FULL_BLOCK_MIN) return 'AIS 완전차단';
|
||||
if (spoofingScore > SPOOFING_THRESHOLD) return 'MMSI 변조 의심';
|
||||
if (gapDurationMin > GAP_LONG_LOSS_MIN) return '장기소실';
|
||||
return '신호 간헐송출';
|
||||
}
|
||||
|
||||
function deriveStatus(item: VesselAnalysisItem): string {
|
||||
const { score } = item.algorithms.riskScore;
|
||||
if (score >= 80) return '추적중';
|
||||
if (score >= 50) return '감시중';
|
||||
if (score >= 30) return '확인중';
|
||||
return '정상';
|
||||
interface Suspect {
|
||||
id: string;
|
||||
mmsi: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
darkTier: string;
|
||||
darkScore: number;
|
||||
darkPatterns: string;
|
||||
risk: number;
|
||||
gap: number;
|
||||
lastAIS: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function deriveFlag(mmsi: string): string {
|
||||
@ -49,21 +38,32 @@ function deriveFlag(mmsi: string): string {
|
||||
return '미상';
|
||||
}
|
||||
|
||||
function mapItemToSuspect(item: VesselAnalysisItem, idx: number): Suspect {
|
||||
const risk = item.algorithms.riskScore.score;
|
||||
const status = deriveStatus(item);
|
||||
const TIER_HEX: Record<string, string> = {
|
||||
CRITICAL: '#ef4444',
|
||||
HIGH: '#f97316',
|
||||
WATCH: '#eab308',
|
||||
NONE: '#6b7280',
|
||||
};
|
||||
|
||||
function mapToSuspect(v: VesselAnalysis, idx: number): Suspect {
|
||||
const feat = v.features ?? {};
|
||||
const darkTier = (feat.dark_tier as string) ?? 'NONE';
|
||||
const darkScore = (feat.dark_suspicion_score as number) ?? 0;
|
||||
const patterns = (feat.dark_patterns as string[]) ?? [];
|
||||
|
||||
return {
|
||||
id: `DV-${String(idx + 1).padStart(3, '0')}`,
|
||||
mmsi: item.mmsi,
|
||||
name: item.classification.vesselType || item.mmsi,
|
||||
flag: deriveFlag(item.mmsi),
|
||||
pattern: derivePattern(item),
|
||||
risk,
|
||||
lastAIS: formatDateTime(item.timestamp),
|
||||
status,
|
||||
label: risk >= 90 ? (status === '추적중' ? '불법' : '-') : status === '정상' ? '정상' : '-',
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
mmsi: v.mmsi,
|
||||
name: v.vesselType || v.mmsi,
|
||||
flag: deriveFlag(v.mmsi),
|
||||
darkTier,
|
||||
darkScore,
|
||||
darkPatterns: patterns.join(', ') || '-',
|
||||
risk: v.riskScore ?? 0,
|
||||
gap: v.gapDurationMin ?? 0,
|
||||
lastAIS: formatDateTime(v.analyzedAt),
|
||||
lat: v.lat ?? 0,
|
||||
lng: v.lon ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -73,12 +73,25 @@ export function DarkVesselDetection() {
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [tierFilter, setTierFilter] = useState<string>('');
|
||||
|
||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
||||
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
|
||||
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'name', label: '선박 유형', sortable: true, render: v => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px', render: (v) => {
|
||||
{ key: 'id', label: 'ID', width: '70px',
|
||||
render: (v) => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
|
||||
{ key: 'darkTier', label: '등급', width: '80px', sortable: true,
|
||||
render: (v) => {
|
||||
const tier = v as string;
|
||||
return <Badge intent={getRiskIntent(tier === 'CRITICAL' ? 90 : tier === 'HIGH' ? 60 : tier === 'WATCH' ? 40 : 10)} size="sm">{tier}</Badge>;
|
||||
} },
|
||||
{ key: 'darkScore', label: '의심점수', width: '80px', align: 'center', sortable: true,
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
return <span className={`font-bold font-mono ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}</span>;
|
||||
} },
|
||||
{ key: 'name', label: '선박 유형', sortable: true,
|
||||
render: (v) => <span className="text-cyan-400 font-medium">{v as string}</span> },
|
||||
{ key: 'mmsi', label: 'MMSI', width: '100px',
|
||||
render: (v) => {
|
||||
const mmsi = v as string;
|
||||
return (
|
||||
<button type="button" className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||
@ -88,17 +101,23 @@ export function DarkVesselDetection() {
|
||||
);
|
||||
} },
|
||||
{ key: 'flag', label: '국적', width: '50px' },
|
||||
{ key: 'gap', label: 'AIS 공백', width: '80px', align: 'right', sortable: true,
|
||||
render: (v) => {
|
||||
const min = v as number;
|
||||
return <span className="text-label font-mono text-[10px]">{min > 0 ? `${min}분` : '-'}</span>;
|
||||
} },
|
||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
||||
render: v => { const n = v as number; return <span className={`font-bold ${n > 80 ? 'text-red-400' : n > 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>; } },
|
||||
{ key: 'lastAIS', label: '최종 AIS', width: '90px', render: v => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
{ key: 'status', label: '상태', width: '70px', align: 'center', sortable: true,
|
||||
render: v => <Badge intent={getVesselSurveillanceIntent(v as string)} size="sm">{getVesselSurveillanceLabel(v as string, tc, lang)}</Badge> },
|
||||
{ key: 'label', label: '라벨', width: '60px', align: 'center',
|
||||
render: v => { const l = v as string; return l === '-' ? <button type="button" className="text-[9px] text-hint hover:text-blue-400"><Tag className="w-3 h-3 inline" /> 분류</button> : <Badge intent={l === '불법' ? 'critical' : 'success'} size="xs">{l}</Badge>; } },
|
||||
], [tc, lang]);
|
||||
render: (v) => {
|
||||
const n = v as number;
|
||||
return <span className={`font-bold ${n >= 70 ? 'text-red-400' : n >= 50 ? 'text-yellow-400' : 'text-green-400'}`}>{n}</span>;
|
||||
} },
|
||||
{ key: 'darkPatterns', label: '의심 패턴', minWidth: '120px',
|
||||
render: (v) => <span className="text-hint text-[9px]">{v as string}</span> },
|
||||
{ key: 'lastAIS', label: '분석시각', width: '90px',
|
||||
render: (v) => <span className="text-muted-foreground text-[10px]">{v as string}</span> },
|
||||
], [tc, lang, navigate]);
|
||||
|
||||
const [darkItems, setDarkItems] = useState<VesselAnalysisItem[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [rawData, setRawData] = useState<VesselAnalysis[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@ -106,12 +125,10 @@ export function DarkVesselDetection() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetchVesselAnalysis();
|
||||
setServiceAvailable(res.serviceAvailable);
|
||||
setDarkItems(filterDarkVessels(res.items));
|
||||
const res = await getDarkVessels({ hours: 1, size: 500 });
|
||||
setRawData(res.content);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다');
|
||||
setServiceAvailable(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -119,15 +136,25 @@ export function DarkVesselDetection() {
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const DATA: Suspect[] = useMemo(
|
||||
() => darkItems.map((item, i) => mapItemToSuspect(item, i)),
|
||||
[darkItems],
|
||||
);
|
||||
const DATA: Suspect[] = useMemo(() => {
|
||||
let items = rawData.map((v, i) => mapToSuspect(v, i));
|
||||
if (tierFilter) {
|
||||
items = items.filter((d) => d.darkTier === tierFilter);
|
||||
}
|
||||
// 의심 점수 내림차순 정렬
|
||||
return items.sort((a, b) => b.darkScore - a.darkScore);
|
||||
}, [rawData, tierFilter]);
|
||||
|
||||
const avgRisk = useMemo(
|
||||
() => DATA.length > 0 ? Math.round(DATA.reduce((s, d) => s + d.risk, 0) / DATA.length) : 0,
|
||||
[DATA],
|
||||
);
|
||||
// KPI 카운트
|
||||
const tierCounts = useMemo(() => {
|
||||
const all = rawData.map((v) => ((v.features ?? {}).dark_tier as string) ?? 'NONE');
|
||||
return {
|
||||
total: all.length,
|
||||
CRITICAL: all.filter((t) => t === 'CRITICAL').length,
|
||||
HIGH: all.filter((t) => t === 'HIGH').length,
|
||||
WATCH: all.filter((t) => t === 'WATCH').length,
|
||||
};
|
||||
}, [rawData]);
|
||||
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
@ -135,21 +162,18 @@ export function DarkVesselDetection() {
|
||||
...STATIC_LAYERS,
|
||||
createRadiusLayer(
|
||||
'dv-radius',
|
||||
DATA.filter(d => d.risk > 80).map(d => ({
|
||||
lat: d.lat,
|
||||
lng: d.lng,
|
||||
radius: 10000,
|
||||
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
|
||||
DATA.filter((d) => d.darkScore >= 70).map((d) => ({
|
||||
lat: d.lat, lng: d.lng, radius: 10000,
|
||||
color: TIER_HEX[d.darkTier] || '#ef4444',
|
||||
})),
|
||||
0.08,
|
||||
),
|
||||
createMarkerLayer(
|
||||
'dv-markers',
|
||||
DATA.map(d => ({
|
||||
lat: d.lat,
|
||||
lng: d.lng,
|
||||
color: getDarkVesselPatternMeta(d.pattern)?.hex || '#ef4444',
|
||||
radius: d.risk > 80 ? 1200 : 800,
|
||||
DATA.filter((d) => d.lat !== 0).map((d) => ({
|
||||
lat: d.lat, lng: d.lng,
|
||||
color: TIER_HEX[d.darkTier] || '#6b7280',
|
||||
radius: d.darkScore >= 70 ? 1200 : 800,
|
||||
label: `${d.id} ${d.name}`,
|
||||
} as MarkerData)),
|
||||
),
|
||||
@ -164,14 +188,19 @@ export function DarkVesselDetection() {
|
||||
iconColor="text-red-400"
|
||||
title={t('darkVessel.title')}
|
||||
description={t('darkVessel.desc')}
|
||||
/>
|
||||
|
||||
{!serviceAvailable && (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 text-yellow-400 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>iran 분석 서비스 미연결 - 실시간 Dark Vessel 데이터를 불러올 수 없습니다</span>
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-3.5 h-3.5 text-hint" />
|
||||
<Select size="sm" value={tierFilter} onChange={(e) => setTierFilter(e.target.value)}
|
||||
title="등급 필터" className="w-32">
|
||||
<option value="">전체 등급</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
<option value="HIGH">HIGH</option>
|
||||
<option value="WATCH">WATCH</option>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
|
||||
{error && <div className="text-xs text-red-400">에러: {error}</div>}
|
||||
|
||||
@ -181,49 +210,51 @@ export function DarkVesselDetection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI — tier 기반 */}
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ l: 'Dark Vessel', v: DATA.length, c: 'text-red-400', i: AlertTriangle },
|
||||
{ l: 'AIS 완전차단', v: DATA.filter(d => d.pattern === 'AIS 완전차단').length, c: 'text-orange-400', i: EyeOff },
|
||||
{ l: 'MMSI 변조', v: DATA.filter(d => d.pattern === 'MMSI 변조 의심').length, c: 'text-yellow-400', i: Radio },
|
||||
{ l: `평균 위험도`, v: avgRisk, c: 'text-cyan-400', i: Tag },
|
||||
].map(k => (
|
||||
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
|
||||
<k.i className={`w-4 h-4 ${k.c}`} /><span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
|
||||
{ l: '전체', v: tierCounts.total, c: 'text-red-400', filter: '' },
|
||||
{ l: 'CRITICAL', v: tierCounts.CRITICAL, c: 'text-red-400', filter: 'CRITICAL' },
|
||||
{ l: 'HIGH', v: tierCounts.HIGH, c: 'text-orange-400', filter: 'HIGH' },
|
||||
{ l: 'WATCH', v: tierCounts.WATCH, c: 'text-yellow-400', filter: 'WATCH' },
|
||||
].map((k) => (
|
||||
<div key={k.l}
|
||||
onClick={() => setTierFilter(k.filter)}
|
||||
className={`flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border cursor-pointer transition-colors ${
|
||||
tierFilter === k.filter ? 'bg-card border-blue-500/30' : 'bg-card border-border hover:border-border'
|
||||
}`}>
|
||||
<AlertTriangle className={`w-4 h-4 ${k.c}`} />
|
||||
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
|
||||
<span className="text-[9px] text-hint">{k.l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="선박유형, MMSI, 패턴 검색..." searchKeys={['name', 'mmsi', 'pattern', 'flag']} exportFilename="Dark_Vessel_탐지" />
|
||||
<DataTable data={DATA} columns={cols} pageSize={10}
|
||||
searchPlaceholder="선박유형, MMSI, 패턴 검색..."
|
||||
searchKeys={['name', 'mmsi', 'darkPatterns', 'flag', 'darkTier']}
|
||||
exportFilename="Dark_Vessel_탐지" />
|
||||
|
||||
{/* 탐지 위치 지도 */}
|
||||
<Card>
|
||||
<CardContent className="p-0 relative">
|
||||
<BaseMap ref={mapRef} center={[36.5, 127.5]} zoom={7} height={450} className="rounded-lg overflow-hidden" />
|
||||
{/* 범례 */}
|
||||
{/* 범례 — tier 기반 */}
|
||||
<div className="absolute bottom-3 left-3 z-[1000] bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-2">
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">탐지 패턴</div>
|
||||
<div className="text-[9px] text-muted-foreground font-bold mb-1.5">Dark Tier</div>
|
||||
<div className="space-y-1">
|
||||
{(['AIS_FULL_BLOCK', 'MMSI_SPOOFING', 'LONG_LOSS', 'INTERMITTENT'] as const).map((p) => {
|
||||
const meta = getDarkVesselPatternMeta(p);
|
||||
if (!meta) return null;
|
||||
return (
|
||||
<div key={p} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: meta.hex }} />
|
||||
<span className="text-[8px] text-muted-foreground">{meta.fallback.ko}</span>
|
||||
{(['CRITICAL', 'HIGH', 'WATCH', 'NONE'] as const).map((tier) => (
|
||||
<div key={tier} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: TIER_HEX[tier] }} />
|
||||
<span className="text-[8px] text-muted-foreground">{tier}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1.5 pt-1.5 border-t border-border">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-red-500/50" /><span className="text-[7px] text-hint">EEZ</span></div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-0 border-t border-dashed border-orange-500/60" /><span className="text-[7px] text-hint">NLL</span></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-[1000] flex items-center gap-2 bg-background/90 backdrop-blur-sm border border-border rounded-lg px-3 py-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[10px] text-red-400 font-bold">{DATA.filter(d => d.risk > 80).length}척</span>
|
||||
<span className="text-[9px] text-hint">고위험 Dark Vessel 탐지</span>
|
||||
<span className="text-[10px] text-red-400 font-bold">{tierCounts.CRITICAL}척</span>
|
||||
<span className="text-[9px] text-hint">CRITICAL Dark Vessel</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -6,16 +6,15 @@ import {
|
||||
Search,
|
||||
Ship, AlertTriangle, Radar, MapPin, Printer,
|
||||
Camera, Crosshair, Ruler, CircleDot, Clock, LayoutGrid, Brain,
|
||||
Loader2, WifiOff, ShieldAlert,
|
||||
Loader2, ShieldAlert, Shield, EyeOff, FileText,
|
||||
} from 'lucide-react';
|
||||
import { BaseMap, STATIC_LAYERS, createZoneLayer, createPolylineLayer, JURISDICTION_AREAS, DEPTH_CONTOURS, useMapLayers, type MapHandle } from '@lib/map';
|
||||
import {
|
||||
fetchVesselAnalysis,
|
||||
type VesselAnalysisItem,
|
||||
} from '@/services/vesselAnalysisApi';
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getEvents, type PredictionEvent } from '@/services/event';
|
||||
import { getAnalysisLatest, getAnalysisHistory, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { getEnforcementRecords, type EnforcementRecord } from '@/services/enforcement';
|
||||
import { ALERT_LEVELS, type AlertLevel, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||
import { getRiskIntent } from '@shared/constants/statusIntent';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@ -57,27 +56,66 @@ const RIGHT_TOOLS = [
|
||||
{ icon: Printer, label: '인쇄' }, { icon: Camera, label: '스냅샷' },
|
||||
];
|
||||
|
||||
// ─── 24h AIS 수신 막대 ───────────────
|
||||
function AisTimeline({ history }: { history: VesselAnalysis[] }) {
|
||||
// 최근 24시간을 1시간 단위 슬롯으로 분할
|
||||
const now = Date.now();
|
||||
const slots = Array.from({ length: 24 }, (_, i) => {
|
||||
const slotStart = now - (24 - i) * 3600_000;
|
||||
const slotEnd = slotStart + 3600_000;
|
||||
const hasData = history.some((h) => {
|
||||
const t = new Date(h.analyzedAt).getTime();
|
||||
return t >= slotStart && t < slotEnd;
|
||||
});
|
||||
return { hour: new Date(slotStart).getHours(), hasData };
|
||||
});
|
||||
const received = slots.filter((s) => s.hasData).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-hint">24h AIS 수신 이력</span>
|
||||
<span className="text-[10px] text-label font-mono">{received}/24h ({Math.round(received / 24 * 100)}%)</span>
|
||||
</div>
|
||||
<div className="flex gap-px h-3">
|
||||
{slots.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-sm ${s.hasData ? 'bg-green-500' : 'bg-red-500/40'}`}
|
||||
title={`${String(s.hour).padStart(2, '0')}시 — ${s.hasData ? '수신' : '소실'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-[7px] text-hint">-24h</span>
|
||||
<span className="text-[7px] text-hint">현재</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ────────────────────
|
||||
|
||||
export function VesselDetail() {
|
||||
const { id: mmsiParam } = useParams<{ id: string }>();
|
||||
|
||||
// 데이터 상태
|
||||
const [vessel, setVessel] = useState<VesselAnalysisItem | null>(null);
|
||||
const [analysis, setAnalysis] = useState<VesselAnalysis | null>(null);
|
||||
const [history, setHistory] = useState<VesselAnalysis[]>([]);
|
||||
const [permit, setPermit] = useState<VesselPermitData | null>(null);
|
||||
const [events, setEvents] = useState<PredictionEvent[]>([]);
|
||||
const [serviceAvailable, setServiceAvailable] = useState(true);
|
||||
const [enforcements, setEnforcements] = useState<EnforcementRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 검색 상태 (검색 패널용)
|
||||
// 검색 상태
|
||||
const [searchMmsi, setSearchMmsi] = useState(mmsiParam ?? '');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
const mapRef = useRef<MapHandle>(null);
|
||||
|
||||
// 데이터 로드
|
||||
// 데이터 로드 — prediction 직접 API
|
||||
useEffect(() => {
|
||||
if (!mmsiParam) {
|
||||
setLoading(false);
|
||||
@ -92,27 +130,21 @@ export function VesselDetail() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [analysisRes, permitRes, eventsRes] = await Promise.all([
|
||||
fetchVesselAnalysis().catch(() => null),
|
||||
const [analysisRes, historyRes, permitRes, eventsRes, enfRes] = await Promise.all([
|
||||
getAnalysisLatest(mmsiParam).catch(() => null),
|
||||
getAnalysisHistory(mmsiParam, 24).catch(() => []),
|
||||
fetchVesselPermit(mmsiParam),
|
||||
getEvents({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
|
||||
getEnforcementRecords({ vesselMmsi: mmsiParam, size: 10 }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!analysisRes) {
|
||||
setServiceAvailable(false);
|
||||
setPermit(permitRes);
|
||||
setEvents(eventsRes?.content ?? []);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setServiceAvailable(analysisRes.serviceAvailable);
|
||||
const found = analysisRes.items.find((item) => item.mmsi === mmsiParam) ?? null;
|
||||
setVessel(found);
|
||||
setAnalysis(analysisRes);
|
||||
setHistory(historyRes);
|
||||
setPermit(permitRes);
|
||||
setEvents(eventsRes?.content ?? []);
|
||||
setEnforcements(enfRes?.content ?? []);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '데이터 로드 실패');
|
||||
@ -127,8 +159,7 @@ export function VesselDetail() {
|
||||
}, [mmsiParam]);
|
||||
|
||||
// 지도 레이어
|
||||
const buildLayers = useCallback(() => {
|
||||
const layers = [
|
||||
const buildLayers = useCallback(() => [
|
||||
...STATIC_LAYERS,
|
||||
createZoneLayer('jurisdiction', JURISDICTION_AREAS.map((a) => ({
|
||||
name: a.name, lat: a.lat, lng: a.lng, color: a.color, radiusM: 80000,
|
||||
@ -138,13 +169,7 @@ export function VesselDetail() {
|
||||
color: '#06b6d4', width: 1, opacity: 0.3, dashArray: [2, 4],
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
// 선박 위치가 없으므로 분석 데이터의 zone 기반으로 대략적 위치 표시는 불가
|
||||
// vessel-analysis에는 좌표가 없으므로 마커 생략
|
||||
|
||||
return layers;
|
||||
}, []);
|
||||
], []);
|
||||
|
||||
useMapLayers(mapRef, buildLayers, []);
|
||||
|
||||
@ -152,11 +177,20 @@ export function VesselDetail() {
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language);
|
||||
|
||||
// 위험도 점수 바
|
||||
const riskScore = vessel?.algorithms.riskScore.score ?? 0;
|
||||
const riskLevel = (vessel?.algorithms.riskScore.level ?? 'LOW') as AlertLevel;
|
||||
// 위험도
|
||||
const riskScore = analysis?.riskScore ?? 0;
|
||||
const riskLevel = (analysis?.riskLevel ?? 'LOW') as AlertLevel;
|
||||
const riskMeta = ALERT_LEVELS[riskLevel] ?? ALERT_LEVELS.LOW;
|
||||
|
||||
// features 추출
|
||||
const features = analysis?.features ?? {};
|
||||
const darkTier = features.dark_tier as string | undefined;
|
||||
const darkScore = features.dark_suspicion_score as number | undefined;
|
||||
const darkPatterns = features.dark_patterns as string[] | undefined;
|
||||
const darkHistory7d = features.dark_history_7d as number | undefined;
|
||||
const transshipTier = features.transship_tier as string | undefined;
|
||||
const transshipScore = features.transship_score as number | undefined;
|
||||
|
||||
return (
|
||||
<PageContainer fullBleed className="flex h-[calc(100vh-7.5rem)] gap-0">
|
||||
|
||||
@ -201,16 +235,6 @@ export function VesselDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!serviceAvailable && !loading && !error && (
|
||||
<div className="p-3 mx-3 mt-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiOff className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-[11px] text-yellow-400 font-medium">분석 서비스 오프라인</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-hint mt-1">iran 백엔드가 연결되지 않아 분석 데이터를 표시할 수 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선박 정보 */}
|
||||
{!loading && !error && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@ -223,17 +247,17 @@ export function VesselDetail() {
|
||||
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
||||
{[
|
||||
['MMSI', mmsiParam ?? '-'],
|
||||
['선박 유형', vessel?.classification.vesselType ?? permit?.vesselType ?? '-'],
|
||||
['선박 유형', analysis?.vesselType ?? permit?.vesselType ?? '-'],
|
||||
['국적', permit?.flagCountry ?? '-'],
|
||||
['선명', permit?.vesselName ?? '-'],
|
||||
['선명(중문)', permit?.vesselNameCn ?? '-'],
|
||||
['톤수', permit?.tonnage != null ? `${permit.tonnage}톤` : '-'],
|
||||
['길이', permit?.lengthM != null ? `${permit.lengthM}m` : '-'],
|
||||
['건조년도', permit?.buildYear != null ? String(permit.buildYear) : '-'],
|
||||
['구역', vessel?.algorithms.location.zone ?? '-'],
|
||||
['기선거리', vessel?.algorithms.location.distToBaselineNm != null
|
||||
? `${vessel.algorithms.location.distToBaselineNm.toFixed(1)}nm` : '-'],
|
||||
['시즌', vessel?.classification.season ?? '-'],
|
||||
['구역', analysis?.zoneCode ?? '-'],
|
||||
['기선거리', analysis?.distToBaselineNm != null
|
||||
? `${Number(analysis.distToBaselineNm).toFixed(1)}nm` : '-'],
|
||||
['시즌', analysis?.season ?? '-'],
|
||||
].map(([k, v], i) => (
|
||||
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
||||
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
||||
@ -268,8 +292,8 @@ export function VesselDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI 분석 결과 */}
|
||||
{vessel && (
|
||||
{/* AI 분석 결과 — prediction 직접 데이터 */}
|
||||
{analysis && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Brain className="w-4 h-4 text-purple-400" />
|
||||
@ -286,14 +310,14 @@ export function VesselDetail() {
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mb-1">
|
||||
<span className={`text-xl font-bold ${riskMeta.classes.text}`}>
|
||||
{Math.round(riskScore * 100)}
|
||||
{riskScore}
|
||||
</span>
|
||||
<span className="text-[10px] text-hint">/100</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-1.5 bg-gradient-to-r from-red-600 to-red-400 rounded-full transition-all"
|
||||
style={{ width: `${riskScore * 100}%` }}
|
||||
style={{ width: `${Math.min(riskScore, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -301,21 +325,17 @@ export function VesselDetail() {
|
||||
{/* 알고리즘 상세 */}
|
||||
<div className="bg-surface-overlay rounded border border-slate-700/20 text-[9px]">
|
||||
{[
|
||||
['활동 상태', vessel.algorithms.activity.state],
|
||||
['UCAF 점수', vessel.algorithms.activity.ucafScore.toFixed(2)],
|
||||
['UCFT 점수', vessel.algorithms.activity.ucftScore.toFixed(2)],
|
||||
['다크베셀', vessel.algorithms.darkVessel.isDark ? '예 (의심)' : '아니오'],
|
||||
['AIS 공백', vessel.algorithms.darkVessel.gapDurationMin > 0
|
||||
? `${vessel.algorithms.darkVessel.gapDurationMin}분` : '-'],
|
||||
['스푸핑 점수', vessel.algorithms.gpsSpoofing.spoofingScore.toFixed(2)],
|
||||
['BD09 오프셋', `${vessel.algorithms.gpsSpoofing.bd09OffsetM.toFixed(0)}m`],
|
||||
['속도 점프', `${vessel.algorithms.gpsSpoofing.speedJumpCount}회`],
|
||||
['클러스터', `#${vessel.algorithms.cluster.clusterId} (${vessel.algorithms.cluster.clusterSize}척)`],
|
||||
['선단 역할', vessel.algorithms.fleetRole.role],
|
||||
['환적 의심', vessel.algorithms.transship.isSuspect ? '예' : '아니오'],
|
||||
['환적 상대', vessel.algorithms.transship.pairMmsi || '-'],
|
||||
['환적 시간', vessel.algorithms.transship.durationMin > 0
|
||||
? `${vessel.algorithms.transship.durationMin}분` : '-'],
|
||||
['활동 상태', analysis.activityState ?? '-'],
|
||||
['다크베셀', analysis.isDark ? '예 (의심)' : '아니오'],
|
||||
['AIS 공백', analysis.gapDurationMin != null && analysis.gapDurationMin > 0
|
||||
? `${analysis.gapDurationMin}분` : '-'],
|
||||
['스푸핑 점수', analysis.spoofingScore != null ? Number(analysis.spoofingScore).toFixed(2) : '-'],
|
||||
['속도 점프', analysis.speedJumpCount != null ? `${analysis.speedJumpCount}회` : '-'],
|
||||
['선단 역할', analysis.fleetRole ?? '-'],
|
||||
['환적 의심', analysis.transshipSuspect ? '예' : '아니오'],
|
||||
['환적 상대', analysis.transshipPairMmsi || '-'],
|
||||
['환적 시간', analysis.transshipDurationMin != null && analysis.transshipDurationMin > 0
|
||||
? `${analysis.transshipDurationMin}분` : '-'],
|
||||
].map(([k, v], i) => (
|
||||
<div key={k} className={`flex ${i % 2 === 0 ? 'bg-surface-overlay' : ''}`}>
|
||||
<span className="w-24 shrink-0 px-2.5 py-1.5 text-hint border-r border-slate-700/20">{k}</span>
|
||||
@ -329,8 +349,68 @@ export function VesselDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dark 패턴 시각화 — features 기반 */}
|
||||
{analysis?.isDark && darkTier && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<EyeOff className="w-4 h-4 text-red-400" />
|
||||
<span className="text-[11px] font-bold text-heading">Dark Vessel 분석</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{/* Dark tier + score */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge intent={getRiskIntent(darkScore ?? 0)} size="sm">{darkTier}</Badge>
|
||||
<span className="text-[10px] text-label font-mono">{darkScore ?? 0}점</span>
|
||||
{darkHistory7d != null && darkHistory7d > 0 && (
|
||||
<span className="text-[9px] text-red-400">7일간 {darkHistory7d}회 반복</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 의심 점수 바 */}
|
||||
<div className="h-1.5 bg-switch-background rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-1.5 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(darkScore ?? 0, 100)}%`,
|
||||
backgroundColor: (darkScore ?? 0) >= 70 ? '#ef4444' : (darkScore ?? 0) >= 50 ? '#f97316' : '#eab308',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Dark 패턴 태그 */}
|
||||
{darkPatterns && darkPatterns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{darkPatterns.map((p) => (
|
||||
<Badge key={p} intent="muted" size="xs">{p}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 환적 분석 — features 기반 */}
|
||||
{analysis?.transshipSuspect && transshipTier && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-[11px] font-bold text-heading">환적 의심 분석</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge intent={getRiskIntent(transshipScore ?? 0)} size="sm">{transshipTier}</Badge>
|
||||
<span className="text-[10px] text-label font-mono">{transshipScore ?? 0}점</span>
|
||||
<span className="text-[9px] text-hint">상대: {analysis.transshipPairMmsi ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 24h AIS 수신 이력 */}
|
||||
{history.length > 0 && (
|
||||
<div className="p-3 border-b border-border">
|
||||
<AisTimeline history={history} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 관련 이벤트 이력 */}
|
||||
<div className="p-3">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-[11px] font-bold text-heading">관련 이벤트 이력</span>
|
||||
@ -340,8 +420,7 @@ export function VesselDetail() {
|
||||
<div className="text-[10px] text-hint text-center py-4">관련 이벤트가 없습니다.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{events.map((evt) => {
|
||||
return (
|
||||
{events.map((evt) => (
|
||||
<div key={evt.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge intent={getAlertLevelIntent(evt.level)} size="xs">
|
||||
@ -353,14 +432,41 @@ export function VesselDetail() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">
|
||||
{evt.occurredAt} {evt.areaName ? `| ${evt.areaName}` : ''}
|
||||
{formatDateTime(evt.occurredAt)} {evt.areaName ? `| ${evt.areaName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{evt.detail && (
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5 truncate">{evt.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 단속 이력 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="w-4 h-4 text-green-400" />
|
||||
<span className="text-[11px] font-bold text-heading">단속 이력</span>
|
||||
<span className="text-[9px] text-hint ml-auto">{enforcements.length}건</span>
|
||||
</div>
|
||||
{enforcements.length === 0 ? (
|
||||
<div className="text-[10px] text-hint text-center py-4">단속 이력이 없습니다.</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{enforcements.map((enf) => (
|
||||
<div key={enf.id} className="bg-surface-overlay rounded border border-slate-700/20 px-2.5 py-2">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<Badge intent="info" size="xs">{enf.enfUid}</Badge>
|
||||
<span className="text-[10px] text-heading font-medium flex-1 truncate">
|
||||
{enf.violationType ?? '단속'}
|
||||
</span>
|
||||
<Badge intent={enf.result === 'PUNISHED' ? 'critical' : 'muted'} size="xs">
|
||||
{enf.result ?? '-'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[9px] text-hint">
|
||||
{formatDateTime(enf.enforcedAt)} {enf.areaName ? `| ${enf.areaName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -376,7 +482,7 @@ export function VesselDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Ship className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-[11px] font-bold text-heading">MMSI: {mmsiParam}</span>
|
||||
{vessel && (
|
||||
{analysis && (
|
||||
<Badge intent={riskMeta.intent} size="sm">
|
||||
위험도: {getAlertLevelLabel(riskLevel, tc, lang)}
|
||||
</Badge>
|
||||
@ -387,8 +493,11 @@ export function VesselDetail() {
|
||||
|
||||
<BaseMap
|
||||
ref={mapRef}
|
||||
center={[34.5, 126.5]}
|
||||
zoom={7}
|
||||
center={[
|
||||
analysis?.lat ?? 34.5,
|
||||
analysis?.lon ?? 126.5,
|
||||
]}
|
||||
zoom={analysis?.lat ? 9 : 7}
|
||||
height="100%"
|
||||
/>
|
||||
|
||||
@ -397,15 +506,15 @@ export function VesselDetail() {
|
||||
<span className="flex items-center gap-1 text-[8px]">
|
||||
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
||||
<span className="text-hint">위도</span>
|
||||
<span className="text-green-400 font-mono font-bold">34.5000</span>
|
||||
<span className="text-green-400 font-mono font-bold">{analysis?.lat?.toFixed(4) ?? '-'}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-[8px]">
|
||||
<MapPin className="w-2.5 h-2.5 text-green-400" />
|
||||
<span className="text-hint">경도</span>
|
||||
<span className="text-green-400 font-mono font-bold">126.5000</span>
|
||||
<span className="text-green-400 font-mono font-bold">{analysis?.lon?.toFixed(4) ?? '-'}</span>
|
||||
</span>
|
||||
<span className="text-[8px]">
|
||||
<span className="text-blue-400 font-bold">UTC</span>
|
||||
<span className="text-blue-400 font-bold">KST</span>
|
||||
<span className="text-label font-mono ml-1">{formatDateTime(new Date())}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -81,11 +81,13 @@ export interface EnforcementPlan {
|
||||
|
||||
export async function getEnforcementRecords(params?: {
|
||||
violationType?: string;
|
||||
vesselMmsi?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<PageResponse<EnforcementRecord>> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.violationType) query.set('violationType', params.violationType);
|
||||
if (params?.vesselMmsi) query.set('vesselMmsi', params.vesselMmsi);
|
||||
query.set('page', String(params?.page ?? 0));
|
||||
query.set('size', String(params?.size ?? 20));
|
||||
const res = await fetch(`${API_BASE}/enforcement/records?${query}`, {
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user