mobile app admin_base admin_contests admin_dashboard chat contest_detail exam_create exam_detail exam_grade exam_list profile themes

This commit is contained in:
2026-03-07 19:07:01 +08:00
parent 2d3163a1a0
commit 86a6c7dd03
13 changed files with 656 additions and 169 deletions

View File

@@ -95,6 +95,7 @@
<span id="chatName" class="text-lg font-bold text-white tracking-tight"></span>
<span id="chatMemberCount" class="badge-futuristic text-xs font-bold px-2 py-0.5 rounded-full"></span>
</div>
<div id="peerExamBanner" class="text-xs font-medium text-amber-400 bg-amber-500/20 px-2 py-1 rounded mt-1 hidden">对方正在考试中,请勿打扰</div>
<div id="typingIndicator" class="text-[11px] font-medium text-cyan-400 hidden animate-pulse mt-0.5"></div>
</div>
</div>
@@ -275,6 +276,8 @@
<div id="imagePreview" class="fixed inset-0 bg-black/80 z-[9990] hidden flex items-center justify-center cursor-pointer" onclick="this.classList.add('hidden')">
<img id="previewImg" class="max-w-[90vw] max-h-[90vh] rounded-lg">
</div>
<!-- 轻提示 -->
<div id="chatToast" class="fixed bottom-24 left-1/2 -translate-x-1/2 px-4 py-2 bg-slate-800 text-amber-400 rounded-lg shadow-lg z-[9995] hidden text-sm font-medium"></div>
{% endblock %}
{% block scripts %}
@@ -297,6 +300,7 @@ let recordingStartTime = null;
let recordingInterval = null;
let searchTimer = null;
let mentionMembers = [];
let peerExamStatus = {}; // {user_id: {in_exam, exam_title, end_time}}
function isMobile() { return window.innerWidth < 640; }
@@ -372,9 +376,16 @@ function switchTab(tab) {
tabChat.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-slate-400 border-b-2 border-transparent hover:text-slate-600';
tabNotif.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-primary border-b-2 border-primary relative';
loadNotifications();
// Mark all notifications as read when switching to the notification tab
fetch('/api/notifications/read-all', { method: 'POST' }).then(() => {
checkUnreadNotifs();
});
}
const badge = document.getElementById('chatNotifBadge');
tabNotif.appendChild(badge);
if (badge) {
tabNotif.appendChild(badge);
}
}
async function loadNotifications() {
@@ -527,11 +538,24 @@ async function checkUnreadNotifs() {
const data = await res.json();
if (data.success) {
const badge = document.getElementById('chatNotifBadge');
if (data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
if (badge) {
if (data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
// Also update the global notification badge if it exists
const globalBadge = document.getElementById('notifBadge');
if (globalBadge) {
if (data.count > 0) {
globalBadge.textContent = data.count > 99 ? '99+' : data.count;
globalBadge.classList.remove('hidden');
} else {
globalBadge.classList.add('hidden');
}
}
}
}
@@ -639,7 +663,9 @@ function renderRooms() {
const search = document.getElementById('roomSearch').value.toLowerCase();
const list = document.getElementById('roomList');
const filtered = rooms.filter(r => (r.name || '').toLowerCase().includes(search));
list.innerHTML = filtered.map(r => `
list.innerHTML = filtered.map(r => {
const peerInExam = r.type === 'private' && r.peer_id && peerExamStatus[r.peer_id]?.in_exam;
return `
<div class="flex items-center gap-3 px-3 py-3 cursor-pointer hover:bg-slate-50 transition ${currentRoomId === r.id ? 'bg-blue-50 border-r-2 border-primary' : ''}" onclick="selectRoom(${r.id})">
<div class="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0 overflow-hidden">
${r.avatar ? `<img src="${r.avatar}" class="w-full h-full object-cover">` :
@@ -648,6 +674,7 @@ function renderRooms() {
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center">
<span class="text-sm font-medium text-slate-800 truncate">${escHtml(r.name || '未命名')}</span>
${peerInExam ? '<span class="text-[10px] text-amber-400 bg-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">考试中</span>' : ''}
<span class="text-xs text-slate-400 flex-shrink-0">${r.last_message ? formatTime(r.last_message.created_at) : ''}</span>
</div>
<div class="flex justify-between items-center mt-0.5">
@@ -656,7 +683,7 @@ function renderRooms() {
</div>
</div>
</div>
`).join('');
`}).join('');
}
function getPreview(r) {
@@ -688,11 +715,34 @@ async function selectRoom(roomId) {
if (room) {
document.getElementById('chatName').textContent = room.name || '聊天';
document.getElementById('chatMemberCount').textContent = room.type !== 'private' ? `(${room.member_count}人)` : '';
// 私聊:获取对方考试状态
if (room.type === 'private' && room.peer_id) {
try {
const res = await fetch(`/api/user/${room.peer_id}/exam-status`);
const data = await res.json();
if (data.success) {
peerExamStatus[room.peer_id] = { in_exam: data.in_exam, exam_title: data.exam_title || '', end_time: data.end_time };
}
} catch (e) { peerExamStatus[room.peer_id] = { in_exam: false }; }
}
updateChatHeaderExamStatus(room);
}
renderRooms();
await loadMessages(roomId);
markRead(roomId);
}
function updateChatHeaderExamStatus(room) {
const examBanner = document.getElementById('peerExamBanner');
if (!examBanner) return;
if (room && room.type === 'private' && room.peer_id && peerExamStatus[room.peer_id]?.in_exam) {
const info = peerExamStatus[room.peer_id];
examBanner.textContent = `${room.name || '对方'} 正在考试中(${info.exam_title || '考试'}),请勿打扰`;
examBanner.classList.remove('hidden');
} else {
examBanner.classList.add('hidden');
}
}
// ========== 消息加载与渲染 ==========
async function loadMessages(roomId, beforeId) {
let url = `/api/chat/rooms/${roomId}/messages?limit=30`;
@@ -1439,6 +1489,33 @@ socket.on('error', (data) => {
if (data.message) alert(data.message);
});
socket.on('friend_exam_status', (data) => {
const uid = data.user_id;
peerExamStatus[uid] = {
in_exam: data.in_exam,
exam_title: data.exam_title || '',
end_time: data.end_time
};
const room = rooms.find(r => r.id === currentRoomId);
if (room && room.type === 'private' && room.peer_id === uid) {
updateChatHeaderExamStatus(room);
}
renderRooms();
});
socket.on('peer_in_exam', (data) => {
showToast(data.message || '对方正在考试中,消息将在考试结束后送达');
});
function showToast(msg) {
const el = document.getElementById('chatToast');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
clearTimeout(el._toastTimer);
el._toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
}
// ========== 工具函数 ==========
function escHtml(s) {
if (!s) return '';