Compare commits
No commits in common. "a8ce9a4ea9f6d33cdfcd36e8b97ee2bbbefba77f" and "f2d145c9a2ddaa4a339e0c07ebdba9ed51687c23" have entirely different histories.
a8ce9a4ea9
...
f2d145c9a2
@ -1,46 +0,0 @@
|
||||
-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed
|
||||
--
|
||||
-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
|
||||
-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용
|
||||
-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고,
|
||||
-- 이 페이지는 전체 목록·통계 운영 대시보드.
|
||||
--
|
||||
-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을
|
||||
-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가.
|
||||
-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음.
|
||||
-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
|
||||
-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR).
|
||||
--
|
||||
-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL)
|
||||
-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
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:transshipment', NULL, '환적 의심 탐지', 1, 48,
|
||||
'/transshipment', 'nav.transshipment',
|
||||
'features/detection/TransshipmentDetection', 910,
|
||||
'{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb)
|
||||
ON CONFLICT (rsrc_cd) DO NOTHING;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
-- 2. 권한 부여 — READ 전용 대시보드
|
||||
-- ADMIN: 5 ops 전부 (메뉴 관리 일관성)
|
||||
-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ
|
||||
-- ──────────────────────────────────────────────────────────────────
|
||||
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
|
||||
SELECT r.role_sn, 'detection:transshipment', 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:transshipment', '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;
|
||||
@ -5,7 +5,6 @@
|
||||
## [Unreleased]
|
||||
|
||||
### 추가
|
||||
- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)** — `/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2)
|
||||
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
|
||||
|
||||
### 수정
|
||||
|
||||
@ -45,9 +45,6 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
|
||||
'features/detection/IllegalFishingPattern': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
|
||||
),
|
||||
'features/detection/TransshipmentDetection': lazy(() =>
|
||||
import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })),
|
||||
),
|
||||
// ── 단속·이벤트 ──
|
||||
'features/enforcement/EnforcementHistory': lazy(() =>
|
||||
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
|
||||
|
||||
@ -1,405 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeftRight, RefreshCw } 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 { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
|
||||
import { useSettingsStore } from '@stores/settingsStore';
|
||||
|
||||
/**
|
||||
* 환적(Transshipment) 의심 선박 탐지 페이지.
|
||||
*
|
||||
* prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
|
||||
* (transship_suspect=true) 를 전체 목록으로 조회·집계·상세 확인.
|
||||
*
|
||||
* features.transship_tier (CRITICAL/HIGH/MEDIUM) 와 transship_score 로 심각도 판단.
|
||||
* 기존 `features/vessel/TransferDetection.tsx` 는 선박 상세 수준, 이 페이지는
|
||||
* 전체 목록·통계 수준의 운영 대시보드.
|
||||
*/
|
||||
|
||||
const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const;
|
||||
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
|
||||
const DEFAULT_HOURS = 6;
|
||||
const DEFAULT_SIZE = 200;
|
||||
|
||||
type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string;
|
||||
|
||||
interface TransshipFeatures {
|
||||
transship_tier?: TransshipTier;
|
||||
transship_score?: number;
|
||||
dark_tier?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function readTier(row: VesselAnalysis): string {
|
||||
const f = (row.features ?? {}) as TransshipFeatures;
|
||||
return f.transship_tier ?? '-';
|
||||
}
|
||||
|
||||
function readScore(row: VesselAnalysis): number | null {
|
||||
const f = (row.features ?? {}) as TransshipFeatures;
|
||||
return typeof f.transship_score === 'number' ? f.transship_score : null;
|
||||
}
|
||||
|
||||
export function TransshipmentDetection() {
|
||||
const { t } = useTranslation('detection');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
|
||||
|
||||
const [rows, setRows] = useState<VesselAnalysis[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const [mmsiFilter, setMmsiFilter] = useState('');
|
||||
const [selected, setSelected] = useState<VesselAnalysis | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE });
|
||||
setRows(resp.content);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hours, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
if (levelFilter && r.riskLevel !== levelFilter) return false;
|
||||
if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rows, levelFilter, mmsiFilter]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const byLevel: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
||||
const byTier: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 };
|
||||
for (const r of rows) {
|
||||
if (r.riskLevel && byLevel[r.riskLevel] !== undefined) {
|
||||
byLevel[r.riskLevel]++;
|
||||
}
|
||||
const tier = readTier(r);
|
||||
if (byTier[tier] !== undefined) {
|
||||
byTier[tier]++;
|
||||
}
|
||||
}
|
||||
return { byLevel, byTier };
|
||||
}, [rows]);
|
||||
|
||||
const cols: DataColumn<VesselAnalysis & Record<string, unknown>>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'analyzedAt',
|
||||
label: t('transshipment.columns.analyzedAt'),
|
||||
width: '140px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mmsi',
|
||||
label: t('transshipment.columns.mmsi'),
|
||||
width: '120px',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
|
||||
{v as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transshipPairMmsi',
|
||||
label: t('transshipment.columns.pairMmsi'),
|
||||
width: '120px',
|
||||
render: (v) => (
|
||||
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'transshipDurationMin',
|
||||
label: t('transshipment.columns.durationMin'),
|
||||
width: '90px',
|
||||
align: 'right',
|
||||
sortable: true,
|
||||
render: (v) => {
|
||||
const n = typeof v === 'number' ? v : Number(v ?? 0);
|
||||
return <span className="font-mono text-label">{n.toFixed(0)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'features',
|
||||
label: t('transshipment.columns.tier'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
render: (_, row) => {
|
||||
const tier = readTier(row);
|
||||
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
||||
return (
|
||||
<Badge
|
||||
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
|
||||
size="sm"
|
||||
>
|
||||
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'riskScore',
|
||||
label: t('transshipment.columns.riskScore'),
|
||||
width: '80px',
|
||||
align: 'right',
|
||||
sortable: true,
|
||||
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
|
||||
},
|
||||
{
|
||||
key: 'riskLevel',
|
||||
label: t('transshipment.columns.riskLevel'),
|
||||
width: '90px',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
render: (v) => (
|
||||
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
|
||||
{getAlertLevelLabel(v as string, tc, lang)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'zoneCode',
|
||||
label: t('transshipment.columns.zone'),
|
||||
width: '130px',
|
||||
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
|
||||
},
|
||||
],
|
||||
[t, tc, lang],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
icon={ArrowLeftRight}
|
||||
iconColor="text-purple-600 dark:text-purple-400"
|
||||
title={t('transshipment.title')}
|
||||
description={t('transshipment.desc')}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{t('transshipment.refresh')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Card variant="default">
|
||||
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Section title={t('transshipment.stats.title')}>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
|
||||
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
|
||||
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
|
||||
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
|
||||
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('transshipment.list.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
|
||||
<Select
|
||||
aria-label={t('transshipment.filters.hours')}
|
||||
value={String(hours)}
|
||||
onChange={(e) => setHours(Number(e.target.value))}
|
||||
>
|
||||
{HOUR_OPTIONS.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{t('transshipment.filters.hoursValue', { h })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
aria-label={t('transshipment.filters.level')}
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
>
|
||||
<option value="">{t('transshipment.filters.allLevel')}</option>
|
||||
{LEVEL_OPTIONS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{getAlertLevelLabel(l, tc, lang)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
aria-label={t('transshipment.filters.mmsi')}
|
||||
placeholder={t('transshipment.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">
|
||||
{filteredRows.length} / {rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredRows.length === 0 && !loading ? (
|
||||
<p className="text-hint text-xs py-4 text-center">
|
||||
{t('transshipment.list.empty', { hours })}
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
|
||||
columns={cols}
|
||||
pageSize={20}
|
||||
showSearch={false}
|
||||
showExport={false}
|
||||
showPrint={false}
|
||||
onRowClick={(row) => setSelected(row as VesselAnalysis)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{selected && (
|
||||
<Section title={t('transshipment.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('transshipment.columns.analyzedAt')}
|
||||
value={formatDateTime(selected.analyzedAt)}
|
||||
/>
|
||||
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.pairMmsi')}
|
||||
value={selected.transshipPairMmsi ?? '-'}
|
||||
mono
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.durationMin')}
|
||||
value={`${selected.transshipDurationMin ?? 0}분`}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.riskScore')}
|
||||
value={String(selected.riskScore ?? 0)}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.columns.zone')}
|
||||
value={selected.zoneCode ?? '-'}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('transshipment.detail.location')}
|
||||
value={
|
||||
selected.lat != null && selected.lon != null
|
||||
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
|
||||
: '-'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-label">
|
||||
{t('transshipment.columns.tier')}:
|
||||
</span>
|
||||
{(() => {
|
||||
const tier = readTier(selected);
|
||||
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
|
||||
return (
|
||||
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
|
||||
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
<span className="text-xs text-label ml-3">
|
||||
{t('transshipment.detail.transshipScore')}:
|
||||
</span>
|
||||
<span className="text-xs font-mono text-heading">
|
||||
{readScore(selected)?.toFixed(1) ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
{selected.features && Object.keys(selected.features).length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] text-hint mb-1">
|
||||
{t('transshipment.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 pt-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
||||
{t('transshipment.detail.close')}
|
||||
</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 text-center">{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 TransshipmentDetection;
|
||||
@ -4,4 +4,3 @@ export { ChinaFishing } from './ChinaFishing';
|
||||
export { GearIdentification } from './GearIdentification';
|
||||
export { GearCollisionDetection } from './GearCollisionDetection';
|
||||
export { IllegalFishingPattern } from './IllegalFishingPattern';
|
||||
export { TransshipmentDetection } from './TransshipmentDetection';
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
"chinaFishing": "Chinese Vessel",
|
||||
"gearCollision": "Gear Collision",
|
||||
"illegalFishing": "Illegal Fishing",
|
||||
"transshipment": "Transshipment",
|
||||
"patrolRoute": "Patrol Route",
|
||||
"fleetOptimization": "Fleet Optimize",
|
||||
"enforcementHistory": "History",
|
||||
|
||||
@ -15,50 +15,6 @@
|
||||
"title": "Gear Identification",
|
||||
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
|
||||
},
|
||||
"transshipment": {
|
||||
"title": "Transshipment Suspects",
|
||||
"desc": "Vessels passing prediction's 5-stage transshipment filter (cross-type pair → monitoring zone → RENDEZVOUS 90min+ → score 50+ → anti-cluster burst). Severity from features.transship_tier",
|
||||
"refresh": "Refresh",
|
||||
"stats": {
|
||||
"title": "Overview",
|
||||
"total": "Total",
|
||||
"tierCritical": "Transship CRITICAL",
|
||||
"tierHigh": "Transship HIGH",
|
||||
"tierMedium": "Transship MEDIUM",
|
||||
"riskCritical": "Risk CRITICAL"
|
||||
},
|
||||
"list": {
|
||||
"title": "Suspect vessels",
|
||||
"empty": "No transshipment suspects in the last {{hours}} hours."
|
||||
},
|
||||
"columns": {
|
||||
"analyzedAt": "Analyzed",
|
||||
"mmsi": "MMSI",
|
||||
"pairMmsi": "Pair MMSI",
|
||||
"durationMin": "Duration (min)",
|
||||
"tier": "Transship tier",
|
||||
"riskScore": "Risk",
|
||||
"riskLevel": "Risk level",
|
||||
"zone": "Zone"
|
||||
},
|
||||
"filters": {
|
||||
"hours": "Window",
|
||||
"level": "Risk level",
|
||||
"mmsi": "MMSI",
|
||||
"hoursValue": "Last {{h}}h",
|
||||
"allLevel": "All levels"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Transshipment detail",
|
||||
"location": "Location",
|
||||
"features": "Raw features",
|
||||
"transshipScore": "Transship score",
|
||||
"close": "Close"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load transshipment suspects."
|
||||
}
|
||||
},
|
||||
"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)",
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
"chinaFishing": "중국어선 분석",
|
||||
"gearCollision": "어구 정체성 충돌",
|
||||
"illegalFishing": "불법 조업 이벤트",
|
||||
"transshipment": "환적 의심 탐지",
|
||||
"patrolRoute": "순찰경로 추천",
|
||||
"fleetOptimization": "다함정 최적화",
|
||||
"enforcementHistory": "단속 이력",
|
||||
|
||||
@ -15,50 +15,6 @@
|
||||
"title": "어구 식별 분석",
|
||||
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
|
||||
},
|
||||
"transshipment": {
|
||||
"title": "환적 의심 탐지",
|
||||
"desc": "prediction 5단계 필터 파이프라인(이종 쌍 → 감시영역 → RENDEZVOUS 90분+ → 점수 50+ → 밀집 방폭) 통과 선박 목록. features.transship_tier 기반 심각도 분류",
|
||||
"refresh": "새로고침",
|
||||
"stats": {
|
||||
"title": "현황 요약",
|
||||
"total": "전체",
|
||||
"tierCritical": "환적 CRITICAL",
|
||||
"tierHigh": "환적 HIGH",
|
||||
"tierMedium": "환적 MEDIUM",
|
||||
"riskCritical": "종합위험 CRITICAL"
|
||||
},
|
||||
"list": {
|
||||
"title": "환적 의심 선박",
|
||||
"empty": "최근 {{hours}}시간 내 환적 의심 선박이 없습니다."
|
||||
},
|
||||
"columns": {
|
||||
"analyzedAt": "분석 시각",
|
||||
"mmsi": "MMSI",
|
||||
"pairMmsi": "상대 MMSI",
|
||||
"durationMin": "지속(분)",
|
||||
"tier": "환적 tier",
|
||||
"riskScore": "위험도",
|
||||
"riskLevel": "종합 위험",
|
||||
"zone": "수역"
|
||||
},
|
||||
"filters": {
|
||||
"hours": "조회 기간",
|
||||
"level": "위험도",
|
||||
"mmsi": "MMSI 검색",
|
||||
"hoursValue": "최근 {{h}}시간",
|
||||
"allLevel": "전체 위험도"
|
||||
},
|
||||
"detail": {
|
||||
"title": "환적 의심 상세",
|
||||
"location": "좌표",
|
||||
"features": "분석 피처 원본",
|
||||
"transshipScore": "환적 점수",
|
||||
"close": "닫기"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "환적 의심 목록을 불러오지 못했습니다."
|
||||
}
|
||||
},
|
||||
"illegalPattern": {
|
||||
"title": "불법 조업 이벤트",
|
||||
"desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user