clean ui
This commit is contained in:
@@ -1,17 +1,27 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { DashboardTab } from './components/DashboardTab';
|
import { DashboardTab } from './components/DashboardTab';
|
||||||
import { ClassesTab } from './components/ClassesTab';
|
import { ClassesTab } from './components/ClassesTab';
|
||||||
import { StudentsTab } from './components/StudentsTab';
|
import { StudentsTab } from './components/StudentsTab';
|
||||||
import { LearningTab } from './components/LearningTab';
|
import { LearningTab } from './components/LearningTab';
|
||||||
import { ExamsTab } from './components/ExamsTab';
|
import { ExamsTab } from './components/ExamsTab';
|
||||||
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
import { ExamRoomWorkspace } from './components/ExamRoomWorkspace';
|
||||||
import { NetworkTab } from './components/NetworkTab';
|
import { SystemTab } from './components/SystemTab';
|
||||||
import { EmailDomainsTab, MyAccountTab } from './components/AccountsTab';
|
import { MyAccountTab } from './components/AccountsTab';
|
||||||
import { ChatWidget } from './components/ChatWidget';
|
import { ChatWidget } from './components/ChatWidget';
|
||||||
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
import { StaffChatSocket } from './hooks/useStaffChatSocket';
|
||||||
import { ClassWorkspace } from './components/ClassWorkspace';
|
import { ClassWorkspace } from './components/ClassWorkspace';
|
||||||
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
import { NavHistoryBar, useRoute } from './components/NavHistoryBar';
|
||||||
import { goBack, navigate, parseRoute, pushNav, TAB_LABELS, type TabId } from './navigation';
|
import {
|
||||||
|
goBack,
|
||||||
|
navigate,
|
||||||
|
navigateSystem,
|
||||||
|
parseRoute,
|
||||||
|
pushNav,
|
||||||
|
SYSTEM_SECTION_LABELS,
|
||||||
|
TAB_LABELS,
|
||||||
|
type SystemSection,
|
||||||
|
type TabId,
|
||||||
|
} from './navigation';
|
||||||
import { useAuth } from './auth/AuthContext';
|
import { useAuth } from './auth/AuthContext';
|
||||||
|
|
||||||
const IconDashboard = () => (
|
const IconDashboard = () => (
|
||||||
@@ -51,36 +61,54 @@ const IconExam = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconNetwork = () => (
|
const IconSystem = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
|
||||||
<circle cx="12" cy="20" r="1" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const IconEmail = () => (
|
const IconMenu = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const SYSTEM_NAV: { section: SystemSection }[] = [
|
||||||
|
{ section: 'organization' },
|
||||||
|
{ section: 'network' },
|
||||||
|
{ section: 'templates' },
|
||||||
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { staff, logout } = useAuth();
|
const { staff, logout } = useAuth();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initial = parseRoute();
|
const initial = parseRoute();
|
||||||
if (initial.classId || initial.examId) {
|
if (initial.classId || initial.examId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushNav({ kind: 'tab', tab: initial.tab, label: TAB_LABELS[initial.tab] });
|
const label =
|
||||||
|
initial.tab === 'system'
|
||||||
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[initial.systemSection]}`
|
||||||
|
: TAB_LABELS[initial.tab];
|
||||||
|
pushNav({
|
||||||
|
kind: 'tab',
|
||||||
|
tab: initial.tab,
|
||||||
|
systemSection: initial.tab === 'system' ? initial.systemSection : undefined,
|
||||||
|
label,
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}, [route.tab, route.classId, route.examId, route.systemSection]);
|
||||||
|
|
||||||
const setActiveTab = (tab: string) => {
|
const setActiveTab = (tab: string) => {
|
||||||
navigate(tab as TabId);
|
navigate(tab as TabId);
|
||||||
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackFromWorkspace = () => {
|
const handleBackFromWorkspace = () => {
|
||||||
@@ -89,9 +117,39 @@ function App() {
|
|||||||
|
|
||||||
const inWorkspace = Boolean(route.classId || route.examId);
|
const inWorkspace = Boolean(route.classId || route.examId);
|
||||||
|
|
||||||
|
const classesActive = route.tab === 'classes';
|
||||||
|
const learningActive = route.tab === 'learning';
|
||||||
|
const examsActive = route.tab === 'exams' || route.examId != null;
|
||||||
|
const systemActive = route.tab === 'system' && !inWorkspace;
|
||||||
|
|
||||||
|
const goSystem = (section: SystemSection) => {
|
||||||
|
navigateSystem(section);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navBtn = (active: boolean) => `nav-btn${active ? ' active' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<aside className="sidebar">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mobile-menu-btn"
|
||||||
|
aria-label="Mở menu"
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<IconMenu />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-backdrop"
|
||||||
|
aria-label="Đóng menu"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}`}>
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
<img src="/logo.jpeg" alt="Simple Care" className="brand-logo-img" />
|
||||||
<div className="brand-text">
|
<div className="brand-text">
|
||||||
@@ -105,8 +163,8 @@ function App() {
|
|||||||
<div className="nav-header">Tổng quan</div>
|
<div className="nav-header">Tổng quan</div>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'dashboard' && !inWorkspace ? 'active' : ''}`}
|
className={navBtn(route.tab === 'dashboard' && !inWorkspace)}
|
||||||
onClick={() => navigate('dashboard')}
|
onClick={() => setActiveTab('dashboard')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconDashboard /></span>
|
<span className="nav-icon"><IconDashboard /></span>
|
||||||
Tổng quan
|
Tổng quan
|
||||||
@@ -115,18 +173,15 @@ function App() {
|
|||||||
|
|
||||||
<div className="nav-header">CSDL liên kết</div>
|
<div className="nav-header">CSDL liên kết</div>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button className={navBtn(classesActive)} onClick={() => setActiveTab('classes')}>
|
||||||
className={`nav-btn ${route.tab === 'classes' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('classes')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconClass /></span>
|
<span className="nav-icon"><IconClass /></span>
|
||||||
Lớp học
|
Lớp học
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'students' && !inWorkspace ? 'active' : ''}`}
|
className={navBtn(route.tab === 'students' && !inWorkspace)}
|
||||||
onClick={() => navigate('students')}
|
onClick={() => setActiveTab('students')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconStudent /></span>
|
<span className="nav-icon"><IconStudent /></span>
|
||||||
Sinh viên
|
Sinh viên
|
||||||
@@ -135,19 +190,13 @@ function App() {
|
|||||||
|
|
||||||
<div className="nav-header">Quản lý học tập</div>
|
<div className="nav-header">Quản lý học tập</div>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button className={navBtn(learningActive)} onClick={() => setActiveTab('learning')}>
|
||||||
className={`nav-btn ${route.tab === 'learning' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('learning')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconLearning /></span>
|
<span className="nav-icon"><IconLearning /></span>
|
||||||
Phòng Học
|
Phòng Học
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button className={navBtn(examsActive)} onClick={() => setActiveTab('exams')}>
|
||||||
className={`nav-btn ${route.tab === 'exams' && !inWorkspace ? 'active' : ''}`}
|
|
||||||
onClick={() => navigate('exams')}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><IconExam /></span>
|
<span className="nav-icon"><IconExam /></span>
|
||||||
Phòng thi
|
Phòng thi
|
||||||
</button>
|
</button>
|
||||||
@@ -156,22 +205,25 @@ function App() {
|
|||||||
<div className="nav-header">Hệ thống</div>
|
<div className="nav-header">Hệ thống</div>
|
||||||
<li className="nav-item">
|
<li className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'email-domains' && !inWorkspace ? 'active' : ''}`}
|
className={navBtn(systemActive)}
|
||||||
onClick={() => navigate('email-domains')}
|
onClick={() => goSystem('organization')}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconEmail /></span>
|
<span className="nav-icon"><IconSystem /></span>
|
||||||
Đuôi email
|
Quản lý hệ thống
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="nav-item">
|
<ul className="nav-sub">
|
||||||
|
{SYSTEM_NAV.map(({ section }) => (
|
||||||
|
<li key={section} className="nav-item">
|
||||||
<button
|
<button
|
||||||
className={`nav-btn ${route.tab === 'network' && !inWorkspace ? 'active' : ''}`}
|
className={navBtn(systemActive && route.systemSection === section)}
|
||||||
onClick={() => navigate('network')}
|
onClick={() => goSystem(section)}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><IconNetwork /></span>
|
{SYSTEM_SECTION_LABELS[section]}
|
||||||
Quản lý mạng
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -179,7 +231,7 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
|
className={`sidebar-user-btn ${route.tab === 'profile' && !inWorkspace ? 'active' : ''}`}
|
||||||
onClick={() => navigate('profile')}
|
onClick={() => setActiveTab('profile')}
|
||||||
title="Tài khoản của tôi"
|
title="Tài khoản của tôi"
|
||||||
>
|
>
|
||||||
<span className="sidebar-user-name">
|
<span className="sidebar-user-name">
|
||||||
@@ -213,8 +265,7 @@ function App() {
|
|||||||
{route.tab === 'students' && <StudentsTab />}
|
{route.tab === 'students' && <StudentsTab />}
|
||||||
{route.tab === 'learning' && <LearningTab />}
|
{route.tab === 'learning' && <LearningTab />}
|
||||||
{route.tab === 'exams' && <ExamsTab />}
|
{route.tab === 'exams' && <ExamsTab />}
|
||||||
{route.tab === 'network' && <NetworkTab />}
|
{route.tab === 'system' && <SystemTab />}
|
||||||
{route.tab === 'email-domains' && <EmailDomainsTab />}
|
|
||||||
{route.tab === 'profile' && <MyAccountTab />}
|
{route.tab === 'profile' && <MyAccountTab />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -502,6 +502,38 @@ export const apiFetchAppPool = async (q = '', limit = 50): Promise<{ data: AppPo
|
|||||||
return res.json();
|
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 {
|
export interface WifiPoolItem {
|
||||||
id: number;
|
id: number;
|
||||||
ssid: string;
|
ssid: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { apiAdmin, type EmailDomainItem } from '../api';
|
|
||||||
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
import { useGitHubConnection } from '../hooks/useGitHubConnection';
|
||||||
import { GitHubReposPanel } from './GitHubReposPanel';
|
import { GitHubReposPanel } from './GitHubReposPanel';
|
||||||
|
import { OrganizationSection } from './OrganizationSection';
|
||||||
|
|
||||||
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
||||||
const { changePassword, logout } = useAuth();
|
const { changePassword, logout } = useAuth();
|
||||||
@@ -67,62 +67,12 @@ export function ChangePasswordPage({ forced }: { forced?: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmailDomainsTab() {
|
export function EmailDomainsTab() {
|
||||||
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
|
||||||
const [newDomain, setNewDomain] = useState('');
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
const res = await apiAdmin.listEmailDomains();
|
|
||||||
setDomains(res.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load().catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addDomain = async () => {
|
|
||||||
if (!newDomain.trim()) return;
|
|
||||||
await apiAdmin.addEmailDomain(newDomain.trim());
|
|
||||||
setNewDomain('');
|
|
||||||
await load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDomain = async (id: number) => {
|
|
||||||
if (!confirm('Xóa đuôi email này?')) return;
|
|
||||||
await apiAdmin.deleteEmailDomain(id);
|
|
||||||
await load();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="tab-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<h1 className="page-title">Đuôi email</h1>
|
<h1 className="page-title">Quản lý tổ chức</h1>
|
||||||
<p className="page-desc">Cấu hình đuôi email được phép đăng ký và đăng nhập.</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
<OrganizationSection />
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
|
||||||
<h2 className="section-title">Đuôi email được phép</h2>
|
|
||||||
<p className="page-desc" style={{ marginTop: 0 }}>
|
|
||||||
Nếu chưa có bản ghi nào, hệ thống chấp nhận mọi đuôi email. Thêm đuôi để giới hạn truy cập.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
|
||||||
<input
|
|
||||||
placeholder="vd: rikkeiacademy.com"
|
|
||||||
value={newDomain}
|
|
||||||
onChange={(e) => setNewDomain(e.target.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={addDomain}>Thêm</button>
|
|
||||||
</div>
|
|
||||||
<ul className="domain-list">
|
|
||||||
{domains.length === 0 && <li className="domain-empty">Chưa cấu hình — chấp nhận tất cả đuôi email</li>}
|
|
||||||
{domains.map((d) => (
|
|
||||||
<li key={d.id} className="domain-item">
|
|
||||||
<span>@{d.domain}</span>
|
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>Xóa</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
131
management/src/components/AppTemplatePickerModal.tsx
Normal file
131
management/src/components/AppTemplatePickerModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||||
|
import { countKeywords } from '../utils/appKeywords';
|
||||||
|
|
||||||
|
interface AppTemplatePickerModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (keywords: string) => void;
|
||||||
|
allowedApps: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppTemplatePickerModal: React.FC<AppTemplatePickerModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
allowedApps,
|
||||||
|
}) => {
|
||||||
|
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await apiAppTemplates.list();
|
||||||
|
setItems(res.data || []);
|
||||||
|
} catch (e: any) {
|
||||||
|
setItems([]);
|
||||||
|
setError(e?.message || 'Không tải được khung ứng dụng');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearch('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? items.filter(
|
||||||
|
(t) =>
|
||||||
|
t.name.toLowerCase().includes(q) ||
|
||||||
|
t.description?.toLowerCase().includes(q) ||
|
||||||
|
t.keywords.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
: items;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-container app-pool-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title" style={{ margin: 0 }}>Chọn khung ứng dụng</h2>
|
||||||
|
<p className="app-pool-modal-sub">
|
||||||
|
Ghép bộ keyword đã lưu vào danh sách ứng dụng được phép. Quản lý khung tại Hệ thống → Khung ứng dụng.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-pool-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="app-pool-search"
|
||||||
|
placeholder="Tìm theo tên, mô tả, keyword..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={load} disabled={loading}>
|
||||||
|
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-pool-body">
|
||||||
|
{error ? (
|
||||||
|
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||||
|
) : loading && items.length === 0 ? (
|
||||||
|
<div className="app-pool-status">Đang tải...</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="app-pool-status">
|
||||||
|
{q ? `Không tìm thấy "${search}"` : 'Chưa có khung nào. Tạo tại Hệ thống → Khung ứng dụng.'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="template-picker-list">
|
||||||
|
{filtered.map((tpl) => (
|
||||||
|
<div key={tpl.id} className="template-picker-card">
|
||||||
|
<div className="template-picker-card-head">
|
||||||
|
<strong>{tpl.name}</strong>
|
||||||
|
<span className="template-picker-count">{countKeywords(tpl.keywords)} keyword</span>
|
||||||
|
</div>
|
||||||
|
{tpl.description && <p className="template-picker-desc">{tpl.description}</p>}
|
||||||
|
<code className="template-picker-kw">{tpl.keywords}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm template-picker-apply"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(tpl.keywords);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Áp dụng khung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="app-pool-footer">
|
||||||
|
Đang có {countKeywords(allowedApps)} keyword trong whitelist hiện tại
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
210
management/src/components/AppTemplatesSection.tsx
Normal file
210
management/src/components/AppTemplatesSection.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiAppTemplates, type AppTemplateItem } from '../api';
|
||||||
|
import { AppPoolModal } from './AppPoolModal';
|
||||||
|
import { countKeywords, mergeKeywordCSV } from '../utils/appKeywords';
|
||||||
|
|
||||||
|
export function AppTemplatesSection() {
|
||||||
|
const [items, setItems] = useState<AppTemplateItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState<AppTemplateItem | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [keywords, setKeywords] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [poolOpen, setPoolOpen] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAppTemplates.list();
|
||||||
|
setItems(res.data || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setCreating(true);
|
||||||
|
setEditing(null);
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setKeywords('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (tpl: AppTemplateItem) => {
|
||||||
|
setEditing(tpl);
|
||||||
|
setCreating(false);
|
||||||
|
setName(tpl.name);
|
||||||
|
setDescription(tpl.description || '');
|
||||||
|
setKeywords(tpl.keywords);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError('Nhập tên khung.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!keywords.trim()) {
|
||||||
|
setError('Thêm ít nhất một keyword.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await apiAppTemplates.update(editing.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
keywords,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiAppTemplates.create({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
keywords,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message || 'Lỗi lưu khung');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (tpl: AppTemplateItem) => {
|
||||||
|
if (!confirm(`Xóa khung "${tpl.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await apiAppTemplates.delete(tpl.id);
|
||||||
|
if (editing?.id === tpl.id) closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Không xóa được');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPoolKeyword = (kw: string) => {
|
||||||
|
setKeywords((prev) => mergeKeywordCSV(prev, kw));
|
||||||
|
};
|
||||||
|
|
||||||
|
const showForm = creating || editing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-section">
|
||||||
|
<div className="system-block">
|
||||||
|
<div className="system-block-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="system-block-title">Khung ứng dụng</h2>
|
||||||
|
<p className="system-block-desc">
|
||||||
|
Tạo bộ keyword whitelist dùng chung — ghép từ kho app hoặc tự nhập. Lớp học và phòng thi có thể áp dụng nhanh thay vì chỉ chọn từng app trong kho.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||||
|
+ Tạo khung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="system-form-card">
|
||||||
|
<h3 className="system-form-title">{editing ? 'Sửa khung' : 'Khung mới'}</h3>
|
||||||
|
<div className="system-form-grid">
|
||||||
|
<label className="login-field">
|
||||||
|
<span>Tên khung</span>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="VD: Java IDE, Thi cuối kỳ..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="login-field system-form-full">
|
||||||
|
<span>Mô tả (tuỳ chọn)</span>
|
||||||
|
<input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Ghi chú ngắn cho giáo viên"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="login-field system-form-full">
|
||||||
|
<span>Keywords (phân tách bằng dấu phẩy)</span>
|
||||||
|
<textarea
|
||||||
|
className="app-textarea"
|
||||||
|
rows={4}
|
||||||
|
value={keywords}
|
||||||
|
onChange={(e) => setKeywords(e.target.value)}
|
||||||
|
placeholder="chrome, vscode, cursor, goland, client"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="system-form-actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
|
||||||
|
Thêm từ kho app
|
||||||
|
</button>
|
||||||
|
<div className="system-form-actions-right">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={closeForm}>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
|
||||||
|
{busy ? 'Đang lưu...' : 'Lưu khung'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="system-empty">Đang tải...</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="system-empty">
|
||||||
|
Chưa có khung nào. Bấm <strong>Tạo khung</strong> để ghép keyword từ kho hoặc tự ghi.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="template-grid">
|
||||||
|
{items.map((tpl) => (
|
||||||
|
<article key={tpl.id} className="template-card">
|
||||||
|
<div className="template-card-head">
|
||||||
|
<h3>{tpl.name}</h3>
|
||||||
|
<span className="template-card-badge">{countKeywords(tpl.keywords)} kw</span>
|
||||||
|
</div>
|
||||||
|
{tpl.description && <p className="template-card-desc">{tpl.description}</p>}
|
||||||
|
<code className="template-card-kw">{tpl.keywords}</code>
|
||||||
|
<div className="template-card-actions">
|
||||||
|
<button type="button" className="btn btn-secondary btn-sm" onClick={() => openEdit(tpl)}>
|
||||||
|
Sửa
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => remove(tpl)}>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppPoolModal
|
||||||
|
open={poolOpen}
|
||||||
|
onClose={() => setPoolOpen(false)}
|
||||||
|
onSelect={addPoolKeyword}
|
||||||
|
allowedApps={keywords}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ import { AttendancePanel } from './AttendancePanel';
|
|||||||
import { StudentAvatar } from './StudentAvatar';
|
import { StudentAvatar } from './StudentAvatar';
|
||||||
import { StudentDetailModal } from './StudentDetailModal';
|
import { StudentDetailModal } from './StudentDetailModal';
|
||||||
import { AppPoolModal } from './AppPoolModal';
|
import { AppPoolModal } from './AppPoolModal';
|
||||||
|
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||||
|
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||||
|
|
||||||
interface ClassWorkspaceProps {
|
interface ClassWorkspaceProps {
|
||||||
classId: number;
|
classId: number;
|
||||||
@@ -52,6 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
const [configOpen, setConfigOpen] = useState(false);
|
const [configOpen, setConfigOpen] = useState(false);
|
||||||
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
|
||||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||||
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||||
|
|
||||||
const loadAllData = async () => {
|
const loadAllData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -177,6 +180,10 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyTemplate = (keywords: string) => {
|
||||||
|
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveAllowedApps = async () => {
|
const handleSaveAllowedApps = async () => {
|
||||||
try {
|
try {
|
||||||
setSavingApps(true);
|
setSavingApps(true);
|
||||||
@@ -329,19 +336,28 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
</div>
|
</div>
|
||||||
<div className="app-pool-open-row">
|
<div className="app-pool-open-row">
|
||||||
<div>
|
<div>
|
||||||
<span className="config-suggestions-label">Kho ứng dụng (toàn hệ thống)</span>
|
<span className="config-suggestions-label">Kho & khung ứng dụng</span>
|
||||||
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
||||||
App bị chặn từ mọi lớp — mở kho để tìm và thêm nhanh.
|
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="app-pool-open-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary app-pool-open-btn"
|
||||||
|
onClick={() => setTemplatePickerOpen(true)}
|
||||||
|
>
|
||||||
|
Chọn khung
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary app-pool-open-btn"
|
className="btn btn-secondary app-pool-open-btn"
|
||||||
onClick={() => setAppPoolOpen(true)}
|
onClick={() => setAppPoolOpen(true)}
|
||||||
>
|
>
|
||||||
Mở kho & tìm kiếm
|
Mở kho
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
style={{ width: '100%', justifyContent: 'center' }}
|
style={{ width: '100%', justifyContent: 'center' }}
|
||||||
@@ -539,6 +555,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
|
|||||||
onSelect={addAppKeyword}
|
onSelect={addAppKeyword}
|
||||||
allowedApps={allowedApps}
|
allowedApps={allowedApps}
|
||||||
/>
|
/>
|
||||||
|
<AppTemplatePickerModal
|
||||||
|
open={templatePickerOpen}
|
||||||
|
onClose={() => setTemplatePickerOpen(false)}
|
||||||
|
onSelect={applyTemplate}
|
||||||
|
allowedApps={allowedApps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { navigate, pushNav } from '../navigation';
|
|||||||
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
|
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
|
||||||
import { openStaffChat } from '../chatEvents';
|
import { openStaffChat } from '../chatEvents';
|
||||||
import { AppPoolModal } from './AppPoolModal';
|
import { AppPoolModal } from './AppPoolModal';
|
||||||
|
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
|
||||||
|
import { mergeKeywordCSV } from '../utils/appKeywords';
|
||||||
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
|
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
|
||||||
import { NavHistoryBar } from './NavHistoryBar';
|
import { NavHistoryBar } from './NavHistoryBar';
|
||||||
import { StudentAvatar } from './StudentAvatar';
|
import { StudentAvatar } from './StudentAvatar';
|
||||||
@@ -75,6 +77,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
const [appPoolOpen, setAppPoolOpen] = useState(false);
|
||||||
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||||
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
|
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -177,6 +180,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyTemplate = (keywords: string) => {
|
||||||
|
setAllowedApps((prev) => mergeKeywordCSV(prev, keywords));
|
||||||
|
};
|
||||||
|
|
||||||
const saveRoom = async () => {
|
const saveRoom = async () => {
|
||||||
setErr(''); setMsg('');
|
setErr(''); setMsg('');
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -623,14 +630,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="app-pool-open-row">
|
<div className="app-pool-open-row">
|
||||||
<div>
|
<div>
|
||||||
<span className="config-suggestions-label">Kho ứng dụng (toàn hệ thống)</span>
|
<span className="config-suggestions-label">Kho & khung ứng dụng</span>
|
||||||
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
<p className="discovered-apps-hint" style={{ margin: '0.25rem 0 0' }}>
|
||||||
App bị chặn từ mọi lớp — mở kho để tìm và thêm nhanh.
|
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
<div className="app-pool-open-actions">
|
||||||
Mở kho & tìm kiếm
|
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setTemplatePickerOpen(true)}>
|
||||||
|
Chọn khung
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
|
||||||
|
Mở kho
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
<button type="button" className="btn btn-primary" style={{ width: '100%', justifyContent: 'center' }} disabled={saving} onClick={saveRoom}>
|
||||||
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
|
{saving ? 'Đang lưu...' : 'Lưu cấu hình apps'}
|
||||||
@@ -1221,6 +1233,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
|||||||
onSelect={addAppKeyword}
|
onSelect={addAppKeyword}
|
||||||
allowedApps={allowedApps}
|
allowedApps={allowedApps}
|
||||||
/>
|
/>
|
||||||
|
<AppTemplatePickerModal
|
||||||
|
open={templatePickerOpen}
|
||||||
|
onClose={() => setTemplatePickerOpen(false)}
|
||||||
|
onSelect={applyTemplate}
|
||||||
|
allowedApps={allowedApps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { apiExam, type ExamRoomItem } from '../api';
|
import { apiExam, type ExamRoomItem } from '../api';
|
||||||
import { openExam } from './NavHistoryBar';
|
import { openExam } from './NavHistoryBar';
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ export const ExamsTab: React.FC = () => {
|
|||||||
const [start, setStart] = useState('');
|
const [start, setStart] = useState('');
|
||||||
const [end, setEnd] = useState('');
|
const [end, setEnd] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -52,7 +53,24 @@ export const ExamsTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const active = rooms.filter((r) => (r.displayStatus || r.status) === 'active').length;
|
||||||
|
const upcoming = rooms.filter((r) => {
|
||||||
|
const st = r.displayStatus || r.status;
|
||||||
|
return st === 'ready' || st === 'draft';
|
||||||
|
}).length;
|
||||||
|
return { total: rooms.length, active, upcoming };
|
||||||
|
}, [rooms]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return rooms;
|
||||||
|
return rooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
|
}, [rooms, search]);
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
if (!name.trim() || !start || !end) return;
|
if (!name.trim() || !start || !end) return;
|
||||||
@@ -65,6 +83,9 @@ export const ExamsTab: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
setName('');
|
setName('');
|
||||||
|
setStart('');
|
||||||
|
setEnd('');
|
||||||
|
await load();
|
||||||
openExam(room.id, room.name);
|
openExam(room.id, room.name);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message || 'Lỗi');
|
alert(e?.message || 'Lỗi');
|
||||||
@@ -74,20 +95,47 @@ export const ExamsTab: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="tab-page exams-page">
|
||||||
<header className="page-header page-header--row">
|
<header className="page-header page-header--row">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Phòng thi</h1>
|
<h1 className="page-title">Danh sách phòng thi</h1>
|
||||||
<p className="page-desc">Tạo phòng thi, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
<p className="page-desc">Tạo phòng, chia đề ngẫu nhiên, gửi đề và thu bài từ sinh viên.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>+ Tạo phòng thi</button>
|
<button type="button" className="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||||
|
+ Tạo phòng thi
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="exams-stats-row">
|
||||||
|
<div className="learning-stat">
|
||||||
|
<span className="learning-stat-value">{stats.total}</span>
|
||||||
|
<span className="learning-stat-label">Tổng phòng</span>
|
||||||
|
</div>
|
||||||
|
<div className="learning-stat learning-stat--accent">
|
||||||
|
<span className="learning-stat-value">{stats.active}</span>
|
||||||
|
<span className="learning-stat-label">Đang thi</span>
|
||||||
|
</div>
|
||||||
|
<div className="learning-stat">
|
||||||
|
<span className="learning-stat-value">{stats.upcoming}</span>
|
||||||
|
<span className="learning-stat-label">Sắp / tạm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="exams-toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="app-pool-search exams-search"
|
||||||
|
placeholder="Tìm theo tên phòng thi..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
<div className="system-form-card exams-create-card">
|
||||||
<h2 className="section-title">Phòng thi mới</h2>
|
<h2 className="system-form-title">Phòng thi mới</h2>
|
||||||
<div className="form-grid" style={{ maxWidth: 520 }}>
|
<div className="system-form-grid">
|
||||||
<label className="login-field">
|
<label className="login-field system-form-full">
|
||||||
<span>Tên phòng thi</span>
|
<span>Tên phòng thi</span>
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
|
||||||
</label>
|
</label>
|
||||||
@@ -100,48 +148,56 @@ export const ExamsTab: React.FC = () => {
|
|||||||
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
|
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
<div className="system-form-actions">
|
||||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
|
<div />
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
|
<div className="system-form-actions-right">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>
|
||||||
|
Hủy
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>
|
||||||
|
{busy ? 'Đang tạo...' : 'Tạo & mở phòng'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card table-card">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="table-empty">Đang tải...</div>
|
<div className="system-empty">Đang tải...</div>
|
||||||
) : rooms.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="table-empty">Chưa có phòng thi nào.</div>
|
<div className="system-empty">
|
||||||
|
{search.trim() ? 'Không tìm thấy phòng thi phù hợp.' : 'Chưa có phòng thi — bấm Tạo phòng thi để bắt đầu.'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table">
|
<div className="exams-card-grid">
|
||||||
<thead>
|
{filtered.map((r) => {
|
||||||
<tr>
|
|
||||||
<th>Tên</th>
|
|
||||||
<th>Thời gian</th>
|
|
||||||
<th>SV / Đề</th>
|
|
||||||
<th>Trạng thái</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rooms.map((r) => {
|
|
||||||
const st = statusLabel(r.displayStatus || r.status);
|
const st = statusLabel(r.displayStatus || r.status);
|
||||||
return (
|
return (
|
||||||
<tr key={r.id}>
|
<article key={r.id} className="exam-list-card">
|
||||||
<td><strong>{r.name}</strong></td>
|
<div className="exam-list-card-head">
|
||||||
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} — {fmtTime(r.endTime)}</td>
|
<h3>{r.name}</h3>
|
||||||
<td>{r.studentCount} SV · {r.paperCount} đề</td>
|
<span className={`badge ${st.cls}`}>{st.text}</span>
|
||||||
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
|
</div>
|
||||||
<td>
|
<div className="exam-list-card-meta">
|
||||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
|
<div>
|
||||||
</td>
|
<span className="exam-list-card-label">Thời gian</span>
|
||||||
</tr>
|
<span>{fmtTime(r.startTime)}</span>
|
||||||
|
<span className="exam-list-card-sep">→</span>
|
||||||
|
<span>{fmtTime(r.endTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="exam-list-card-label">Quy mô</span>
|
||||||
|
<span>{r.studentCount} sinh viên · {r.paperCount} đề</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm exam-list-card-open" onClick={() => openExam(r.id, r.name)}>
|
||||||
|
Mở phòng thi
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
TAB_LABELS,
|
TAB_LABELS,
|
||||||
|
SYSTEM_SECTION_LABELS,
|
||||||
type NavEntry,
|
type NavEntry,
|
||||||
type TabId,
|
type TabId,
|
||||||
parseRoute,
|
parseRoute,
|
||||||
@@ -24,7 +25,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
return () => window.removeEventListener('popstate', refresh);
|
return () => window.removeEventListener('popstate', refresh);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentTabLabel = TAB_LABELS[route.tab];
|
const currentTabLabel =
|
||||||
|
route.tab === 'system'
|
||||||
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[route.systemSection]}`
|
||||||
|
: TAB_LABELS[route.tab];
|
||||||
const recent = history.slice(0, 6);
|
const recent = history.slice(0, 6);
|
||||||
|
|
||||||
const handlePillClick = (entry: NavEntry) => {
|
const handlePillClick = (entry: NavEntry) => {
|
||||||
@@ -32,6 +36,8 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
navigate(entry.tab, entry.classId, entry.label, 'class');
|
navigate(entry.tab, entry.classId, entry.label, 'class');
|
||||||
} else if (entry.kind === 'exam' && entry.examId) {
|
} else if (entry.kind === 'exam' && entry.examId) {
|
||||||
navigate(entry.tab, entry.examId, entry.label, 'exam');
|
navigate(entry.tab, entry.examId, entry.label, 'exam');
|
||||||
|
} else if (entry.tab === 'system' && entry.systemSection) {
|
||||||
|
navigate('system', null, undefined, 'class', entry.systemSection);
|
||||||
} else {
|
} else {
|
||||||
navigate(entry.tab);
|
navigate(entry.tab);
|
||||||
}
|
}
|
||||||
@@ -44,6 +50,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
if (entry.kind === 'exam' && route.examId) {
|
if (entry.kind === 'exam' && route.examId) {
|
||||||
return entry.examId === route.examId;
|
return entry.examId === route.examId;
|
||||||
}
|
}
|
||||||
|
if (entry.kind === 'tab' && route.tab === 'system') {
|
||||||
|
return !route.classId && !route.examId && entry.tab === 'system' && entry.systemSection === route.systemSection;
|
||||||
|
}
|
||||||
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
|
return entry.kind === 'tab' && !route.classId && !route.examId && entry.tab === route.tab;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +70,13 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
|
|||||||
Simple Care
|
Simple Care
|
||||||
</button>
|
</button>
|
||||||
<span className="breadcrumb-sep">/</span>
|
<span className="breadcrumb-sep">/</span>
|
||||||
<button type="button" className="breadcrumb-link" onClick={() => navigate(route.tab)}>
|
<button type="button" className="breadcrumb-link" onClick={() => {
|
||||||
|
if (route.tab === 'system') {
|
||||||
|
navigate('system', null, undefined, 'class', route.systemSection);
|
||||||
|
} else {
|
||||||
|
navigate(route.tab);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
{currentTabLabel}
|
{currentTabLabel}
|
||||||
</button>
|
</button>
|
||||||
{route.classId && classLabel && (
|
{route.classId && classLabel && (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function formatBssid(raw: string): string | null {
|
|||||||
return key.match(/.{2}/g)!.join(':');
|
return key.match(/.{2}/g)!.join(':');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NetworkTab: React.FC = () => {
|
export const NetworkSection: React.FC = () => {
|
||||||
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
|
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,8 +31,8 @@ export const NetworkTab: React.FC = () => {
|
|||||||
const res = await apiFetchAcceptedWifis();
|
const res = await apiFetchAcceptedWifis();
|
||||||
setAcceptedItems(
|
setAcceptedItems(
|
||||||
(res.data || [])
|
(res.data || [])
|
||||||
.filter(r => r.ssid && r.bssid)
|
.filter((r) => r.ssid && r.bssid)
|
||||||
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
|
.map((r) => ({ ssid: r.ssid, bssid: r.bssid })),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setAcceptedItems([]);
|
setAcceptedItems([]);
|
||||||
@@ -50,15 +50,15 @@ export const NetworkTab: React.FC = () => {
|
|||||||
const trimmedBssid = bssid.trim();
|
const trimmedBssid = bssid.trim();
|
||||||
const key = bssidKey(trimmedBssid);
|
const key = bssidKey(trimmedBssid);
|
||||||
if (!trimmedSsid || key.length !== 12) return;
|
if (!trimmedSsid || key.length !== 12) return;
|
||||||
setAcceptedItems(prev => {
|
setAcceptedItems((prev) => {
|
||||||
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
|
if (prev.some((item) => bssidKey(item.bssid) === key)) return prev;
|
||||||
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
|
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeWifi = (bssid: string) => {
|
const removeWifi = (bssid: string) => {
|
||||||
const key = bssidKey(bssid);
|
const key = bssidKey(bssid);
|
||||||
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
|
setAcceptedItems((prev) => prev.filter((item) => bssidKey(item.bssid) !== key));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleManualAdd = () => {
|
const handleManualAdd = () => {
|
||||||
@@ -73,11 +73,11 @@ export const NetworkTab: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = bssidKey(bssid);
|
const key = bssidKey(bssid);
|
||||||
if (acceptedItems.some(item => bssidKey(item.bssid) === key)) {
|
if (acceptedItems.some((item) => bssidKey(item.bssid) === key)) {
|
||||||
setManualError('BSSID này đã có trong danh sách.');
|
setManualError('BSSID này đã có trong danh sách.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
|
setAcceptedItems((prev) => [...prev, { ssid, bssid }]);
|
||||||
setManualSsid('');
|
setManualSsid('');
|
||||||
setManualBssid('');
|
setManualBssid('');
|
||||||
setManualError(null);
|
setManualError(null);
|
||||||
@@ -96,41 +96,36 @@ export const NetworkTab: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptedBssidKeys = acceptedItems.map(item => bssidKey(item.bssid)).join(',');
|
const acceptedBssidKeys = acceptedItems.map((item) => bssidKey(item.bssid)).join(',');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="system-section system-section--grid">
|
||||||
<div className="page-header">
|
<div className="system-block">
|
||||||
|
<div className="system-block-head">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Quản lý mạng</h1>
|
<h2 className="system-block-title">Gói mạng được chấp nhận</h2>
|
||||||
<p className="page-subtitle">
|
<p className="system-block-desc">
|
||||||
Cấu hình điểm phát WiFi được phép (SSID + BSSID/MAC) áp dụng <strong>toàn hệ thống</strong>.
|
SSID + BSSID (MAC điểm phát) áp dụng toàn hệ thống. Hotspot giả trùng tên nhưng MAC khác sẽ bị chặn.
|
||||||
Sinh viên không thể giả mạo bằng hotspot trùng tên.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="system-stat-pill">{acceptedItems.length} điểm phát</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="network-grid">
|
|
||||||
<div className="card config-card-flat">
|
|
||||||
<div className="card-header-title">WiFi được chấp nhận</div>
|
|
||||||
<p className="config-card-desc">
|
|
||||||
Chọn từ kho WiFi hoặc thêm thủ công SSID + BSSID. Để trống = chưa bật kiểm tra WiFi.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="network-manual-form"
|
className="network-manual-form"
|
||||||
onSubmit={e => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleManualAdd();
|
handleManualAdd();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="network-manual-hint">Thêm thủ công khi cần (vd: lấy BSSID từ router hoặc lệnh netsh)</p>
|
<p className="network-manual-hint">Thêm thủ công (router, lệnh netsh...)</p>
|
||||||
|
<div className="system-inline-form system-inline-form--stack">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="app-pool-search"
|
className="app-pool-search"
|
||||||
placeholder="SSID (tên WiFi)"
|
placeholder="SSID (tên WiFi)"
|
||||||
value={manualSsid}
|
value={manualSsid}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
setManualSsid(e.target.value);
|
setManualSsid(e.target.value);
|
||||||
setManualError(null);
|
setManualError(null);
|
||||||
}}
|
}}
|
||||||
@@ -141,7 +136,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
className="app-pool-search"
|
className="app-pool-search"
|
||||||
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
|
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
|
||||||
value={manualBssid}
|
value={manualBssid}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
setManualBssid(e.target.value);
|
setManualBssid(e.target.value);
|
||||||
setManualError(null);
|
setManualError(null);
|
||||||
}}
|
}}
|
||||||
@@ -151,17 +146,19 @@ export const NetworkTab: React.FC = () => {
|
|||||||
<button type="submit" className="btn btn-secondary" disabled={loading}>
|
<button type="submit" className="btn btn-secondary" disabled={loading}>
|
||||||
+ Thêm thủ công
|
+ Thêm thủ công
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
{manualError && <p className="network-manual-error">{manualError}</p>}
|
{manualError && <p className="network-manual-error">{manualError}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="app-pool-status">Đang tải...</div>
|
<div className="system-empty">Đang tải...</div>
|
||||||
) : acceptedItems.length === 0 ? (
|
) : acceptedItems.length === 0 ? (
|
||||||
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
|
<div className="system-empty system-empty--muted">
|
||||||
Chưa có điểm phát nào. Mở kho WiFi để thêm từ máy sinh viên đang kết nối đúng mạng trường.
|
Chưa có điểm phát. Mở kho WiFi để thêm từ máy sinh viên đang kết nối đúng mạng trường.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
|
<div className="system-table-wrap">
|
||||||
|
<table className="data-table app-pool-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>SSID</th>
|
<th>SSID</th>
|
||||||
@@ -170,7 +167,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{acceptedItems.map(item => (
|
{acceptedItems.map((item) => (
|
||||||
<tr key={bssidKey(item.bssid)}>
|
<tr key={bssidKey(item.bssid)}>
|
||||||
<td><code className="app-pool-kw">{item.ssid}</code></td>
|
<td><code className="app-pool-kw">{item.ssid}</code></td>
|
||||||
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||||
@@ -179,7 +176,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary app-pool-add-btn"
|
className="btn btn-ghost btn-sm"
|
||||||
onClick={() => removeWifi(item.bssid)}
|
onClick={() => removeWifi(item.bssid)}
|
||||||
>
|
>
|
||||||
Xóa
|
Xóa
|
||||||
@@ -189,6 +186,7 @@ export const NetworkTab: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="network-actions">
|
<div className="network-actions">
|
||||||
@@ -201,16 +199,14 @@ export const NetworkTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card config-card-flat network-info-card">
|
<div className="system-block system-block--info">
|
||||||
<div className="card-header-title">Cách hoạt động</div>
|
<h3 className="system-info-title">Cách hoạt động</h3>
|
||||||
<ul className="network-info-list">
|
<ul className="network-info-list">
|
||||||
<li>Sinh viên kết nối WiFi → app gửi <strong>SSID + BSSID</strong> (MAC điểm phát) lên kho</li>
|
<li>Sinh viên kết nối WiFi → app gửi <strong>SSID + BSSID</strong> lên kho</li>
|
||||||
<li>Thầy cô chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
|
<li>Chọn từ kho hoặc thêm thủ công SSID + BSSID</li>
|
||||||
<li>Hotspot giả trùng tên nhưng MAC khác → app báo lỗi và thoát</li>
|
|
||||||
<li>Chưa cấu hình → không chặn (để thu thập kho trước)</li>
|
<li>Chưa cấu hình → không chặn (để thu thập kho trước)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<WifiPoolModal
|
<WifiPoolModal
|
||||||
open={poolOpen}
|
open={poolOpen}
|
||||||
@@ -221,3 +217,13 @@ export const NetworkTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @deprecated Dùng SystemTab → Gói mạng */
|
||||||
|
export const NetworkTab: React.FC = () => (
|
||||||
|
<div className="tab-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<h1 className="page-title">Quản lý mạng</h1>
|
||||||
|
</header>
|
||||||
|
<NetworkSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
103
management/src/components/OrganizationSection.tsx
Normal file
103
management/src/components/OrganizationSection.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiAdmin, type EmailDomainItem } from '../api';
|
||||||
|
|
||||||
|
export function OrganizationSection() {
|
||||||
|
const [domains, setDomains] = useState<EmailDomainItem[]>([]);
|
||||||
|
const [newDomain, setNewDomain] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdmin.listEmailDomains();
|
||||||
|
setDomains(res.data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load().catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addDomain = async () => {
|
||||||
|
if (!newDomain.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiAdmin.addEmailDomain(newDomain.trim());
|
||||||
|
setNewDomain('');
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Không thêm được');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDomain = async (id: number) => {
|
||||||
|
if (!confirm('Xóa đuôi email này?')) return;
|
||||||
|
try {
|
||||||
|
await apiAdmin.deleteEmailDomain(id);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e?.message || 'Không xóa được');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="system-section">
|
||||||
|
<div className="system-block">
|
||||||
|
<div className="system-block-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="system-block-title">Đuôi email được phép</h2>
|
||||||
|
<p className="system-block-desc">
|
||||||
|
Giới hạn tài khoản giáo viên / nhân sự theo tổ chức. Chưa cấu hình = chấp nhận mọi đuôi email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="system-stat-pill">{domains.length} đuôi</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="system-inline-form">
|
||||||
|
<input
|
||||||
|
placeholder="vd: rikkeiacademy.com"
|
||||||
|
value={newDomain}
|
||||||
|
onChange={(e) => setNewDomain(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addDomain()}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={addDomain} disabled={busy}>
|
||||||
|
Thêm đuôi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="system-empty">Đang tải...</div>
|
||||||
|
) : domains.length === 0 ? (
|
||||||
|
<div className="system-empty system-empty--muted">
|
||||||
|
Chưa giới hạn đuôi email — mọi địa chỉ đều có thể đăng ký.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="system-domain-list">
|
||||||
|
{domains.map((d) => (
|
||||||
|
<li key={d.id} className="system-domain-item">
|
||||||
|
<span className="system-domain-label">@{d.domain}</span>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => removeDomain(d.id)}>
|
||||||
|
Xóa
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="system-block system-block--info">
|
||||||
|
<h3 className="system-info-title">Gợi ý</h3>
|
||||||
|
<ul className="network-info-list">
|
||||||
|
<li>Thêm từng đuôi email thuộc tổ chức (vd: <code>rikkei.edu.vn</code>)</li>
|
||||||
|
<li>Sinh viên đăng nhập client không bị ảnh hưởng bởi cấu hình này</li>
|
||||||
|
<li>Chỉ áp dụng cho tài khoản quản lý trên portal này</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
management/src/components/SystemTab.tsx
Normal file
92
management/src/components/SystemTab.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
navigateSystem,
|
||||||
|
parseRoute,
|
||||||
|
SYSTEM_SECTION_LABELS,
|
||||||
|
type SystemSection,
|
||||||
|
} from '../navigation';
|
||||||
|
import { OrganizationSection } from './OrganizationSection';
|
||||||
|
import { NetworkSection } from './NetworkSection';
|
||||||
|
import { AppTemplatesSection } from './AppTemplatesSection';
|
||||||
|
|
||||||
|
const SECTIONS: { id: SystemSection; icon: React.ReactNode; hint: string }[] = [
|
||||||
|
{
|
||||||
|
id: 'organization',
|
||||||
|
hint: 'Đuôi email & tổ chức',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 21h18" /><path d="M5 21V7l8-4v18" /><path d="M19 21V11l-6-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'network',
|
||||||
|
hint: 'WiFi được phép',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||||
|
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||||
|
<circle cx="12" cy="20" r="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'templates',
|
||||||
|
hint: 'Bộ keyword app',
|
||||||
|
icon: (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SystemTab: React.FC = () => {
|
||||||
|
const [section, setSection] = useState<SystemSection>(() => parseRoute().systemSection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setSection(parseRoute().systemSection);
|
||||||
|
window.addEventListener('popstate', sync);
|
||||||
|
return () => window.removeEventListener('popstate', sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectSection = (id: SystemSection) => {
|
||||||
|
setSection(id);
|
||||||
|
navigateSystem(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-page system-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<h1 className="page-title">Quản lý hệ thống</h1>
|
||||||
|
<p className="page-desc">
|
||||||
|
Cấu hình tổ chức, mạng WiFi và khung ứng dụng dùng chung cho toàn hệ thống.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav className="system-subnav" aria-label="Mục hệ thống">
|
||||||
|
{SECTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className={`system-subnav-btn ${section === s.id ? 'active' : ''}`}
|
||||||
|
onClick={() => selectSection(s.id)}
|
||||||
|
>
|
||||||
|
<span className="system-subnav-icon">{s.icon}</span>
|
||||||
|
<span className="system-subnav-text">
|
||||||
|
<strong>{SYSTEM_SECTION_LABELS[s.id]}</strong>
|
||||||
|
<small>{s.hint}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="system-panel">
|
||||||
|
{section === 'organization' && <OrganizationSection />}
|
||||||
|
{section === 'network' && <NetworkSection />}
|
||||||
|
{section === 'templates' && <AppTemplatesSection />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4505,3 +4505,549 @@ input:checked + .slider:before {
|
|||||||
.exam-git-last a {
|
.exam-git-last a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile sidebar drawer ── */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
z-index: 30;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 19;
|
||||||
|
background: rgba(15, 23, 42, 0.45);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-sub {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 0.35rem 0;
|
||||||
|
padding: 0 0 0 2.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-sub .nav-btn {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-sub .nav-btn::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-sub .nav-btn.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-backdrop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-110%);
|
||||||
|
transition: transform 0.28s ease;
|
||||||
|
z-index: 25;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 3.75rem 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── System management ── */
|
||||||
|
.system-page {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-btn:hover {
|
||||||
|
border-color: rgba(198, 40, 40, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-light);
|
||||||
|
box-shadow: 0 0 0 1px rgba(198, 40, 40, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-btn.active .system-subnav-icon {
|
||||||
|
background: rgba(198, 40, 40, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-text strong {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-subnav-text small {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-panel {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-section--grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 280px;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-block {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.15rem 1.25rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-block--info {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-block-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-block-title {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-block-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-stat-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-inline-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-inline-form input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-inline-form--stack {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-empty {
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-empty--muted {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-domain-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-domain-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-domain-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-info-title {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-card {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-title {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-actions-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-kw {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 4.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-count {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-desc {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-kw {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-picker-apply {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-pool-open-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Exams list cards ── */
|
||||||
|
.exams-page {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exams-stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exams-toolbar {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exams-search {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exams-create-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exams-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card:hover {
|
||||||
|
border-color: rgba(198, 40, 40, 0.3);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-meta > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exam-list-card-open {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.system-subnav {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-section--grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exams-stats-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'network' | 'email-domains' | 'profile';
|
export type SystemSection = 'organization' | 'network' | 'templates';
|
||||||
|
|
||||||
|
export type TabId = 'dashboard' | 'classes' | 'students' | 'learning' | 'exams' | 'system' | 'profile';
|
||||||
|
|
||||||
export interface NavEntry {
|
export interface NavEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6,18 +8,24 @@ export interface NavEntry {
|
|||||||
tab: TabId;
|
tab: TabId;
|
||||||
classId?: number;
|
classId?: number;
|
||||||
examId?: number;
|
examId?: number;
|
||||||
|
systemSection?: SystemSection;
|
||||||
label: string;
|
label: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SYSTEM_SECTION_LABELS: Record<SystemSection, string> = {
|
||||||
|
organization: 'Tổ chức',
|
||||||
|
network: 'Gói mạng',
|
||||||
|
templates: 'Khung ứng dụng',
|
||||||
|
};
|
||||||
|
|
||||||
export const TAB_LABELS: Record<TabId, string> = {
|
export const TAB_LABELS: Record<TabId, string> = {
|
||||||
dashboard: 'Tổng quan',
|
dashboard: 'Tổng quan',
|
||||||
classes: 'Lớp học',
|
classes: 'Lớp học',
|
||||||
students: 'Sinh viên',
|
students: 'Sinh viên',
|
||||||
learning: 'Giám sát & Lịch học',
|
learning: 'Giám sát & Lịch học',
|
||||||
exams: 'Phòng thi',
|
exams: 'Phòng thi',
|
||||||
network: 'Quản lý mạng',
|
system: 'Hệ thống',
|
||||||
'email-domains': 'Đuôi email',
|
|
||||||
profile: 'Tài khoản của tôi',
|
profile: 'Tài khoản của tôi',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,14 +33,47 @@ const HISTORY_KEY = 'sc_nav_history';
|
|||||||
const MAX_HISTORY = 10;
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
function isTabId(value: string | null): value is TabId {
|
function isTabId(value: string | null): value is TabId {
|
||||||
return value === 'dashboard' || value === 'classes' || value === 'students' || value === 'learning' || value === 'exams' || value === 'network' || value === 'email-domains' || value === 'profile';
|
return (
|
||||||
|
value === 'dashboard' ||
|
||||||
|
value === 'classes' ||
|
||||||
|
value === 'students' ||
|
||||||
|
value === 'learning' ||
|
||||||
|
value === 'exams' ||
|
||||||
|
value === 'system' ||
|
||||||
|
value === 'profile'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRoute(): { tab: TabId; classId: number | null; examId: number | null } {
|
function isSystemSection(value: string | null): value is SystemSection {
|
||||||
|
return value === 'organization' || value === 'network' || value === 'templates';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTabAndSection(tabParam: string | null): { tab: TabId; systemSection: SystemSection } {
|
||||||
|
if (tabParam === 'email-domains' || tabParam === 'accounts') {
|
||||||
|
return { tab: 'system', systemSection: 'organization' };
|
||||||
|
}
|
||||||
|
if (tabParam === 'network') {
|
||||||
|
return { tab: 'system', systemSection: 'network' };
|
||||||
|
}
|
||||||
|
const tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const secParam = params.get('section');
|
||||||
|
let systemSection: SystemSection = 'organization';
|
||||||
|
if (isSystemSection(secParam)) {
|
||||||
|
systemSection = secParam;
|
||||||
|
}
|
||||||
|
return { tab, systemSection };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoute(): {
|
||||||
|
tab: TabId;
|
||||||
|
classId: number | null;
|
||||||
|
examId: number | null;
|
||||||
|
systemSection: SystemSection;
|
||||||
|
} {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const tabParam = params.get('tab');
|
const tabParam = params.get('tab');
|
||||||
let tab: TabId = isTabId(tabParam) ? tabParam : 'dashboard';
|
const { tab, systemSection } = resolveTabAndSection(tabParam);
|
||||||
if (tabParam === 'accounts') tab = 'email-domains';
|
|
||||||
const classIdRaw = params.get('classId');
|
const classIdRaw = params.get('classId');
|
||||||
const classId = classIdRaw ? Number(classIdRaw) : null;
|
const classId = classIdRaw ? Number(classIdRaw) : null;
|
||||||
const examIdRaw = params.get('examId');
|
const examIdRaw = params.get('examId');
|
||||||
@@ -41,12 +82,21 @@ export function parseRoute(): { tab: TabId; classId: number | null; examId: numb
|
|||||||
tab,
|
tab,
|
||||||
classId: classId && !Number.isNaN(classId) ? classId : null,
|
classId: classId && !Number.isNaN(classId) ? classId : null,
|
||||||
examId: examId && !Number.isNaN(examId) ? examId : null,
|
examId: examId && !Number.isNaN(examId) ? examId : null,
|
||||||
|
systemSection,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildUrl(tab: TabId, classId?: number | null, examId?: number | null): string {
|
export function buildUrl(
|
||||||
|
tab: TabId,
|
||||||
|
classId?: number | null,
|
||||||
|
examId?: number | null,
|
||||||
|
systemSection?: SystemSection,
|
||||||
|
): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('tab', tab);
|
params.set('tab', tab);
|
||||||
|
if (tab === 'system' && systemSection) {
|
||||||
|
params.set('section', systemSection);
|
||||||
|
}
|
||||||
if (classId) {
|
if (classId) {
|
||||||
params.set('classId', String(classId));
|
params.set('classId', String(classId));
|
||||||
}
|
}
|
||||||
@@ -71,15 +121,16 @@ function writeHistory(entries: NavEntry[]) {
|
|||||||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
|
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(entries.slice(0, MAX_HISTORY)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number) {
|
function entryKey(kind: NavEntry['kind'], tab: TabId, id?: number, section?: SystemSection) {
|
||||||
if (kind === 'class') return `class:${id}`;
|
if (kind === 'class') return `class:${id}`;
|
||||||
if (kind === 'exam') return `exam:${id}`;
|
if (kind === 'exam') return `exam:${id}`;
|
||||||
|
if (tab === 'system' && section) return `tab:system:${section}`;
|
||||||
return `tab:${tab}`;
|
return `tab:${tab}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
||||||
const history = readHistory();
|
const history = readHistory();
|
||||||
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId);
|
const id = entryKey(entry.kind, entry.tab, entry.classId ?? entry.examId, entry.systemSection);
|
||||||
const next: NavEntry = {
|
const next: NavEntry = {
|
||||||
...entry,
|
...entry,
|
||||||
id,
|
id,
|
||||||
@@ -89,21 +140,47 @@ export function pushNav(entry: Omit<NavEntry, 'id' | 'timestamp'>) {
|
|||||||
writeHistory([next, ...filtered]);
|
writeHistory([next, ...filtered]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigate(tab: TabId, id?: number | null, label?: string, kind: 'class' | 'exam' = 'class') {
|
export function navigate(
|
||||||
const url = kind === 'exam'
|
tab: TabId,
|
||||||
? buildUrl(tab, null, id)
|
id?: number | null,
|
||||||
: buildUrl(tab, id);
|
label?: string,
|
||||||
|
kind: 'class' | 'exam' = 'class',
|
||||||
|
systemSection?: SystemSection,
|
||||||
|
) {
|
||||||
|
const url =
|
||||||
|
kind === 'exam'
|
||||||
|
? buildUrl(tab, null, id, systemSection)
|
||||||
|
: buildUrl(tab, id, undefined, systemSection);
|
||||||
window.history.pushState({}, '', url);
|
window.history.pushState({}, '', url);
|
||||||
|
|
||||||
if (id && label) {
|
if (id && label) {
|
||||||
pushNav({ kind, tab, classId: kind === 'class' ? id : undefined, examId: kind === 'exam' ? id : undefined, label });
|
pushNav({
|
||||||
|
kind,
|
||||||
|
tab,
|
||||||
|
classId: kind === 'class' ? id : undefined,
|
||||||
|
examId: kind === 'exam' ? id : undefined,
|
||||||
|
label,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
pushNav({ kind: 'tab', tab, label: TAB_LABELS[tab] });
|
const navLabel =
|
||||||
|
tab === 'system' && systemSection
|
||||||
|
? `Hệ thống · ${SYSTEM_SECTION_LABELS[systemSection]}`
|
||||||
|
: TAB_LABELS[tab];
|
||||||
|
pushNav({
|
||||||
|
kind: 'tab',
|
||||||
|
tab,
|
||||||
|
systemSection: tab === 'system' ? systemSection : undefined,
|
||||||
|
label: navLabel,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function navigateSystem(section: SystemSection) {
|
||||||
|
navigate('system', null, undefined, 'class', section);
|
||||||
|
}
|
||||||
|
|
||||||
export function goBack(fallbackTab: TabId = 'classes') {
|
export function goBack(fallbackTab: TabId = 'classes') {
|
||||||
const history = readHistory();
|
const history = readHistory();
|
||||||
const current = parseRoute();
|
const current = parseRoute();
|
||||||
@@ -111,18 +188,19 @@ export function goBack(fallbackTab: TabId = 'classes') {
|
|||||||
? entryKey('class', current.tab, current.classId)
|
? entryKey('class', current.tab, current.classId)
|
||||||
: current.examId
|
: current.examId
|
||||||
? entryKey('exam', current.tab, current.examId)
|
? entryKey('exam', current.tab, current.examId)
|
||||||
: entryKey('tab', current.tab);
|
: entryKey('tab', current.tab, undefined, current.systemSection);
|
||||||
|
|
||||||
const remaining = history.filter((h) => h.id !== currentId);
|
const remaining = history.filter((h) => h.id !== currentId);
|
||||||
writeHistory(remaining);
|
writeHistory(remaining);
|
||||||
|
|
||||||
const previous = remaining[0];
|
const previous = remaining[0];
|
||||||
if (previous) {
|
if (previous) {
|
||||||
const url = previous.kind === 'class' && previous.classId
|
const url =
|
||||||
|
previous.kind === 'class' && previous.classId
|
||||||
? buildUrl(previous.tab, previous.classId)
|
? buildUrl(previous.tab, previous.classId)
|
||||||
: previous.kind === 'exam' && previous.examId
|
: previous.kind === 'exam' && previous.examId
|
||||||
? buildUrl(previous.tab, null, previous.examId)
|
? buildUrl(previous.tab, null, previous.examId)
|
||||||
: buildUrl(previous.tab);
|
: buildUrl(previous.tab, null, null, previous.systemSection);
|
||||||
window.history.pushState({}, '', url);
|
window.history.pushState({}, '', url);
|
||||||
} else {
|
} else {
|
||||||
window.history.pushState({}, '', buildUrl(fallbackTab));
|
window.history.pushState({}, '', buildUrl(fallbackTab));
|
||||||
|
|||||||
21
management/src/utils/appKeywords.ts
Normal file
21
management/src/utils/appKeywords.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function parseKeywordSet(csv: string): Set<string> {
|
||||||
|
return new Set(
|
||||||
|
csv
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeKeywordCSV(current: string, addition: string): string {
|
||||||
|
const set = parseKeywordSet(current);
|
||||||
|
addition.split(',').forEach((k) => {
|
||||||
|
const kw = k.trim().toLowerCase();
|
||||||
|
if (kw) set.add(kw);
|
||||||
|
});
|
||||||
|
return Array.from(set).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countKeywords(csv: string): number {
|
||||||
|
return parseKeywordSet(csv).size;
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.ClassCourse{},
|
&models.ClassCourse{},
|
||||||
&models.ClassAllowedApp{},
|
&models.ClassAllowedApp{},
|
||||||
&models.AppPoolEntry{},
|
&models.AppPoolEntry{},
|
||||||
|
&models.AppTemplate{},
|
||||||
&models.WifiPoolEntry{},
|
&models.WifiPoolEntry{},
|
||||||
&models.AcceptedWifi{},
|
&models.AcceptedWifi{},
|
||||||
&models.StudentSession{},
|
&models.StudentSession{},
|
||||||
|
|||||||
119
server/internal/handlers/handlers_app_templates.go
Normal file
119
server/internal/handlers/handlers_app_templates.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"server/internal/models"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/app-templates
|
||||||
|
func ListAppTemplatesHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var rows []models.AppTemplate
|
||||||
|
if err := db.Order("name asc").Find(&rows).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"data": rows})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/app-templates
|
||||||
|
func CreateAppTemplateHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Keywords string `json:"keywords"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"})
|
||||||
|
}
|
||||||
|
keywords := normalizeKeywordCSV(req.Keywords)
|
||||||
|
if keywords == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "keywords required"})
|
||||||
|
}
|
||||||
|
row := models.AppTemplate{
|
||||||
|
Name: name,
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
Keywords: keywords,
|
||||||
|
}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "data": row})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/app-templates/:id
|
||||||
|
func UpdateAppTemplateHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
var row models.AppTemplate
|
||||||
|
if err := db.First(&row, c.Params("id")).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not found"})
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Keywords *string `json:"keywords"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid payload"})
|
||||||
|
}
|
||||||
|
if req.Name != nil {
|
||||||
|
name := strings.TrimSpace(*req.Name)
|
||||||
|
if name == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"})
|
||||||
|
}
|
||||||
|
row.Name = name
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
row.Description = strings.TrimSpace(*req.Description)
|
||||||
|
}
|
||||||
|
if req.Keywords != nil {
|
||||||
|
keywords := normalizeKeywordCSV(*req.Keywords)
|
||||||
|
if keywords == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "keywords required"})
|
||||||
|
}
|
||||||
|
row.Keywords = keywords
|
||||||
|
}
|
||||||
|
if err := db.Save(&row).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "data": row})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/app-templates/:id
|
||||||
|
func DeleteAppTemplateHandler(db *gorm.DB) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
if err := db.Delete(&models.AppTemplate{}, c.Params("id")).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"ok": true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKeywordCSV(raw string) string {
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []string
|
||||||
|
for _, p := range parts {
|
||||||
|
kw := strings.ToLower(strings.TrimSpace(p))
|
||||||
|
if kw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[kw]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[kw] = struct{}{}
|
||||||
|
out = append(out, kw)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ", ")
|
||||||
|
}
|
||||||
@@ -122,6 +122,18 @@ type AppPoolEntry struct {
|
|||||||
|
|
||||||
func (AppPoolEntry) TableName() string { return "app_pool" }
|
func (AppPoolEntry) TableName() string { return "app_pool" }
|
||||||
|
|
||||||
|
// AppTemplate — khung ứng dụng (bộ keyword whitelist) dùng chung cho lớp / phòng thi
|
||||||
|
type AppTemplate struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
Name string `gorm:"column:name;size:120;not null" json:"name"`
|
||||||
|
Description string `gorm:"column:description;type:text" json:"description"`
|
||||||
|
Keywords string `gorm:"column:keywords;type:text;not null" json:"keywords"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AppTemplate) TableName() string { return "app_templates" }
|
||||||
|
|
||||||
// WifiPoolEntry — kho WiFi phát hiện từ máy sinh viên (SSID + BSSID điểm phát)
|
// WifiPoolEntry — kho WiFi phát hiện từ máy sinh viên (SSID + BSSID điểm phát)
|
||||||
type WifiPoolEntry struct {
|
type WifiPoolEntry struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ func main() {
|
|||||||
staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
|
staff.Get("/classes/:rkId/allowed-apps", handlers.GetAllowedAppsHandler(gormDB))
|
||||||
staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
|
staff.Post("/classes/:rkId/allowed-apps", handlers.SaveAllowedAppsHandler(gormDB))
|
||||||
staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
|
staff.Get("/app-pool", handlers.ListAppPoolHandler(gormDB))
|
||||||
|
staff.Get("/app-templates", handlers.ListAppTemplatesHandler(gormDB))
|
||||||
|
staff.Post("/app-templates", handlers.CreateAppTemplateHandler(gormDB))
|
||||||
|
staff.Patch("/app-templates/:id", handlers.UpdateAppTemplateHandler(gormDB))
|
||||||
|
staff.Delete("/app-templates/:id", handlers.DeleteAppTemplateHandler(gormDB))
|
||||||
|
|
||||||
staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
|
staff.Get("/wifi-pool", handlers.ListWifiPoolHandler(gormDB))
|
||||||
staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
|
staff.Get("/network/accepted-wifis", handlers.ListAcceptedWifisHandler(gormDB))
|
||||||
|
|||||||
Reference in New Issue
Block a user