From e401c07dd3e92f44cde547e8209b6d3e485f499c Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 9 Apr 2026 11:43:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=97=B0=EA=B2=B0=20Step=205=20=E2=80=94?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=EA=B0=B1=EC=8B=A0=20+=20=EB=AA=A8?= =?UTF-8?q?=EC=84=A0=EC=B6=94=EB=A1=A0=20=EC=97=B0=EA=B2=B0=20+=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 갱신 (30초, 깜박임 없음): - eventStore: silentRefresh() 메서드 추가 (loading 상태 미변경, 데이터만 교체) - EventList: 30초 인터벌로 silentRefresh + loadStats 호출 - DarkVesselDetection: 30초 인터벌로 getDarkVessels silent 갱신 모선추론 자동 연결: - ParentReview CONFIRM → createLabelSession 자동 호출 (학습 데이터 수집 시작) - ParentReview REJECT → excludeForGroup 자동 호출 (잘못된 후보 재추론 방지) - 자동 연결 실패 시 리뷰 자체는 유지 (catch 무시) i18n (ko/en): - darkTier: CRITICAL/HIGH/WATCH/NONE 라벨 - transshipTier: CRITICAL/HIGH/WATCH 라벨 - adminSubGroup: AI 플랫폼/시스템 운영/사용자 관리/감사·보안 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../detection/DarkVesselDetection.tsx | 11 +++++++++ .../src/features/enforcement/EventList.tsx | 11 +++++++++ .../parent-inference/ParentReview.tsx | 24 +++++++++++++++++++ frontend/src/lib/i18n/locales/en/common.json | 17 +++++++++++++ frontend/src/lib/i18n/locales/ko/common.json | 17 +++++++++++++ frontend/src/stores/eventStore.ts | 19 +++++++++++++++ 6 files changed, 99 insertions(+) diff --git a/frontend/src/features/detection/DarkVesselDetection.tsx b/frontend/src/features/detection/DarkVesselDetection.tsx index 815779f..7d716c9 100644 --- a/frontend/src/features/detection/DarkVesselDetection.tsx +++ b/frontend/src/features/detection/DarkVesselDetection.tsx @@ -136,6 +136,17 @@ export function DarkVesselDetection() { useEffect(() => { loadData(); }, [loadData]); + // 30초 자동 갱신 (깜박임 없음 — loading 상태 변경 없이 데이터만 교체) + useEffect(() => { + const timer = setInterval(async () => { + try { + const res = await getDarkVessels({ hours: 1, size: 500 }); + setRawData(res.content); + } catch { /* silent */ } + }, 30_000); + return () => clearInterval(timer); + }, []); + const DATA: Suspect[] = useMemo(() => { let items = rawData.map((v, i) => mapToSuspect(v, i)); if (tierFilter) { diff --git a/frontend/src/features/enforcement/EventList.tsx b/frontend/src/features/enforcement/EventList.tsx index 785a872..3aa1a4b 100644 --- a/frontend/src/features/enforcement/EventList.tsx +++ b/frontend/src/features/enforcement/EventList.tsx @@ -58,6 +58,7 @@ export function EventList() { loading, error, load, + silentRefresh, loadStats, } = useEventStore(); const [actionLoading, setActionLoading] = useState(null); @@ -210,6 +211,16 @@ export function EventList() { fetchData(); }, [fetchData]); + // 30초 자동 갱신 (깜박임 없음 — silentRefresh 사용) + useEffect(() => { + const params = levelFilter ? { level: levelFilter } : undefined; + const timer = setInterval(() => { + silentRefresh(params); + loadStats(); + }, 30_000); + return () => clearInterval(timer); + }, [levelFilter, silentRefresh, loadStats]); + // store events -> EventRow 변환 (rawEvents에서 numeric id 참조) const EVENTS: EventRow[] = storeEvents.map((e, idx) => ({ id: e.id, diff --git a/frontend/src/features/parent-inference/ParentReview.tsx b/frontend/src/features/parent-inference/ParentReview.tsx index 72c73f5..b8df06c 100644 --- a/frontend/src/features/parent-inference/ParentReview.tsx +++ b/frontend/src/features/parent-inference/ParentReview.tsx @@ -9,6 +9,8 @@ import { useAuth } from '@/app/auth/AuthContext'; import { fetchReviewList, reviewParent, + createLabelSession, + excludeForGroup, type ParentResolution, } from '@/services/parentInferenceApi'; import { formatDateTime } from '@shared/utils/dateFormat'; @@ -70,6 +72,28 @@ export function ParentReview() { selectedParentMmsi: selectedMmsi || item.selectedParentMmsi || undefined, comment: `${action} via UI`, }); + + // CONFIRM → LabelSession 자동 생성 (학습 데이터 수집 시작) + if (action === 'CONFIRM') { + const mmsi = selectedMmsi || item.selectedParentMmsi; + if (mmsi) { + await createLabelSession(item.groupKey, item.subClusterId, { + labelParentMmsi: mmsi, + }).catch(() => { /* LabelSession 실패는 무시 — 리뷰 자체는 성공 */ }); + } + } + + // REJECT → Exclusion 자동 등록 (잘못된 후보 재추론 방지) + if (action === 'REJECT') { + const mmsi = item.selectedParentMmsi; + if (mmsi) { + await excludeForGroup(item.groupKey, item.subClusterId, { + excludedMmsi: mmsi, + reason: '운영자 거부', + }).catch(() => { /* Exclusion 실패는 무시 */ }); + } + } + await load(); } catch (e: unknown) { const msg = e instanceof Error ? e.message : 'unknown'; diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index f3dbc55..6685d58 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -132,6 +132,23 @@ "INTERMITTENT": "Intermittent", "SPEED_ANOMALY": "Speed Anomaly" }, + "darkTier": { + "CRITICAL": "Intentional Loss (Critical)", + "HIGH": "Suspicious Loss", + "WATCH": "Under Watch", + "NONE": "Normal" + }, + "transshipTier": { + "CRITICAL": "Confirmed Transship", + "HIGH": "Suspected Transship", + "WATCH": "Under Watch" + }, + "adminSubGroup": { + "aiPlatform": "AI Platform", + "systemOps": "System Operations", + "userMgmt": "User Management", + "auditSecurity": "Audit & Security" + }, "userAccountStatus": { "ACTIVE": "Active", "PENDING": "Pending", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 9e68ca4..9539842 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -132,6 +132,23 @@ "INTERMITTENT": "신호 간헐송출", "SPEED_ANOMALY": "속도 이상" }, + "darkTier": { + "CRITICAL": "고의 소실 (위험)", + "HIGH": "의심 소실", + "WATCH": "관찰 대상", + "NONE": "정상" + }, + "transshipTier": { + "CRITICAL": "환적 확실", + "HIGH": "환적 의심", + "WATCH": "관찰 대상" + }, + "adminSubGroup": { + "aiPlatform": "AI 플랫폼", + "systemOps": "시스템 운영", + "userMgmt": "사용자 관리", + "auditSecurity": "감사·보안" + }, "userAccountStatus": { "ACTIVE": "활성", "PENDING": "승인 대기", diff --git a/frontend/src/stores/eventStore.ts b/frontend/src/stores/eventStore.ts index 8c85589..206b2e0 100644 --- a/frontend/src/stores/eventStore.ts +++ b/frontend/src/stores/eventStore.ts @@ -29,6 +29,8 @@ interface EventStore { loaded: boolean; /** API 호출 */ load: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise; + /** 화면 깜박임 없는 백그라운드 갱신 (loading 상태 변경 없음) */ + silentRefresh: (params?: { level?: string; status?: string; category?: string; page?: number; size?: number }) => Promise; loadStats: () => Promise; filterByLevel: (level: string | null) => LegacyEventRecord[]; } @@ -68,6 +70,23 @@ export const useEventStore = create((set, get) => ({ } }, + silentRefresh: async (params) => { + try { + const res = await getEvents(params); + const legacy = res.content.map(toLegacyEvent); + set({ + rawEvents: res.content, + events: legacy, + totalElements: res.totalElements, + totalPages: res.totalPages, + currentPage: res.number, + pageSize: res.size, + }); + } catch { + // silent: 에러 무시 — 다음 갱신에서 재시도 + } + }, + loadStats: async () => { try { const stats = await getEventStats();