feat(frontend): 워크플로우 연결 Step 2 — EventList 워크플로우 + MMSI 링크
- EventList 인라인 액션 버튼 4종 추가 (확인/선박상세/단속등록/오탐)
- 확인(ACK): NEW 상태 이벤트만 활성, ackEvent API 연동
- 선박 상세: /vessel/{mmsi} 네비게이션
- 단속 등록: createEnforcementRecord API → 이벤트 RESOLVED 자동 전환
- 오탐 처리: updateEventStatus(FALSE_POSITIVE) 연동
- MMSI → VesselDetail 링크 3개 화면 적용
- EventList: MMSI 컬럼 클릭 → /vessel/{mmsi}
- DarkVesselDetection: MMSI 컬럼 클릭 → /vessel/{mmsi}
- EnforcementHistory: 대상 선박 컬럼 클릭 → /vessel/{mmsi}
- PredictionEvent 타입에 features 필드 추가 (dark_tier, transship_score 등)
- analysisApi.ts 서비스 신규 생성 (직접 조회 API 5개 연동)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
5c804aa38f
커밋
0679c04bfe
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card, CardContent } from '@shared/components/ui/card';
|
import { Card, CardContent } from '@shared/components/ui/card';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
@ -70,13 +71,22 @@ export function DarkVesselDetection() {
|
|||||||
const { t } = useTranslation('detection');
|
const { t } = useTranslation('detection');
|
||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
const lang = useSettingsStore((s) => s.language);
|
const lang = useSettingsStore((s) => s.language);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const cols: DataColumn<Suspect>[] = useMemo(() => [
|
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: '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,
|
{ key: 'pattern', label: '탐지 패턴', width: '120px', sortable: true,
|
||||||
render: v => <Badge intent={getDarkVesselPatternIntent(v as string)} size="sm">{getDarkVesselPatternLabel(v as string, tc, lang)}</Badge> },
|
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: '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 => <span className="text-hint font-mono text-[10px]">{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]"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
||||||
|
{mmsi}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} },
|
||||||
{ key: 'flag', label: '국적', width: '50px' },
|
{ key: 'flag', label: '국적', width: '50px' },
|
||||||
{ key: 'risk', label: '위험도', width: '70px', align: 'center', sortable: true,
|
{ 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>; } },
|
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>; } },
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { PageContainer, PageHeader } from '@shared/components/layout';
|
import { PageContainer, PageHeader } from '@shared/components/layout';
|
||||||
@ -18,6 +19,7 @@ interface Record {
|
|||||||
date: string;
|
date: string;
|
||||||
zone: string;
|
zone: string;
|
||||||
vessel: string;
|
vessel: string;
|
||||||
|
mmsi: string;
|
||||||
violation: string;
|
violation: string;
|
||||||
action: string;
|
action: string;
|
||||||
aiMatch: string;
|
aiMatch: string;
|
||||||
@ -29,7 +31,8 @@ export function EnforcementHistory() {
|
|||||||
const { t } = useTranslation('enforcement');
|
const { t } = useTranslation('enforcement');
|
||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
const lang = useSettingsStore((s) => s.language);
|
const lang = useSettingsStore((s) => s.language);
|
||||||
const { records, loading, error, load } = useEnforcementStore();
|
const navigate = useNavigate();
|
||||||
|
const { records, rawRecords, loading, error, load } = useEnforcementStore();
|
||||||
|
|
||||||
const cols: DataColumn<Record>[] = useMemo(() => [
|
const cols: DataColumn<Record>[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
@ -55,9 +58,20 @@ export function EnforcementHistory() {
|
|||||||
key: 'vessel',
|
key: 'vessel',
|
||||||
label: '대상 선박',
|
label: '대상 선박',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (v) => (
|
render: (_v, row) => {
|
||||||
<span className="text-cyan-400 font-medium">{v as string}</span>
|
const mmsi = row.mmsi;
|
||||||
),
|
const vessel = row.vessel as string;
|
||||||
|
if (mmsi && mmsi !== '-') {
|
||||||
|
return (
|
||||||
|
<button type="button"
|
||||||
|
className="text-cyan-400 hover:text-cyan-300 hover:underline font-medium"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}>
|
||||||
|
{vessel}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-cyan-400 font-medium">{vessel}</span>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'violation',
|
key: 'violation',
|
||||||
@ -119,7 +133,10 @@ export function EnforcementHistory() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const DATA: Record[] = records as Record[];
|
const DATA: Record[] = records.map((r, idx) => ({
|
||||||
|
...r,
|
||||||
|
mmsi: rawRecords[idx]?.vesselMmsi ?? '-',
|
||||||
|
})) as Record[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Badge } from '@shared/components/ui/badge';
|
import { Badge } from '@shared/components/ui/badge';
|
||||||
import { Button } from '@shared/components/ui/button';
|
import { Button } from '@shared/components/ui/button';
|
||||||
@ -9,8 +10,11 @@ import { FileUpload } from '@shared/components/common/FileUpload';
|
|||||||
import {
|
import {
|
||||||
AlertTriangle, Eye, Anchor, Radar, Crosshair,
|
AlertTriangle, Eye, Anchor, Radar, Crosshair,
|
||||||
Filter, Upload, X, Loader2,
|
Filter, Upload, X, Loader2,
|
||||||
|
CheckCircle, Ship, Shield, Ban,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEventStore } from '@stores/eventStore';
|
import { useEventStore } from '@stores/eventStore';
|
||||||
|
import { ackEvent, updateEventStatus } from '@/services/event';
|
||||||
|
import { createEnforcementRecord } from '@/services/enforcement';
|
||||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||||
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
import { type AlertLevel as AlertLevelType, getAlertLevelLabel, getAlertLevelIntent } from '@shared/constants/alertLevels';
|
||||||
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
|
import { getEventStatusIntent, getEventStatusLabel } from '@shared/constants/eventStatuses';
|
||||||
@ -27,6 +31,7 @@ type AlertLevel = AlertLevelType;
|
|||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
_eventId: number;
|
||||||
time: string;
|
time: string;
|
||||||
level: AlertLevel;
|
level: AlertLevel;
|
||||||
type: string;
|
type: string;
|
||||||
@ -45,14 +50,55 @@ export function EventList() {
|
|||||||
const { t } = useTranslation('enforcement');
|
const { t } = useTranslation('enforcement');
|
||||||
const { t: tc } = useTranslation('common');
|
const { t: tc } = useTranslation('common');
|
||||||
const lang = useSettingsStore((s) => s.language);
|
const lang = useSettingsStore((s) => s.language);
|
||||||
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
events: storeEvents,
|
events: storeEvents,
|
||||||
|
rawEvents,
|
||||||
stats,
|
stats,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
load,
|
load,
|
||||||
loadStats,
|
loadStats,
|
||||||
} = useEventStore();
|
} = useEventStore();
|
||||||
|
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleAck = useCallback(async (eventId: number) => {
|
||||||
|
setActionLoading(eventId);
|
||||||
|
try {
|
||||||
|
await ackEvent(eventId);
|
||||||
|
load({ level: '' });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleFalsePositive = useCallback(async (eventId: number) => {
|
||||||
|
setActionLoading(eventId);
|
||||||
|
try {
|
||||||
|
await updateEventStatus(eventId, 'FALSE_POSITIVE', '오탐 처리');
|
||||||
|
load({ level: '' });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleCreateEnforcement = useCallback(async (row: EventRow) => {
|
||||||
|
setActionLoading(row._eventId);
|
||||||
|
try {
|
||||||
|
await createEnforcementRecord({
|
||||||
|
eventId: row._eventId,
|
||||||
|
enforcedAt: new Date().toISOString(),
|
||||||
|
vesselMmsi: row.mmsi !== '-' ? row.mmsi : undefined,
|
||||||
|
vesselName: row.vesselName !== '-' ? row.vesselName : undefined,
|
||||||
|
zoneCode: row.area !== '-' ? row.area : undefined,
|
||||||
|
violationType: row.type,
|
||||||
|
action: 'PATROL_DISPATCH',
|
||||||
|
});
|
||||||
|
load({ level: '' });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const columns: DataColumn<EventRow>[] = useMemo(() => [
|
const columns: DataColumn<EventRow>[] = useMemo(() => [
|
||||||
{
|
{
|
||||||
@ -83,12 +129,24 @@ export function EventList() {
|
|||||||
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
render: (val) => <span className="text-cyan-400 font-medium">{val as string}</span>,
|
||||||
},
|
},
|
||||||
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
{ key: 'mmsi', label: 'MMSI', minWidth: '90px', maxWidth: '120px',
|
||||||
render: (val) => <span className="text-hint font-mono text-[10px]">{val as string}</span>,
|
render: (_val, row) => {
|
||||||
|
const mmsi = row.mmsi;
|
||||||
|
if (!mmsi || mmsi === '-') return <span className="text-hint">-</span>;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-cyan-400 hover:text-cyan-300 hover:underline font-mono text-[10px]"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/vessel/${mmsi}`); }}
|
||||||
|
>
|
||||||
|
{mmsi}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
|
{ key: 'area', label: '해역', minWidth: '80px', maxWidth: '140px', sortable: true },
|
||||||
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
|
{ key: 'speed', label: '속력', minWidth: '56px', maxWidth: '80px', align: 'right' },
|
||||||
{
|
{
|
||||||
key: 'status', label: '처리상태', minWidth: '80px', maxWidth: '120px', sortable: true,
|
key: 'status', label: '처리상태', minWidth: '70px', maxWidth: '100px', sortable: true,
|
||||||
render: (val) => {
|
render: (val) => {
|
||||||
const s = val as string;
|
const s = val as string;
|
||||||
return (
|
return (
|
||||||
@ -98,8 +156,46 @@ export function EventList() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'assignee', label: '담당', minWidth: '60px', maxWidth: '100px' },
|
{
|
||||||
], [tc, lang]);
|
key: '_eventId', label: '액션', minWidth: '120px', maxWidth: '180px',
|
||||||
|
render: (_val, row) => {
|
||||||
|
const eid = row._eventId;
|
||||||
|
const isNew = row.status === 'NEW';
|
||||||
|
const isActionable = row.status !== 'RESOLVED' && row.status !== 'FALSE_POSITIVE';
|
||||||
|
const busy = actionLoading === eid;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isNew && (
|
||||||
|
<button type="button" aria-label="확인" title="확인(ACK)"
|
||||||
|
className="p-0.5 rounded hover:bg-blue-500/20 text-blue-400 disabled:opacity-30"
|
||||||
|
disabled={busy} onClick={(e) => { e.stopPropagation(); handleAck(eid); }}>
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" aria-label="선박 상세" title="선박 상세"
|
||||||
|
className="p-0.5 rounded hover:bg-cyan-500/20 text-cyan-400"
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (row.mmsi !== '-') navigate(`/vessel/${row.mmsi}`); }}>
|
||||||
|
<Ship className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{isActionable && (
|
||||||
|
<>
|
||||||
|
<button type="button" aria-label="단속 등록" title="단속 등록"
|
||||||
|
className="p-0.5 rounded hover:bg-green-500/20 text-green-400 disabled:opacity-30"
|
||||||
|
disabled={busy} onClick={(e) => { e.stopPropagation(); handleCreateEnforcement(row); }}>
|
||||||
|
<Shield className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" aria-label="오탐 처리" title="오탐 처리"
|
||||||
|
className="p-0.5 rounded hover:bg-red-500/20 text-red-400 disabled:opacity-30"
|
||||||
|
disabled={busy} onClick={(e) => { e.stopPropagation(); handleFalsePositive(eid); }}>
|
||||||
|
<Ban className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [tc, lang, actionLoading, handleAck, handleFalsePositive, handleCreateEnforcement, navigate]);
|
||||||
|
|
||||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
@ -114,9 +210,10 @@ export function EventList() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
// store events -> EventRow 변환
|
// store events -> EventRow 변환 (rawEvents에서 numeric id 참조)
|
||||||
const EVENTS: EventRow[] = storeEvents.map((e) => ({
|
const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
|
_eventId: rawEvents[idx]?.id ?? 0,
|
||||||
time: e.time,
|
time: e.time,
|
||||||
level: e.level as AlertLevel,
|
level: e.level as AlertLevel,
|
||||||
type: e.type,
|
type: e.type,
|
||||||
|
|||||||
113
frontend/src/services/analysisApi.ts
Normal file
113
frontend/src/services/analysisApi.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* vessel_analysis_results 직접 조회 API 서비스.
|
||||||
|
* 백엔드 /api/analysis/* 엔드포인트 연동.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
|
|
||||||
|
export interface VesselAnalysis {
|
||||||
|
id: number;
|
||||||
|
mmsi: string;
|
||||||
|
analyzedAt: string;
|
||||||
|
vesselType: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
fishingPct: number | null;
|
||||||
|
season: string | null;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
|
zoneCode: string | null;
|
||||||
|
distToBaselineNm: number | null;
|
||||||
|
activityState: string | null;
|
||||||
|
isDark: boolean | null;
|
||||||
|
gapDurationMin: number | null;
|
||||||
|
darkPattern: string | null;
|
||||||
|
spoofingScore: number | null;
|
||||||
|
speedJumpCount: number | null;
|
||||||
|
transshipSuspect: boolean | null;
|
||||||
|
transshipPairMmsi: string | null;
|
||||||
|
transshipDurationMin: number | null;
|
||||||
|
fleetClusterId: number | null;
|
||||||
|
fleetRole: string | null;
|
||||||
|
fleetIsLeader: boolean | null;
|
||||||
|
riskScore: number | null;
|
||||||
|
riskLevel: string | null;
|
||||||
|
gearCode: string | null;
|
||||||
|
gearJudgment: string | null;
|
||||||
|
permitStatus: string | null;
|
||||||
|
features: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisPageResponse {
|
||||||
|
content: VesselAnalysis[];
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 분석 결과 목록 조회 */
|
||||||
|
export async function getAnalysisVessels(params?: {
|
||||||
|
mmsi?: string;
|
||||||
|
zoneCode?: string;
|
||||||
|
riskLevel?: string;
|
||||||
|
isDark?: boolean;
|
||||||
|
hours?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<AnalysisPageResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.mmsi) query.set('mmsi', params.mmsi);
|
||||||
|
if (params?.zoneCode) query.set('zoneCode', params.zoneCode);
|
||||||
|
if (params?.riskLevel) query.set('riskLevel', params.riskLevel);
|
||||||
|
if (params?.isDark != null) query.set('isDark', String(params.isDark));
|
||||||
|
query.set('hours', String(params?.hours ?? 1));
|
||||||
|
query.set('page', String(params?.page ?? 0));
|
||||||
|
query.set('size', String(params?.size ?? 50));
|
||||||
|
const res = await fetch(`${API_BASE}/analysis/vessels?${query}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 특정 선박 최신 분석 결과 */
|
||||||
|
export async function getAnalysisLatest(mmsi: string): Promise<VesselAnalysis> {
|
||||||
|
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 특정 선박 분석 이력 */
|
||||||
|
export async function getAnalysisHistory(mmsi: string, hours = 24): Promise<VesselAnalysis[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/analysis/vessels/${mmsi}/history?hours=${hours}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다크 베셀 목록 */
|
||||||
|
export async function getDarkVessels(params?: {
|
||||||
|
hours?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<AnalysisPageResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('hours', String(params?.hours ?? 1));
|
||||||
|
query.set('page', String(params?.page ?? 0));
|
||||||
|
query.set('size', String(params?.size ?? 50));
|
||||||
|
const res = await fetch(`${API_BASE}/analysis/dark?${query}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 환적 의심 목록 */
|
||||||
|
export async function getTransshipSuspects(params?: {
|
||||||
|
hours?: number;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<AnalysisPageResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('hours', String(params?.hours ?? 1));
|
||||||
|
query.set('page', String(params?.page ?? 0));
|
||||||
|
query.set('size', String(params?.size ?? 50));
|
||||||
|
const res = await fetch(`${API_BASE}/analysis/transship?${query}`, { credentials: 'include' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@ -30,6 +30,15 @@ export interface PredictionEvent {
|
|||||||
resolvedAt: string | null;
|
resolvedAt: string | null;
|
||||||
resolutionNote: string | null;
|
resolutionNote: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
features?: {
|
||||||
|
dark_suspicion_score?: number;
|
||||||
|
dark_tier?: string;
|
||||||
|
dark_patterns?: string[];
|
||||||
|
dark_history_7d?: number;
|
||||||
|
transship_tier?: string;
|
||||||
|
transship_score?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventPageResponse {
|
export interface EventPageResponse {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user