platform/lms-adm-fe release promotion snapshot

This commit is contained in:
2026-08-12 05:51:05 +00:00
parent 8e15589413
commit 7bf76c4265
7 changed files with 88 additions and 11 deletions
+59 -1
View File
@@ -3,7 +3,7 @@ import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ToastProvider } from './components/ToastProvider'; import { ToastProvider } from './components/ToastProvider';
import { InstructorLayout } from './layouts/InstructorLayout'; import { InstructorLayout } from './layouts/InstructorLayout';
import { clearStoredSession, loadStoredSession, logoutInstructor, refreshCurrentUser, refreshInstructorSession } from './lib/auth'; import { clearStoredSession, loadStoredSession, logoutInstructor, refreshCurrentUser, refreshInstructorSession } from './lib/auth';
import { listInstructorCourses, type InstructorCourse } from './lib/courses'; import { createInitialInstructorCourse, listInstructorCourses, type InstructorCourse } from './lib/courses';
import { AnalyticsPage } from './pages/analytics/AnalyticsPage'; import { AnalyticsPage } from './pages/analytics/AnalyticsPage';
import { CurriculumPage } from './pages/curriculum/CurriculumPage'; import { CurriculumPage } from './pages/curriculum/CurriculumPage';
import { DashboardPage } from './pages/dashboard/DashboardPage'; import { DashboardPage } from './pages/dashboard/DashboardPage';
@@ -22,6 +22,10 @@ function App() {
const [courses, setCourses] = useState<InstructorCourse[]>([]); const [courses, setCourses] = useState<InstructorCourse[]>([]);
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? ''); const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem('lms-admin-selected-course-id') ?? '');
const [sessionExpired, setSessionExpired] = useState(false); const [sessionExpired, setSessionExpired] = useState(false);
const [initialCourseOpen, setInitialCourseOpen] = useState(false);
const [initialCourseTitle, setInitialCourseTitle] = useState('');
const [initialCourseError, setInitialCourseError] = useState<string | null>(null);
const [creatingInitialCourse, setCreatingInitialCourse] = useState(false);
const [chatThreadOpenRequest, setChatThreadOpenRequest] = useState<{ threadId: string; requestId: number } | null>(null); const [chatThreadOpenRequest, setChatThreadOpenRequest] = useState<{ threadId: string; requestId: number } | null>(null);
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -96,6 +100,7 @@ function App() {
.then((payload) => { .then((payload) => {
if (cancelled) return; if (cancelled) return;
setCourses(payload.courses); setCourses(payload.courses);
setInitialCourseOpen(payload.courses.length === 0);
setSelectedCourseId((current) => { setSelectedCourseId((current) => {
const next = payload.courses.some((course) => course.id === current) const next = payload.courses.some((course) => course.id === current)
? current ? current
@@ -117,6 +122,28 @@ function App() {
if (courseId) window.localStorage.setItem('lms-admin-selected-course-id', courseId); if (courseId) window.localStorage.setItem('lms-admin-selected-course-id', courseId);
}; };
const handleCreateInitialCourse = async () => {
const title = initialCourseTitle.trim();
if (!title) {
setInitialCourseError('강좌명을 입력해 주세요.');
return;
}
setCreatingInitialCourse(true);
setInitialCourseError(null);
try {
const { course } = await createInitialInstructorCourse(session!.accessToken, title);
setCourses([course]);
setSelectedCourseId(course.id);
window.localStorage.setItem('lms-admin-selected-course-id', course.id);
setInitialCourseOpen(false);
} catch (error) {
setInitialCourseError(error instanceof Error ? error.message : '강좌를 생성하지 못했습니다.');
} finally {
setCreatingInitialCourse(false);
}
};
useEffect(() => { useEffect(() => {
if (!session?.accessToken || !session.refreshToken) return; if (!session?.accessToken || !session.refreshToken) return;
@@ -203,6 +230,37 @@ function App() {
/> />
</div> </div>
</InstructorLayout> </InstructorLayout>
{initialCourseOpen && (
<div className="fixed inset-0 z-[100] grid place-items-center bg-[#111827]/35 px-5" role="dialog" aria-modal="true" aria-labelledby="initial-course-title">
<form
className="w-full max-w-[400px] rounded-lg border border-[#d9e0ea] bg-white p-6 shadow-[0_22px_70px_rgba(15,23,42,0.22)]"
onSubmit={(event) => {
event.preventDefault();
void handleCreateInitialCourse();
}}
>
<h1 id="initial-course-title" className="text-[18px] font-black text-[#111827]">강좌명을 입력하세요</h1>
<p className="mt-2 text-[13px] leading-relaxed text-[#64748b]">NAV2 기반 자율주행 교육 과정 구성을 새 강좌로 복제합니다.</p>
<input
autoFocus
value={initialCourseTitle}
maxLength={120}
onChange={(event) => setInitialCourseTitle(event.target.value)}
className="mt-5 h-10 w-full rounded-md border border-[#cbd5e1] px-3 text-[14px] font-semibold text-[#111827] outline-none focus:border-[#2563eb]"
placeholder="예: 2026 하반기 자율주행 교육"
disabled={creatingInitialCourse}
/>
{initialCourseError && <p className="mt-2 text-[12px] font-semibold text-[#dc2626]">{initialCourseError}</p>}
<button
type="submit"
disabled={creatingInitialCourse}
className="mt-5 h-10 w-full rounded-md bg-[#2563eb] text-[14px] font-black text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-60"
>
{creatingInitialCourse ? '생성 중...' : '확인'}
</button>
</form>
</div>
)}
</ToastProvider> </ToastProvider>
); );
} }
+5 -4
View File
@@ -15,6 +15,7 @@ import type { AuthSession } from '../types/auth';
type InstructorChatPanelProps = { type InstructorChatPanelProps = {
session: AuthSession; session: AuthSession;
courseId: string;
open: boolean; open: boolean;
initialThreadId?: string | null; initialThreadId?: string | null;
initialThreadRequestId?: number; initialThreadRequestId?: number;
@@ -30,7 +31,7 @@ type ChatNotificationPayload = {
createdAt: string; createdAt: string;
}; };
export function InstructorChatPanel({ session, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) { export function InstructorChatPanel({ session, courseId, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) {
const [threads, setThreads] = useState<InstructorChatThread[]>([]); const [threads, setThreads] = useState<InstructorChatThread[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null); const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -45,7 +46,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
if (showLoading) setLoading(true); if (showLoading) setLoading(true);
setError(null); setError(null);
const request = listInstructorChatThreads(session.accessToken) const request = listInstructorChatThreads(session.accessToken, courseId)
.then((items) => { .then((items) => {
if (!cancelled) setThreads(items); if (!cancelled) setThreads(items);
}) })
@@ -91,7 +92,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
return () => { return () => {
refresh.cancel(); refresh.cancel();
}; };
}, [open, session.accessToken]); }, [courseId, open, session.accessToken]);
useEffect(() => { useEffect(() => {
if (!open || !initialThreadId) return; if (!open || !initialThreadId) return;
@@ -124,7 +125,7 @@ export function InstructorChatPanel({ session, open, initialThreadId = null, ini
socket.off('chat.message.created', handleMessageCreated); socket.off('chat.message.created', handleMessageCreated);
socket.disconnect(); socket.disconnect();
}; };
}, [open, selectedThreadId, session.accessToken]); }, [courseId, open, selectedThreadId, session.accessToken]);
useEffect(() => { useEffect(() => {
if (!open) setSelectedThreadId(null); if (!open) setSelectedThreadId(null);
+1
View File
@@ -505,6 +505,7 @@ export function InstructorLayout({ session, route, onNavigate, onLogout, courses
<InstructorChatPanel <InstructorChatPanel
session={session} session={session}
courseId={selectedCourseId}
open={chatOpen} open={chatOpen}
initialThreadId={chatThreadId} initialThreadId={chatThreadId}
initialThreadRequestId={chatThreadRequestId} initialThreadRequestId={chatThreadRequestId}
+2 -1
View File
@@ -49,8 +49,9 @@ type ChatThreadsPayload = {
data?: unknown[]; data?: unknown[];
}; };
export async function listInstructorChatThreads(accessToken: string, week?: number): Promise<InstructorChatThread[]> { export async function listInstructorChatThreads(accessToken: string, courseId: string, week?: number): Promise<InstructorChatThread[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (courseId) params.set('courseId', courseId);
if (week) params.set('week', String(week)); if (week) params.set('week', String(week));
const queryString = params.toString(); const queryString = params.toString();
+17
View File
@@ -24,3 +24,20 @@ export async function listInstructorCourses(accessToken: string): Promise<{ cour
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`); if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<{ courses: InstructorCourse[] }>; return response.json() as Promise<{ courses: InstructorCourse[] }>;
} }
export async function createInitialInstructorCourse(accessToken: string, title: string): Promise<{ course: InstructorCourse }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses/initial`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
});
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string } | null;
throw new Error(payload?.message || `강좌를 생성하지 못했습니다. (${response.status})`);
}
return response.json() as Promise<{ course: InstructorCourse }>;
}
+3 -4
View File
@@ -26,10 +26,6 @@ function isPlatformEnvironment(config: unknown): boolean {
return target === 'remote-jetson' || target === 'platform'; return target === 'remote-jetson' || target === 'platform';
} }
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
}
function parseModuleParameter(parameter: string): ModuleParameterDraft { function parseModuleParameter(parameter: string): ModuleParameterDraft {
const separatorIndex = parameter.indexOf(':='); const separatorIndex = parameter.indexOf(':=');
if (separatorIndex < 0) return { key: parameter.trim(), value: '' }; if (separatorIndex < 0) return { key: parameter.trim(), value: '' };
@@ -1584,3 +1580,6 @@ function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
} }
function environmentStorageKey(profile: Pick<CurriculumEnvironmentProfile, 'config' | 'profileType'>): 'local' | 'platform' {
return profile.profileType.toUpperCase() === 'PLATFORM' || isPlatformEnvironment(profile.config) ? 'platform' : 'local';
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function DashboardPage({ session, selectedCourseId, onNavigate, onOpenCha
Promise.allSettled([ Promise.allSettled([
listInstructorPlatforms(session.accessToken), listInstructorPlatforms(session.accessToken),
listInstructorGroups(session.accessToken, selectedCourseId || null), listInstructorGroups(session.accessToken, selectedCourseId || null),
listInstructorChatThreads(session.accessToken), listInstructorChatThreads(session.accessToken, selectedCourseId),
]) ])
.then(([platformResult, groupResult, chatResult]) => { .then(([platformResult, groupResult, chatResult]) => {
if (cancelled) return; if (cancelled) return;