All checks were successful
Deploy on Master Change / deploy (push) Successful in 44s
1092 lines
38 KiB
TypeScript
1092 lines
38 KiB
TypeScript
export const API_BASE = import.meta.env.VITE_API_BASE || 'https://sv.rikkeiraia.org/api';
|
|
|
|
export function getWsUrl(path: string): string {
|
|
try {
|
|
if (API_BASE.startsWith('http://') || API_BASE.startsWith('https://')) {
|
|
const url = new URL(API_BASE);
|
|
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${url.host}${path}`;
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${window.location.host}${path}`;
|
|
}
|
|
|
|
|
|
function getToken(): string | null {
|
|
return localStorage.getItem('sc_staff_token');
|
|
}
|
|
|
|
export async function staffFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
const headers = new Headers(init.headers);
|
|
const token = getToken();
|
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
|
if (init.body && !headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
|
}
|
|
|
|
async function parseError(res: Response, fallback: string): Promise<never> {
|
|
const errData = await res.json().catch(() => ({}));
|
|
const msg = (errData as { error?: string }).error;
|
|
if (msg) throw new Error(msg);
|
|
if (res.status === 413) throw new Error('File quá lớn (tối đa 100MB)');
|
|
throw new Error(`${fallback} (HTTP ${res.status})`);
|
|
}
|
|
|
|
export interface StaffUser {
|
|
id: number;
|
|
email: string;
|
|
fullName: string;
|
|
mustChangePassword: boolean;
|
|
}
|
|
|
|
export const apiAuth = {
|
|
provision: async (email: string): Promise<{ ok: boolean; message: string }> => {
|
|
const res = await fetch(`${API_BASE}/auth/provision`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Provision failed');
|
|
return res.json();
|
|
},
|
|
login: async (email: string, password: string): Promise<{ token: string; staff: StaffUser; mustChangePassword: boolean }> => {
|
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Đăng nhập thất bại');
|
|
return res.json();
|
|
},
|
|
me: async (): Promise<{ staff: StaffUser }> => {
|
|
const res = await staffFetch('/auth/me');
|
|
if (!res.ok) await parseError(res, 'Unauthorized');
|
|
return res.json();
|
|
},
|
|
changePassword: async (oldPassword: string, newPassword: string): Promise<{ ok: boolean; token: string; staff: StaffUser }> => {
|
|
const res = await staffFetch('/auth/change-password', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ oldPassword, newPassword }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Đổi mật khẩu thất bại');
|
|
return res.json();
|
|
},
|
|
forgotPassword: async (email: string): Promise<{ ok: boolean; message: string }> => {
|
|
const res = await fetch(`${API_BASE}/auth/forgot-password`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Gửi mail thất bại');
|
|
return res.json();
|
|
},
|
|
resetPassword: async (token: string, newPassword: string): Promise<{ ok: boolean; message: string }> => {
|
|
const res = await fetch(`${API_BASE}/auth/reset-password`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token, newPassword }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Đặt lại mật khẩu thất bại');
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
export interface GitHubStatus {
|
|
connected: boolean;
|
|
githubLogin?: string;
|
|
connectedAt?: string;
|
|
scope?: string;
|
|
canDeleteRepos?: boolean;
|
|
}
|
|
|
|
export const apiGitHub = {
|
|
status: async (): Promise<GitHubStatus> => {
|
|
const res = await staffFetch('/auth/github/status');
|
|
if (!res.ok) await parseError(res, 'Không tải trạng thái GitHub');
|
|
return res.json();
|
|
},
|
|
authorizeUrl: async (): Promise<{ authorizeUrl: string }> => {
|
|
const res = await staffFetch('/auth/github/authorize');
|
|
if (!res.ok) await parseError(res, 'Không bắt đầu OAuth GitHub');
|
|
return res.json();
|
|
},
|
|
disconnect: async () => {
|
|
const res = await staffFetch('/auth/github', { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Ngắt kết nối GitHub thất bại');
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
export interface GitHubRepoItem {
|
|
key: string;
|
|
ownerLogin: string;
|
|
repoName: string;
|
|
repoHtmlUrl: string;
|
|
private?: boolean;
|
|
examRoomId?: number;
|
|
examRoomName?: string;
|
|
studentLabel?: string;
|
|
publishMode?: 'room' | 'student' | '';
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
fromSimpleCare?: boolean;
|
|
}
|
|
|
|
export const apiGitHubRepos = {
|
|
list: async (): Promise<{ data: GitHubRepoItem[]; total: number; githubLogin?: string }> => {
|
|
const res = await staffFetch('/auth/github/repos');
|
|
if (!res.ok) await parseError(res, 'Không tải danh sách repo');
|
|
return res.json();
|
|
},
|
|
bulkDelete: async (repos: string[]) => {
|
|
const res = await staffFetch('/auth/github/repos/bulk-delete', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ repos }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Xóa repo thất bại');
|
|
return res.json() as Promise<{ ok: boolean; deleted: number; failures?: string[]; message: string }>;
|
|
},
|
|
};
|
|
|
|
export interface EmailDomainItem {
|
|
id: number;
|
|
domain: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
export const apiAdmin = {
|
|
listEmailDomains: async (): Promise<{ data: EmailDomainItem[] }> => {
|
|
const res = await staffFetch('/admin/email-domains');
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
addEmailDomain: async (domain: string) => {
|
|
const res = await staffFetch('/admin/email-domains', { method: 'POST', body: JSON.stringify({ domain }) });
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
deleteEmailDomain: async (id: number) => {
|
|
const res = await staffFetch(`/admin/email-domains/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
export interface ChatStudent {
|
|
studentRkId: number;
|
|
fullName: string;
|
|
studentCode: string;
|
|
email: string;
|
|
online: boolean;
|
|
}
|
|
|
|
export interface ChatMessage {
|
|
id: number;
|
|
staffId: number;
|
|
targetStaffId?: number;
|
|
studentRkId: number;
|
|
senderRole: 'staff' | 'student';
|
|
body: string;
|
|
createdAt: string;
|
|
staffName?: string;
|
|
}
|
|
|
|
export interface ChatConversation {
|
|
studentRkId: number;
|
|
fullName: string;
|
|
studentCode: string;
|
|
lastMessage: string;
|
|
lastAt: string;
|
|
unread: number;
|
|
}
|
|
|
|
export const apiChat = {
|
|
listConversations: async (): Promise<{ data: ChatConversation[] }> => {
|
|
const res = await staffFetch('/chat/conversations');
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
searchStudents: async (q = ''): Promise<{ data: ChatStudent[] }> => {
|
|
const params = q ? `?q=${encodeURIComponent(q)}` : '';
|
|
const res = await staffFetch(`/chat/students${params}`);
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
listMessages: async (studentRkId: number): Promise<{ data: ChatMessage[] }> => {
|
|
const res = await staffFetch(`/chat/messages/${studentRkId}`);
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
sendMessage: async (studentRkId: number, body: string): Promise<{ ok: boolean; data: ChatMessage }> => {
|
|
const res = await staffFetch('/chat/messages', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ studentRkId, body }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
export interface ClassItem {
|
|
rkId: number;
|
|
name: string;
|
|
classCode: string;
|
|
type: string;
|
|
studentCount: number;
|
|
specializeRkId?: number;
|
|
specializeName?: string;
|
|
systemRkId?: number;
|
|
systemCode?: string;
|
|
systemName?: string;
|
|
isStudying: boolean;
|
|
courses: { courseRkId: number; courseName: string }[];
|
|
}
|
|
|
|
export interface StudentItem {
|
|
id: number;
|
|
rkId: number;
|
|
studentCode: string;
|
|
fullName: string;
|
|
phone?: string;
|
|
email: string;
|
|
dateOfBirth?: string;
|
|
gender?: number;
|
|
status?: string;
|
|
location?: string;
|
|
systemId?: number;
|
|
systemName?: string;
|
|
avatar?: string;
|
|
}
|
|
|
|
export interface StatsResponse {
|
|
totalClasses: number;
|
|
activeClasses: number;
|
|
totalStudents: number;
|
|
}
|
|
|
|
export interface GuideLinkItem {
|
|
id: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
title: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface AppDownloadItem {
|
|
id: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
name: string;
|
|
description: string;
|
|
downloadUrl: string;
|
|
platform: string;
|
|
}
|
|
|
|
export interface AppGuideItem {
|
|
id: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
title: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface PaginatedResponse<T> {
|
|
data: T[];
|
|
total: number;
|
|
page: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
export interface SyncStatus {
|
|
running: boolean;
|
|
done: boolean;
|
|
error?: string;
|
|
total: number;
|
|
synced: number;
|
|
updatedAt: number;
|
|
page?: number;
|
|
pageSize?: number;
|
|
}
|
|
|
|
export interface ClassScheduleItem {
|
|
id?: number;
|
|
classRkId?: number;
|
|
dayOfWeek: number;
|
|
period: number;
|
|
startTime: string;
|
|
endTime: string;
|
|
courseId: number;
|
|
courseName: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
export interface ClassCourseItem {
|
|
id: number;
|
|
name: string;
|
|
courseCode?: string;
|
|
}
|
|
|
|
export interface AttendanceRow {
|
|
studentRkId: number;
|
|
studentCode: string;
|
|
fullName: string;
|
|
email: string;
|
|
status: number;
|
|
statusLabel: string;
|
|
onlineMinutes: number;
|
|
statusEditedByTeacher: boolean;
|
|
pushedToQldtAt?: string;
|
|
}
|
|
|
|
export interface AttendanceShiftInfo {
|
|
startTime?: string;
|
|
endTime?: string;
|
|
courseId?: number;
|
|
courseName?: string;
|
|
isActive?: boolean;
|
|
pushedToQldtAt?: string;
|
|
qldtDirty?: boolean;
|
|
}
|
|
|
|
export const ATTENDANCE_STATUS_OPTIONS = [
|
|
{ value: 0, label: 'Nghỉ không phép', short: 'NKP' },
|
|
{ value: 1, label: 'Nghỉ có phép', short: 'NCP' },
|
|
{ value: 2, label: 'Nghỉ nửa buổi', short: 'Nửa buổi' },
|
|
{ value: 3, label: 'Đi học muộn', short: 'Muộn' },
|
|
{ value: 4, label: 'Đi học đầy đủ', short: 'Đầy đủ' },
|
|
] as const;
|
|
|
|
export function attendanceStatusClass(status: number): string {
|
|
return `attendance-status--${status}`;
|
|
}
|
|
|
|
export function attendanceStatusLabel(status: number): string {
|
|
return ATTENDANCE_STATUS_OPTIONS.find(o => o.value === status)?.label ?? `Trạng thái ${status}`;
|
|
}
|
|
|
|
export interface StudentSessionLogItem {
|
|
id: number;
|
|
studentRkId: number;
|
|
studentCode: string;
|
|
fullName: string;
|
|
email: string;
|
|
onlineSeconds: number;
|
|
offlineSeconds: number;
|
|
wifiSsids: string;
|
|
lastActiveAt?: string;
|
|
}
|
|
|
|
export const api = {
|
|
getStats: async (): Promise<StatsResponse> => {
|
|
const res = await staffFetch('/stats');
|
|
if (!res.ok) throw new Error('Failed to fetch dashboard stats');
|
|
return res.json();
|
|
},
|
|
|
|
listGuideLinks: async (): Promise<{ data: GuideLinkItem[] }> => {
|
|
const res = await staffFetch('/guide-links');
|
|
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu');
|
|
return res.json();
|
|
},
|
|
createGuideLink: async (title: string, url: string): Promise<{ data: GuideLinkItem }> => {
|
|
const res = await staffFetch('/guide-links', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, url }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Thêm tài liệu thất bại');
|
|
return res.json();
|
|
},
|
|
updateGuideLink: async (id: number, title: string, url: string): Promise<{ data: GuideLinkItem }> => {
|
|
const res = await staffFetch(`/guide-links/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ title, url }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Cập nhật tài liệu thất bại');
|
|
return res.json();
|
|
},
|
|
deleteGuideLink: async (id: number): Promise<{ ok: boolean }> => {
|
|
const res = await staffFetch(`/guide-links/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Xóa tài liệu thất bại');
|
|
return res.json();
|
|
},
|
|
|
|
listAppDownloads: async (): Promise<{ data: AppDownloadItem[] }> => {
|
|
const res = await staffFetch('/app-downloads');
|
|
if (!res.ok) throw new Error('Không thể tải danh sách ứng dụng');
|
|
return res.json();
|
|
},
|
|
createAppDownload: async (name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => {
|
|
const res = await staffFetch('/app-downloads', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description, downloadUrl, platform }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Thêm ứng dụng thất bại');
|
|
return res.json();
|
|
},
|
|
updateAppDownload: async (id: number, name: string, description: string, downloadUrl: string, platform: string): Promise<{ data: AppDownloadItem }> => {
|
|
const res = await staffFetch(`/app-downloads/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name, description, downloadUrl, platform }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Cập nhật ứng dụng thất bại');
|
|
return res.json();
|
|
},
|
|
deleteAppDownload: async (id: number): Promise<{ ok: boolean }> => {
|
|
const res = await staffFetch(`/app-downloads/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Xóa ứng dụng thất bại');
|
|
return res.json();
|
|
},
|
|
|
|
listAppGuides: async (): Promise<{ data: AppGuideItem[] }> => {
|
|
const res = await staffFetch('/app-guides');
|
|
if (!res.ok) throw new Error('Không thể tải danh sách tài liệu hướng dẫn');
|
|
return res.json();
|
|
},
|
|
createAppGuide: async (title: string, url: string): Promise<{ data: AppGuideItem }> => {
|
|
const res = await staffFetch('/app-guides', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, url }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Thêm tài liệu hướng dẫn thất bại');
|
|
return res.json();
|
|
},
|
|
updateAppGuide: async (id: number, title: string, url: string): Promise<{ data: AppGuideItem }> => {
|
|
const res = await staffFetch(`/app-guides/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ title, url }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Cập nhật tài liệu hướng dẫn thất bại');
|
|
return res.json();
|
|
},
|
|
deleteAppGuide: async (id: number): Promise<{ ok: boolean }> => {
|
|
const res = await staffFetch(`/app-guides/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Xóa tài liệu hướng dẫn thất bại');
|
|
return res.json();
|
|
},
|
|
|
|
getClasses: async (params: {
|
|
page: number;
|
|
pageSize: number;
|
|
q?: string;
|
|
systemRkId?: number;
|
|
studyingOnly?: boolean;
|
|
}): Promise<PaginatedResponse<ClassItem>> => {
|
|
const query = new URLSearchParams({
|
|
page: String(params.page),
|
|
pageSize: String(params.pageSize),
|
|
});
|
|
if (params.q) query.set('q', params.q);
|
|
if (params.systemRkId) query.set('systemRkId', String(params.systemRkId));
|
|
if (params.studyingOnly) query.set('studyingOnly', 'true');
|
|
|
|
const res = await staffFetch(`/classes?${query}`);
|
|
if (!res.ok) throw new Error('Failed to fetch classes');
|
|
return res.json();
|
|
},
|
|
|
|
updateClassStudying: async (rkId: number, isStudying: boolean): Promise<any> => {
|
|
const res = await staffFetch(`/classes/${rkId}/studying`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ isStudying }),
|
|
});
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.error || 'Failed to update class studying status');
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
getClassStudents: async (rkId: number): Promise<{ data: StudentItem[]; total: number }> => {
|
|
const res = await staffFetch(`/classes/${rkId}/students`);
|
|
if (!res.ok) throw new Error('Failed to fetch class students roster');
|
|
return res.json();
|
|
},
|
|
|
|
getClass: async (rkId: number): Promise<ClassItem> => {
|
|
const res = await staffFetch(`/classes/${rkId}`);
|
|
if (!res.ok) throw new Error('Failed to fetch class');
|
|
return res.json();
|
|
},
|
|
|
|
getStudents: async (params: {
|
|
page: number;
|
|
pageSize: number;
|
|
q?: string;
|
|
}): Promise<PaginatedResponse<StudentItem>> => {
|
|
const query = new URLSearchParams({
|
|
page: String(params.page),
|
|
pageSize: String(params.pageSize),
|
|
});
|
|
if (params.q) query.set('q', params.q);
|
|
|
|
const res = await staffFetch(`/students?${query}`);
|
|
if (!res.ok) throw new Error('Failed to fetch students');
|
|
return res.json();
|
|
},
|
|
|
|
startClassesSync: async (): Promise<{ ok: boolean; message: string }> => {
|
|
const res = await staffFetch('/sync/classes/start', { method: 'POST' });
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.error || 'Failed to start classes sync');
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
getClassesSyncStatus: async (): Promise<SyncStatus> => {
|
|
const res = await staffFetch('/sync/classes/status');
|
|
if (!res.ok) throw new Error('Failed to get classes sync status');
|
|
return res.json();
|
|
},
|
|
|
|
startStudentsSync: async (): Promise<{ ok: boolean; message: string }> => {
|
|
const res = await staffFetch('/sync/students/start', { method: 'POST' });
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.error || 'Failed to start students sync');
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
getStudentsSyncStatus: async (): Promise<SyncStatus> => {
|
|
const res = await staffFetch('/sync/students/status');
|
|
if (!res.ok) throw new Error('Failed to get students sync status');
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
// Export individual learning functions to simplify imports in components
|
|
export const apiFetchActiveSchedules = async (): Promise<{ data: any[] }> => {
|
|
const res = await staffFetch('/classes/schedules');
|
|
if (!res.ok) throw new Error('Failed to fetch active schedules');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchClassSchedule = async (rkId: number): Promise<{ data: ClassScheduleItem[] }> => {
|
|
const res = await staffFetch(`/classes/${rkId}/schedule`);
|
|
if (!res.ok) throw new Error('Failed to fetch class schedule');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiSaveClassSchedule = async (rkId: number, schedules: ClassScheduleItem[]): Promise<any> => {
|
|
const res = await staffFetch(`/classes/${rkId}/schedule`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ schedules }),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to save class schedule');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiDeleteClassSchedule = async (rkId: number): Promise<any> => {
|
|
const res = await staffFetch(`/classes/${rkId}/schedule`, { method: 'DELETE' });
|
|
if (!res.ok) throw new Error('Failed to delete class schedule');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchAllowedApps = async (rkId: number): Promise<{ classRkId: number; keywords: string }> => {
|
|
const res = await staffFetch(`/classes/${rkId}/allowed-apps`);
|
|
if (!res.ok) throw new Error('Failed to fetch allowed apps keywords');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiSaveAllowedApps = async (rkId: number, keywords: string): Promise<any> => {
|
|
const res = await staffFetch(`/classes/${rkId}/allowed-apps`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ keywords }),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to save allowed apps keywords');
|
|
return res.json();
|
|
};
|
|
|
|
export interface AppPoolItem {
|
|
id: number;
|
|
processName: string;
|
|
windowTitle: string;
|
|
keyword: string;
|
|
hitCount: number;
|
|
lastSeenAt: string;
|
|
lastStudentRkId?: number;
|
|
lastClassRkId?: number;
|
|
}
|
|
|
|
export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPoolItem[]; q?: string; limit?: number }> => {
|
|
const params = new URLSearchParams();
|
|
if (q.trim()) params.set('q', q.trim());
|
|
params.set('limit', String(limit));
|
|
const query = params.toString() ? `?${params.toString()}` : '';
|
|
const res = await staffFetch(`/app-pool${query}`);
|
|
if (!res.ok) throw new Error('Failed to fetch app pool');
|
|
return res.json();
|
|
};
|
|
|
|
export interface AppTemplateItem {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
keywords: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export const apiAppTemplates = {
|
|
list: async (): Promise<{ data: AppTemplateItem[] }> => {
|
|
const res = await staffFetch('/app-templates');
|
|
if (!res.ok) throw new Error('Không tải được khung ứng dụng');
|
|
return res.json();
|
|
},
|
|
create: async (body: { name: string; description?: string; keywords: string }) => {
|
|
const res = await staffFetch('/app-templates', { method: 'POST', body: JSON.stringify(body) });
|
|
if (!res.ok) await parseError(res, 'Không tạo được khung');
|
|
return res.json();
|
|
},
|
|
update: async (id: number, body: { name?: string; description?: string; keywords?: string }) => {
|
|
const res = await staffFetch(`/app-templates/${id}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
if (!res.ok) await parseError(res, 'Không cập nhật được khung');
|
|
return res.json();
|
|
},
|
|
delete: async (id: number) => {
|
|
const res = await staffFetch(`/app-templates/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Không xóa được khung');
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
export interface WifiPoolItem {
|
|
id: number;
|
|
ssid: string;
|
|
bssid: string;
|
|
hitCount: number;
|
|
lastSeenAt: string;
|
|
lastStudentRkId?: number;
|
|
}
|
|
|
|
export interface AcceptedWifiItem {
|
|
id: number;
|
|
ssid: string;
|
|
bssid: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export type WifiAcceptItem = { ssid: string; bssid: string };
|
|
|
|
export const apiFetchWifiPool = async (q = '', limit = 50): Promise<{ data: WifiPoolItem[] }> => {
|
|
const params = new URLSearchParams();
|
|
if (q.trim()) params.set('q', q.trim());
|
|
params.set('limit', String(limit));
|
|
const query = params.toString() ? `?${params.toString()}` : '';
|
|
const res = await staffFetch(`/wifi-pool${query}`);
|
|
if (!res.ok) throw new Error('Failed to fetch wifi pool');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchAcceptedWifis = async (): Promise<{ data: AcceptedWifiItem[] }> => {
|
|
const res = await staffFetch('/network/accepted-wifis');
|
|
if (!res.ok) throw new Error('Failed to fetch accepted wifis');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiSaveAcceptedWifis = async (items: WifiAcceptItem[]): Promise<any> => {
|
|
const res = await staffFetch('/network/accepted-wifis', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ items }),
|
|
});
|
|
if (!res.ok) throw new Error('Failed to save accepted wifis');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchClassStudents = api.getClassStudents;
|
|
export const apiFetchClasses = api.getClasses;
|
|
|
|
export const apiFetchClassSessionLogs = async (
|
|
rkId: number,
|
|
date?: string,
|
|
period?: number
|
|
): Promise<{ data: StudentSessionLogItem[]; period?: number; date?: string }> => {
|
|
const params = new URLSearchParams();
|
|
if (date) params.set('date', date);
|
|
if (period) params.set('period', String(period));
|
|
const query = params.toString() ? `?${params.toString()}` : '';
|
|
const res = await staffFetch(`/classes/${rkId}/session-logs${query}`);
|
|
if (!res.ok) throw new Error('Failed to fetch class session logs');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchOnlineStudents = async (rkId: number): Promise<{ onlineStudentIds: number[] }> => {
|
|
const res = await staffFetch(`/classes/${rkId}/online-students`);
|
|
if (!res.ok) throw new Error('Failed to fetch online students list');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchClassCourses = async (rkId: number): Promise<{
|
|
data: ClassCourseItem[];
|
|
source?: string;
|
|
cached?: boolean;
|
|
warning?: string;
|
|
hint?: string;
|
|
}> => {
|
|
const res = await staffFetch(`/classes/${rkId}/courses`);
|
|
const body = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
throw new Error(body.error || body.hint || 'Không tải được danh sách môn học từ QLĐT');
|
|
}
|
|
return body;
|
|
};
|
|
|
|
export const apiApplyScheduleTemplate = async (rkId: number): Promise<{ created: number; skipped: number }> => {
|
|
const res = await staffFetch(`/classes/${rkId}/schedule/apply-template`, { method: 'POST' });
|
|
if (!res.ok) throw new Error('Failed to apply schedule template');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchAttendanceShifts = async (rkId: number, date?: string) => {
|
|
const q = date ? `?date=${date}` : '';
|
|
const res = await staffFetch(`/classes/${rkId}/attendance/shifts${q}`);
|
|
if (!res.ok) throw new Error('Failed to fetch attendance shifts');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiFetchAttendance = async (rkId: number, date: string, period: number) => {
|
|
const res = await staffFetch(`/classes/${rkId}/attendance?date=${date}&period=${period}`);
|
|
if (!res.ok) throw new Error('Failed to fetch attendance');
|
|
return res.json();
|
|
};
|
|
|
|
export const apiUpdateAttendanceStatus = async (
|
|
rkId: number,
|
|
payload: { date: string; period: number; studentRkId: number; status: number }
|
|
) => {
|
|
const res = await staffFetch(`/classes/${rkId}/attendance/status`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to update attendance status');
|
|
}
|
|
return res.json();
|
|
};
|
|
|
|
export const apiUpdateAttendanceBulkStatus = async (
|
|
rkId: number,
|
|
payload: { date: string; period: number; studentRkIds: number[]; status: number }
|
|
) => {
|
|
const res = await staffFetch(`/classes/${rkId}/attendance/bulk-status`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to update attendance status');
|
|
}
|
|
return res.json();
|
|
};
|
|
|
|
export const apiPushAttendanceQLDT = async (rkId: number, date: string, period: number) => {
|
|
const res = await staffFetch(`/classes/${rkId}/attendance/push-qldt`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ date, period }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.error || 'Failed to push attendance to QLĐT');
|
|
}
|
|
return res.json();
|
|
};
|
|
|
|
async function staffUpload(path: string, form: FormData): Promise<Response> {
|
|
const headers = new Headers();
|
|
const token = getToken();
|
|
if (token) headers.set('Authorization', `Bearer ${token}`);
|
|
return fetch(`${API_BASE}${path}`, { method: 'POST', headers, body: form });
|
|
}
|
|
|
|
export interface ExamRoomItem {
|
|
id: number;
|
|
name: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
allowedApps: string;
|
|
quizUrl: string;
|
|
gitRepoUrl?: string;
|
|
gitBranch?: string;
|
|
gitPublishUrl?: string;
|
|
status: 'draft' | 'ready' | 'ended' | 'cancelled';
|
|
studentCount: number;
|
|
paperCount: number;
|
|
displayStatus: 'draft' | 'ready' | 'active' | 'ended' | 'cancelled';
|
|
}
|
|
|
|
export interface ExamPaperResource {
|
|
id: number;
|
|
examPaperId: number;
|
|
fileName: string;
|
|
}
|
|
|
|
export interface ExamPaper {
|
|
id: number;
|
|
examRoomId: number;
|
|
title: string;
|
|
pdfPath: string;
|
|
sortOrder: number;
|
|
resources: ExamPaperResource[];
|
|
}
|
|
|
|
export interface ExamRoomStudent {
|
|
id: number;
|
|
examRoomId: number;
|
|
studentRkId: number;
|
|
assignedPaperId?: number;
|
|
paperSentAt?: string;
|
|
paperScheduledAt?: string;
|
|
fullName: string;
|
|
studentCode: string;
|
|
email: string;
|
|
avatar?: string;
|
|
paperTitle?: string;
|
|
submitted: boolean;
|
|
}
|
|
|
|
export interface ExamSubmission {
|
|
id: number;
|
|
examRoomId: number;
|
|
studentRkId: number;
|
|
fileName: string;
|
|
createdAt: string;
|
|
fullName: string;
|
|
studentCode: string;
|
|
gitRepoUrl?: string;
|
|
gitPublishUrl?: string;
|
|
}
|
|
|
|
function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out.buffer;
|
|
}
|
|
|
|
export const apiExam = {
|
|
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
|
const res = await staffFetch('/exam-rooms');
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json();
|
|
},
|
|
create: async (payload: { name: string; startTime: string; endTime: string; allowedApps?: string; quizUrl?: string }) => {
|
|
const res = await staffFetch('/exam-rooms', { method: 'POST', body: JSON.stringify(payload) });
|
|
if (!res.ok) await parseError(res, 'Tạo phòng thi thất bại');
|
|
return res.json();
|
|
},
|
|
get: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}`);
|
|
if (!res.ok) await parseError(res, 'Không tìm thấy');
|
|
return res.json() as Promise<{
|
|
room: ExamRoomItem;
|
|
papers: ExamPaper[];
|
|
students: ExamRoomStudent[];
|
|
displayStatus: string;
|
|
editable: boolean;
|
|
prepEditable: boolean;
|
|
canPublish: boolean;
|
|
canUnpublish: boolean;
|
|
canCancel: boolean;
|
|
}>;
|
|
},
|
|
publish: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/publish`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({})) as { error?: string; issues?: string[] };
|
|
if (data.issues?.length) {
|
|
throw new Error(data.issues.join('\n'));
|
|
}
|
|
throw new Error(data.error || 'Đẩy phòng thi thất bại');
|
|
}
|
|
return res.json();
|
|
},
|
|
unpublish: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/unpublish`, { method: 'POST' });
|
|
if (!res.ok) await parseError(res, 'Thu hồi thất bại');
|
|
return res.json();
|
|
},
|
|
cancel: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/cancel`, { method: 'POST' });
|
|
if (!res.ok) await parseError(res, 'Hủy phòng thi thất bại');
|
|
return res.json();
|
|
},
|
|
update: async (id: number, payload: Partial<{ name: string; startTime: string; endTime: string; allowedApps: string; quizUrl: string }>) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'PATCH', body: JSON.stringify(payload) });
|
|
if (!res.ok) await parseError(res, 'Cập nhật thất bại');
|
|
return res.json();
|
|
},
|
|
remove: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Xóa thất bại');
|
|
return res.json();
|
|
},
|
|
searchStudents: async (q: string) => {
|
|
const res = await staffFetch(`/exam-rooms/search-students?q=${encodeURIComponent(q)}`);
|
|
if (!res.ok) await parseError(res, 'Tìm kiếm thất bại');
|
|
return res.json() as Promise<{ data: { studentRkId: number; fullName: string; studentCode: string; email: string; classCodes: string }[] }>;
|
|
},
|
|
addStudents: async (id: number, studentRkIds: number[]) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/students`, { method: 'POST', body: JSON.stringify({ studentRkIds }) });
|
|
if (!res.ok) await parseError(res, 'Thêm sinh viên thất bại');
|
|
return res.json();
|
|
},
|
|
removeStudent: async (id: number, studentRkId: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/students/${studentRkId}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Xóa sinh viên thất bại');
|
|
return res.json();
|
|
},
|
|
fetchOnlineStudents: async (id: number): Promise<{ onlineStudentIds: number[] }> => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/online-students`);
|
|
if (!res.ok) throw new Error('Không tải được trạng thái online');
|
|
return res.json();
|
|
},
|
|
uploadPackage: async (id: number, title: string, pdf: File, resources: File[] = []) => {
|
|
const form = new FormData();
|
|
form.append('title', title);
|
|
form.append('pdf', pdf);
|
|
resources.forEach((f) => form.append('resources', f));
|
|
const res = await staffUpload(`/exam-rooms/${id}/papers`, form);
|
|
if (!res.ok) await parseError(res, 'Tạo gói đề thất bại');
|
|
return res.json();
|
|
},
|
|
uploadPaper: async (id: number, title: string, pdf: File, resources: File[] = []) => {
|
|
return apiExam.uploadPackage(id, title, pdf, resources);
|
|
},
|
|
uploadPackageResources: async (id: number, paperId: number, files: File[]) => {
|
|
const form = new FormData();
|
|
files.forEach((f) => form.append('resources', f));
|
|
const res = await staffUpload(`/exam-rooms/${id}/papers/${paperId}/resources`, form);
|
|
if (!res.ok) await parseError(res, 'Thêm tài nguyên thất bại');
|
|
return res.json();
|
|
},
|
|
uploadResource: async (id: number, paperId: number, file: File) => {
|
|
return apiExam.uploadPackageResources(id, paperId, [file]);
|
|
},
|
|
deletePaper: async (id: number, paperId: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/papers/${paperId}`, { method: 'DELETE' });
|
|
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
|
|
return res.json();
|
|
},
|
|
fetchPaperPdfBytes: async (examId: number, paperId: number): Promise<ArrayBuffer> => {
|
|
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/view`);
|
|
if (!res.ok) await parseError(res, 'Không mở được đề PDF');
|
|
const json = await res.json() as { data: string };
|
|
if (!json.data) throw new Error('Server không trả dữ liệu PDF');
|
|
return decodeBase64ToArrayBuffer(json.data);
|
|
},
|
|
fetchPaperResourceBytes: async (examId: number, paperId: number, resourceId: number): Promise<ArrayBuffer> => {
|
|
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/resources/${resourceId}/view`);
|
|
if (!res.ok) await parseError(res, 'Không tải tài nguyên');
|
|
const json = await res.json() as { data: string };
|
|
if (!json.data) throw new Error('Server không trả dữ liệu file');
|
|
return decodeBase64ToArrayBuffer(json.data);
|
|
},
|
|
downloadPaperFile: async (examId: number, paperId: number, opts?: { resourceId?: number; fileName?: string }) => {
|
|
const params = new URLSearchParams();
|
|
if (opts?.resourceId) {
|
|
params.set('kind', 'resource');
|
|
params.set('fileId', String(opts.resourceId));
|
|
} else {
|
|
params.set('kind', 'pdf');
|
|
}
|
|
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/download?${params}`);
|
|
if (!res.ok) await parseError(res, 'Tải file thất bại');
|
|
const buf = await res.arrayBuffer();
|
|
const url = URL.createObjectURL(new Blob([buf]));
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = opts?.fileName || 'de-thi.pdf';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
assignRandom: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
|
|
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
|
return res.json() as Promise<{ assigned: number; packages: number }>;
|
|
},
|
|
sendPapers: async (id: number, payload?: { scheduledAt?: string; studentRkIds?: number[] }) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/send-papers`, { method: 'POST', body: JSON.stringify(payload || {}) });
|
|
if (!res.ok) await parseError(res, 'Gửi gói đề thất bại');
|
|
return res.json();
|
|
},
|
|
listSubmissions: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/submissions`);
|
|
if (!res.ok) await parseError(res, 'Failed');
|
|
return res.json() as Promise<{ data: ExamSubmission[] }>;
|
|
},
|
|
downloadSubmission: (id: number, subId: number) => `${API_BASE}/exam-rooms/${id}/submissions/${subId}/download`,
|
|
downloadAllSubmissions: async (id: number, fallbackName: string) => {
|
|
let res: Response;
|
|
try {
|
|
res = await staffFetch(`/exam-rooms/${id}/submissions/download-all`);
|
|
} catch {
|
|
throw new Error('Không kết nối được server (Failed to fetch). Kiểm tra server đang chạy và thử lại.');
|
|
}
|
|
if (!res.ok) await parseError(res, 'Tải bài nộp gộp thất bại');
|
|
const blob = await res.blob();
|
|
if (!blob.size) throw new Error('File ZIP trống — có thể bài nộp trên server bị thiếu file');
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${fallbackName.replace(/[<>:"/\\|?*]+/g, '_')}_bai_nop.zip`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
saveGitSettings: async (id: number, payload: { gitRepoUrl?: string; gitBranch?: string }) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/git-settings`, { method: 'PATCH', body: JSON.stringify(payload) });
|
|
if (!res.ok) await parseError(res, 'Lưu cấu hình Git thất bại');
|
|
return res.json() as Promise<{ gitRepoUrl: string; gitBranch: string; gitPublishUrl?: string }>;
|
|
},
|
|
publishSubmissionsGit: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] }));
|
|
const msg = errData.error || 'Đẩy lên Git thất bại';
|
|
if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`);
|
|
throw new Error(msg);
|
|
}
|
|
return res.json() as Promise<{ ok: boolean; url: string; gitPublishUrl: string; openUrl?: string; message: string }>;
|
|
},
|
|
publishSubmissionsGitStudents: async (id: number) => {
|
|
const res = await staffFetch(`/exam-rooms/${id}/submissions/publish-git-students`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({} as { error?: string; warnings?: string[] }));
|
|
const msg = errData.error || 'Đẩy repo từng SV thất bại';
|
|
if (errData.warnings?.length) throw new Error(`${msg} — ${errData.warnings.join('; ')}`);
|
|
throw new Error(msg);
|
|
}
|
|
return res.json() as Promise<{
|
|
ok: boolean;
|
|
published: number;
|
|
openUrl?: string;
|
|
message: string;
|
|
warnings?: string[];
|
|
studentRepos?: { studentRkId: number; studentCode: string; fullName: string; url: string }[];
|
|
}>;
|
|
},
|
|
};
|
|
|
|
export const apiQldt = {
|
|
getToken: async (): Promise<{ token: string }> => {
|
|
const res = await staffFetch('/qldt-token');
|
|
if (!res.ok) await parseError(res, 'Không tải được token QLĐT');
|
|
return res.json();
|
|
},
|
|
saveToken: async (token: string): Promise<{ ok: boolean; message: string }> => {
|
|
const res = await staffFetch('/qldt-token', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
if (!res.ok) await parseError(res, 'Lưu token thất bại');
|
|
return res.json();
|
|
},
|
|
};
|
|
|