first commit

This commit is contained in:
2026-02-27 10:37:11 +08:00
commit 74f19aad0b
86 changed files with 18642 additions and 0 deletions

439
templates/base.html Normal file
View File

@@ -0,0 +1,439 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}智联青云{% endblock %}</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#10b981',
accent: '#8b5cf6',
surface: '#ffffff',
background: '#f8fafc',
},
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
boxShadow: {
'soft': '0 4px 20px -2px rgba(0, 0, 0, 0.05)',
'glass': '0 8px 32px 0 rgba(31, 38, 135, 0.07)',
}
}
}
}
</script>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/rich-editor.css">
<!-- KaTeX 数学公式渲染 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
onload="document.addEventListener('DOMContentLoaded',function(){renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false});})"></script>
<script src="/static/js/rich-editor.js"></script>
</head>
<body class="min-h-screen bg-slate-50 font-sans text-slate-800 antialiased selection:bg-primary/30 selection:text-slate-900">
{% block navbar %}
<nav class="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-slate-200/80 shadow-sm transition-all duration-300">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<a href="/" class="flex-shrink-0 flex items-center">
<span class="text-xl font-bold text-primary">智联青云</span>
</a>
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="/contests" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/></svg>
杯赛专栏
</a>
<a href="/exams" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
考试系统
</a>
<a href="/forum" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
社区论坛
</a>
<a href="/chat" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
消息
</a>
<a href="/profile" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
个人
</a>
{% if user and user.role == 'student' %}
<a href="/apply-teacher" class="border-transparent text-slate-500 hover:border-primary hover:text-slate-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
申请老师
</a>
{% endif %}
</div>
</div>
<div class="hidden sm:ml-6 sm:flex sm:items-center">
{% if user %}
<div class="flex items-center space-x-4">
<!-- 通知铃铛 -->
<div class="relative" id="notif-wrapper">
<button onclick="toggleNotifPanel()" class="text-slate-500 hover:text-slate-700 relative" title="通知">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
<span id="notifBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center hidden" style="font-size:10px">0</span>
</button>
<div id="notifPanel" class="hidden absolute right-0 top-10 w-96 max-h-[500px] bg-white rounded-lg shadow-xl border border-slate-200 z-50 overflow-hidden">
<div class="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<span class="font-medium text-sm text-slate-900">通知</span>
<a href="/chat?tab=notif" class="text-xs text-primary hover:underline">查看全部</a>
</div>
<div id="notifList" class="overflow-y-auto max-h-[440px] divide-y divide-slate-100"></div>
</div>
</div>
{% if user.role == 'admin' or user.role == 'teacher' %}
<a href="/admin" class="text-slate-500 hover:text-slate-700" title="管理后台">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</a>
{% endif %}
<span class="text-sm text-slate-700">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ get_user_display_name() }} ({{ '管理员' if user.role == 'admin' else ('老师' if user.role == 'teacher' else '学生') }})
</span>
<a href="/logout" class="text-slate-500 hover:text-slate-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
</a>
</div>
{% else %}
<div class="space-x-4">
<a href="/login" class="text-slate-500 hover:text-slate-700 text-sm font-medium">登录</a>
<a href="/register" class="bg-primary text-white px-4 py-2 rounded-lg shadow-sm text-sm font-medium hover:bg-blue-600 hover:shadow transition-all duration-200 transform hover:-translate-y-0.5">注册</a>
</div>
{% endif %}
</div>
</div>
</div>
</nav>
{% endblock %}
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
{% if user %}
<!-- 通知面板脚本 -->
<script>
(function(){
function toggleNotifPanel() {
const panel = document.getElementById('notifPanel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) loadNotifications();
}
window.toggleNotifPanel = toggleNotifPanel;
// 点击外部关闭
document.addEventListener('click', function(e) {
const wrapper = document.getElementById('notif-wrapper');
if (wrapper && !wrapper.contains(e.target)) {
document.getElementById('notifPanel').classList.add('hidden');
}
});
function updateNotifBadge() {
fetch('/api/notifications/unread-count').then(r=>r.json()).then(d=>{
const badge = document.getElementById('notifBadge');
if (d.count > 0) {
badge.textContent = d.count > 99 ? '99+' : d.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}).catch(()=>{});
}
updateNotifBadge();
setInterval(updateNotifBadge, 30000);
function loadNotifications() {
fetch('/api/notifications').then(r=>r.json()).then(d=>{
if (!d.success) return;
const list = document.getElementById('notifList');
if (d.notifications.length === 0) {
list.innerHTML = '<div class="px-4 py-8 text-center text-slate-400 text-sm">暂无通知</div>';
return;
}
list.innerHTML = d.notifications.map(n => {
let actions = '';
const typeIcons = {'teacher_application':'👨‍🏫','teacher_result':'🎓','contest_application':'🏆','contest_result':'🏅','contest_new_exam':'📝','exam_graded':'✅'};
const icon = typeIcons[n.type] || '🔔';
// 教师申请:显示同意/拒绝按钮
if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id) {
actions = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveTeacher(${n.application_id},${n.id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">同意</button>
<button onclick="event.stopPropagation();rejectTeacher(${n.application_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'teacher_application' && n.application_status === 'approved') {
actions = '<div class="text-xs text-green-600 mt-1">✅ 已同意</div>';
} else if (n.type === 'teacher_application' && n.application_status === 'rejected') {
actions = '<div class="text-xs text-red-600 mt-1">❌ 已拒绝</div>';
}
// 杯赛申请:显示同意/拒绝按钮(仅管理员)
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id) {
actions = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveContest(${n.post_id},${n.id})" class="px-3 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">同意</button>
<button onclick="event.stopPropagation();rejectContest(${n.post_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'contest_application' && n.application_status === 'approved') {
actions = '<div class="text-xs text-green-600 mt-1">✅ 已同意</div>';
} else if (n.type === 'contest_application' && n.application_status === 'rejected') {
actions = '<div class="text-xs text-red-600 mt-1">❌ 已拒绝</div>';
}
const unreadDot = n.read ? '' : '<span class="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0"></span>';
return `<div class="px-4 py-3 hover:bg-slate-50 cursor-pointer flex gap-2 items-start ${n.read?'':'bg-blue-50/50'}" onclick="markRead(${n.id},this)">
<span class="text-lg flex-shrink-0">${icon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm text-slate-700">${escNotif(n.content)}</div>
<div class="text-xs text-slate-400 mt-1">${n.created_at}</div>
${actions}
</div>
${unreadDot}
</div>`;
}).join('');
});
}
function markRead(nid, el) {
fetch(`/api/notifications/${nid}/read`, {method:'POST'});
el.classList.remove('bg-blue-50/50');
const dot = el.querySelector('.bg-blue-500');
if (dot) dot.remove();
updateNotifBadge();
}
window.markRead = markRead;
window.approveTeacher = async function(appId, nid) {
try {
const res = await fetch(`/api/teacher-applications/${appId}/approve`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
window.rejectTeacher = async function(appId, nid) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/teacher-applications/${appId}/reject`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
window.approveContest = async function(appId, nid) {
try {
const res = await fetch(`/api/contest-applications/${appId}/approve`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
window.rejectContest = async function(appId, nid) {
if (!confirm('确定拒绝该申请?')) return;
try {
const res = await fetch(`/api/contest-applications/${appId}/reject`, {method:'POST'});
const d = await res.json();
alert(d.message);
loadNotifications();
updateNotifBadge();
} catch(e) { alert('操作失败'); }
};
function escNotif(s) { if(!s)return''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
})();
</script>
<!-- 浮动聊天气泡 -->
<div id="chatBubble" class="fixed bottom-6 right-6 z-40">
<button onclick="toggleMiniChat()" class="w-14 h-14 bg-gradient-to-tr from-primary to-blue-400 text-white rounded-full shadow-lg hover:shadow-xl hover:bg-blue-600 flex items-center justify-center relative transition-all duration-300 hover:scale-110">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
<span id="bubbleBadge" class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center hidden">0</span>
</button>
</div>
<!-- 迷你聊天窗口 -->
<div id="miniChat" class="fixed bottom-24 right-6 w-[380px] h-[500px] bg-white rounded-xl shadow-2xl border border-slate-200 z-40 hidden flex flex-col overflow-hidden">
<div class="px-4 py-3 bg-primary text-white flex justify-between items-center rounded-t-xl">
<span class="font-medium text-sm" id="miniTitle">消息</span>
<div class="flex items-center gap-3">
<a href="/chat" class="text-white/80 hover:text-white text-xs">打开完整版</a>
<button onclick="toggleMiniChat()" class="text-white/80 hover:text-white">&times;</button>
</div>
</div>
<div id="miniRoomList" class="flex-1 overflow-y-auto"></div>
<div id="miniChatView" class="flex-1 flex-col hidden">
<div class="px-3 py-2 border-b border-slate-200 flex items-center gap-2">
<button onclick="miniBack()" class="text-slate-400 hover:text-slate-600"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg></button>
<span id="miniChatName" class="text-sm font-medium truncate"></span>
</div>
<div id="miniMessages" class="flex-1 overflow-y-auto px-3 py-2 space-y-2" style="max-height:340px"></div>
<div class="px-3 py-2 border-t border-slate-200 flex gap-2">
<input id="miniInput" type="text" placeholder="输入消息..." class="flex-1 px-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary/50" onkeydown="if(event.key==='Enter'){event.preventDefault();miniSend();}">
<button onclick="miniSend()" class="px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:bg-blue-600">发送</button>
</div>
</div>
</div>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
(function(){
if (document.getElementById('chatApp')) return; // chat.html 已有自己的socket
const bubbleSocket = io();
let miniRoomId = null;
let miniRooms = [];
function updateBadge() {
fetch('/api/chat/unread-total').then(r=>r.json()).then(d=>{
const badge = document.getElementById('bubbleBadge');
if (d.total > 0) {
badge.textContent = d.total > 99 ? '99+' : d.total;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}).catch(()=>{});
}
updateBadge();
setInterval(updateBadge, 30000);
bubbleSocket.on('new_message', (msg) => {
updateBadge();
// 气泡抖动
const btn = document.querySelector('#chatBubble button');
btn.classList.add('animate-bounce');
setTimeout(() => btn.classList.remove('animate-bounce'), 1000);
// 迷你聊天窗口更新
if (miniRoomId === msg.room_id) {
appendMiniMsg(msg);
}
loadMiniRooms();
});
bubbleSocket.on('message_recalled', () => {
if (miniRoomId) loadMiniMessages(miniRoomId);
});
window.toggleMiniChat = function() {
const mc = document.getElementById('miniChat');
mc.classList.toggle('hidden');
if (!mc.classList.contains('hidden')) {
loadMiniRooms();
}
};
function loadMiniRooms() {
fetch('/api/chat/rooms').then(r=>r.json()).then(d=>{
if (!d.success) return;
miniRooms = d.rooms;
const list = document.getElementById('miniRoomList');
list.innerHTML = d.rooms.map(r => `
<div class="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-slate-50 border-b border-slate-50" onclick="miniOpenRoom(${r.id})">
<div class="w-9 h-9 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">` : `<span class="text-xs text-slate-500">${(r.name||'?')[0]}</span>`}
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between"><span class="text-sm font-medium truncate">${esc(r.name||'')}</span>
${r.unread>0?`<span class="bg-red-500 text-white text-xs rounded-full px-1.5 min-w-[16px] text-center">${r.unread}</span>`:''}</div>
<p class="text-xs text-slate-400 truncate">${r.last_message?esc(r.last_message.content||''):'暂无消息'}</p>
</div>
</div>
`).join('');
});
}
window.miniOpenRoom = function(roomId) {
miniRoomId = roomId;
const room = miniRooms.find(r=>r.id===roomId);
document.getElementById('miniTitle').textContent = room?.name || '聊天';
document.getElementById('miniChatName').textContent = room?.name || '聊天';
document.getElementById('miniRoomList').classList.add('hidden');
const cv = document.getElementById('miniChatView');
cv.classList.remove('hidden');
cv.classList.add('flex');
loadMiniMessages(roomId);
fetch(`/api/chat/rooms/${roomId}/read`, {method:'POST'});
updateBadge();
};
window.miniBack = function() {
miniRoomId = null;
document.getElementById('miniRoomList').classList.remove('hidden');
const cv = document.getElementById('miniChatView');
cv.classList.add('hidden');
cv.classList.remove('flex');
document.getElementById('miniTitle').textContent = '消息';
loadMiniRooms();
};
function loadMiniMessages(roomId) {
fetch(`/api/chat/rooms/${roomId}/messages?limit=20`).then(r=>r.json()).then(d=>{
if (!d.success) return;
const area = document.getElementById('miniMessages');
const currUserStr = '{{ user | tojson | safe }}';
const currUser = currUserStr ? JSON.parse(currUserStr) : null;
area.innerHTML = d.messages.map(m => {
if (m.type==='system') return `<div class="text-center"><span class="text-xs text-slate-400">${esc(m.content)}</span></div>`;
if (m.recalled) return `<div class="text-center"><span class="text-xs text-slate-400">${esc(m.sender_name)} 撤回了一条消息</span></div>`;
const isMe = m.sender_id === currUser.id;
const bubble = isMe ? 'bg-primary text-white ml-auto' : 'bg-slate-100 text-slate-800';
let content = m.type==='image'?'[图片]':m.type==='file'?'[文件]':esc(m.content);
return `<div class="${isMe?'text-right':'text-left'}">
${!isMe?`<div class="text-xs text-slate-400 mb-0.5">${esc(m.sender_name)}</div>`:''}
<div class="inline-block px-3 py-1.5 rounded-lg text-sm max-w-[80%] ${bubble}">${content}</div>
</div>`;
}).join('');
area.scrollTop = area.scrollHeight;
});
}
function appendMiniMsg(msg) {
const area = document.getElementById('miniMessages');
const currUserStr = '{{ user | tojson | safe }}';
const currUser = currUserStr ? JSON.parse(currUserStr) : null;
const isMe = currUser && msg.sender_id === currUser.id;
if (msg.type==='system') {
area.innerHTML += `<div class="text-center"><span class="text-xs text-slate-400">${esc(msg.content)}</span></div>`;
} else {
const bubble = isMe ? 'bg-primary text-white ml-auto' : 'bg-slate-100 text-slate-800';
let content = msg.type==='image'?'[图片]':msg.type==='file'?'[文件]':(typeof renderRichContent==='function'?renderRichContent(msg.content):esc(msg.content));
area.innerHTML += `<div class="${isMe?'text-right':'text-left'}">
${!isMe?`<div class="text-xs text-slate-400 mb-0.5">${esc(msg.sender_name)}</div>`:''}
<div class="inline-block px-3 py-1.5 rounded-lg text-sm max-w-[80%] ${bubble}">${content}</div>
</div>`;
}
area.scrollTop = area.scrollHeight;
}
window.miniSend = function() {
const input = document.getElementById('miniInput');
const content = input.value.trim();
if (!content || !miniRoomId) return;
bubbleSocket.emit('send_message', { room_id: miniRoomId, type: 'text', content: content });
input.value = '';
};
function esc(s) { if(!s)return''; const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
})();
</script>
{% endif %}
</body>
</html>