diff --git a/src/components/InstructorLearningGuideModal.tsx b/src/components/InstructorLearningGuideModal.tsx
new file mode 100644
index 0000000..d709243
--- /dev/null
+++ b/src/components/InstructorLearningGuideModal.tsx
@@ -0,0 +1,187 @@
+import { useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import { BookOpenText, X } from 'lucide-react';
+
+type GuideLesson = {
+ code: string;
+ title: string;
+ guidance: string;
+ tools: string;
+};
+
+type GuideDay = {
+ title: string;
+ lessons: GuideLesson[];
+};
+
+const GUIDE_DAYS: GuideDay[] = [
+ {
+ title: 'DAY 1. ROS2 & Simulation',
+ lessons: [
+ {
+ code: '01',
+ title: 'ROS2 기초',
+ guidance: 'Gazebo와 TurtleBot3 또는 MK-mini를 실행하고 /odom, /tf, /scan, /cmd_vel, /map의 역할을 설명합니다. Goal 지정은 Nav2 Action의 실제 사례로 사용하고, TF tree는 터미널에서 map-odom-base_link 관계를 확인합니다.',
+ tools: '모듈 리스트, ROS 연결상태, 터미널, 시각화, RViz2',
+ },
+ {
+ code: '02',
+ title: 'Sensor Processing',
+ guidance: 'NAV2-Perception을 실행해 LiDAR /scan을 확인하고, 퍼셉션 입력 토픽과 TF, LiDAR 장착 위치 설정을 읽고 변경 전후를 비교합니다.',
+ tools: 'NAV2-Perception, 코드 편집, 파라미터 설정, 시각화 Sensor',
+ },
+ {
+ code: '03',
+ title: 'Simulation 환경 구축 Part 1',
+ guidance: '기본 월드와 실내 환경을 비교한 뒤 커스텀 맵에서 벽과 장애물을 배치합니다. Gazebo 레이저가 환경을 어떻게 인식하는지 함께 확인합니다.',
+ tools: '맵 설정, 커스텀 맵 편집, NAV2-Gazebo, 시각화',
+ },
+ {
+ code: '04',
+ title: 'Simulation 환경 구축 Part 2',
+ guidance: '동일한 맵에서 TurtleBot3와 MK-mini를 번갈아 실행해 차동구동과 애커만 조향 차량의 차이를 비교합니다.',
+ tools: 'NAV2-TurtleBot3Burger, NAV2-MKMini, Gazebo, 시각화',
+ },
+ ],
+ },
+ {
+ title: 'DAY 2-3. SLAM & Localization',
+ lessons: [
+ {
+ code: '05',
+ title: 'LiDAR SLAM',
+ guidance: '차량 또는 Gazebo, Perception, SLAM, 시각화 순서로 실행합니다. /scan, /odom, /map, /tf 관계를 확인하고 결과 맵을 저장합니다.',
+ tools: 'NAV2-SLAM, NAV2-Perception, 맵 저장, 시각화, ROS 연결상태',
+ },
+ {
+ code: '06',
+ title: 'LiDAR Localization',
+ guidance: '저장한 맵을 선택하고 Map, Localization, Planning, Control 조합으로 전환합니다. Pose 지정과 Goal 주행으로 map 기준 위치 보정을 확인합니다.',
+ tools: 'NAV2-Map, NAV2-Localization, NAV2-Planning, NAV2-Control, 시각화 Pose/Goal',
+ },
+ {
+ code: '07',
+ title: 'Parameter 튜닝',
+ guidance: '모듈 파라미터와 설정 파일을 바꾸고 로그, 토픽, 주행 경로의 변화를 비교합니다. 수정 근거와 결과를 Git 변경사항으로 남기게 합니다.',
+ tools: '파라미터 설정, 코드 편집, 로그, 소스 제어',
+ },
+ ],
+ },
+ {
+ title: 'DAY 4-5. Planning & Control',
+ lessons: [
+ {
+ code: '09',
+ title: 'Occupancy Map',
+ guidance: 'SLAM으로 생성된 2D 점유맵을 Nav2 장애물 인식 기준으로 설명합니다. 커스텀 월드, LiDAR 스캔, 저장된 맵을 비교해 맵 품질이 주행에 미치는 영향을 확인합니다.',
+ tools: '커스텀 맵, NAV2-SLAM, NAV2-Map, 시각화',
+ },
+ {
+ code: '10',
+ title: 'Planning',
+ guidance: 'Goal을 지정하고 NAV2-Planning 경로를 확인합니다. TurtleBot3와 MK-mini의 경로 차이, 장애물 회피, 전진 중심과 후진 허용 설정을 비교합니다.',
+ tools: 'NAV2-Planning, 시각화 Path/Goal, ROS 연결상태',
+ },
+ {
+ code: '11',
+ title: 'Control',
+ guidance: 'NAV2-Control의 /cmd_vel 흐름과 차량 조향을 연결해 설명합니다. 시뮬레이션으로 검증한 뒤 원격 플랫폼 제어권을 배정해 저속 실차 검증을 진행합니다.',
+ tools: 'NAV2-Control, NAV2-MKMini, 원격 플랫폼 워크스페이스, 시각화',
+ },
+ ],
+ },
+];
+
+type InstructorLearningGuideModalProps = {
+ open: boolean;
+ onClose: () => void;
+};
+
+export function InstructorLearningGuideModal({ open, onClose }: InstructorLearningGuideModalProps) {
+ useEffect(() => {
+ if (!open) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose, open]);
+
+ if (!open) return null;
+
+ return createPortal(
+
{
+ if (event.currentTarget === event.target) onClose();
+ }}
+ >
+
+
+
+
+
+
+
+
LMS 학습 운영 가이드
+
ROS2, 시뮬레이션, SLAM, Planning 및 Control 실습 운영 기준
+
+
+
+
+
+
+
+
+
+ 강사 운영 흐름
+
+ 1. 수업 전 구성 커리큘럼, 학습단계, 사용 모듈, 기본 파라미터를 구성하고 그룹에 플랫폼을 배정합니다.
+ 2. 수업 중 검증 모듈 실행, ROS 연결상태, 코드 수정, 시각화 순서로 원인을 확인합니다.
+ 3. 수업 후 기록 저장 맵, 파라미터, Git 변경사항, 주행 결과를 제출 기준으로 남깁니다.
+
+
+
+
+ {GUIDE_DAYS.map((day) => (
+
+ {day.title}
+
+ {day.lessons.map((lesson, index) => (
+
+
+ {lesson.code}
+
{lesson.title}
+
+ {lesson.guidance}
+ LMS 사용 기능 {lesson.tools}
+
+ ))}
+
+
+ ))}
+
+
+
+ 팀 프로젝트 운영
+ 3~4인 그룹마다 문제 상황과 맵을 정하고, 강사는 그룹별 원격 플랫폼 사용 시간을 배정합니다. 학생은 커스텀 맵에서 먼저 SLAM, Localization, Planning, Control을 검증한 뒤 원격 MK-mini에서 저속 검증합니다. 질의응답 강사 탭에는 주행 실패 원인, 파라미터 변경 근거, 토픽 상태 캡처를 남기게 합니다.
+
+
+
+ 현재 과정에서 제외할 항목
+
+ 카메라 기반 SLAM과 딥러닝 객체 인식은 현재 정식 LMS 모듈 흐름으로 구성되어 있지 않습니다.
+ 저장된 3D SLAM 결과를 로컬 Gazebo의 물리 충돌 월드로 재현하는 실습은 지원하지 않습니다.
+
+
+
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/src/layouts/InstructorLayout.tsx b/src/layouts/InstructorLayout.tsx
index c4bea5c..b74a1c8 100644
--- a/src/layouts/InstructorLayout.tsx
+++ b/src/layouts/InstructorLayout.tsx
@@ -23,6 +23,7 @@ import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import { ConfirmDialog } from '../components/ConfirmDialog';
import { InstructorChatPanel } from '../components/InstructorChatPanel';
+import { InstructorLearningGuideModal } from '../components/InstructorLearningGuideModal';
import { useToast } from '../components/ToastProvider';
import autonomyLogo from '../assets/icon.png';
import { useProfileAvatar } from '../hooks/useProfileAvatar';
@@ -78,6 +79,7 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
const [notificationTab, setNotificationTab] = useState<'unread' | 'read'>('unread');
const [notificationPage, setNotificationPage] = useState(1);
const [chatOpen, setChatOpen] = useState(false);
+ const [guideOpen, setGuideOpen] = useState(false);
const [hasUnreadChat, setHasUnreadChat] = useState(false);
const [notifications, setNotifications] = useState([]);
const profileRef = useRef(null);
@@ -503,6 +505,8 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
onConfirm={() => void handleAvatarDelete()}
/>
+ setGuideOpen(false)} />
+
-
+ setGuideOpen(true)} className="flex h-8 w-full items-center justify-center gap-2 rounded-md bg-white/10 text-[12px] font-bold text-[#a78bfa] hover:bg-white/14" type="button">
가이드 보기
diff --git a/src/lib/platforms.ts b/src/lib/platforms.ts
index b045ede..15215de 100644
--- a/src/lib/platforms.ts
+++ b/src/lib/platforms.ts
@@ -269,6 +269,18 @@ export async function assignInstructorPlatform(
return response.json() as Promise;
}
+export async function syncInstructorPlatformAccess(accessToken: string): Promise<{ ok: boolean; assignmentCount: number; syncedAt: string }> {
+ const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/access-sync`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!response.ok) {
+ throw new Error(await platformErrorMessage(response, '플랫폼 접근 설정을 동기화하지 못했습니다.'));
+ }
+ return response.json() as Promise<{ ok: boolean; assignmentCount: number; syncedAt: string }>;
+}
+
async function platformErrorMessage(response: Response, fallback: string) {
const payload = await response.json().catch((): null => null);
return platformErrorMessageFromPayload(response, payload, fallback);
diff --git a/src/pages/platforms/PlatformsPage.tsx b/src/pages/platforms/PlatformsPage.tsx
index 8259ede..9c420e9 100644
--- a/src/pages/platforms/PlatformsPage.tsx
+++ b/src/pages/platforms/PlatformsPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
-import { Activity, Battery, Car, CircleAlert, KeyRound, Loader2, Plus, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react';
+import { Activity, Battery, Car, CircleAlert, KeyRound, Loader2, Plus, RefreshCw, Search, Settings, ShieldAlert, Timer, Trash2, Users, X, type LucideIcon } from 'lucide-react';
import { ConfirmDialog } from '../../components/ConfirmDialog';
import { PageHeader } from '../../components/PageHeader';
import { PaginationBar } from '../../components/PaginationBar';
@@ -12,6 +12,7 @@ import {
listInstructorPlatformAgents,
listInstructorPlatforms,
issueInstructorPlatformRegistration,
+ syncInstructorPlatformAccess,
PlatformInUseError,
updateInstructorPlatform,
type InstructorPlatform,
@@ -95,6 +96,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
const [assignmentGroupsError, setAssignmentGroupsError] = useState(null);
const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState('');
const [assigningPlatformId, setAssigningPlatformId] = useState(null);
+ const [syncingAccess, setSyncingAccess] = useState(false);
const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup | null; session: PlatformInUseError['activeSession'] } | null>(null);
const filteredPlatforms = useMemo(() => {
@@ -262,6 +264,22 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
}
};
+ const handleSyncPlatformAccess = async () => {
+ setSyncingAccess(true);
+ setError(null);
+ try {
+ const result = await syncInstructorPlatformAccess(session.accessToken);
+ showToast({
+ title: '플랫폼 접근 설정을 동기화했습니다.',
+ message: `현재 배정 기준 ${result.assignmentCount}개 플랫폼을 반영했습니다.`,
+ });
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : '플랫폼 접근 설정을 동기화하지 못했습니다.');
+ } finally {
+ setSyncingAccess(false);
+ }
+ };
+
const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => {
setDeletingPlatformId(platform.id);
setError(null);
@@ -378,6 +396,15 @@ export function PlatformsPage({ session }: { session: AuthSession }) {
route="platforms"
action={
+
+ {syncingAccess ? : }
+ 접근 동기화
+