chore: init lms adm fe

This commit is contained in:
2026-07-07 17:23:40 +09:00
commit d682e9a382
57 changed files with 12065 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
import type { AuthAppRole, AuthSession } from '../types/auth';
const AUTH_BASE_URL = (import.meta.env.VITE_ATNMY_AUTH_URL ?? import.meta.env.VITE_ADS_IDE_AUTH_URL ?? 'http://localhost:4100').replace(/\/+$/, '');
const ADMIN_SESSION_STORAGE_KEY = 'atnmy.lms.admin.auth.session';
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
export const isInstructorSession = (session: AuthSession): boolean => {
const roles = new Set(getSessionRoles(session));
return [...INSTRUCTOR_ROLES].some((role) => roles.has(role));
};
const getSessionRoles = (session: AuthSession): string[] => {
return [
session.user.role,
...(session.user.roles ?? []),
...(session.roles ?? []),
...getAppRoleCodes(session.user.appRoles),
...getAppRoleCodes(session.user.app_roles),
...getAppRoleCodes(session.appRoles),
...getAppRoleCodes(session.app_roles),
]
.filter((role): role is string => Boolean(role))
.map((role) => role.toUpperCase());
};
const getAppRoleCodes = (roles: AuthAppRole[] | undefined): string[] => {
if (!Array.isArray(roles)) return [];
return roles
.filter((role) => {
const appKey = role.appKey ?? role.app_key;
return !appKey || appKey.toUpperCase() === 'LMS';
})
.map((role) => role.roleCode ?? role.role_code ?? role.role ?? role.code)
.filter((role): role is string => Boolean(role));
};
export const loadStoredSession = (): AuthSession | null => {
try {
const raw = window.localStorage.getItem(ADMIN_SESSION_STORAGE_KEY);
if (!raw) return null;
const session = JSON.parse(raw) as AuthSession;
if (!isInstructorSession(session) || isStoredSessionExpired(session)) {
clearStoredSession();
return null;
}
return session;
} catch {
return null;
}
};
export const storeSession = (session: AuthSession): void => {
window.localStorage.setItem(ADMIN_SESSION_STORAGE_KEY, JSON.stringify({
...session,
obtainedAt: session.obtainedAt ?? Date.now(),
}));
};
export const clearStoredSession = (): void => {
window.localStorage.removeItem(ADMIN_SESSION_STORAGE_KEY);
};
export const isStoredSessionExpired = (session: AuthSession): boolean => {
const obtainedAt = session.obtainedAt ?? 0;
if (!obtainedAt) return false;
if (session.refreshToken && session.refreshTokenExpiresIn) {
return Date.now() >= obtainedAt + session.refreshTokenExpiresIn * 1000;
}
return Date.now() >= obtainedAt + (session.accessTokenExpiresIn ?? 0) * 1000;
};
export const loginInstructor = async (email: string, password: string): Promise<AuthSession> => {
const response = await fetch(`${AUTH_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ email, password })
});
if (!response.ok) {
throw new Error(response.status === 429 ? '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요.' : '이메일 또는 비밀번호를 확인해주세요.');
}
const session = {
...(await response.json()) as AuthSession,
obtainedAt: Date.now(),
};
if (!isInstructorSession(session)) {
clearStoredSession();
throw new Error('강사 계정만 로그인할 수 있습니다.');
}
storeSession(session);
return session;
};
export const refreshInstructorSession = async (session: AuthSession): Promise<AuthSession> => {
if (!session.refreshToken) {
throw new Error('리프레시 토큰이 없습니다.');
}
const response = await fetch(`${AUTH_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ refreshToken: session.refreshToken })
});
if (!response.ok) {
throw new Error('세션을 갱신하지 못했습니다.');
}
const nextSession = {
...(await response.json()) as AuthSession,
obtainedAt: Date.now(),
};
if (!isInstructorSession(nextSession)) {
clearStoredSession();
throw new Error('강사 계정만 로그인할 수 있습니다.');
}
storeSession(nextSession);
return nextSession;
};
export const refreshCurrentUser = async (session: AuthSession): Promise<AuthSession> => {
const response = await fetch(`${AUTH_BASE_URL}/auth/me`, {
headers: {
Authorization: `Bearer ${session.accessToken}`
}
});
if (!response.ok) {
throw new Error('사용자 정보를 다시 가져오지 못했습니다.');
}
const payload = (await response.json()) as { user?: AuthSession['user'] };
const nextSession = {
...session,
user: payload.user ?? session.user
};
if (!isInstructorSession(nextSession)) {
clearStoredSession();
throw new Error('강사 계정만 로그인할 수 있습니다.');
}
storeSession(nextSession);
return nextSession;
};
export const logoutInstructor = async (): Promise<void> => {
try {
await fetch(`${AUTH_BASE_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} finally {
clearStoredSession();
}
};
+129
View File
@@ -0,0 +1,129 @@
export type ChatSenderType = 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM' | string;
export type InstructorChatMessage = {
id: string;
senderType: ChatSenderType;
senderName?: string;
senderAvatarUrl?: string | null;
content: string;
createdAt?: string;
};
export type InstructorChatThread = {
id: string;
title: string;
studentName: string;
studentAvatarUrl?: string | null;
groupName?: string;
week?: number | string;
status: string;
lastMessage: string;
lastMessageAt?: string;
messages: InstructorChatMessage[];
};
const CHAT_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export const CHAT_SOCKET_BASE_URL = CHAT_API_BASE_URL.replace(/\/api$/, '');
type ChatThreadsPayload = {
threads?: unknown[];
data?: unknown[];
};
export async function listInstructorChatThreads(accessToken: string, week?: number): Promise<InstructorChatThread[]> {
const params = new URLSearchParams();
if (week) params.set('week', String(week));
const queryString = params.toString();
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads${queryString ? `?${queryString}` : ''}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`강사 채팅 목록을 불러오지 못했습니다. (${response.status})`);
}
const payload = (await response.json()) as ChatThreadsPayload | unknown[];
const items = Array.isArray(payload) ? payload : Array.isArray(payload?.threads) ? payload.threads : Array.isArray(payload?.data) ? payload.data : [];
return items.map(normalizeThread).filter((thread): thread is InstructorChatThread => Boolean(thread));
}
export async function getInstructorChatThread(accessToken: string, threadId: string): Promise<InstructorChatThread> {
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`강사 채팅방을 불러오지 못했습니다. (${response.status})`);
}
const payload = await response.json();
const thread = normalizeThread(payload?.thread ?? payload);
if (!thread) throw new Error('강사 채팅방 응답이 올바르지 않습니다.');
return thread;
}
export async function createInstructorChatMessage(accessToken: string, threadId: string, content: string): Promise<InstructorChatThread> {
const response = await fetch(`${CHAT_API_BASE_URL}/chat/instructor/threads/${threadId}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
});
if (!response.ok) {
throw new Error(`강사 답변을 저장하지 못했습니다. (${response.status})`);
}
const payload = await response.json();
const thread = normalizeThread(payload?.thread ?? payload);
if (!thread) throw new Error('강사 답변 응답이 올바르지 않습니다.');
return thread;
}
function normalizeThread(raw: unknown): InstructorChatThread | null {
if (!raw || typeof raw !== 'object') return null;
const item = raw as Record<string, any>;
const messages = Array.isArray(item.messages) ? item.messages.map(normalizeMessage).filter((message): message is InstructorChatMessage => Boolean(message)) : [];
const lastMessageFromList = messages.length > 0 ? messages[messages.length - 1]?.content : undefined;
const rawLastMessage = item.lastMessage?.content ?? item.lastMessageContent ?? item.lastMessage;
const studentName = item.student?.name ?? item.studentName ?? item.student?.email ?? item.studentEmail ?? item.studentId ?? '수강생';
const title = item.title ?? item.threadTitle ?? studentName;
const weekValue = typeof item.week === 'object' && item.week ? item.week.week : item.week;
return {
id: String(item.id ?? item.threadId ?? title),
title: String(title),
studentName: String(studentName),
studentAvatarUrl: typeof item.studentAvatarUrl === 'string' ? item.studentAvatarUrl : null,
groupName: item.group?.name ?? item.groupName ?? item.metadata?.groupName,
week: weekValue ?? item.weekId ?? item.metadata?.week,
status: String(item.status ?? 'WAITING_INSTRUCTOR'),
lastMessage: String(rawLastMessage ?? lastMessageFromList ?? '저장된 문의 내용을 확인해 주세요.'),
lastMessageAt: item.lastMessageAt ?? item.updatedAt ?? item.createdAt,
messages,
};
}
function normalizeMessage(raw: unknown): InstructorChatMessage | null {
if (!raw || typeof raw !== 'object') return null;
const item = raw as Record<string, any>;
return {
id: String(item.id ?? `${item.senderType ?? 'MESSAGE'}-${item.createdAt ?? item.content ?? 'unknown'}`),
senderType: String(item.senderType ?? 'STUDENT'),
senderName: item.senderName ?? item.sender?.name,
senderAvatarUrl: typeof item.senderAvatarUrl === 'string' ? item.senderAvatarUrl : null,
content: String(item.content ?? ''),
createdAt: item.createdAt,
};
}
+26
View File
@@ -0,0 +1,26 @@
export type InstructorCourse = {
id: string;
slug: string;
title: string;
subtitle?: string | null;
description?: string | null;
organizationName: string;
teacher?: {
id: string;
name?: string | null;
email: string;
};
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorCourses(accessToken: string): Promise<{ courses: InstructorCourse[] }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/courses`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(`강좌 목록을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<{ courses: InstructorCourse[] }>;
}
+275
View File
@@ -0,0 +1,275 @@
export type CurriculumWeek = {
week: number;
title: string;
tags: string[];
progress: number;
state: string;
topics: string[];
duration: string;
github: string;
detail: {
overview: string;
learningSteps?: Array<{
title: string;
description: string;
videoEnabled?: boolean;
videoUrl?: string;
concepts?: string[];
}>;
topicDetails: string[];
topicVideoUrls?: string[];
practiceContents?: Array<{
title: string;
description: string;
}>;
references?: CurriculumReference[];
exampleCodes?: CurriculumReference[];
deliverable: string;
requiresSourceSubmission: boolean;
};
modules?: CurriculumModule[];
visualizationPlatforms?: CurriculumVisualizationPlatform[];
};
export type CurriculumModule = {
id: string;
sortOrder: number;
category: string;
name: string;
folderName: string;
templateRepositoryUrl?: string | null;
dockerImage?: string | null;
enabled?: boolean;
};
export type CurriculumReference = {
id: string;
kind?: string;
title: string;
description?: string | null;
fileName: string;
contentType?: string | null;
fileSizeBytes?: number | string | null;
week?: number | string | null;
weekNumber?: number | string | null;
downloadUrl: string;
createdAt?: string;
};
export type CurriculumVisualizationPlatform = {
key: string;
type: string;
displayName: string;
description?: string | null;
config?: unknown;
};
export type CurriculumTagOption = {
key: string;
label: string;
};
export type InstructorCurriculumResponse = {
course: {
id: string;
title: string;
subtitle?: string | null;
description?: string | null;
organizationName: string;
} | null;
weeks: CurriculumWeek[];
setting: {
groupId?: string | null;
activeWeek: number;
activeLearningStep?: string | null;
} | null;
tagOptions?: CurriculumTagOption[];
visualizationPlatforms?: CurriculumVisualizationPlatform[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function getInstructorCurriculum(accessToken: string, groupId?: string | null, courseId?: string | null): Promise<InstructorCurriculumResponse> {
const params = new URLSearchParams();
if (groupId) params.set('groupId', groupId);
if (courseId) params.set('courseId', courseId);
const query = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum${query}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(`커리큘럼을 불러오지 못했습니다. (${response.status})`);
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function updateInstructorCurriculum(
accessToken: string,
input: {
courseId?: string | null;
groupId?: string | null;
activeWeek: number;
activeLearningStep?: string | null;
applyToStudents?: boolean;
completed?: boolean;
tagLabel?: string;
title?: string;
duration?: string;
overview?: string;
deliverable?: string;
requiresSourceSubmission?: boolean;
topics?: string[];
learningSteps?: Array<{
title?: string;
description?: string;
videoEnabled?: boolean;
videoUrl?: string;
concepts?: string[];
}>;
topicDetails?: string[];
topicVideoUrls?: string[];
practiceContents?: Array<{
title?: string;
description?: string;
}>;
disabledModuleIds?: string[];
visualizationPlatformKeys?: string[];
},
): Promise<InstructorCurriculumResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `커리큘럼을 적용하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function deleteInstructorCurriculumWeek(
accessToken: string,
week: number,
): Promise<InstructorCurriculumResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${week}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `주차를 삭제하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function uploadCurriculumReference(
accessToken: string,
input: {
courseId?: string | null;
week: number;
title?: string;
file: File;
},
): Promise<InstructorCurriculumResponse> {
const formData = new FormData();
formData.set('file', input.file);
formData.set('courseId', input.courseId ?? '');
formData.set('title', input.title?.trim() || input.file.name);
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/references`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: formData,
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `참고자료를 업로드하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function uploadCurriculumExampleCode(
accessToken: string,
input: {
courseId?: string | null;
week: number;
title?: string;
file: File;
},
): Promise<InstructorCurriculumResponse> {
const formData = new FormData();
formData.set('file', input.file);
formData.set('courseId', input.courseId ?? '');
formData.set('title', input.title?.trim() || input.file.name);
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/weeks/${input.week}/example-codes`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: formData,
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `예제 코드를 업로드하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
export async function deleteCurriculumReference(
accessToken: string,
referenceId: string,
courseId?: string | null,
): Promise<InstructorCurriculumResponse> {
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/curriculum/references/${referenceId}${query}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `참고자료를 삭제하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorCurriculumResponse>;
}
+127
View File
@@ -0,0 +1,127 @@
export type GroupStudentOption = {
id: string;
name: string;
email: string;
};
export type InstructorGroup = {
id: string;
name: string;
week: string;
status: string;
members: string[];
membersDetailed?: GroupStudentOption[];
instructorPlatformId?: string | null;
platform: string;
progress: number;
completedMissions: number;
activeUsers: number;
};
export type InstructorGroupsSummary = {
totalGroups: number;
assignedPlatforms: number;
totalStudents: number;
averageProgress: number;
};
export type InstructorGroupsResponse = {
summary: InstructorGroupsSummary;
groups: InstructorGroup[];
unassignedStudents?: GroupStudentOption[];
platformOptions?: GroupPlatformOption[];
};
export type GroupPlatformOption = {
id: string;
name: string;
};
export type SaveInstructorGroupInput = {
courseId?: string | null;
name: string;
instructorPlatformId?: string | null;
platformName?: string | null;
memberIds: string[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorGroups(accessToken: string, courseId?: string | null): Promise<InstructorGroupsResponse> {
const params = new URLSearchParams();
if (courseId) params.set('courseId', courseId);
const query = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups${query}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`그룹 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorGroupsResponse>;
}
export async function createInstructorGroup(
accessToken: string,
input: SaveInstructorGroupInput,
): Promise<InstructorGroupsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 추가하지 못했습니다.'));
return response.json() as Promise<InstructorGroupsResponse>;
}
export async function updateInstructorGroup(
accessToken: string,
groupId: string,
input: SaveInstructorGroupInput,
): Promise<InstructorGroupsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 수정하지 못했습니다.'));
return response.json() as Promise<InstructorGroupsResponse>;
}
export async function deleteInstructorGroup(
accessToken: string,
groupId: string,
courseId?: string | null,
): Promise<InstructorGroupsResponse> {
const params = new URLSearchParams();
if (courseId) params.set('courseId', courseId);
const query = params.toString() ? `?${params.toString()}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/groups/${groupId}${query}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(await groupErrorMessage(response, '그룹을 삭제하지 못했습니다.'));
return response.json() as Promise<InstructorGroupsResponse>;
}
async function groupErrorMessage(response: Response, fallback: string) {
const payload = await response.json().catch((): null => null);
if (payload && typeof payload === 'object' && 'message' in payload) {
return String(payload.message);
}
return `${fallback} (${response.status})`;
}
+262
View File
@@ -0,0 +1,262 @@
export type InstructorPlatform = {
id: string;
masterPlatformId: string;
name: string;
type: string;
status: string;
battery: number;
uptime: string;
speed: string;
errors: number;
group: string | null;
user: string | null;
emergencyStop: boolean;
vehicleType: string;
wheelbaseMeters: number;
maxSpeedMetersPerSecond: number;
maxSteeringAngleDegrees: number;
sensors: PlatformSettingItem[];
drivers: PlatformSettingItem[];
algorithms: PlatformAlgorithms;
controlDurationMinutes: number;
controlWarningMinutes: number;
agent: InstructorPlatformAgentBinding | null;
};
export type InstructorPlatformAgentBinding = {
nodeId: string | null;
hostname: string | null;
ownerEmail: string | null;
ip: string | null;
connected: boolean;
lastSeenAt: string | null;
};
export type PlatformInUseSession = {
id: string;
holderUserId: string;
holderName: string | null;
holderEmail: string | null;
startedAt: string;
endsAt: string;
};
export class PlatformInUseError extends Error {
activeSession: PlatformInUseSession;
constructor(message: string, activeSession: PlatformInUseSession) {
super(message);
this.name = 'PlatformInUseError';
this.activeSession = activeSession;
}
}
export type TeachingAgent = {
id: string;
hostname: string;
name: string;
user: string;
tags: string[];
ip: string;
sshUser?: string | null;
workspaceRoot?: string | null;
connected: boolean;
expired: boolean;
lastSeenAt: string | null;
};
export type PlatformSettingItem = {
title: string;
description: string;
enabled?: boolean;
installed?: boolean;
};
export type PlatformAlgorithms = {
slam: string;
planning: string;
control: string;
};
export type MasterPlatform = {
id: string;
name: string;
description: string;
iconKey: string;
};
export type InstructorPlatformsSummary = {
totalPlatforms: number;
activePlatforms: number;
standbyPlatforms: number;
errorPlatforms: number;
};
export type InstructorPlatformsResponse = {
summary: InstructorPlatformsSummary;
platforms: InstructorPlatform[];
masterPlatforms: MasterPlatform[];
agents: TeachingAgent[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorPlatforms(accessToken: string): Promise<InstructorPlatformsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`플랫폼 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function listInstructorPlatformAgents(accessToken: string): Promise<{ agents: TeachingAgent[] }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/agents`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`에이전트 목록을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<{ agents: TeachingAgent[] }>;
}
export async function createInstructorPlatform(
accessToken: string,
platformId: string,
displayName?: string,
agent?: TeachingAgent | null,
ssh?: { user?: string; port?: string },
): Promise<InstructorPlatformsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
platformId,
displayName,
agentNodeId: agent?.id,
agentHostname: agent?.hostname || agent?.name,
agentOwnerEmail: agent?.user,
sshUser: ssh?.user ?? agent?.sshUser ?? undefined,
sshPort: ssh?.port,
workspaceRoot: agent?.workspaceRoot ?? undefined,
}),
});
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼을 등록하지 못했습니다.'));
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function updateInstructorPlatform(
accessToken: string,
platformId: string,
displayName: string,
vehicleType: string,
wheelbaseMeters: number,
maxSpeedMetersPerSecond: number,
maxSteeringAngleDegrees: number,
sensors: PlatformSettingItem[],
algorithms: PlatformAlgorithms,
controlDurationMinutes: number,
controlWarningMinutes: number,
): Promise<InstructorPlatformsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
displayName,
vehicleType,
wheelbaseMeters,
maxSpeedMetersPerSecond,
maxSteeringAngleDegrees,
sensors,
algorithms,
controlDurationMinutes,
controlWarningMinutes,
}),
});
if (!response.ok) throw new Error(await platformErrorMessage(response, '플랫폼 설정을 저장하지 못했습니다.'));
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function deleteInstructorPlatform(
accessToken: string,
platformId: string,
options?: { force?: boolean },
): Promise<InstructorPlatformsResponse> {
const query = options?.force ? '?force=true' : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}${query}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
if (activeSession) {
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
}
}
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 삭제하지 못했습니다.'));
}
return response.json() as Promise<InstructorPlatformsResponse>;
}
export async function assignInstructorPlatform(
accessToken: string,
platformId: string,
groupId: string,
options?: { force?: boolean },
): Promise<InstructorPlatformsResponse> {
const query = options?.force ? '?force=true' : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/platforms/${platformId}/assignment${query}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ groupId }),
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
if (response.status === 409 && payload && typeof payload === 'object' && (payload as { code?: unknown }).code === 'PLATFORM_IN_USE') {
const activeSession = (payload as { activeSession?: PlatformInUseSession }).activeSession;
if (activeSession) {
throw new PlatformInUseError(String((payload as { message?: unknown }).message ?? '현재 학생이 플랫폼을 사용 중입니다.'), activeSession);
}
}
throw new Error(platformErrorMessageFromPayload(response, payload, '플랫폼을 배정하지 못했습니다.'));
}
return response.json() as Promise<InstructorPlatformsResponse>;
}
async function platformErrorMessage(response: Response, fallback: string) {
const payload = await response.json().catch((): null => null);
return platformErrorMessageFromPayload(response, payload, fallback);
}
function platformErrorMessageFromPayload(response: Response, payload: unknown, fallback: string) {
if (payload && typeof payload === 'object' && 'message' in payload) {
return String((payload as { message?: unknown }).message);
}
return `${fallback} (${response.status})`;
}
+86
View File
@@ -0,0 +1,86 @@
const PROFILE_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL ?? 'http://localhost:4200/api').replace(/\/+$/, '');
const AVATAR_MAX_BYTES = 80 * 1024;
export async function fetchProfileAvatar(accessToken: string, version: number): Promise<Blob | null> {
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar?v=${version}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
});
if (response.status === 404) return null;
if (!response.ok) throw new Error('avatar_load_failed');
return response.blob();
}
export async function uploadProfileAvatar(accessToken: string, imageBlob: Blob): Promise<void> {
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': imageBlob.type,
},
credentials: 'include',
body: imageBlob,
});
if (!response.ok) throw new Error('avatar_upload_failed');
}
export async function deleteProfileAvatar(accessToken: string): Promise<void> {
const response = await fetch(`${PROFILE_API_BASE_URL}/auth/profile/avatar`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
});
if (!response.ok) throw new Error('avatar_delete_failed');
}
export async function resizeImageToWebp(file: File, size = 128): Promise<Blob> {
if (!file.type.startsWith('image/')) throw new Error('이미지 파일만 등록할 수 있습니다.');
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('이미지를 읽을 수 없습니다.'));
reader.readAsDataURL(file);
});
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('이미지를 불러올 수 없습니다.'));
img.src = dataUrl;
});
const sourceSize = Math.min(image.naturalWidth, image.naturalHeight);
const sourceX = Math.max(0, (image.naturalWidth - sourceSize) / 2);
const sourceY = Math.max(0, (image.naturalHeight - sourceSize) / 2);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) throw new Error('이미지를 처리할 수 없습니다.');
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, size, size);
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', 0.82);
});
if (!blob) throw new Error('이미지를 압축할 수 없습니다.');
if (blob.size > AVATAR_MAX_BYTES) throw new Error('이미지 용량이 너무 큽니다.');
return blob;
}
export function notifyProfileAvatarUpdated(): void {
window.dispatchEvent(new Event('profile-avatar-updated'));
}
export function notifyProfileAvatarDeleted(): void {
window.dispatchEvent(new Event('profile-avatar-deleted'));
}
+109
View File
@@ -0,0 +1,109 @@
export type InstructorStudentIssue = {
type: 'MISSING_SUBMISSION' | 'COMPLETED_WITHOUT_SUBMISSION' | 'LOW_AVERAGE_SCORE';
title: string;
detail: string;
week?: number;
};
export type InstructorStudent = {
id: string;
name: string;
email: string;
group: string;
week: string;
progress: number;
commits: number;
issues: number;
issueItems: InstructorStudentIssue[];
lastActive: string;
online: boolean;
courseTitle?: string;
};
export type InstructorStudentsSummary = {
totalStudents: number;
onlineStudents: number;
averageProgress: number;
unresolvedIssues: number;
};
export type InstructorStudentsResponse = {
organization: {
id?: string;
name: string;
} | null;
summary: InstructorStudentsSummary;
students: InstructorStudent[];
};
export type CreateInstructorStudentInput = {
courseId?: string | null;
email: string;
name: string;
groupId?: string | null;
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorStudents(accessToken: string, courseId?: string | null): Promise<InstructorStudentsResponse> {
const query = courseId ? `?courseId=${encodeURIComponent(courseId)}` : '';
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students${query}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`수강생 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorStudentsResponse>;
}
export async function createInstructorStudent(
accessToken: string,
input: CreateInstructorStudentInput,
): Promise<InstructorStudentsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students`, {
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);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `수강생을 등록하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorStudentsResponse>;
}
export async function removeInstructorStudent(
accessToken: string,
studentId: string,
): Promise<InstructorStudentsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/students/${studentId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const payload = await response.json().catch((): null => null);
const message =
payload && typeof payload === 'object' && 'message' in payload
? String(payload.message)
: `수강생을 제외하지 못했습니다. (${response.status})`;
throw new Error(message);
}
return response.json() as Promise<InstructorStudentsResponse>;
}
+127
View File
@@ -0,0 +1,127 @@
export type InstructorSubmissionCommitFile = {
path: string;
status: string;
patch: string;
};
export type InstructorSubmissionCommit = {
hash: string;
shortHash: string;
message: string;
author: string;
authorEmail: string;
date: string | null;
files: InstructorSubmissionCommitFile[];
};
export type InstructorSubmissionItem = {
id: string;
status: 'NOT_INSTALLED' | 'PENDING' | 'SUBMITTED';
submitted: boolean;
submittedAt: string | null;
commitCount: number;
score: number;
course: {
id: string;
title: string;
slug: string;
};
week: {
id: string;
week: number;
title: string;
};
module: {
id: string;
name: string;
folderName: string;
sortOrder: number;
};
student: {
id: string;
name: string;
email: string;
group: string;
};
repository: {
id: string;
owner: string;
name: string;
url: string;
} | null;
commits: InstructorSubmissionCommit[];
finalFiles: InstructorSubmissionCommitFile[];
};
export type InstructorSubmissionsResponse = {
organization: {
id?: string;
name: string;
} | null;
summary: {
totalItems: number;
submittedItems: number;
pendingItems: number;
notInstalledItems: number;
totalCommits: number;
};
courses: Array<{
id: string;
title: string;
slug: string;
weeks: Array<{
id: string;
week: number;
title: string;
modules: Array<{
id: string;
name: string;
folderName: string;
sortOrder: number;
}>;
}>;
}>;
students: Array<{
id: string;
name: string;
email: string;
group: string;
}>;
items: InstructorSubmissionItem[];
};
const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4400/api').replace(/\/+$/, '');
export async function listInstructorSubmissions(accessToken: string): Promise<InstructorSubmissionsResponse> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error(`제출 현황을 불러오지 못했습니다. (${response.status})`);
}
return response.json() as Promise<InstructorSubmissionsResponse>;
}
export async function saveInstructorSubmissionScore(
accessToken: string,
input: { studentId: string; weekId: string; score: number },
): Promise<{ score: number }> {
const response = await fetch(`${LMS_API_BASE_URL}/instructor/submissions/score`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(`점수를 저장하지 못했습니다. (${response.status})`);
}
return response.json() as Promise<{ score: number }>;
}