This commit is contained in:
2026-06-30 15:53:01 +07:00
parent 595aebd8a5
commit 74b540cac2
19 changed files with 1719 additions and 252 deletions

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useAuth } from '../auth/AuthContext';
import { apiAdmin, type EmailDomainItem } from '../api';
import { useGitHubConnection } from '../hooks/useGitHubConnection';
import { GitHubReposPanel } from './GitHubReposPanel';
import { OrganizationSection } from './OrganizationSection';
export function ChangePasswordPage({ forced }: { forced?: boolean }) {
const { changePassword, logout } = useAuth();
@@ -67,62 +67,12 @@ export function ChangePasswordPage({ forced }: { forced?: boolean }) {
}
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 (
<div className="page-stack">
<div className="tab-page">
<header className="page-header">
<h1 className="page-title">Đuôi email</h1>
<p className="page-desc">Cấu hình đuôi email đưc phép đăng đăng nhập.</p>
<h1 className="page-title">Quản tổ chức</h1>
</header>
<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 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>
<OrganizationSection />
</div>
);
}

View 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 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 {countKeywords(allowedApps)} keyword trong whitelist hiện tại
</div>
</div>
</div>
);
};

View 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 phòng thi thể áp dụng nhanh thay 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> 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 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>
);
}

View File

