feat(detection): 불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)
docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소. event_generator 가 생산하는 카테고리 중 불법 조업 관련 3종을 READ 전용 대시보드로 통합. 대상 카테고리: - GEAR_ILLEGAL — G-01 수역·어구 / G-05 고정어구 drift / G-06 쌍끌이 - EEZ_INTRUSION — 영해 침범 / 접속수역 + 고위험 - ZONE_DEPARTURE — 특정수역 진입 (risk ≥ 40) ### 변경 - frontend/src/services/illegalFishingPatternApi.ts 신설 - 기존 /api/events 를 category 다중 병렬 조회 후 머지 (backend 변경 없음) - category '' 이면 3 카테고리 통합, 지정 시 단일 카테고리만 - size 기본 200 × 3 categories = 최대 600건, occurredAt desc 정렬 - byCategory / byLevel 집계 포함 - frontend/src/features/detection/IllegalFishingPattern.tsx 신설 (391 라인) - PageContainer + PageHeader(Ban 아이콘) + Section + KPI 5장 + 카테고리별 3장 - DataTable (occurredAt/level/category/title/mmsi/zone/status 7컬럼) - 필터: category / level / mmsi (최근 DEFAULT_SIZE 건 범위) - 상세 패널: JSON features 포함, EventList 로 네비게이션 링크 - design-system SSOT 준수: Badge intent, Select aria-label, text-* 시맨틱 토큰 - index.ts + componentRegistry.ts export/lazy 등록 - detection.json (ko/en) illegalPattern.* 네임스페이스 추가 (각 60키) - common.json (ko/en) nav.illegalFishing 추가 - V032__menu_illegal_fishing_pattern.sql - auth_perm_tree 엔트리 (rsrc_cd=detection:illegal-fishing, nav_sort=920) - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ - READ 전용 페이지 (처리 액션은 EventList 경유) ### 검증 - npx tsc --noEmit 통과 (0 에러) - 백엔드 변경 없음 (기존 /api/events category 필터 재사용) - Flyway V032 자동 적용 (백엔드 재배포 필요)
This commit is contained in:
부모
2e674ccc5b
커밋
e49ab0f4e8
@ -0,0 +1,40 @@
|
||||
-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed
|
||||
--
|
||||
-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종
|
||||
-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는
|
||||
-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행.
|
||||
--
|
||||
-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째.
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
|
||||
-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm_tree
|
||||
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
|
||||
url_path, label_key, component_key, nav_sort, labels)
|
||||
VALUES
|
||||
('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45,
|
||||
'/illegal-fishing', 'nav.illegalFishing',
|
||||
'features/detection/IllegalFishingPattern', 920,
|
||||
'{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 2. 권한 부여
|
||||
-- READ 전용 페이지 — 모든 역할에 READ만 부여.
|
||||
-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동.
|
||||
-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도).
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y'
|
||||
FROM kcg.auth_role r
|
||||
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
|
||||
WHERE r.role_cd = 'ADMIN'
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y'
|
||||
FROM kcg.auth_role r
|
||||
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
|
||||
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
|
||||
@ -42,6 +42,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
||||
'features/detection/GearCollisionDetection': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
|
||||
),
|
||||
'features/detection/IllegalFishingPattern': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
|
||||
),
|
||||
// ── 단속·이벤트 ──
|
||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||
|
||||
391
frontend/src/features/detection/IllegalFishingPattern.tsx
Normal file
391
frontend/src/features/detection/IllegalFishingPattern.tsx
Normal file
@ -0,0 +1,391 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Ban, RefreshCw, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
|
||||
import { Badge } from '@shared/components/ui/badge';
|
||||
import { Button } from '@shared/components/ui/button';
|
||||
import { Input } from '@shared/components/ui/input';
|
||||
import { Select } from '@shared/components/ui/select';
|
||||
import { Card, CardContent } from '@shared/components/ui/card';
|
||||
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
|
||||
|
||||
import { formatDateTime } from '@shared/utils/dateFormat';
|
||||
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
|
||||
import {
|
||||
ILLEGAL_FISHING_CATEGORIES,
|
||||
listIllegalFishingEvents,
|
||||
type IllegalFishingCategory,
|
||||
type IllegalFishingPatternPage,
|
||||
} from '@/services/illegalFishingPatternApi';
|
||||
import type { PredictionEvent } from '@/services/event';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/**
|
||||
* 불법 조업 이벤트 — event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종을
|
||||
* 묶어 한 화면에서 조회하는 대시보드.
|
||||
*
|
||||
* GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이 공조
|
||||
* EEZ_INTRUSION : 영해 침범 / 접속수역 고위험
|
||||
* ZONE_DEPARTURE : 특정수역 진입 (risk ≥ 40)
|
||||
*
|
||||
* 운영자 액션(확인/상태변경/단속 등록)은 /event-list 이벤트 목록에서 수행.
|
||||
* 이 페이지는 **READ 전용** 대시보드.
|
||||
*/
|
||||
|
||||
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||
const DEFAULT_SIZE = 200;
|
||||
|
||||
export function IllegalFishingPattern() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||
|
||||
const [page, setPage] = useState<IllegalFishingPatternPage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<IllegalFishingCategory | ''>('');
|
||||
const [levelFilter, setLevelFilter] = useState<string>('');
|
||||
const [mmsiFilter, setMmsiFilter] = useState('');
|
||||
const [selected, setSelected] = useState<PredictionEvent | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await listIllegalFishingEvents({
|
||||
category: categoryFilter || undefined,
|
||||
level: levelFilter || undefined,
|
||||
vesselMmsi: mmsiFilter || undefined,
|
||||
size: DEFAULT_SIZE,
|
||||
});
|
||||
setPage(result);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryFilter, levelFilter, mmsiFilter, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const rows = page?.content ?? [];
|
||||
const levelCount = (code: string) => page?.byLevel?.[code] ?? 0;
|
||||
const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0;
|
||||
|
||||
const cols: DataColumn<PredictionEvent & Record<string, unknown>>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'occurredAt',
|
||||
label: t('illegalPattern.columns.occurredAt'),
|
||||
width: '140px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: t('illegalPattern.columns.level'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||
{getAlertLevelLabel(v as string, tc, lang)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: t('illegalPattern.columns.category'),
|
||||
width: '130px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent="info" size="sm">
|
||||
{t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: t('illegalPattern.columns.title'),
|
||||
minWidth: '260px',
|
||||
render: (v) => <span className="text-label">{v as string}</span>,
|
||||
},
|
||||
{
|
||||
key: 'vesselMmsi',
|
||||
label: t('illegalPattern.columns.mmsi'),
|
||||
width: '110px',
|
||||
render: (v, row) => (
|
||||
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
||||
{(v as string) || '-'}
|
||||
{row.vesselName ? <span className="text-hint ml-1">({row.vesselName})</span> : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'zoneCode',
|
||||
label: t('illegalPattern.columns.zone'),
|
||||
width: '130px',
|
||||
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('illegalPattern.columns.status'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={(v as string) === 'NEW' ? 'warning' : 'muted'} size="sm">
|
||||
{t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, tc, lang],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={Ban}
|
||||
iconColor="text-red-600 dark:text-red-400"
|
||||
title={t('illegalPattern.title')}
|
||||
description={t('illegalPattern.desc')}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t('illegalPattern.refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card variant="default">
|
||||
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Section title={t('illegalPattern.stats.title')}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<StatCard label={t('illegalPattern.stats.total')} value={page?.content.length ?? 0} />
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('CRITICAL', tc, lang)}
|
||||
value={levelCount('CRITICAL')}
|
||||
intent="critical"
|
||||
/>
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('HIGH', tc, lang)}
|
||||
value={levelCount('HIGH')}
|
||||
intent="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('MEDIUM', tc, lang)}
|
||||
value={levelCount('MEDIUM')}
|
||||
intent="info"
|
||||
/>
|
||||
<StatCard
|
||||
label={getAlertLevelLabel('LOW', tc, lang)}
|
||||
value={levelCount('LOW')}
|
||||
intent="muted"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('illegalPattern.byCategory.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
{ILLEGAL_FISHING_CATEGORIES.map((code) => (
|
||||
<Card key={code} variant="default">
|
||||
<CardContent className="py-2 px-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-heading">
|
||||
{t(`illegalPattern.category.${code}`)}
|
||||
</div>
|
||||
<div className="text-[10px] text-hint">
|
||||
{t(`illegalPattern.categoryDesc.${code}`)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-heading">{categoryCount(code)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('illegalPattern.list.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||
<Select
|
||||
aria-label={t('illegalPattern.filters.category')}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value as IllegalFishingCategory | '')}
|
||||
>
|
||||
<option value="">{t('illegalPattern.filters.allCategory')}</option>
|
||||
{ILLEGAL_FISHING_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`illegalPattern.category.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
aria-label={t('illegalPattern.filters.level')}
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t('illegalPattern.filters.allLevel')}</option>
|
||||
{LEVEL_OPTIONS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{getAlertLevelLabel(l, tc, lang)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
aria-label={t('illegalPattern.filters.mmsi')}
|
||||
placeholder={t('illegalPattern.filters.mmsi')}
|
||||
value={mmsiFilter}
|
||||
onChange={(e) => setMmsiFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Badge intent="info" size="sm">
|
||||
{t('illegalPattern.filters.limit')} · {DEFAULT_SIZE}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 && !loading ? (
|
||||
<p className="text-hint text-xs py-4 text-center">{t('illegalPattern.list.empty')}</p>
|
||||
) : (
|
||||
<DataTable
|
||||
data={rows as (PredictionEvent & Record<string, unknown>)[]}
|
||||
columns={cols}
|
||||
pageSize={20}
|
||||
showSearch={false}
|
||||
showExport={false}
|
||||
showPrint={false}
|
||||
onRowClick={(row) => setSelected(row as PredictionEvent)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{selected && (
|
||||
<Section title={t('illegalPattern.detail.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-1.5">
|
||||
<DetailRow
|
||||
label={t('illegalPattern.columns.occurredAt')}
|
||||
value={formatDateTime(selected.occurredAt)}
|
||||
/>
|
||||
<DetailRow label={t('illegalPattern.columns.category')} value={selected.category} />
|
||||
<DetailRow label={t('illegalPattern.columns.level')} value={selected.level} />
|
||||
<DetailRow label={t('illegalPattern.columns.title')} value={selected.title} />
|
||||
<DetailRow
|
||||
label={t('illegalPattern.columns.mmsi')}
|
||||
value={selected.vesselMmsi ?? '-'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('illegalPattern.detail.vesselName')}
|
||||
value={selected.vesselName ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('illegalPattern.columns.zone')}
|
||||
value={selected.zoneCode ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('illegalPattern.detail.location')}
|
||||
value={
|
||||
selected.lat != null && selected.lon != null
|
||||
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
<DetailRow label={t('illegalPattern.columns.status')} value={selected.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selected.detail && (
|
||||
<p className="text-xs text-label border-l-2 border-border pl-2">
|
||||
{selected.detail}
|
||||
</p>
|
||||
)}
|
||||
{selected.features && Object.keys(selected.features).length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-hint mb-1">
|
||||
{t('illegalPattern.detail.features')}
|
||||
</div>
|
||||
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
|
||||
{JSON.stringify(selected.features, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
||||
{t('illegalPattern.detail.close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<ExternalLink className="w-3.5 h-3.5" />}
|
||||
onClick={() => {
|
||||
window.location.href = `/event-list?category=${selected.category}&mmsi=${selected.vesselMmsi ?? ''}`;
|
||||
}}
|
||||
>
|
||||
{t('illegalPattern.detail.openEventList')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 내부 컴포넌트 ─────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
intent?: 'warning' | 'info' | 'critical' | 'muted';
|
||||
}
|
||||
|
||||
function StatCard({ label, value, intent }: StatCardProps) {
|
||||
return (
|
||||
<Card variant="default">
|
||||
<CardContent className="py-3 flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] text-hint">{label}</span>
|
||||
{intent ? (
|
||||
<Badge intent={intent} size="md">
|
||||
{value}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-heading">{value}</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DetailRowProps {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: DetailRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-hint w-24 shrink-0">{label}</span>
|
||||
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IllegalFishingPattern;
|
||||
@ -3,3 +3,4 @@ export { GearDetection } from './GearDetection';
|
||||
export { ChinaFishing } from './ChinaFishing';
|
||||
export { GearIdentification } from './GearIdentification';
|
||||
export { GearCollisionDetection } from './GearCollisionDetection';
|
||||
export { IllegalFishingPattern } from './IllegalFishingPattern';
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"gearDetection": "Gear Detection",
|
||||
"chinaFishing": "Chinese Vessel",
|
||||
"gearCollision": "Gear Collision",
|
||||
"illegalFishing": "Illegal Fishing",
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
|
||||
@ -15,6 +15,66 @@
|
||||
"title": "Gear Identification",
|
||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||
},
|
||||
"illegalPattern": {
|
||||
"title": "Illegal Fishing Events",
|
||||
"desc": "Integrated view of illegal fishing–related events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)",
|
||||
"refresh": "Refresh",
|
||||
"stats": {
|
||||
"title": "Severity distribution",
|
||||
"total": "Total"
|
||||
},
|
||||
"byCategory": {
|
||||
"title": "By category"
|
||||
},
|
||||
"category": {
|
||||
"GEAR_ILLEGAL": "Gear Violation",
|
||||
"EEZ_INTRUSION": "Territorial/Contiguous",
|
||||
"ZONE_DEPARTURE": "Protected Zone Entry"
|
||||
},
|
||||
"categoryDesc": {
|
||||
"GEAR_ILLEGAL": "G-01/G-05/G-06 zone-gear mismatch, fixed-gear drift, pair trawl",
|
||||
"EEZ_INTRUSION": "Territorial sea (CRITICAL) / Contiguous zone high-risk",
|
||||
"ZONE_DEPARTURE": "Protected zone entry with risk ≥ 40"
|
||||
},
|
||||
"list": {
|
||||
"title": "Events",
|
||||
"empty": "No illegal fishing events match the filters."
|
||||
},
|
||||
"columns": {
|
||||
"occurredAt": "Occurred",
|
||||
"level": "Level",
|
||||
"category": "Category",
|
||||
"title": "Title",
|
||||
"mmsi": "MMSI",
|
||||
"zone": "Zone",
|
||||
"status": "Status"
|
||||
},
|
||||
"filters": {
|
||||
"category": "Category",
|
||||
"level": "Level",
|
||||
"mmsi": "MMSI",
|
||||
"limit": "Limit",
|
||||
"allCategory": "All categories",
|
||||
"allLevel": "All levels"
|
||||
},
|
||||
"status": {
|
||||
"NEW": "New",
|
||||
"ACKED": "Acked",
|
||||
"RESOLVED": "Resolved",
|
||||
"FALSE_ALARM": "False alarm"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Event detail",
|
||||
"vesselName": "Vessel name",
|
||||
"location": "Location",
|
||||
"features": "Extra info",
|
||||
"close": "Close",
|
||||
"openEventList": "Open in Event List"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load events."
|
||||
}
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "Gear Identity Collision",
|
||||
"desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion",
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"gearDetection": "어구 탐지",
|
||||
"chinaFishing": "중국어선 분석",
|
||||
"gearCollision": "어구 정체성 충돌",
|
||||
"illegalFishing": "불법 조업 이벤트",
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
|
||||
@ -15,6 +15,66 @@
|
||||
"title": "어구 식별 분석",
|
||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||
},
|
||||
"illegalPattern": {
|
||||
"title": "불법 조업 이벤트",
|
||||
"desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",
|
||||
"refresh": "새로고침",
|
||||
"stats": {
|
||||
"title": "심각도 분포",
|
||||
"total": "전체"
|
||||
},
|
||||
"byCategory": {
|
||||
"title": "카테고리별 건수"
|
||||
},
|
||||
"category": {
|
||||
"GEAR_ILLEGAL": "어구 위반",
|
||||
"EEZ_INTRUSION": "영해/접속수역 침범",
|
||||
"ZONE_DEPARTURE": "특정수역 진입"
|
||||
},
|
||||
"categoryDesc": {
|
||||
"GEAR_ILLEGAL": "G-01/G-05/G-06 수역·어구 불일치, 고정어구 drift, 쌍끌이 공조",
|
||||
"EEZ_INTRUSION": "영해(CRITICAL) / 접속수역 + 고위험 위반",
|
||||
"ZONE_DEPARTURE": "관심 수역(ZONE_I~IV) 진입 + 위험도 40+"
|
||||
},
|
||||
"list": {
|
||||
"title": "이벤트 목록",
|
||||
"empty": "조건에 맞는 불법 조업 이벤트가 없습니다."
|
||||
},
|
||||
"columns": {
|
||||
"occurredAt": "발생 시각",
|
||||
"level": "심각도",
|
||||
"category": "카테고리",
|
||||
"title": "제목",
|
||||
"mmsi": "MMSI",
|
||||
"zone": "수역",
|
||||
"status": "상태"
|
||||
},
|
||||
"filters": {
|
||||
"category": "카테고리",
|
||||
"level": "심각도",
|
||||
"mmsi": "MMSI 검색",
|
||||
"limit": "최대",
|
||||
"allCategory": "전체 카테고리",
|
||||
"allLevel": "전체 심각도"
|
||||
},
|
||||
"status": {
|
||||
"NEW": "신규",
|
||||
"ACKED": "확인",
|
||||
"RESOLVED": "처리완료",
|
||||
"FALSE_ALARM": "오탐"
|
||||
},
|
||||
"detail": {
|
||||
"title": "이벤트 상세",
|
||||
"vesselName": "선박명",
|
||||
"location": "좌표",
|
||||
"features": "추가 정보",
|
||||
"close": "닫기",
|
||||
"openEventList": "이벤트 목록에서 열기"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "이벤트를 불러오지 못했습니다."
|
||||
}
|
||||
},
|
||||
"gearCollision": {
|
||||
"title": "어구 정체성 충돌 탐지",
|
||||
"desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심",
|
||||
|
||||
77
frontend/src/services/illegalFishingPatternApi.ts
Normal file
77
frontend/src/services/illegalFishingPatternApi.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 불법 조업 이벤트 전용 서비스 — 기존 /api/events 를 category 다중 조회로 래핑.
|
||||
*
|
||||
* category 는 event_generator 의 rule 에서 오는 단일 값이지만, UI 관점에서 "불법 조업"
|
||||
* 은 여러 카테고리의 합집합이다:
|
||||
* - GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이
|
||||
* - EEZ_INTRUSION : 영해 침범 / 접속수역 + 고위험
|
||||
* - ZONE_DEPARTURE : 특정수역 진입 (관심구역 모니터링)
|
||||
*
|
||||
* backend 변경 없이 클라이언트에서 병렬 조회 후 머지한다.
|
||||
*/
|
||||
import { getEvents, type EventPageResponse, type PredictionEvent } from './event';
|
||||
|
||||
export const ILLEGAL_FISHING_CATEGORIES = [
|
||||
'GEAR_ILLEGAL',
|
||||
'EEZ_INTRUSION',
|
||||
'ZONE_DEPARTURE',
|
||||
] as const;
|
||||
|
||||
export type IllegalFishingCategory = (typeof ILLEGAL_FISHING_CATEGORIES)[number];
|
||||
|
||||
export interface ListParams {
|
||||
/** 단일 카테고리를 지정하면 해당 카테고리만, '' 이면 3개 모두 병합 조회 */
|
||||
category?: IllegalFishingCategory | '';
|
||||
level?: string;
|
||||
status?: string;
|
||||
vesselMmsi?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface IllegalFishingPatternPage {
|
||||
content: PredictionEvent[];
|
||||
totalElements: number;
|
||||
byCategory: Record<string, number>;
|
||||
byLevel: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 조회 — category 미지정 시 3개 병렬 조회 후 occurredAt desc 정렬로 머지.
|
||||
* 각 카테고리 최대 size 건씩 수집하므로, 기본 200 * 3 = 600 건이 상한.
|
||||
*/
|
||||
export async function listIllegalFishingEvents(params?: ListParams): Promise<IllegalFishingPatternPage> {
|
||||
const size = params?.size ?? 200;
|
||||
const targetCategories: IllegalFishingCategory[] = params?.category
|
||||
? [params.category]
|
||||
: [...ILLEGAL_FISHING_CATEGORIES];
|
||||
|
||||
const pages: EventPageResponse[] = await Promise.all(
|
||||
targetCategories.map((category) =>
|
||||
getEvents({
|
||||
category,
|
||||
level: params?.level,
|
||||
status: params?.status,
|
||||
vesselMmsi: params?.vesselMmsi,
|
||||
page: 0,
|
||||
size,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const allEvents: PredictionEvent[] = pages.flatMap((p) => p.content);
|
||||
allEvents.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||
|
||||
const byCategory: Record<string, number> = {};
|
||||
const byLevel: Record<string, number> = {};
|
||||
for (const e of allEvents) {
|
||||
byCategory[e.category] = (byCategory[e.category] ?? 0) + 1;
|
||||
byLevel[e.level] = (byLevel[e.level] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
content: allEvents,
|
||||
totalElements: pages.reduce((acc, p) => acc + p.totalElements, 0),
|
||||
byCategory,
|
||||
byLevel,
|
||||
};
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user