Files
zlqy/templates/chat.html

1389 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}消息 - 智联青云{% endblock %}
{% block content %}
<div id="chatApp" class="flex bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-white/50 overflow-hidden" style="height:calc(100vh - 120px);min-height:500px;">
<!-- 左侧面板 -->
<div id="leftPanel" class="w-full sm:w-80 border-r border-slate-200/60 bg-white/50 flex flex-col flex-shrink-0 relative z-10">
<!-- Tab 切换:聊天 / 通知 -->
<div class="flex p-3 gap-2 bg-white/40 border-b border-slate-200/50 backdrop-blur-md sticky top-0 z-20">
<button id="tabChat" onclick="switchTab('chat')" class="flex-1 px-4 py-2 text-sm font-bold text-white bg-gradient-to-r from-indigo-500 to-purple-500 rounded-xl shadow-md transition-all duration-300">💬 聊天</button>
<button id="tabNotif" onclick="switchTab('notif')" class="flex-1 px-4 py-2 text-sm font-medium text-slate-600 hover:text-indigo-600 hover:bg-white bg-white/50 rounded-xl transition-all duration-300 relative border border-white/50">
🔔 通知
<span id="chatNotifBadge" class="hidden absolute -top-1.5 -right-1.5 bg-rose-500 text-white text-[10px] font-bold rounded-full h-5 min-w-[20px] px-1.5 flex items-center justify-center shadow-sm border-2 border-white transform animate-bounce">0</span>
</button>
</div>
<!-- 聊天面板 -->
<div id="chatPanel" class="flex-1 flex flex-col overflow-hidden">
<!-- 搜索 + 创建 -->
<div class="p-4 space-y-3 bg-white/30 backdrop-blur-sm border-b border-slate-200/50">
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="w-4 h-4 text-slate-400 group-focus-within:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</div>
<input id="roomSearch" type="text" placeholder="搜索聊天..." class="w-full pl-10 pr-4 py-2.5 text-sm bg-white border border-slate-200/80 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 transition-all shadow-sm" oninput="filterRooms()">
</div>
<button onclick="showCreateGroup()" class="w-full px-4 py-2.5 text-sm font-bold bg-white text-indigo-600 border border-indigo-100 rounded-xl hover:bg-indigo-50 hover:shadow-md transition-all flex items-center justify-center gap-2 group">
<div class="w-6 h-6 rounded-full bg-indigo-100 text-indigo-600 flex items-center justify-center group-hover:bg-indigo-600 group-hover:text-white transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
</div>
发起群聊
</button>
</div>
<!-- 会话列表 -->
<div id="roomList" class="flex-1 overflow-y-auto hide-scrollbar p-2 space-y-1 bg-transparent"></div>
</div>
<!-- 通知面板 -->
<div id="chatNotifPanel" class="flex-1 overflow-y-auto hide-scrollbar hidden p-2 space-y-2 bg-transparent">
<div id="chatNotifList" class="space-y-2"></div>
<div id="chatNotifEmpty" class="flex flex-col items-center justify-center h-64 text-slate-400">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-3">📭</div>
<div class="text-sm font-medium">暂无新通知</div>
</div>
</div>
</div>
<!-- 右侧聊天区 -->
<div id="rightPanel" class="flex-1 flex flex-col hidden sm:flex bg-[url('data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' viewBox=\'0 0 60 60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'none\' fill-rule=\'evenodd\'%3E%3Cg fill=\'%236366f1\' fill-opacity=\'0.03\'%3E%3Cpath d=\'M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z\'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E')] relative">
<div class="absolute inset-0 bg-gradient-to-br from-white/90 to-white/50 backdrop-blur-[2px] z-0"></div>
<!-- 未选中状态 -->
<div id="emptyState" class="flex-1 flex items-center justify-center z-10">
<div class="text-center transform transition-all hover:scale-105 duration-500">
<div class="w-32 h-32 mx-auto bg-gradient-to-tr from-indigo-100 to-purple-100 rounded-full flex items-center justify-center mb-6 shadow-inner relative">
<div class="absolute inset-0 bg-indigo-400 blur-2xl opacity-20 rounded-full animate-pulse"></div>
<span class="text-6xl animate-bounce">💬</span>
</div>
<h3 class="text-2xl font-extrabold text-slate-800 tracking-tight mb-2">欢迎来到联考消息中心</h3>
<p class="text-slate-500 font-medium">在左侧选择一个聊天,或发起新的对话</p>
</div>
</div>
<!-- 通知详情视图 -->
<div id="notifDetailView" class="flex-1 flex-col hidden z-10 bg-white/80 backdrop-blur-md">
<div class="px-6 py-4 border-b border-slate-200/60 flex items-center gap-4 bg-white/50 sticky top-0 backdrop-blur-xl">
<button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center flex-shrink-0" aria-label="返回">
<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>
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-2xl shadow-sm border border-white" id="notifDetailIconContainer">
<span id="notifDetailIcon"></span>
</div>
<div>
<div id="notifDetailType" class="text-lg font-extrabold text-slate-900"></div>
<div id="notifDetailTime" class="text-xs font-medium text-slate-500 mt-1"></div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-8 hide-scrollbar">
<div class="bg-white rounded-3xl p-8 shadow-sm border border-slate-100 relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50/50 rounded-bl-full -z-10"></div>
<div id="notifDetailContent" class="text-base text-slate-700 leading-relaxed font-medium"></div>
<div id="notifDetailFrom" class="text-sm text-slate-400 mt-6 pt-6 border-t border-slate-100 flex items-center gap-2"></div>
<div id="notifDetailActions" class="mt-6"></div>
</div>
</div>
</div>
<!-- 聊天视图 -->
<div id="chatView" class="flex-1 flex-col hidden z-10 relative">
<!-- 顶栏 -->
<div id="chatHeader" class="px-6 py-4 border-b border-slate-200/60 flex items-center justify-between bg-white/60 backdrop-blur-xl sticky top-0 z-20">
<div class="flex items-center gap-3">
<button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center mr-1" aria-label="返回">
<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>
<div id="chatAvatarContainer" class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-100 to-purple-100 flex items-center justify-center text-indigo-600 font-bold shadow-sm overflow-hidden">
<!-- 头像动态插入 -->
</div>
<div>
<div class="flex items-center gap-2">
<span id="chatName" class="text-lg font-bold text-slate-900 tracking-tight"></span>
<span id="chatMemberCount" class="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-full border border-indigo-100"></span>
</div>
<div id="typingIndicator" class="text-[11px] font-medium text-indigo-500 hidden animate-pulse mt-0.5"></div>
</div>
</div>
<div class="flex items-center gap-3">
<button onclick="showAnnouncement()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-amber-600 hover:bg-amber-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="群公告" id="btnAnnouncement">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/></svg>
</button>
<button onclick="showSearchPanel()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="搜索聊天记录">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
<button onclick="showFileList()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-emerald-600 hover:bg-emerald-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="群文件">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
</button>
<button onclick="showMembers()" class="w-10 h-10 rounded-xl bg-white text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm border border-slate-100 transition-all flex items-center justify-center group" title="成员列表">
<svg class="w-5 h-5 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
</button>
</div>
</div>
<!-- 消息区域 -->
<div id="messageArea" class="flex-1 overflow-y-auto hide-scrollbar px-6 py-4 space-y-5" onscroll="handleScroll()"></div>
<!-- 引用回复预览 -->
<div id="replyPreview" class="px-4 py-3 bg-indigo-50/80 backdrop-blur-md border-t border-indigo-100 hidden flex items-center justify-between shadow-inner">
<div class="flex items-center gap-2 overflow-hidden">
<div class="w-1 h-4 bg-indigo-500 rounded-full"></div>
<div class="text-sm font-medium text-indigo-900 truncate"><span id="replyToText"></span></div>
</div>
<button onclick="cancelReply()" class="w-6 h-6 rounded-full bg-white/50 text-indigo-400 hover:text-indigo-600 hover:bg-white flex items-center justify-center transition-colors shadow-sm ml-2 flex-shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- 输入区域 -->
<div class="p-4 bg-white/80 backdrop-blur-xl border-t border-slate-200/60 shadow-[0_-4px_20px_-15px_rgba(0,0,0,0.1)]">
<!-- Emoji 面板 -->
<div id="emojiPanel" class="hidden absolute bottom-[calc(100%+10px)] left-4 p-3 bg-white/95 backdrop-blur-xl border border-slate-200/60 rounded-2xl shadow-xl max-h-48 overflow-y-auto w-64 z-50">
<div class="flex flex-wrap gap-2 justify-center" id="emojiGrid"></div>
</div>
<!-- @提及自动补全 -->
<div id="mentionPanel" class="hidden absolute bottom-[calc(100%+10px)] left-20 bg-white border border-slate-200 rounded-xl shadow-xl max-h-48 overflow-y-auto w-56 z-50">
<div id="mentionList" class="py-1"></div>
</div>
<!-- 录音指示器 -->
<div id="recordingIndicator" class="hidden absolute bottom-[calc(100%+10px)] left-1/2 -translate-x-1/2 bg-rose-500 text-white px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 z-50">
<div class="w-3 h-3 bg-white rounded-full animate-pulse"></div>
<span class="text-sm font-medium">录音中... <span id="recordingTime">0s</span></span>
</div>
<div class="flex items-end gap-3">
<div class="flex flex-col gap-2 pb-1.5">
<button onclick="toggleEmoji()" class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-amber-500 hover:bg-amber-50 transition-colors flex items-center justify-center shadow-sm" title="表情">
😀
</button>
<label class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-emerald-500 hover:bg-emerald-50 transition-colors flex items-center justify-center shadow-sm cursor-pointer" 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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
<input type="file" accept="image/*" class="hidden" onchange="uploadFile(this,'image')">
</label>
<label class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center shadow-sm cursor-pointer" 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.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
<input type="file" class="hidden" onchange="uploadFile(this,'file')">
</label>
<button onclick="insertMention()" class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-indigo-500 hover:bg-indigo-50 transition-colors flex items-center justify-center shadow-sm font-bold text-sm" title="@提及">
@
</button>
<button id="voiceBtn" onmousedown="startRecording()" onmouseup="stopRecording()" ontouchstart="startRecording()" ontouchend="stopRecording()" class="w-9 h-9 rounded-xl bg-slate-50 text-slate-500 hover:text-rose-500 hover:bg-rose-50 transition-colors flex items-center justify-center shadow-sm" 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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/></svg>
</button>
</div>
<div class="flex-1 bg-white rounded-2xl border border-slate-200/80 shadow-sm focus-within:ring-2 focus-within:ring-indigo-500/30 focus-within:border-indigo-400 transition-all overflow-hidden">
<textarea id="msgInput" rows="1" placeholder="输入消息... (Enter发送, Shift+Enter换行)" class="flex-1 w-full px-4 py-3.5 text-sm bg-transparent border-none focus:outline-none focus:ring-0 resize-none max-h-32 min-h-[48px] hide-scrollbar" oninput="handleTyping(); this.style.height = 'auto'; this.style.height = (this.scrollHeight) + 'px';"></textarea>
</div>
<button onclick="sendMessage()" class="w-12 h-12 rounded-2xl bg-gradient-to-r from-indigo-500 to-purple-600 text-white flex items-center justify-center hover:shadow-lg hover:shadow-indigo-500/30 transform hover:-translate-y-0.5 transition-all flex-shrink-0 group">
<svg class="w-5 h-5 transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/></svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 创建群聊弹窗 -->
<div id="createGroupModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-96 max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">创建群聊</h3>
<button onclick="hideCreateGroup()" class="text-slate-400 hover:text-slate-600">&times;</button>
</div>
<div class="p-4 space-y-3">
<input id="groupName" type="text" placeholder="群聊名称" class="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50">
<p class="text-sm text-slate-500">选择好友加入群聊:</p>
<div id="friendListForGroup" class="max-h-48 overflow-y-auto space-y-1"></div>
</div>
<div class="px-4 py-3 border-t border-slate-200">
<button onclick="createGroup()" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 text-sm">创建</button>
</div>
</div>
</div>
<!-- 成员列表弹窗 -->
<div id="membersModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-[420px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">群成员</h3>
<div class="flex items-center gap-2">
<button onclick="showNicknameModal()" class="text-xs text-indigo-500 hover:text-indigo-700" title="设置群昵称">我的昵称</button>
<button onclick="hideMembers()" class="text-slate-400 hover:text-slate-600 text-lg">&times;</button>
</div>
</div>
<div id="membersList" class="p-4 overflow-y-auto max-h-96 space-y-2"></div>
<div id="inviteSection" class="px-4 py-3 border-t border-slate-200 hidden">
<button onclick="showInvite()" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 text-sm">邀请好友</button>
</div>
</div>
</div>
<!-- 群公告弹窗 -->
<div id="announcementModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-96 flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">群公告</h3>
<button onclick="hideAnnouncement()" class="text-slate-400 hover:text-slate-600 text-lg">&times;</button>
</div>
<div class="p-4 space-y-3">
<div id="announcementDisplay" class="text-sm text-slate-600 min-h-[40px] whitespace-pre-wrap">暂无公告</div>
<div id="announcementMeta" class="text-xs text-slate-400 hidden"></div>
<div id="announcementEditSection" class="hidden">
<textarea id="announcementInput" class="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none" rows="4" placeholder="输入群公告内容..."></textarea>
<div class="flex justify-end gap-2 mt-2">
<button onclick="cancelEditAnnouncement()" class="px-3 py-1.5 text-sm text-slate-500 hover:bg-slate-100 rounded-lg">取消</button>
<button onclick="saveAnnouncement()" class="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-blue-600">发布</button>
</div>
</div>
<button id="btnEditAnnouncement" onclick="editAnnouncement()" class="hidden text-sm text-primary hover:text-blue-700">编辑公告</button>
</div>
</div>
</div>
<!-- 搜索聊天记录弹窗 -->
<div id="searchModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-[480px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">搜索聊天记录</h3>
<button onclick="hideSearchPanel()" class="text-slate-400 hover:text-slate-600 text-lg">&times;</button>
</div>
<div class="p-4">
<input id="msgSearchInput" type="text" placeholder="输入关键词搜索..." class="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50" oninput="debounceSearch()">
</div>
<div id="searchResults" class="flex-1 overflow-y-auto px-4 pb-4 space-y-2 max-h-96"></div>
</div>
</div>
<!-- 群文件弹窗 -->
<div id="fileListModal" class="fixed inset-0 bg-black/50 z-[9990] hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-[480px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">群文件</h3>
<button onclick="hideFileList()" class="text-slate-400 hover:text-slate-600 text-lg">&times;</button>
</div>
<div id="fileListContent" class="flex-1 overflow-y-auto p-4 space-y-2 max-h-96"></div>
</div>
</div>
<!-- 群昵称弹窗 -->
<div id="nicknameModal" class="fixed inset-0 bg-black/50 z-[9991] hidden flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl w-80 flex flex-col">
<div class="px-4 py-3 border-b border-slate-200 flex justify-between items-center">
<h3 class="font-semibold">设置群昵称</h3>
<button onclick="hideNicknameModal()" class="text-slate-400 hover:text-slate-600 text-lg">&times;</button>
</div>
<div class="p-4 space-y-3">
<input id="nicknameInput" type="text" placeholder="输入群内昵称(留空使用默认名称)" class="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/50" maxlength="50">
<button onclick="saveNickname()" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-blue-600 text-sm">保存</button>
</div>
</div>
</div>
<!-- 图片预览弹窗 -->
<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>
{% endblock %}
{% block scripts %}
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
const currentUser = {{ user | tojson }};
if (!currentUser) window.location.href = '/login';
const socket = io();
let currentRoomId = null;
let rooms = [];
let replyToId = null;
let loadingMore = false;
let oldestMsgId = null;
let currentRoomMembers = [];
let currentRoomCreatorId = null;
let myRoomRole = null;
let mediaRecorder = null;
let audioChunks = [];
let recordingStartTime = null;
let recordingInterval = null;
let searchTimer = null;
let mentionMembers = [];
function isMobile() { return window.innerWidth < 640; }
function mobileBack() {
if (!isMobile()) return;
document.getElementById('rightPanel').classList.add('hidden');
document.getElementById('rightPanel').classList.remove('flex');
document.getElementById('leftPanel').classList.remove('hidden');
document.getElementById('leftPanel').classList.add('flex');
}
function mobileShowRight() {
if (!isMobile()) return;
document.getElementById('leftPanel').classList.add('hidden');
document.getElementById('leftPanel').classList.remove('flex');
document.getElementById('rightPanel').classList.remove('hidden');
document.getElementById('rightPanel').classList.add('flex');
}
const EMOJIS = ['😀','😂','😍','🥰','😎','🤔','😅','😭','😤','👍','👎','❤️','🔥','🎉','👏','🙏','💪','😊','🥺','😢','😡','🤣','😘','🤗','😱','💯','✅','❌','⭐','🌟'];
// ========== 初始化 ==========
document.addEventListener('DOMContentLoaded', () => {
loadRooms();
initEmoji();
checkUnreadNotifs();
// 初始化富文本编辑器
new RichEditor('msgInput', {
compact: true,
onEnter: sendMessage
});
// URL参数自动打开聊天室
const params = new URLSearchParams(window.location.search);
if (params.get('room_id')) {
setTimeout(() => selectRoom(parseInt(params.get('room_id'))), 500);
}
if (params.get('dm')) {
const dmUserId = parseInt(params.get('dm'));
setTimeout(async () => {
try {
const res = await fetch(`/api/chat/private/${dmUserId}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
await loadRooms();
selectRoom(data.room_id);
}
} catch(e) { console.error('打开私聊失败', e); }
}, 500);
}
if (params.get('tab') === 'notif') {
switchTab('notif');
}
});
// ========== 通知系统 ==========
let notifData = [];
function switchTab(tab) {
const chatPanel = document.getElementById('chatPanel');
const notifPanel = document.getElementById('chatNotifPanel');
const tabChat = document.getElementById('tabChat');
const tabNotif = document.getElementById('tabNotif');
if (tab === 'chat') {
chatPanel.classList.remove('hidden');
chatPanel.classList.add('flex');
notifPanel.classList.add('hidden');
tabChat.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-primary border-b-2 border-primary';
tabNotif.className = 'flex-1 px-3 py-2.5 text-sm font-medium text-slate-400 border-b-2 border-transparent hover:text-slate-600 relative';
} else {
chatPanel.classList.add('hidden');
chatPanel.classList.remove('flex');
notifPanel.classList.remove('hidden');
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();
}
const badge = document.getElementById('chatNotifBadge');
tabNotif.appendChild(badge);
}
async function loadNotifications() {
const res = await fetch('/api/notifications');
const data = await res.json();
if (data.success) {
notifData = data.notifications;
renderNotifications();
}
}
function renderNotifications() {
const list = document.getElementById('chatNotifList');
const empty = document.getElementById('chatNotifEmpty');
if (notifData.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
list.innerHTML = notifData.map(n => {
const isContestApp = n.type === 'contest_application';
const isTeacherApp = n.type === 'teacher_application';
const isFriendReq = n.type === 'friend_request';
const isResult = n.type === 'contest_result' || n.type === 'teacher_result';
const isNewExam = n.type === 'contest_new_exam';
const isGraded = n.type === 'exam_graded';
const isSystem = n.type === 'system_announcement';
const isPendingContest = isContestApp && n.application_status === 'pending';
const isPendingTeacher = isTeacherApp && n.application_status === 'pending';
const isPendingFriend = isFriendReq && n.application_status === 'pending';
let statusHtml = '';
if ((isContestApp || isTeacherApp) && n.application_status === 'approved') statusHtml = '<span class="text-xs text-green-600 font-medium">已批准</span>';
else if ((isContestApp || isTeacherApp) && n.application_status === 'rejected') statusHtml = '<span class="text-xs text-red-600 font-medium">已拒绝</span>';
else if (isFriendReq && n.application_status === 'accepted') statusHtml = '<span class="text-xs text-green-600 font-medium">已同意</span>';
const icon = isContestApp ? '📋' : isTeacherApp ? '👨‍🏫' : isFriendReq ? '👤' : isResult ? '📢' : isNewExam ? '📝' : isGraded ? '✅' : isSystem ? '📢' : '🔔';
const iconBg = isContestApp ? 'bg-orange-100' : isTeacherApp ? 'bg-purple-100' : isFriendReq ? 'bg-blue-100' : isResult ? 'bg-green-100' : isNewExam ? 'bg-indigo-100' : isGraded ? 'bg-emerald-100' : isSystem ? 'bg-amber-100' : 'bg-blue-100';
const clickAction = `showNotifDetail(${n.id})`;
let actionsHtml = '';
if (isPendingContest && currentUser.role === 'admin') {
actionsHtml = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveContest(${n.post_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})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
}
if (isPendingTeacher && currentUser.role === 'admin' && n.application_id) {
actionsHtml = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();approveTeacher(${n.application_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})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
}
if (isPendingFriend && n.friend_request_id) {
actionsHtml = `<div class="flex gap-2 mt-2">
<button onclick="event.stopPropagation();acceptFriendReq(${n.friend_request_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();rejectFriendReq(${n.friend_request_id},${n.id})" class="px-3 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">拒绝</button>
</div>`;
}
return `<div class="px-3 py-3 ${n.read ? '' : 'bg-blue-50'} hover:bg-slate-50 border-b border-slate-100 transition cursor-pointer" onclick="${clickAction}">
<div class="flex items-start gap-2">
<div class="w-8 h-8 rounded-full ${iconBg} flex items-center justify-center flex-shrink-0 mt-0.5">
<span class="text-sm">${icon}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-slate-700">${escHtml(n.content)}</p>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-slate-400">${n.created_at}</span>
${statusHtml}
</div>
${actionsHtml}
</div>
</div>
</div>`;
}).join('');
}
async function markNotifRead(nid, el) {
if (el && el.classList.contains('bg-blue-50')) {
el.classList.remove('bg-blue-50');
await fetch(`/api/notifications/${nid}/read`, { method: 'POST' });
checkUnreadNotifs();
}
}
async function approveContest(appId) {
if (!confirm('确定批准该杯赛申请?')) return;
const res = await fetch(`/api/contest-applications/${appId}/approve`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadNotifications();
} else {
alert(data.message || '操作失败');
}
}
async function rejectContest(appId) {
if (!confirm('确定拒绝该杯赛申请?')) return;
const res = await fetch(`/api/contest-applications/${appId}/reject`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadNotifications();
} else {
alert(data.message || '操作失败');
}
}
async function approveTeacher(appId) {
if (!confirm('确定批准该教师申请?')) return;
const res = await fetch(`/api/teacher-applications/${appId}/approve`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadNotifications();
} else {
alert(data.message || '操作失败');
}
}
async function rejectTeacher(appId) {
if (!confirm('确定拒绝该教师申请?')) return;
const res = await fetch(`/api/teacher-applications/${appId}/reject`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadNotifications();
} else {
alert(data.message || '操作失败');
}
}
async function acceptFriendReq(reqId, notifId) {
const res = await fetch(`/api/friend/accept/${reqId}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadNotifications();
} else {
alert(data.message || '操作失败');
}
}
async function rejectFriendReq(reqId, notifId) {
const res = await fetch(`/api/friend/reject/${reqId}`, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadNotifications();
} else {
alert(data.message || '操作失败');
}
}
async function checkUnreadNotifs() {
const res = await fetch('/api/notifications/unread-count');
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');
}
}
}
setInterval(checkUnreadNotifs, 30000);
function showNotifDetail(nid) {
const n = notifData.find(x => x.id === nid);
if (!n) return;
// 标记已读
if (!n.read) {
n.read = true;
fetch(`/api/notifications/${nid}/read`, { method: 'POST' });
checkUnreadNotifs();
renderNotifications();
}
// 隐藏聊天视图和空状态,显示通知详情
document.getElementById('emptyState').classList.add('hidden');
document.getElementById('chatView').classList.add('hidden');
document.getElementById('chatView').classList.remove('flex');
const detail = document.getElementById('notifDetailView');
detail.classList.remove('hidden');
detail.classList.add('flex');
mobileShowRight();
const typeLabels = {
'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
'system_announcement': '系统通知', 'friend_request': '好友申请'
};
const typeIcons = {
'teacher_application': '👨‍🏫', 'teacher_result': '🎓',
'contest_application': '📋', 'contest_result': '🏅',
'contest_new_exam': '📝', 'exam_graded': '✅',
'system_announcement': '📢', 'friend_request': '👤'
};
document.getElementById('notifDetailIcon').textContent = typeIcons[n.type] || '🔔';
document.getElementById('notifDetailType').textContent = typeLabels[n.type] || '通知';
document.getElementById('notifDetailTime').textContent = n.created_at || '';
document.getElementById('notifDetailContent').textContent = n.content || '';
document.getElementById('notifDetailFrom').textContent = n.from_user ? '来自:' + n.from_user : '';
// 操作按钮
let actHtml = '';
if (n.type === 'contest_application' && n.application_status === 'pending' && n.post_id && currentUser.role === 'admin') {
actHtml = `<div class="flex gap-3">
<button onclick="approveContest(${n.post_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button>
<button onclick="rejectContest(${n.post_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'teacher_application' && n.application_status === 'pending' && n.application_id && currentUser.role === 'admin') {
actHtml = `<div class="flex gap-3">
<button onclick="approveTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">批准</button>
<button onclick="rejectTeacher(${n.application_id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'contest_new_exam' || n.type === 'exam_graded') {
actHtml = `<a href="/exams/${n.post_id}" class="inline-block px-4 py-2 text-sm bg-primary text-white rounded-md hover:bg-blue-700">查看考试</a>`;
} else if (n.type === 'friend_request' && n.application_status === 'pending' && n.friend_request_id) {
actHtml = `<div class="flex gap-3">
<button onclick="acceptFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-green-500 text-white rounded-md hover:bg-green-600">同意</button>
<button onclick="rejectFriendReq(${n.friend_request_id},${n.id})" class="px-4 py-2 text-sm bg-red-500 text-white rounded-md hover:bg-red-600">拒绝</button>
</div>`;
} else if (n.type === 'friend_request' && n.application_status === 'accepted') {
actHtml = '<span class="text-sm text-green-600 font-medium">✅ 已同意</span>';
} else if ((n.type === 'contest_application' || n.type === 'teacher_application') && n.application_status === 'approved') {
actHtml = '<span class="text-sm text-green-600 font-medium">✅ 已批准</span>';
} else if ((n.type === 'contest_application' || n.type === 'teacher_application') && n.application_status === 'rejected') {
actHtml = '<span class="text-sm text-red-600 font-medium">❌ 已拒绝</span>';
}
document.getElementById('notifDetailActions').innerHTML = actHtml;
}
function initEmoji() {
const grid = document.getElementById('emojiGrid');
EMOJIS.forEach(e => {
const btn = document.createElement('button');
btn.className = 'w-8 h-8 text-lg hover:bg-slate-100 rounded flex items-center justify-center';
btn.textContent = e;
btn.onclick = () => { insertEmoji(e); };
grid.appendChild(btn);
});
}
function insertEmoji(e) {
const input = document.getElementById('msgInput');
input.value += e;
input.focus();
}
function toggleEmoji() {
document.getElementById('emojiPanel').classList.toggle('hidden');
}
// ========== 聊天室列表 ==========
async function loadRooms() {
const res = await fetch('/api/chat/rooms');
const data = await res.json();
if (data.success) {
rooms = data.rooms;
renderRooms();
}
}
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 => `
<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">` :
`<span class="text-sm font-medium text-slate-500">${(r.name || '?')[0]}</span>`}
</div>
<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>
<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">
<span class="text-xs text-slate-400 truncate">${r.last_message ? escHtml(getPreview(r)) : '暂无消息'}</span>
${r.unread > 0 ? `<span class="bg-red-500 text-white text-xs rounded-full px-1.5 min-w-[18px] text-center flex-shrink-0">${r.unread > 99 ? '99+' : r.unread}</span>` : ''}
</div>
</div>
</div>
`).join('');
}
function getPreview(r) {
if (!r.last_message) return '';
const lm = r.last_message;
let text = lm.content || '';
if (lm.type === 'image') text = '[图片]';
else if (lm.type === 'file') text = '[文件]';
if (r.type !== 'private' && lm.sender_name) text = lm.sender_name + ': ' + text;
return text;
}
function filterRooms() { renderRooms(); }
// ========== 选择聊天室 ==========
async function selectRoom(roomId) {
currentRoomId = roomId;
oldestMsgId = null;
document.getElementById('emptyState').classList.add('hidden');
// 隐藏通知详情
const nd = document.getElementById('notifDetailView');
nd.classList.add('hidden');
nd.classList.remove('flex');
const cv = document.getElementById('chatView');
cv.classList.remove('hidden');
cv.classList.add('flex');
mobileShowRight();
const room = rooms.find(r => r.id === roomId);
if (room) {
document.getElementById('chatName').textContent = room.name || '聊天';
document.getElementById('chatMemberCount').textContent = room.type !== 'private' ? `(${room.member_count}人)` : '';
}
renderRooms();
await loadMessages(roomId);
markRead(roomId);
}
// ========== 消息加载与渲染 ==========
async function loadMessages(roomId, beforeId) {
let url = `/api/chat/rooms/${roomId}/messages?limit=30`;
if (beforeId) url += `&before_id=${beforeId}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success) return;
const area = document.getElementById('messageArea');
if (!beforeId) {
area.innerHTML = '';
data.messages.forEach(m => area.appendChild(createMsgEl(m)));
area.scrollTop = area.scrollHeight;
} else {
const oldH = area.scrollHeight;
data.messages.forEach((m, i) => area.insertBefore(createMsgEl(m), area.children[i]));
area.scrollTop = area.scrollHeight - oldH;
}
if (data.messages.length > 0) oldestMsgId = data.messages[0].id;
loadingMore = false;
}
function handleScroll() {
const area = document.getElementById('messageArea');
if (area.scrollTop < 50 && !loadingMore && oldestMsgId && currentRoomId) {
loadingMore = true;
loadMessages(currentRoomId, oldestMsgId);
}
}
function createMsgEl(msg) {
const div = document.createElement('div');
div.id = `msg-${msg.id}`;
div.dataset.msgId = msg.id;
if (msg.type === 'system') {
div.className = 'text-center';
div.innerHTML = `<span class="text-xs text-slate-400 bg-slate-100 px-3 py-1 rounded-full">${escHtml(msg.content)}</span>`;
return div;
}
const isMe = msg.sender_id === currentUser.id;
if (msg.recalled) {
div.className = 'text-center';
div.innerHTML = `<span class="text-xs text-slate-400">${escHtml(msg.sender_name)} 撤回了一条消息</span>`;
return div;
}
div.className = `flex ${isMe ? 'justify-end' : 'justify-start'} gap-2`;
const avatarHtml = msg.sender_avatar
? `<img src="${msg.sender_avatar}" class="w-8 h-8 rounded-full object-cover flex-shrink-0">`
: `<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center flex-shrink-0"><span class="text-xs text-slate-500">${(msg.sender_name||'?')[0]}</span></div>`;
let contentHtml = '';
if (msg.type === 'image' && msg.file_url) {
contentHtml = `<img src="${msg.file_url}" class="max-w-[240px] rounded-lg cursor-pointer hover:opacity-90" onclick="previewImage('${msg.file_url}')">`;
} else if (msg.type === 'file' && msg.file_url) {
contentHtml = `<a href="${msg.file_url}" download="${escHtml(msg.file_name||'文件')}" class="flex items-center gap-2 text-sm text-primary hover:underline">
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
${escHtml(msg.file_name||'文件')}</a>`;
} else if (msg.type === 'voice' && msg.file_url) {
contentHtml = `<div class="flex items-center gap-2 cursor-pointer voice-msg" onclick="playVoice(this, '${msg.file_url}')">
<svg class="w-5 h-5 text-current" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12m-3.536-2.464a5 5 0 010-7.072"/></svg>
<span class="text-sm">${escHtml(msg.content || '语音')}</span>
<div class="voice-bars flex items-end gap-0.5 h-4"><span class="w-0.5 bg-current rounded-full" style="height:40%"></span><span class="w-0.5 bg-current rounded-full" style="height:70%"></span><span class="w-0.5 bg-current rounded-full" style="height:100%"></span><span class="w-0.5 bg-current rounded-full" style="height:60%"></span><span class="w-0.5 bg-current rounded-full" style="height:30%"></span></div>
</div>`;
} else {
contentHtml = `<span class="whitespace-pre-wrap break-words">${renderRichContent(msg.content)}</span>`;
}
// 表情回应
let reactionsHtml = '';
if (msg.reactions && Object.keys(msg.reactions).length > 0) {
const items = Object.entries(msg.reactions).map(([emoji, data]) =>
`<button class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs border ${data.users.some(u=>u.id===currentUser.id) ? 'bg-indigo-50 border-indigo-200 text-indigo-600' : 'bg-slate-50 border-slate-200 text-slate-500'} hover:bg-indigo-50 transition" onclick="toggleReaction(${msg.id},'${emoji}')" title="${data.users.map(u=>u.name).join(', ')}">${emoji} ${data.count}</button>`
).join('');
reactionsHtml = `<div class="flex flex-wrap gap-1 mt-1" id="reactions-${msg.id}">${items}</div>`;
} else {
reactionsHtml = `<div class="flex flex-wrap gap-1 mt-1" id="reactions-${msg.id}"></div>`;
}
let replyHtml = '';
if (msg.reply_to) {
replyHtml = `<div class="text-xs text-slate-400 mb-1 px-2 py-1 bg-slate-50 rounded border-l-2 border-slate-300 truncate">回复 ${escHtml(msg.reply_to.sender_name)}: ${escHtml(msg.reply_to.content)}</div>`;
}
const bubbleColor = isMe ? 'bg-primary text-white' : 'bg-white border border-slate-200 text-slate-800';
const nameHtml = !isMe ? `<div class="text-xs text-slate-400 mb-0.5">${escHtml(msg.sender_name)}</div>` : '';
const readStatus = isMe && rooms.find(r=>r.id===currentRoomId)?.type === 'private'
? `<div class="text-xs text-slate-400 mt-0.5 text-right" id="read-${msg.id}"></div>` : '';
if (isMe) {
div.innerHTML = `
<div class="max-w-[70%] flex flex-col items-end">
${replyHtml}
<div class="${bubbleColor} px-3 py-2 rounded-xl rounded-tr-sm text-sm shadow-sm cursor-pointer" oncontextmenu="showMsgMenu(event,${msg.id},true)">${contentHtml}</div>
${reactionsHtml}
<div class="text-xs text-slate-400 mt-0.5">${formatTime(msg.created_at)}</div>
${readStatus}
</div>
${avatarHtml}`;
} else {
div.innerHTML = `
${avatarHtml}
<div class="max-w-[70%]">
${nameHtml}${replyHtml}
<div class="${bubbleColor} px-3 py-2 rounded-xl rounded-tl-sm text-sm shadow-sm cursor-pointer" oncontextmenu="showMsgMenu(event,${msg.id},false)">${contentHtml}</div>
${reactionsHtml}
<div class="text-xs text-slate-400 mt-0.5">${formatTime(msg.created_at)}</div>
</div>`;
}
return div;
}
// ========== 发送消息 ==========
function sendMessage() {
const input = document.getElementById('msgInput');
const content = input.value.trim();
if (!content || !currentRoomId) return;
const mentionsStr = mentionMembers.length > 0 ? (mentionMembers.includes('all') ? 'all' : JSON.stringify(mentionMembers)) : '';
socket.emit('send_message', {
room_id: currentRoomId, type: 'text', content: content,
reply_to_id: replyToId, mentions: mentionsStr
});
input.value = '';
mentionMembers = [];
cancelReply();
document.getElementById('emojiPanel').classList.add('hidden');
}
function handleInputKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
// @ 触发提及面板
if (e.key === '@' || e.key === '2' && e.shiftKey) {
setTimeout(() => insertMention(), 50);
}
}
let typingTimer = null;
function handleTyping() {
if (!currentRoomId) return;
if (!typingTimer) {
socket.emit('typing', { room_id: currentRoomId });
}
clearTimeout(typingTimer);
typingTimer = setTimeout(() => { typingTimer = null; }, 2000);
}
// ========== 文件上传 ==========
async function uploadFile(input, type) {
if (!input.files[0] || !currentRoomId) return;
const fd = new FormData();
fd.append('file', input.files[0]);
const fileName = input.files[0].name;
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const data = await res.json();
if (data.success) {
socket.emit('send_message', {
room_id: currentRoomId,
type: type,
content: type === 'image' ? '[图片]' : fileName,
file_url: data.url,
file_name: fileName,
reply_to_id: replyToId
});
cancelReply();
} else {
alert(data.message || '上传失败');
}
input.value = '';
}
// ========== 引用回复 ==========
function setReply(msgId) {
const msgEl = document.getElementById(`msg-${msgId}`);
if (!msgEl) return;
replyToId = msgId;
const text = msgEl.querySelector('.whitespace-pre-wrap')?.textContent || '[消息]';
document.getElementById('replyToText').textContent = '回复: ' + text.substring(0, 30);
document.getElementById('replyPreview').classList.remove('hidden');
document.getElementById('msgInput').focus();
hideMsgMenu();
}
function cancelReply() {
replyToId = null;
document.getElementById('replyPreview').classList.add('hidden');
}
// ========== 消息右键菜单 ==========
let msgMenuEl = null;
function showMsgMenu(e, msgId, isMe) {
e.preventDefault();
hideMsgMenu();
const menu = document.createElement('div');
menu.id = 'msgContextMenu';
menu.className = 'fixed bg-white border border-slate-200 rounded-lg shadow-lg py-1 z-50';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.innerHTML = `
<div class="flex gap-1 px-2 py-1.5 border-b border-slate-100">${EMOJIS.slice(0,8).map(e => `<button class="w-7 h-7 text-base hover:bg-slate-100 rounded flex items-center justify-center" onclick="toggleReaction(${msgId},'${e}')">${e}</button>`).join('')}</div>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-slate-50" onclick="setReply(${msgId})">引用回复</button>
<button class="w-full px-4 py-2 text-sm text-left hover:bg-slate-50" onclick="copyMsg(${msgId})">复制文字</button>
${isMe ? `<button class="w-full px-4 py-2 text-sm text-left text-red-500 hover:bg-red-50" onclick="recallMsg(${msgId})">撤回</button>` : ''}
`;
document.body.appendChild(menu);
msgMenuEl = menu;
setTimeout(() => document.addEventListener('click', hideMsgMenu, { once: true }), 10);
}
function hideMsgMenu() {
if (msgMenuEl) { msgMenuEl.remove(); msgMenuEl = null; }
}
function copyMsg(msgId) {
const el = document.querySelector(`#msg-${msgId} .whitespace-pre-wrap`);
if (el) navigator.clipboard.writeText(el.textContent);
hideMsgMenu();
}
async function recallMsg(msgId) {
hideMsgMenu();
const res = await fetch(`/api/chat/messages/${msgId}/recall`, { method: 'POST' });
const data = await res.json();
if (!data.success) alert(data.message || '撤回失败');
}
// ========== 图片预览 ==========
function previewImage(url) {
document.getElementById('previewImg').src = url;
document.getElementById('imagePreview').classList.remove('hidden');
}
// ========== 标记已读 ==========
function markRead(roomId) {
fetch(`/api/chat/rooms/${roomId}/read`, { method: 'POST' });
socket.emit('mark_read', { room_id: roomId });
const room = rooms.find(r => r.id === roomId);
if (room) { room.unread = 0; renderRooms(); }
}
// ========== 创建群聊 ==========
async function showCreateGroup() {
document.getElementById('createGroupModal').classList.remove('hidden');
// 重置弹窗标题和按钮(防止被邀请好友功能覆盖)
document.querySelector('#createGroupModal h3').textContent = '创建群聊';
const createBtn = document.querySelector('#createGroupModal .bg-primary');
createBtn.textContent = '创建';
createBtn.onclick = createGroup;
document.getElementById('groupName').value = '';
const res = await fetch('/api/user/friends');
const data = await res.json();
const list = document.getElementById('friendListForGroup');
if (data.success && data.friends.length > 0) {
list.innerHTML = data.friends.map(f => `
<label class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-slate-50 cursor-pointer">
<input type="checkbox" value="${f.id}" class="friend-check rounded">
<div class="w-7 h-7 rounded-full bg-slate-200 flex items-center justify-center overflow-hidden flex-shrink-0">
${f.avatar ? `<img src="${f.avatar}" class="w-full h-full object-cover">` : `<span class="text-xs">${f.name[0]}</span>`}
</div>
<span class="text-sm">${escHtml(f.name)}</span>
</label>
`).join('');
} else {
list.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">暂无好友,请先添加好友</p>';
}
}
function hideCreateGroup() { document.getElementById('createGroupModal').classList.add('hidden'); }
async function createGroup() {
const name = document.getElementById('groupName').value.trim();
if (!name) return alert('请输入群名称');
const ids = [...document.querySelectorAll('.friend-check:checked')].map(c => parseInt(c.value));
const res = await fetch('/api/chat/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, member_ids: ids })
});
const data = await res.json();
if (data.success) {
hideCreateGroup();
await loadRooms();
selectRoom(data.room_id);
} else {
alert(data.message || '创建失败');
}
}
// ========== 成员列表 ==========
async function showMembers() {
if (!currentRoomId) return;
document.getElementById('membersModal').classList.remove('hidden');
const res = await fetch(`/api/chat/rooms/${currentRoomId}/members`);
const data = await res.json();
if (!data.success) return;
currentRoomMembers = data.members;
currentRoomCreatorId = data.creator_id;
myRoomRole = data.members.find(m => m.id === currentUser.id)?.role || 'member';
const isAdmin = myRoomRole === 'admin';
const isCreator = currentUser.id === data.creator_id;
const room = rooms.find(r => r.id === currentRoomId);
const isGroup = room && room.type !== 'private';
const list = document.getElementById('membersList');
list.innerHTML = data.members.map(m => {
const roleTag = m.is_creator ? '<span class="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">群主</span>'
: m.role === 'admin' ? '<span class="text-xs bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded">管理员</span>' : '';
const mutedTag = m.muted ? '<span class="text-xs bg-red-100 text-red-600 px-1.5 py-0.5 rounded">已禁言</span>' : '';
const nicknameTag = m.nickname ? `<span class="text-xs text-slate-400">(${escHtml(m.nickname)})</span>` : '';
let actions = '';
if (isGroup && m.id !== currentUser.id) {
if (isAdmin && !m.is_creator) {
actions += `<button onclick="muteMember(${m.id})" class="text-xs ${m.muted ? 'text-green-500 hover:text-green-700' : 'text-orange-500 hover:text-orange-700'}">${m.muted ? '解禁' : '禁言'}</button>`;
actions += `<button onclick="kickMember(${m.id},'${escHtml(m.name)}')" class="text-xs text-red-500 hover:text-red-700">移除</button>`;
}
if (isCreator && !m.is_creator) {
actions += `<button onclick="setAdmin(${m.id})" class="text-xs text-indigo-500 hover:text-indigo-700">${m.role === 'admin' ? '取消管理' : '设为管理'}</button>`;
actions += `<button onclick="transferOwner(${m.id},'${escHtml(m.name)}')" class="text-xs text-amber-500 hover:text-amber-700">转让群主</button>`;
}
}
return `<div class="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-slate-50">
<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center overflow-hidden flex-shrink-0">
${m.avatar ? `<img src="${m.avatar}" class="w-full h-full object-cover">` : `<span class="text-xs">${m.name[0]}</span>`}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-sm font-medium">${escHtml(m.name)}</span>${nicknameTag}${roleTag}${mutedTag}
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">${actions}</div>
</div>`;
}).join('');
document.getElementById('inviteSection').classList.toggle('hidden', !isGroup);
}
function hideMembers() { document.getElementById('membersModal').classList.add('hidden'); }
async function showInvite() {
hideMembers();
showCreateGroup();
document.querySelector('#createGroupModal h3').textContent = '邀请好友';
const btn = document.querySelector('#createGroupModal .bg-primary');
btn.textContent = '邀请';
btn.onclick = async () => {
const ids = [...document.querySelectorAll('.friend-check:checked')].map(c => parseInt(c.value));
if (ids.length === 0) return;
const res = await fetch(`/api/chat/rooms/${currentRoomId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: ids })
});
const data = await res.json();
hideCreateGroup();
if (data.success) loadRooms();
};
}
// ========== 群管理操作 ==========
async function muteMember(uid) {
const res = await fetch(`/api/chat/rooms/${currentRoomId}/mute/${uid}`, { method: 'POST' });
const data = await res.json();
if (data.success) showMembers();
else alert(data.message || '操作失败');
}
async function kickMember(uid, name) {
if (!confirm(`确定移除 ${name} `)) return;
const res = await fetch(`/api/chat/rooms/${currentRoomId}/members/${uid}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) { showMembers(); loadRooms(); }
else alert(data.message || '操作失败');
}
async function setAdmin(uid) {
const res = await fetch(`/api/chat/rooms/${currentRoomId}/set-admin/${uid}`, { method: 'POST' });
const data = await res.json();
if (data.success) showMembers();
else alert(data.message || '操作失败');
}
async function transferOwner(uid, name) {
if (!confirm(`确定将群主转让给 ${name} ?此操作不可撤销!`)) return;
const res = await fetch(`/api/chat/rooms/${currentRoomId}/transfer/${uid}`, { method: 'POST' });
const data = await res.json();
if (data.success) { showMembers(); loadRooms(); }
else alert(data.message || '操作失败');
}
// ========== 群公告 ==========
async function showAnnouncement() {
if (!currentRoomId) return;
document.getElementById('announcementModal').classList.remove('hidden');
const res = await fetch(`/api/chat/rooms/${currentRoomId}/announcement`);
const data = await res.json();
if (data.success) {
const display = document.getElementById('announcementDisplay');
const meta = document.getElementById('announcementMeta');
display.textContent = data.announcement || '暂无公告';
if (data.by) {
meta.textContent = `${data.by} 发布于 ${data.at}`;
meta.classList.remove('hidden');
} else { meta.classList.add('hidden'); }
}
document.getElementById('announcementEditSection').classList.add('hidden');
// 检查是否管理员
if (!currentRoomMembers.length) {
const mRes = await fetch(`/api/chat/rooms/${currentRoomId}/members`);
const mData = await mRes.json();
if (mData.success) { currentRoomMembers = mData.members; myRoomRole = mData.members.find(m => m.id === currentUser.id)?.role || 'member'; }
}
document.getElementById('btnEditAnnouncement').classList.toggle('hidden', myRoomRole !== 'admin');
}
function hideAnnouncement() { document.getElementById('announcementModal').classList.add('hidden'); }
function editAnnouncement() {
document.getElementById('announcementEditSection').classList.remove('hidden');
document.getElementById('btnEditAnnouncement').classList.add('hidden');
document.getElementById('announcementInput').value = document.getElementById('announcementDisplay').textContent === '暂无公告' ? '' : document.getElementById('announcementDisplay').textContent;
}
function cancelEditAnnouncement() {
document.getElementById('announcementEditSection').classList.add('hidden');
document.getElementById('btnEditAnnouncement').classList.remove('hidden');
}
async function saveAnnouncement() {
const content = document.getElementById('announcementInput').value.trim();
const res = await fetch(`/api/chat/rooms/${currentRoomId}/announcement`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
const data = await res.json();
if (data.success) { hideAnnouncement(); showAnnouncement(); }
else alert(data.message || '操作失败');
}
// ========== 搜索聊天记录 ==========
function showSearchPanel() { if (!currentRoomId) return; document.getElementById('searchModal').classList.remove('hidden'); document.getElementById('msgSearchInput').value = ''; document.getElementById('searchResults').innerHTML = ''; document.getElementById('msgSearchInput').focus(); }
function hideSearchPanel() { document.getElementById('searchModal').classList.add('hidden'); }
function debounceSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(doSearch, 300); }
async function doSearch() {
const q = document.getElementById('msgSearchInput').value.trim();
const container = document.getElementById('searchResults');
if (!q) { container.innerHTML = ''; return; }
const res = await fetch(`/api/chat/rooms/${currentRoomId}/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!data.success) return;
if (data.messages.length === 0) { container.innerHTML = '<p class="text-sm text-slate-400 text-center py-4">未找到相关消息</p>'; return; }
container.innerHTML = data.messages.map(m => `
<div class="p-3 bg-slate-50 rounded-lg hover:bg-slate-100 cursor-pointer transition" onclick="hideSearchPanel();scrollToMsg(${m.id})">
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-slate-600">${escHtml(m.sender_name)}</span>
<span class="text-xs text-slate-400">${m.created_at}</span>
</div>
<p class="text-sm text-slate-700 line-clamp-2">${escHtml(m.content)}</p>
</div>
`).join('');
}
function scrollToMsg(msgId) {
const el = document.getElementById(`msg-${msgId}`);
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.background = '#fef3c7'; setTimeout(() => el.style.background = '', 2000); }
}
// ========== 群文件 ==========
async function showFileList() {
if (!currentRoomId) return;
document.getElementById('fileListModal').classList.remove('hidden');
const res = await fetch(`/api/chat/rooms/${currentRoomId}/files`);
const data = await res.json();
const container = document.getElementById('fileListContent');
if (!data.success || data.files.length === 0) { container.innerHTML = '<p class="text-sm text-slate-400 text-center py-8">暂无文件</p>'; return; }
container.innerHTML = data.files.map(f => {
const icon = f.type === 'image' ? '🖼️' : '📄';
return `<div class="flex items-center gap-3 p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition">
<span class="text-xl">${icon}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-slate-700 truncate">${escHtml(f.file_name)}</div>
<div class="text-xs text-slate-400">${escHtml(f.sender_name)} · ${f.created_at}</div>
</div>
<a href="${f.file_url}" download="${escHtml(f.file_name)}" class="text-xs text-primary hover:text-blue-700 flex-shrink-0" ${f.type === 'image' ? `onclick="event.preventDefault();previewImage('${f.file_url}')"` : ''}>
${f.type === 'image' ? '查看' : '下载'}
</a>
</div>`;
}).join('');
}
function hideFileList() { document.getElementById('fileListModal').classList.add('hidden'); }
// ========== 群昵称 ==========
function showNicknameModal() { document.getElementById('nicknameModal').classList.remove('hidden'); const me = currentRoomMembers.find(m => m.id === currentUser.id); document.getElementById('nicknameInput').value = me?.nickname || ''; }
function hideNicknameModal() { document.getElementById('nicknameModal').classList.add('hidden'); }
async function saveNickname() {
const nickname = document.getElementById('nicknameInput').value.trim();
const res = await fetch(`/api/chat/rooms/${currentRoomId}/nickname`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nickname })
});
const data = await res.json();
if (data.success) { hideNicknameModal(); showMembers(); }
else alert(data.message || '操作失败');
}
// ========== 表情回应 ==========
async function toggleReaction(msgId, emoji) {
hideMsgMenu();
const res = await fetch(`/api/chat/messages/${msgId}/reactions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emoji })
});
const data = await res.json();
if (data.success) updateReactionsUI(msgId, data.reactions);
}
function updateReactionsUI(msgId, reactions) {
const container = document.getElementById(`reactions-${msgId}`);
if (!container) return;
if (!reactions || Object.keys(reactions).length === 0) { container.innerHTML = ''; return; }
container.innerHTML = Object.entries(reactions).map(([emoji, data]) =>
`<button class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs border ${data.users.some(u=>u.id===currentUser.id) ? 'bg-indigo-50 border-indigo-200 text-indigo-600' : 'bg-slate-50 border-slate-200 text-slate-500'} hover:bg-indigo-50 transition" onclick="toggleReaction(${msgId},'${emoji}')" title="${data.users.map(u=>u.name).join(', ')}">${emoji} ${data.count}</button>`
).join('');
}
// ========== @提及 ==========
async function insertMention() {
if (!currentRoomId) return;
if (!currentRoomMembers.length) {
const res = await fetch(`/api/chat/rooms/${currentRoomId}/members`);
const data = await res.json();
if (data.success) currentRoomMembers = data.members;
}
showMentionPanel(currentRoomMembers);
}
function showMentionPanel(members) {
const panel = document.getElementById('mentionPanel');
const list = document.getElementById('mentionList');
list.innerHTML = `<button class="w-full px-3 py-2 text-sm text-left hover:bg-indigo-50 text-indigo-600 font-medium" onclick="doMention('all','所有人')">@所有人</button>` +
members.filter(m => m.id !== currentUser.id).map(m =>
`<button class="w-full px-3 py-2 text-sm text-left hover:bg-slate-50 flex items-center gap-2" onclick="doMention(${m.id},'${escHtml(m.name)}')">
<div class="w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center overflow-hidden flex-shrink-0">${m.avatar ? `<img src="${m.avatar}" class="w-full h-full object-cover">` : `<span class="text-[10px]">${m.name[0]}</span>`}</div>
${escHtml(m.name)}</button>`
).join('');
panel.classList.remove('hidden');
setTimeout(() => document.addEventListener('click', closeMentionPanel, { once: true }), 10);
}
function closeMentionPanel() { document.getElementById('mentionPanel').classList.add('hidden'); }
function doMention(id, name) {
const input = document.getElementById('msgInput');
input.value += `@${name} `;
input.focus();
if (!mentionMembers.includes(id)) mentionMembers.push(id);
closeMentionPanel();
}
// ========== 语音消息 ==========
function playVoice(el, url) {
const existing = document.querySelector('audio.chat-voice-player');
if (existing) { existing.pause(); existing.remove(); }
const audio = new Audio(url);
audio.className = 'chat-voice-player';
audio.style.display = 'none';
document.body.appendChild(audio);
el.classList.add('opacity-60');
audio.play();
audio.onended = () => { el.classList.remove('opacity-60'); audio.remove(); };
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
mediaRecorder.start();
recordingStartTime = Date.now();
document.getElementById('recordingIndicator').classList.remove('hidden');
document.getElementById('voiceBtn').classList.add('bg-rose-100', 'text-rose-500');
recordingInterval = setInterval(() => {
const sec = Math.floor((Date.now() - recordingStartTime) / 1000);
document.getElementById('recordingTime').textContent = sec + 's';
}, 200);
} catch(e) { alert('无法访问麦克风,请检查权限设置'); }
}
async function stopRecording() {
if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
clearInterval(recordingInterval);
document.getElementById('recordingIndicator').classList.add('hidden');
document.getElementById('voiceBtn').classList.remove('bg-rose-100', 'text-rose-500');
const duration = Math.floor((Date.now() - recordingStartTime) / 1000);
if (duration < 1) { mediaRecorder.stop(); mediaRecorder.stream.getTracks().forEach(t => t.stop()); return; }
mediaRecorder.onstop = async () => {
mediaRecorder.stream.getTracks().forEach(t => t.stop());
const blob = new Blob(audioChunks, { type: 'audio/webm' });
const fd = new FormData();
fd.append('file', blob, 'voice.webm');
fd.append('duration', duration);
await fetch(`/api/chat/rooms/${currentRoomId}/voice`, { method: 'POST', body: fd });
};
mediaRecorder.stop();
}
// ========== SocketIO 事件监听 ==========
socket.on('new_message', (msg) => {
if (msg.room_id === currentRoomId) {
const area = document.getElementById('messageArea');
area.appendChild(createMsgEl(msg));
area.scrollTop = area.scrollHeight;
markRead(currentRoomId);
}
// 更新会话列表
const room = rooms.find(r => r.id === msg.room_id);
if (room) {
room.last_message = {
content: msg.content, sender_name: msg.sender_name,
created_at: msg.created_at, type: msg.type
};
if (msg.room_id !== currentRoomId) room.unread = (room.unread || 0) + 1;
rooms.sort((a, b) => {
const ta = a.last_message?.created_at || '';
const tb = b.last_message?.created_at || '';
return tb.localeCompare(ta);
});
renderRooms();
} else {
loadRooms();
}
});
socket.on('message_recalled', (data) => {
const el = document.getElementById(`msg-${data.message_id}`);
if (el) {
el.className = 'text-center';
el.innerHTML = `<span class="text-xs text-slate-400">消息已撤回</span>`;
}
});
socket.on('user_typing', (data) => {
if (data.room_id === currentRoomId) {
const ind = document.getElementById('typingIndicator');
ind.textContent = `${data.user_name} 正在输入...`;
ind.classList.remove('hidden');
clearTimeout(ind._timer);
ind._timer = setTimeout(() => ind.classList.add('hidden'), 3000);
}
});
socket.on('read_update', (data) => {
if (data.room_id === currentRoomId && data.user_id !== currentUser.id) {
// 私聊已读回执
const room = rooms.find(r => r.id === currentRoomId);
if (room?.type === 'private') {
document.querySelectorAll('[id^="read-"]').forEach(el => {
el.textContent = '已读';
});
}
}
});
socket.on('room_updated', () => { loadRooms(); });
socket.on('reaction_updated', (data) => {
if (data.room_id === currentRoomId) {
updateReactionsUI(data.message_id, data.reactions);
}
});
socket.on('announcement_updated', (data) => {
if (data.room_id === currentRoomId) {
// 如果公告弹窗打开则刷新
if (!document.getElementById('announcementModal').classList.contains('hidden')) {
showAnnouncement();
}
}
});
socket.on('error', (data) => {
if (data.message) alert(data.message);
});
// ========== 工具函数 ==========
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts.replace(' ', 'T'));
const now = new Date();
const diff = now - d;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前';
if (d.toDateString() === now.toDateString()) return ts.substring(11, 16);
return ts.substring(5, 16);
}
</script>
{% endblock %}