diff --git a/index.html b/index.html index 2c77dfe..e1e63fb 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + 오토노미스튜디오 LMS | 강사로그인 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..ba648d5 Binary files /dev/null and b/public/favicon.ico differ diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index f87e817..c1b9dda 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -28,7 +28,19 @@ export type CurriculumWeek = { requiresSourceSubmission: boolean; }; modules?: CurriculumModule[]; + environmentProfiles?: CurriculumEnvironmentProfile[]; visualizationPlatforms?: CurriculumVisualizationPlatform[]; + mapKeys?: string[]; +}; + +export type CurriculumMap = { + key: string; + platformKey: 'NAV2'; + name: string; + description: string; + worldFile: string; + viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; + custom?: boolean; }; export type CurriculumModule = { @@ -39,9 +51,18 @@ export type CurriculumModule = { folderName: string; templateRepositoryUrl?: string | null; dockerImage?: string | null; + defaultParameters?: string[]; enabled?: boolean; }; +export type CurriculumEnvironmentProfile = { + id: string; + platformKey: string; + profileType: string; + name: string; + config?: Record; +}; + export type CurriculumReference = { id: string; kind?: string; @@ -85,6 +106,7 @@ export type InstructorCurriculumResponse = { } | null; tagOptions?: CurriculumTagOption[]; visualizationPlatforms?: CurriculumVisualizationPlatform[]; + mapOptions?: CurriculumMap[]; }; const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, ''); @@ -139,7 +161,21 @@ export async function updateInstructorCurriculum( description?: string; }>; disabledModuleIds?: string[]; + moduleDefaultParameters?: Array<{ + moduleId: string; + parameters: string[]; + }>; + environmentModuleSets?: Array<{ + platformKey: string; + moduleIds: string[]; + modules?: Array<{ + id: string; + defaultParameters: string[]; + dockerImage?: string | null; + }>; + }>; visualizationPlatformKeys?: string[]; + mapKeys?: string[]; }, ): Promise { const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, { @@ -163,6 +199,27 @@ export async function updateInstructorCurriculum( return response.json() as Promise; } +export async function createInstructorCurriculumMap( + accessToken: string, + input: { courseId?: string | null; name: string; description?: string }, +): Promise { + const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/maps`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + const payload = await response.json().catch((): null => null); + throw new Error(payload && typeof payload === 'object' && 'message' in payload + ? String(payload.message) + : `학습 맵을 등록하지 못했습니다. (${response.status})`); + } + return response.json() as Promise; +} + export async function deleteInstructorCurriculumWeek( accessToken: string, week: number, diff --git a/src/lib/platforms.ts b/src/lib/platforms.ts index 88762e6..b045ede 100644 --- a/src/lib/platforms.ts +++ b/src/lib/platforms.ts @@ -99,6 +99,11 @@ export type InstructorPlatformsResponse = { agents: TeachingAgent[]; }; +export type InstructorPlatformRegistrationResponse = { + code: string; + expiresAt: string | null; +}; + const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://workspace.autonomystudio.net/lms-api/api').replace(/\/+$/, ''); export async function listInstructorPlatforms(accessToken: string): Promise { @@ -158,6 +163,21 @@ export async function createInstructorPlatform( return response.json() as Promise; } +export async function issueInstructorPlatformRegistration( + accessToken: string, +): Promise { + const response = await fetch(`${LMS_API_BASE_URL}/instructor/platform-registration-code`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + }); + + if (!response.ok) throw new Error(await platformErrorMessage(response, '등록 키를 발급하지 못했습니다.')); + return response.json() as Promise; +} + export async function updateInstructorPlatform( accessToken: string, platformId: string, diff --git a/src/pages/curriculum/CurriculumPage.tsx b/src/pages/curriculum/CurriculumPage.tsx index 9082e28..a8644b9 100644 --- a/src/pages/curriculum/CurriculumPage.tsx +++ b/src/pages/curriculum/CurriculumPage.tsx @@ -1,14 +1,77 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Plus, RadioTower, Save, Target, Trash2, Upload, type LucideIcon } from 'lucide-react'; +import { CheckCircle2, CirclePlay, Clock3, FileCode2, FileText, Loader2, Map, Plus, RadioTower, Save, Target, Trash2, Upload, type LucideIcon } from 'lucide-react'; import { ConfirmDialog } from '../../components/ConfirmDialog'; import { PageHeader } from '../../components/PageHeader'; import { useToast } from '../../components/ToastProvider'; -import { deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; +import { createInstructorCurriculumMap, deleteCurriculumReference, deleteInstructorCurriculumWeek, getInstructorCurriculum, updateInstructorCurriculum, uploadCurriculumExampleCode, uploadCurriculumReference, type CurriculumEnvironmentProfile, type CurriculumWeek, type InstructorCurriculumResponse } from '../../lib/curriculum'; import { listInstructorGroups, type InstructorGroup } from '../../lib/groups'; import type { AuthSession } from '../../types/auth'; const CURRICULUM_SELECTED_GROUP_STORAGE_PREFIX = 'lms-admin-curriculum-selected-group-id'; +type ModuleParameterDraft = { + key: string; + value: string; +}; + +function parseModuleParameter(parameter: string): ModuleParameterDraft { + const separatorIndex = parameter.indexOf(':='); + if (separatorIndex < 0) return { key: parameter.trim(), value: '' }; + return { + key: parameter.slice(0, separatorIndex).trim(), + value: parameter.slice(separatorIndex + 2).trim(), + }; +} + +function readEnvironmentModuleIds(profile: CurriculumEnvironmentProfile, modules: NonNullable): string[] { + const config = profile.config; + const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet) + ? config.moduleSet as Record + : null; + if (!moduleSet || moduleSet.includeUnlisted !== false || !Array.isArray(moduleSet.modules)) { + return modules.map((module) => module.id); + } + return moduleSet.modules.flatMap((item) => ( + item && typeof item === 'object' && typeof (item as Record).id === 'string' + ? [(item as Record).id as string] + : [] + )); +} + +function readEnvironmentModuleParameters(profile: CurriculumEnvironmentProfile): Record { + const config = profile.config; + const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet) + ? config.moduleSet as Record + : null; + if (!moduleSet || !Array.isArray(moduleSet.modules)) return {}; + + return Object.fromEntries(moduleSet.modules.flatMap((item) => { + if (!item || typeof item !== 'object') return []; + const module = item as Record; + if (typeof module.id !== 'string') return []; + const parameters = Array.isArray(module.defaultParameters) + ? module.defaultParameters.filter((value): value is string => typeof value === 'string').map(parseModuleParameter) + : []; + return [[module.id, parameters]]; + })); +} + +function readEnvironmentModuleDockerImages(profile: CurriculumEnvironmentProfile): Record { + const config = profile.config; + const moduleSet = config && typeof config.moduleSet === 'object' && config.moduleSet !== null && !Array.isArray(config.moduleSet) + ? config.moduleSet as Record + : null; + if (!moduleSet || !Array.isArray(moduleSet.modules)) return {}; + + return Object.fromEntries(moduleSet.modules.flatMap((item) => { + if (!item || typeof item !== 'object') return []; + const module = item as Record; + return typeof module.id === 'string' && typeof module.dockerImage === 'string' + ? [[module.id, module.dockerImage]] + : []; + })); +} + export function CurriculumPage({ session, selectedCourseId }: { session: AuthSession; selectedCourseId: string }) { const { showToast } = useToast(); const [weeks, setWeeks] = useState([]); @@ -31,9 +94,19 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const [draftTopicConcepts, setDraftTopicConcepts] = useState([]); const [draftPracticeContents, setDraftPracticeContents] = useState>([]); const [moduleEnabledById, setModuleEnabledById] = useState>({}); + const [moduleDefaultParametersById, setModuleDefaultParametersById] = useState>({}); + const [environmentModuleIdsByPlatformKey, setEnvironmentModuleIdsByPlatformKey] = useState>({}); + const [environmentModuleParametersByPlatformKey, setEnvironmentModuleParametersByPlatformKey] = useState>>({}); + const [environmentModuleDockerImagesByPlatformKey, setEnvironmentModuleDockerImagesByPlatformKey] = useState>>({}); const [tagOptions, setTagOptions] = useState>([]); const [visualizationPlatforms, setVisualizationPlatforms] = useState>([]); const [visualizationEnabledByKey, setVisualizationEnabledByKey] = useState>({}); + const [mapOptions, setMapOptions] = useState>([]); + const [mapEnabledByKey, setMapEnabledByKey] = useState>({}); + const [mapCreateOpen, setMapCreateOpen] = useState(false); + const [mapName, setMapName] = useState(''); + const [mapDescription, setMapDescription] = useState(''); + const [creatingMap, setCreatingMap] = useState(false); const [referenceTitle, setReferenceTitle] = useState(''); const [referenceFile, setReferenceFile] = useState(null); const [exampleCodeTitle, setExampleCodeTitle] = useState(''); @@ -90,6 +163,7 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes setWeeks(payload.weeks); setTagOptions(payload.tagOptions ?? []); setVisualizationPlatforms(payload.visualizationPlatforms ?? []); + setMapOptions(payload.mapOptions ?? []); const progress = resolveProgressSelection(payload.weeks, payload.setting); setCurrentProgressWeek(progress.week); setCurrentProgressLearningStep(progress.learningStep); @@ -164,7 +238,21 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes setDraftTopicConcepts(selectedWeek.topics.map((_, index) => selectedWeek.detail.learningSteps?.[index]?.concepts?.join('\n') ?? '')); setDraftPracticeContents(selectedWeek.detail.practiceContents?.length ? selectedWeek.detail.practiceContents : []); setModuleEnabledById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, module.enabled !== false]))); + setModuleDefaultParametersById(Object.fromEntries((selectedWeek.modules ?? []).map((module) => [module.id, (module.defaultParameters ?? []).map(parseModuleParameter)]))); + setEnvironmentModuleIdsByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [ + profile.platformKey, + readEnvironmentModuleIds(profile, selectedWeek.modules ?? []), + ]))); + setEnvironmentModuleParametersByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [ + profile.platformKey, + readEnvironmentModuleParameters(profile), + ]))); + setEnvironmentModuleDockerImagesByPlatformKey(Object.fromEntries((selectedWeek.environmentProfiles ?? []).map((profile) => [ + profile.platformKey, + readEnvironmentModuleDockerImages(profile), + ]))); setVisualizationEnabledByKey(Object.fromEntries((selectedWeek.visualizationPlatforms ?? []).map((platform) => [platform.key, true]))); + setMapEnabledByKey(Object.fromEntries((selectedWeek.mapKeys ?? []).map((key) => [key, true]))); setReferenceTitle(''); setReferenceFile(null); setExampleCodeTitle(''); @@ -330,6 +418,23 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes const disabledModuleIds = (selectedWeek.modules ?? []) .filter((module) => moduleEnabledById[module.id] === false) .map((module) => module.id); + const moduleDefaultParameters = (selectedWeek.modules ?? []).map((module) => ({ + moduleId: module.id, + parameters: (moduleDefaultParametersById[module.id] ?? []) + .filter((parameter) => parameter.key.trim()) + .map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`), + })); + const environmentModuleSets = (selectedWeek.environmentProfiles ?? []).map((profile) => ({ + platformKey: profile.platformKey, + moduleIds: environmentModuleIdsByPlatformKey[profile.platformKey] ?? [], + modules: (environmentModuleIdsByPlatformKey[profile.platformKey] ?? []).map((id) => ({ + id, + defaultParameters: (environmentModuleParametersByPlatformKey[profile.platformKey]?.[id] ?? []) + .filter((parameter) => parameter.key.trim()) + .map((parameter) => `${parameter.key.trim()}:=${parameter.value.trim()}`), + dockerImage: environmentModuleDockerImagesByPlatformKey[profile.platformKey]?.[id]?.trim() || undefined, + })), + })); const payload = await updateInstructorCurriculum(session.accessToken, { courseId: selectedCourseId || null, groupId: selectedGroupId || null, @@ -337,9 +442,12 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes activeLearningStep, applyToStudents: false, disabledModuleIds, + moduleDefaultParameters, + environmentModuleSets, visualizationPlatformKeys: visualizationPlatforms .filter((platform) => visualizationEnabledByKey[platform.key]) .map((platform) => platform.key), + mapKeys: mapOptions.filter((map) => mapEnabledByKey[map.key]).map((map) => map.key), }); applyPayload(payload); setActiveWeek(selectedWeek.week); @@ -354,6 +462,29 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes } }; + const handleCreateMap = async () => { + const name = mapName.trim(); + if (!name) return; + setCreatingMap(true); + try { + const map = await createInstructorCurriculumMap(session.accessToken, { + courseId: selectedCourseId || null, + name, + description: mapDescription.trim(), + }); + setMapOptions((current) => [...current, map]); + setMapEnabledByKey((current) => ({ ...current, [map.key]: true })); + setMapCreateOpen(false); + setMapName(''); + setMapDescription(''); + showToast({ title: '학습 맵이 추가되었습니다.', message: '현재 주차에 선택된 상태입니다.' }); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '학습 맵을 등록하지 못했습니다.'); + } finally { + setCreatingMap(false); + } + }; + const handleAddWeek = async () => { const nextWeek = Math.max(0, ...weeks.map((week) => week.week)) + 1; setSaving(true); @@ -767,34 +898,236 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes {selectedWeek?.modules?.length ? selectedWeek.modules.map((module) => { const enabled = moduleEnabledById[module.id] !== false; return ( - +
+ { + const checked = event.target.checked; + setModuleEnabledById((current) => ({ ...current, [module.id]: checked })); + }} + /> + {module.sortOrder} + + {module.name} + {module.category} · {module.folderName} + + + {enabled ? '사용' : '미사용'} + +
+
+
+ 기본 파라미터 + +
+
+ {(moduleDefaultParametersById[module.id] ?? []).map((parameter, parameterIndex) => ( +
+ setModuleDefaultParametersById((current) => ({ + ...current, + [module.id]: (current[module.id] ?? []).map((item, index) => index === parameterIndex ? { ...item, key: event.target.value } : item), + }))} + /> + setModuleDefaultParametersById((current) => ({ + ...current, + [module.id]: (current[module.id] ?? []).map((item, index) => index === parameterIndex ? { ...item, value: event.target.value } : item), + }))} + /> + +
+ ))} +
+
+ ); }) : (

등록된 모듈이 없습니다.

)} + {(selectedWeek?.environmentProfiles?.length ?? 0) > 0 ? ( +
+
+

환경별 모듈 구성

+

소스 저장소는 공통으로 유지하고, 로컬과 플랫폼에서 실행할 모듈만 다르게 선택합니다.

+
+
+ {(selectedWeek.environmentProfiles ?? []).map((profile) => { + const selectedModuleIds = new Set(environmentModuleIdsByPlatformKey[profile.platformKey] ?? []); + const selectedCount = (selectedWeek.modules ?? []).filter((module) => selectedModuleIds.has(module.id)).length; + const targetLabel = profile.platformKey === 'futuredrive-local-pc' ? '로컬' : '플랫폼'; + return ( +
+
+
+ {profile.name} + {targetLabel} · {profile.profileType} +
+ {selectedCount}/{selectedWeek.modules?.length ?? 0} +
+
+ {(selectedWeek.modules ?? []).map((module) => { + const globallyEnabled = moduleEnabledById[module.id] !== false; + const checked = selectedModuleIds.has(module.id); + const parameterRows = environmentModuleParametersByPlatformKey[profile.platformKey]?.[module.id] ?? []; + return ( +
+ + {checked ? ( +
+ +
+ 실행 파라미터 + +
+ {parameterRows.map((parameter, parameterIndex) => ( +
+ setEnvironmentModuleParametersByPlatformKey((current) => { + const rows = [...(current[profile.platformKey]?.[module.id] ?? [])]; + rows[parameterIndex] = { ...rows[parameterIndex], key: event.target.value }; + return { ...current, [profile.platformKey]: { ...(current[profile.platformKey] ?? {}), [module.id]: rows } }; + })} + /> + setEnvironmentModuleParametersByPlatformKey((current) => { + const rows = [...(current[profile.platformKey]?.[module.id] ?? [])]; + rows[parameterIndex] = { ...rows[parameterIndex], value: event.target.value }; + return { ...current, [profile.platformKey]: { ...(current[profile.platformKey] ?? {}), [module.id]: rows } }; + })} + /> + +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ ); + })} +
+
+ ) : null}
@@ -838,6 +1171,43 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes )}
+
+
+
+

학습 맵

+

학생이 맵 선택 화면에서 사용할 NAV2 학습 환경을 선택하세요.

+
+ +
+
+ {mapOptions.map((map) => { + const enabled = Boolean(mapEnabledByKey[map.key]); + return ( + + ); + })} +
+
) : activeTab === 'examples' ? (
@@ -1218,6 +1588,34 @@ export function CurriculumPage({ session, selectedCourseId }: { session: AuthSes
+ {mapCreateOpen ? ( +
+
+
+

학습 맵 추가

+

등록 후 현재 주차에 선택된 상태로 추가됩니다.

+
+
+ + +
+
+ + +
+
+
+ ) : null} + (null); const [selectedMasterId, setSelectedMasterId] = useState(''); const [selectedAgentId, setSelectedAgentId] = useState(''); const [platformName, setPlatformName] = useState(''); const [agentsLoading, setAgentsLoading] = useState(false); const [agentsError, setAgentsError] = useState(null); + const [registrationCode, setRegistrationCode] = useState(null); + const [registrationExpiresAt, setRegistrationExpiresAt] = useState(null); const [settingsName, setSettingsName] = useState(''); const [settingsVehicleType, setSettingsVehicleType] = useState('MK-mini'); const [settingsWheelbaseMeters, setSettingsWheelbaseMeters] = useState(0.325); @@ -90,7 +94,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) { const [assignmentGroupsError, setAssignmentGroupsError] = useState(null); const [selectedAssignmentGroupId, setSelectedAssignmentGroupId] = useState(''); const [assigningPlatformId, setAssigningPlatformId] = useState(null); - const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup; session: PlatformInUseError['activeSession'] } | null>(null); + const [forceAssignmentTarget, setForceAssignmentTarget] = useState<{ platform: InstructorPlatform; group: InstructorGroup | null; session: PlatformInUseError['activeSession'] } | null>(null); const filteredPlatforms = useMemo(() => { const keyword = query.trim().toLowerCase(); @@ -106,7 +110,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) { const keys = new Set(); platforms.forEach((platform) => { if (!platform.agent) return; - [platform.agent.nodeId, platform.agent.hostname, platform.agent.ip, platform.agent.ownerEmail] + [platform.agent.nodeId, platform.agent.hostname, platform.agent.ip] .map(normalizeAgentKey) .filter(Boolean) .forEach((key) => keys.add(key)); @@ -121,6 +125,7 @@ export function PlatformsPage({ session }: { session: AuthSession }) { { label: '오류 발생', value: `${summary.errorPlatforms}대`, icon: CircleAlert, tone: 'bg-[#fff1f2] text-[#e11d48]' }, ]; const selectedAssignmentGroup = assignmentGroups.find((group) => group.id === selectedAssignmentGroupId) ?? null; + const assignmentUnassignedSelected = selectedAssignmentGroupId === 'unassigned'; const applyPayload = (payload: InstructorPlatformsResponse) => { setError(null); @@ -159,13 +164,13 @@ export function PlatformsPage({ session }: { session: AuthSession }) { }, [filteredPlatforms.length, page, pageSize]); useEffect(() => { - if (!modalOpen && !settingsTarget && !deleteTarget && !assignmentTarget) return; + if (!modalOpen && !registrationModalOpen && !settingsTarget && !deleteTarget && !assignmentTarget) return; const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalOverflow; }; - }, [assignmentTarget, modalOpen, settingsTarget, deleteTarget]); + }, [assignmentTarget, deleteTarget, modalOpen, registrationModalOpen, settingsTarget]); useEffect(() => { if (!modalOpen) return; @@ -238,6 +243,20 @@ export function PlatformsPage({ session }: { session: AuthSession }) { } }; + const handleIssueRegistrationCode = async () => { + setSaving(true); + setError(null); + try { + const payload = await issueInstructorPlatformRegistration(session.accessToken); + setRegistrationCode(payload.code); + setRegistrationExpiresAt(payload.expiresAt); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '등록 키를 발급하지 못했습니다.'); + } finally { + setSaving(false); + } + }; + const handleDeletePlatform = async (platform: InstructorPlatform, force = false) => { setDeletingPlatformId(platform.id); setError(null); @@ -283,20 +302,20 @@ export function PlatformsPage({ session }: { session: AuthSession }) { const handleAssignPlatform = async ( platform: InstructorPlatform, - group: InstructorGroup, + group: InstructorGroup | null, force = false, ) => { setAssigningPlatformId(platform.id); setError(null); try { - const payload = await assignInstructorPlatform(session.accessToken, platform.id, group.id, { force }); + const payload = await assignInstructorPlatform(session.accessToken, platform.id, group?.id ?? 'unassigned', { force }); applyPayload(payload); setAssignmentTarget(null); setForceAssignmentTarget(null); setSelectedAssignmentGroupId(''); showToast({ - title: '플랫폼이 배정되었습니다.', - message: `${platform.name} · ${group.name}`, + title: group ? '플랫폼이 배정되었습니다.' : '플랫폼 배정이 해제되었습니다.', + message: group ? `${platform.name} · ${group.name}` : platform.name, }); } catch (reason) { if (reason instanceof PlatformInUseError) { @@ -353,10 +372,24 @@ export function PlatformsPage({ session }: { session: AuthSession }) { -