@@ -22,6 +22,8 @@ import { AttendancePanel } from './AttendancePanel';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
interface ClassWorkspaceProps {
classId: number;
@@ -52,6 +54,7 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'apps' | 'schedule'>('schedule');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const loadAllData = async () => {
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 () => {
try {
setSavingApps(true);
@@ -329,18 +336,27 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
</div>
<div className="app-pool-open-row">
<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' }}>
App bị chặn từ mọi lớp mở kho đ tìm thêm nhanh.
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
</p>
</div>
<button
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setAppPoolOpen(true)}
>
Mở kho & tìm kiếm
</button>
<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
type="button"
className="btn btn-secondary app-pool-open-btn"
onClick={() => setAppPoolOpen(true)}
>
Mở kho
</button>
</div>
</div>
<button
className="btn btn-primary"
@@ -539,6 +555,12 @@ export const ClassWorkspace: React.FC<ClassWorkspaceProps> = ({ classId, sourceT
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</div>
);
};

View File

@@ -12,6 +12,8 @@ import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal';
import { AppTemplatePickerModal } from './AppTemplatePickerModal';
import { mergeKeywordCSV } from '../utils/appKeywords';
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar';
@@ -75,6 +77,7 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const [appPoolOpen, setAppPoolOpen] = useState(false);
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [studentPickerOpen, setStudentPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
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 () => {
setErr(''); setMsg('');
setSaving(true);
@@ -623,14 +630,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div>
<div className="app-pool-open-row">
<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' }}>
App bị chặn từ mọi lớp mở kho đ tìm thêm nhanh.
Thêm từng app trong kho hoặc áp dụng cả bộ keyword đã lưu.
</p>
</div>
<button type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
Mở kho & tìm kiếm
</button>
<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 type="button" className="btn btn-secondary app-pool-open-btn" onClick={() => setAppPoolOpen(true)}>
Mở kho
</button>
</div>
</div>
<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'}
@@ -1221,6 +1233,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
onSelect={addAppKeyword}
allowedApps={allowedApps}
/>
<AppTemplatePickerModal
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={applyTemplate}
allowedApps={allowedApps}
/>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { apiExam, type ExamRoomItem } from '../api';
import { openExam } from './NavHistoryBar';
@@ -39,6 +39,7 @@ export const ExamsTab: React.FC = () => {
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
const load = async () => {
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 () => {
if (!name.trim() || !start || !end) return;
@@ -65,6 +83,9 @@ export const ExamsTab: React.FC = () => {
});
setShowCreate(false);
setName('');
setStart('');
setEnd('');
await load();
openExam(room.id, room.name);
} catch (e: any) {
alert(e?.message || 'Lỗi');
@@ -74,20 +95,47 @@ export const ExamsTab: React.FC = () => {
};
return (
<div className="page-stack">
<div className="tab-page exams-page">
<header className="page-header page-header--row">
<div>
<h1 className="page-title">Phòng thi</h1>
<p className="page-desc">Tạo phòng thi, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p>
<h1 className="page-title">Danh sách phòng thi</h1>
<p className="page-desc">Tạo phòng, chia đ ngẫu nhiên, gửi đ thu bài từ sinh viên.</p>
</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>
<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 && (
<div className="card" style={{ padding: '1.25rem' }}>
<h2 className="section-title">Phòng thi mới</h2>
<div className="form-grid" style={{ maxWidth: 520 }}>
<label className="login-field">
<div className="system-form-card exams-create-card">
<h2 className="system-form-title">Phòng thi mới</h2>
<div className="system-form-grid">
<label className="login-field system-form-full">
<span>Tên phòng thi</span>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Thi cuối kỳ Java" />
</label>
@@ -100,48 +148,56 @@ export const ExamsTab: React.FC = () => {
<input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={create}>Tạo</button>
<button type="button" className="btn btn-ghost" onClick={() => setShowCreate(false)}>Hủy</button>
<div className="system-form-actions">
<div />
<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 className="card table-card">
{loading ? (
<div className="table-empty">Đang tải...</div>
) : rooms.length === 0 ? (
<div className="table-empty">Chưa phòng thi nào.</div>
) : (
<table className="data-table">
<thead>
<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);
return (
<tr key={r.id}>
<td><strong>{r.name}</strong></td>
<td style={{ fontSize: '0.82rem' }}>{fmtTime(r.startTime)} {fmtTime(r.endTime)}</td>
<td>{r.studentCount} SV · {r.paperCount} đ</td>
<td><span className={`badge ${st.cls}`}>{st.text}</span></td>
<td>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => openExam(r.id, r.name)}>Mở</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{loading ? (
<div className="system-empty">Đang tải...</div>
) : filtered.length === 0 ? (
<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>
) : (
<div className="exams-card-grid">
{filtered.map((r) => {
const st = statusLabel(r.displayStatus || r.status);
return (
<article key={r.id} className="exam-list-card">
<div className="exam-list-card-head">
<h3>{r.name}</h3>
<span className={`badge ${st.cls}`}>{st.text}</span>
</div>
<div className="exam-list-card-meta">
<div>
<span className="exam-list-card-label">Thời gian</span>
<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 </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>
);
})}
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import {
TAB_LABELS,
SYSTEM_SECTION_LABELS,
type NavEntry,
type TabId,
parseRoute,
@@ -24,7 +25,10 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
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 handlePillClick = (entry: NavEntry) => {
@@ -32,6 +36,8 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
navigate(entry.tab, entry.classId, entry.label, 'class');
} else if (entry.kind === 'exam' && entry.examId) {
navigate(entry.tab, entry.examId, entry.label, 'exam');
} else if (entry.tab === 'system' && entry.systemSection) {
navigate('system', null, undefined, 'class', entry.systemSection);
} else {
navigate(entry.tab);
}
@@ -44,6 +50,9 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
if (entry.kind === 'exam' && 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;
};
@@ -61,7 +70,13 @@ export const NavHistoryBar: React.FC<NavHistoryBarProps> = ({ classLabel, onBack
Simple Care
</button>
<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}
</button>
{route.classId && classLabel && (

View File

@@ -16,7 +16,7 @@ function formatBssid(raw: string): string | null {
return key.match(/.{2}/g)!.join(':');
}
export const NetworkTab: React.FC = () => {
export const NetworkSection: React.FC = () => {
const [acceptedItems, setAcceptedItems] = useState<WifiAcceptItem[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
@@ -31,8 +31,8 @@ export const NetworkTab: React.FC = () => {
const res = await apiFetchAcceptedWifis();
setAcceptedItems(
(res.data || [])
.filter(r => r.ssid && r.bssid)
.map(r => ({ ssid: r.ssid, bssid: r.bssid }))
.filter((r) => r.ssid && r.bssid)
.map((r) => ({ ssid: r.ssid, bssid: r.bssid })),
);
} catch {
setAcceptedItems([]);
@@ -50,15 +50,15 @@ export const NetworkTab: React.FC = () => {
const trimmedBssid = bssid.trim();
const key = bssidKey(trimmedBssid);
if (!trimmedSsid || key.length !== 12) return;
setAcceptedItems(prev => {
if (prev.some(item => bssidKey(item.bssid) === key)) return prev;
setAcceptedItems((prev) => {
if (prev.some((item) => bssidKey(item.bssid) === key)) return prev;
return [...prev, { ssid: trimmedSsid, bssid: trimmedBssid }];
});
};
const removeWifi = (bssid: string) => {
const key = bssidKey(bssid);
setAcceptedItems(prev => prev.filter(item => bssidKey(item.bssid) !== key));
setAcceptedItems((prev) => prev.filter((item) => bssidKey(item.bssid) !== key));
};
const handleManualAdd = () => {
@@ -73,11 +73,11 @@ export const NetworkTab: React.FC = () => {
return;
}
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.');
return;
}
setAcceptedItems(prev => [...prev, { ssid, bssid }]);
setAcceptedItems((prev) => [...prev, { ssid, bssid }]);
setManualSsid('');
setManualBssid('');
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 (
<div className="page-container">
<div className="page-header">
<div>
<h1 className="page-title">Quản mạng</h1>
<p className="page-subtitle">
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>.
Sinh viên không thể giả mạo bằng hotspot trùng tên.
</p>
<div className="system-section system-section--grid">
<div className="system-block">
<div className="system-block-head">
<div>
<h2 className="system-block-title">Gói mạng đưc chấp nhận</h2>
<p className="system-block-desc">
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.
</p>
</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
className="network-manual-form"
onSubmit={e => {
e.preventDefault();
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>
<form
className="network-manual-form"
onSubmit={(e) => {
e.preventDefault();
handleManualAdd();
}}
>
<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
type="text"
className="app-pool-search"
placeholder="SSID (tên WiFi)"
value={manualSsid}
onChange={e => {
onChange={(e) => {
setManualSsid(e.target.value);
setManualError(null);
}}
@@ -141,7 +136,7 @@ export const NetworkTab: React.FC = () => {
className="app-pool-search"
placeholder="BSSID / MAC (f0:61:c0:b0:fd:d2)"
value={manualBssid}
onChange={e => {
onChange={(e) => {
setManualBssid(e.target.value);
setManualError(null);
}}
@@ -151,17 +146,19 @@ export const NetworkTab: React.FC = () => {
<button type="submit" className="btn btn-secondary" disabled={loading}>
+ Thêm thủ công
</button>
{manualError && <p className="network-manual-error">{manualError}</p>}
</form>
</div>
{manualError && <p className="network-manual-error">{manualError}</p>}
</form>
{loading ? (
<div className="app-pool-status">Đang tải...</div>
) : acceptedItems.length === 0 ? (
<div className="app-pool-status" style={{ marginBottom: '0.75rem' }}>
Chưa đ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.
</div>
) : (
<table className="data-table app-pool-table" style={{ marginBottom: '0.75rem' }}>
{loading ? (
<div className="system-empty">Đang tải...</div>
) : acceptedItems.length === 0 ? (
<div className="system-empty system-empty--muted">
Chưa đ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 className="system-table-wrap">
<table className="data-table app-pool-table">
<thead>
<tr>
<th>SSID</th>
@@ -170,7 +167,7 @@ export const NetworkTab: React.FC = () => {
</tr>
</thead>
<tbody>
{acceptedItems.map(item => (
{acceptedItems.map((item) => (
<tr key={bssidKey(item.bssid)}>
<td><code className="app-pool-kw">{item.ssid}</code></td>
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
@@ -179,7 +176,7 @@ export const NetworkTab: React.FC = () => {
<td>
<button
type="button"
className="btn btn-secondary app-pool-add-btn"
className="btn btn-ghost btn-sm"
onClick={() => removeWifi(item.bssid)}
>
Xóa
@@ -189,29 +186,28 @@ export const NetworkTab: React.FC = () => {
))}
</tbody>
</table>
)}
<div className="network-actions">
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
Mở kho WiFi
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
</button>
</div>
</div>
)}
<div className="card config-card-flat network-info-card">
<div className="card-header-title">Cách hoạt đng</div>
<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>Thầy chọn từ kho hoặc <strong>thêm thủ công</strong> SSID + BSSID khi cần</li>
<li>Hotspot giả trùng tên nhưng MAC khác app báo lỗi thoát</li>
<li>Chưa cấu hình không chặn (đ thu thập kho trước)</li>
</ul>
<div className="network-actions">
<button type="button" className="btn btn-secondary" onClick={() => setPoolOpen(true)}>
Mở kho WiFi
</button>
<button type="button" className="btn btn-primary" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Đang lưu...' : 'Lưu cấu hình'}
</button>
</div>
</div>
<div className="system-block system-block--info">
<h3 className="system-info-title">Cách hoạt đng</h3>
<ul className="network-info-list">
<li>Sinh viên kết nối WiFi app gửi <strong>SSID + BSSID</strong> lên kho</li>
<li>Chọn từ kho hoặc thêm thủ công SSID + BSSID</li>
<li>Chưa cấu hình không chặn (đ thu thập kho trước)</li>
</ul>
</div>
<WifiPoolModal
open={poolOpen}
onClose={() => setPoolOpen(false)}
@@ -221,3 +217,13 @@ export const NetworkTab: React.FC = () => {
</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 mạng</h1>
</header>
<NetworkSection />
</div>
);

View 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 thể đăng .
</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 trên portal này</li>
</ul>
</div>
</div>
);
}

View 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 hệ thống</h1>
<p className="page-desc">
Cấu hình tổ chức, mạng WiFi 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>
);
};