fix ui thi
All checks were successful
Deploy on Master Change / deploy (push) Successful in 47s

This commit is contained in:
2026-07-02 15:51:29 +07:00
parent 4b8f4d288a
commit 1b424ab62d
3 changed files with 188 additions and 54 deletions

View File

@@ -61,6 +61,9 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [editable, setEditable] = useState(false);
const [prepEditable, setPrepEditable] = useState(false);
const [displayStatus, setDisplayStatus] = useState('draft');
const canEditApps = displayStatus === 'draft' || displayStatus === 'ready' || displayStatus === 'active';
const [searchMode, setSearchMode] = useState<'single' | 'bulk'>('single');
const [bulkInput, setBulkInput] = useState('');
const [canPublish, setCanPublish] = useState(false);
const [canUnpublish, setCanUnpublish] = useState(false);
const [canCancel, setCanCancel] = useState(false);
@@ -189,14 +192,18 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
setErr(''); setMsg('');
setSaving(true);
try {
await apiExam.update(examId, {
name: roomName.trim(),
startTime: localInputToISO(start),
endTime: localInputToISO(end),
allowedApps,
quizUrl: quizUrl.trim(),
});
setMsg('Đã lưu cấu hình phòng thi');
const payload: any = {};
if (editable) {
payload.name = roomName.trim();
payload.startTime = localInputToISO(start);
payload.endTime = localInputToISO(end);
payload.allowedApps = allowedApps;
payload.quizUrl = quizUrl.trim();
} else {
payload.allowedApps = allowedApps;
}
await apiExam.update(examId, payload);
setMsg(editable ? 'Đã lưu cấu hình phòng thi' : 'Đã cập nhật danh sách ứng dụng được phép');
await load();
} catch (e: any) {
setErr(e?.message || 'Lỗi');
@@ -211,6 +218,29 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
await fetchOnline();
};
const handleBulkSearch = async () => {
const q = bulkInput.trim();
if (!q) return;
try {
const res = await apiExam.searchStudents(q);
setSearchHits(res.data);
// Auto-select all unadded hits
const enrolledIds = new Set(students.map((s) => s.studentRkId));
const unaddedHits = res.data.filter((h) => !enrolledIds.has(h.studentRkId));
setSelectedPickerRkIds(unaddedHits.map((h) => h.studentRkId));
} catch (e: any) {
alert(e?.message || 'Tìm kiếm thất bại');
}
};
const handleSwitchTab = (mode: 'single' | 'bulk') => {
setSearchMode(mode);
setSearchHits([]);
setSelectedPickerRkIds([]);
setSearchQ('');
setBulkInput('');
};
const toggleHitSelection = (studentRkId: number) => {
setSelectedPickerRkIds((prev) =>
prev.includes(studentRkId) ? prev.filter((id) => id !== studentRkId) : [...prev, studentRkId]
@@ -643,14 +673,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<p className="config-card-desc">
Từ khóa tiến trình hoặc tiêu đ đưc phép (ngăn cách bằng dấu phẩy).
</p>
{!editable && canEditApps && (
<p className="config-card-desc" style={{ color: 'var(--success)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<span>💡</span> Phòng thi đã đy hoặc đang diễn ra, bạn vẫn thể thay đi danh sách ng dụng đưc phép bên dưới.
</p>
)}
<textarea
className="app-textarea"
placeholder="chrome, msedge, acrobat, wails, simple_care, client"
value={allowedApps}
onChange={(e) => setAllowedApps(e.target.value)}
disabled={!editable}
disabled={!canEditApps}
/>
{editable && (
{canEditApps && (
<>
<div className="config-suggestions">
<span className="config-suggestions-label">Gợi ý nhanh:</span>
@@ -1202,6 +1237,24 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</button>
</div>
<div className="exam-student-modal-body">
<div className="tab-btn-group" style={{ marginBottom: '1rem' }}>
<button
type="button"
className={`tab-sub-btn ${searchMode === 'single' ? 'active' : ''}`}
onClick={() => handleSwitchTab('single')}
>
Tìm từng bạn
</button>
<button
type="button"
className={`tab-sub-btn ${searchMode === 'bulk' ? 'active' : ''}`}
onClick={() => handleSwitchTab('bulk')}
>
Tìm hàng loạt
</button>
</div>
{searchMode === 'single' ? (
<div className="exam-student-modal-search search-input-wrap" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="search"
@@ -1228,12 +1281,64 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</button>
)}
</div>
) : (
<div className="exam-student-modal-search" style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<textarea
className="search-input"
placeholder="Nhập hoặc paste danh sách sinh viên tại đây (Họ tên, mã sinh viên, email...).&#10;Mỗi dòng một bạn, hoặc ngăn cách bằng dấu phẩy (,) hoặc chấm phẩy (;)."
value={bulkInput}
onChange={(e) => setBulkInput(e.target.value)}
style={{
width: '100%',
height: '100px',
resize: 'vertical',
padding: '0.5rem 0.75rem',
fontFamily: 'inherit',
fontSize: '0.9rem',
lineHeight: '1.4'
}}
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '0.25rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
Mẹo: thể copy một cột Excel dán trực tiếp vào đây.
</span>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{searchHits.length > 0 && searchHits.some((h) => !enrolledIds.has(h.studentRkId)) && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={toggleSelectAllHits}
>
{searchHits.filter((h) => !enrolledIds.has(h.studentRkId)).every((h) => selectedPickerRkIds.includes(h.studentRkId))
? 'Bỏ chọn tất cả'
: 'Chọn tất cả'}
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleBulkSearch}
disabled={!bulkInput.trim()}
>
Tìm hàng loạt
</button>
</div>
</div>
</div>
)}
<div className="exam-student-hits">
{searchQ.trim() === '' ? (
{searchMode === 'single' && searchQ.trim() === '' ? (
<div className="exam-student-hits-empty">
<span>🔍</span>
<p> ít nhất vài tự đ tìm sinh viên.</p>
</div>
) : searchMode === 'bulk' && searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📋</span>
<p>Paste danh sách bấm "Tìm hàng loạt" đ hiển thị kết quả.</p>
</div>
) : searchHits.length === 0 ? (
<div className="exam-student-hits-empty">
<span>📭</span>

View File

@@ -302,9 +302,6 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
if err := db.First(&room, id).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy"})
}
if !internalDb.ExamRoomEditable(room) {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời"})
}
var req struct {
Name *string `json:"name"`
StartTime *string `json:"startTime"`
@@ -315,6 +312,15 @@ func UpdateExamRoomHandler(db *gorm.DB) fiber.Handler {
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
}
if !internalDb.ExamRoomEditable(room) {
if room.Status == models.ExamStatusReady {
if req.Name != nil || req.StartTime != nil || req.EndTime != nil || req.QuizURL != nil {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được cấu hình ứng dụng được phép khi phòng thi đã đẩy hoặc đang thi"})
}
} else {
return c.Status(403).JSON(fiber.Map{"error": "Chỉ sửa được phòng thi ở trạng thái tạm thời hoặc đang sẵn sàng/đang thi"})
}
}
if req.Name != nil {
room.Name = strings.TrimSpace(*req.Name)
}
@@ -462,12 +468,33 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
if q == "" {
return c.JSON(fiber.Map{"data": []any{}})
}
pat := "%" + q + "%"
// Split search query by newline, comma, or semicolon
var terms []string
for _, line := range strings.FieldsFunc(q, func(r rune) bool {
return r == '\n' || r == '\r' || r == ';' || r == ','
}) {
t := strings.TrimSpace(line)
if t != "" {
terms = append(terms, t)
}
}
if len(terms) == 0 {
return c.JSON(fiber.Map{"data": []any{}})
}
if len(terms) > 100 {
terms = terms[:100]
}
var ids []int64
for _, term := range terms {
pat := "%" + term + "%"
var termIDs []int64
_ = db.Model(&models.Student{}).
Where("full_name LIKE ? OR student_code LIKE ? OR email LIKE ?", pat, pat, pat).
Limit(30).
Pluck("rk_id", &ids).Error
Pluck("rk_id", &termIDs).Error
ids = append(ids, termIDs...)
var classIDs []int64
_ = db.Model(&models.Class{}).Where("class_code LIKE ? OR name LIKE ?", pat, pat).Pluck("rk_id", &classIDs).Error
if len(classIDs) > 0 {
@@ -475,6 +502,8 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
_ = db.Model(&models.ClassStudent{}).Where("class_rk_id IN ?", classIDs).Distinct().Pluck("student_rk_id", &more).Error
ids = append(ids, more...)
}
}
seen := map[int64]bool{}
uniq := make([]int64, 0)
for _, id := range ids {
@@ -483,12 +512,12 @@ func SearchExamStudentsHandler(db *gorm.DB) fiber.Handler {
uniq = append(uniq, id)
}
}
if len(uniq) > 40 {
uniq = uniq[:40]
if len(uniq) > 100 {
uniq = uniq[:100]
}
var students []models.Student
if len(uniq) > 0 {
_ = db.Where("rk_id IN ?", uniq).Limit(40).Find(&students).Error
_ = db.Where("rk_id IN ?", uniq).Limit(100).Find(&students).Error
}
type row struct {
StudentRkID int64 `json:"studentRkId"`

Binary file not shown.