Files
zlqy/templates/chat.html

1540 lines
81 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 futuristic-card-dark overflow-hidden" style="height:calc(100vh - 120px);min-height:500px;">
<!-- 左侧面板 -->
<div id="leftPanel" class="w-full sm:w-80 border-r border-white/10 bg-slate-900/50 flex flex-col flex-shrink-0 relative z-10">
<!-- Tab 切换:聊天 / 通知 -->
<div class="flex p-3 gap-2 bg-slate-900/60 border-b border-white/10 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-blue-500 to-cyan-500 rounded-xl shadow-md transition-all duration-300 hover:shadow-blue-500/50">💬 聊天</button>
<button id="tabNotif" onclick="switchTab('notif')" class="flex-1 px-4 py-2 text-sm font-medium text-slate-300 hover:text-cyan-400 hover:bg-slate-800/50 bg-slate-800/30 rounded-xl transition-all duration-300 relative border border-white/10">
🔔 通知
<span id="chatNotifBadge" class="hidden absolute -top-1.5 -right-1.5 badge-futuristic text-[10px] font-bold rounded-full h-5 min-w-[20px] px-1.5 flex items-center justify-center shadow-sm border-2 border-slate-900 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-slate-900/40 backdrop-blur-sm border-b border-white/10">
<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-500 group-focus-within:text-cyan-400 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="input-futuristic w-full pl-10 pr-4 py-2.5 text-sm" oninput="filterRooms()">
</div>
<button onclick="showCreateGroup()" class="btn-futuristic w-full px-4 py-2.5 text-sm font-bold flex items-center justify-center gap-2 group">
<div class="w-6 h-6 rounded-full bg-cyan-500/20 text-cyan-400 flex items-center justify-center group-hover:bg-cyan-500 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-800/50 rounded-full flex items-center justify-center mb-3 border border-white/10">📭</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=\'%2306b6d4\' fill-opacity=\'0.05\'%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-slate-900/95 to-slate-800/90 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-cyan-500/20 to-blue-500/20 rounded-full flex items-center justify-center mb-6 shadow-inner relative border border-cyan-500/30">
<div class="absolute inset-0 bg-cyan-400 blur-2xl opacity-20 rounded-full animate-pulse"></div>
<span class="text-6xl animate-bounce">💬</span>
</div>
<h3 class="text-2xl font-extrabold bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent tracking-tight mb-2">欢迎来到联考消息中心</h3>
<p class="text-slate-400 font-medium">在左侧选择一个聊天,或发起新的对话</p>
</div>
</div>
<!-- 通知详情视图 -->
<div id="notifDetailView" class="flex-1 flex-col hidden z-10 bg-slate-900/80 backdrop-blur-md">
<div class="px-6 py-4 border-b border-white/10 flex items-center gap-4 bg-slate-800/50 sticky top-0 backdrop-blur-xl">
<button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-700/50 text-slate-300 flex items-center justify-center flex-shrink-0 border border-white/10" 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-cyan-500/20 to-blue-500/20 flex items-center justify-center text-2xl shadow-sm border border-cyan-500/30" id="notifDetailIconContainer">
<span id="notifDetailIcon"></span>
</div>
<div>
<div id="notifDetailType" class="text-lg font-extrabold text-white"></div>
<div id="notifDetailTime" class="text-xs font-medium text-slate-400 mt-1"></div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-8 hide-scrollbar">
<div class="futuristic-card-dark rounded-3xl p-8 shadow-sm relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-cyan-500/10 rounded-bl-full -z-10"></div>
<div id="notifDetailContent" class="text-base text-slate-300 leading-relaxed font-medium"></div>
<div id="notifDetailFrom" class="text-sm text-slate-500 mt-6 pt-6 border-t border-white/10 flex items-center gap-2"></div>
<div id="notifDetailActions" class="mt-6"></div>
</div>
</div>
</div>
<!-- 聊天视图 -->
<div id="chatView" class="flex-1 hidden z-10 relative h-full">
<!-- 顶栏 -->
<div id="chatHeader" class="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-slate-800/60 backdrop-blur-xl flex-shrink-0 z-20">
<div class="flex items-center gap-3">
<button onclick="mobileBack()" class="sm:hidden w-8 h-8 rounded-lg bg-slate-700/50 text-slate-300 flex items-center justify-center mr-1 border border-white/10" 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-cyan-500/20 to-blue-500/20 flex items-center justify-center text-cyan-400 font-bold shadow-sm overflow-hidden border border-cyan-500/30">
<!-- 头像动态插入 -->
</div>
<div>
<div class="flex items-center gap-2">
<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>
<div class="flex items-center gap-3">
<button onclick="showAnnouncement()" class="w-10 h-10 rounded-xl bg-slate-700/50 text-slate-400 hover:text-amber-400 hover:bg-amber-500/20 hover:shadow-sm border border-white/10 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-slate-700/50 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/20 hover:shadow-sm border border-white/10 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-slate-700/50 text-slate-400 hover:text-emerald-400 hover:bg-emerald-500/20 hover:shadow-sm border border-white/10 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-slate-700/50 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/20 hover:shadow-sm border border-white/10 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 px-6 py-4 space-y-5" style="overflow-anchor: none;" onscroll="handleScroll()"></div>
<!-- 引用回复预览 -->
<div id="replyPreview" class="px-4 py-3 bg-cyan-500/10 backdrop-blur-md border-t border-cyan-500/30 hidden flex items-center justify-between shadow-inner flex-shrink-0">
<div class="flex items-center gap-2 overflow-hidden">
<div class="w-1 h-4 bg-cyan-400 rounded-full"></div>
<div class="text-sm font-medium text-cyan-300 truncate"><span id="replyToText"></span></div>
</div>
<button onclick="cancelReply()" class="w-6 h-6 rounded-full bg-slate-700/50 text-cyan-400 hover:text-cyan-300 hover:bg-slate-700 flex items-center justify-center transition-colors shadow-sm ml-2 flex-shrink-0 border border-white/10">
<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-slate-800/80 backdrop-blur-xl border-t border-white/10 shadow-[0_-4px_20px_-15px_rgba(0,0,0,0.3)] flex-shrink-0 relative">
<!-- Emoji 面板 -->
<div id="emojiPanel" class="hidden absolute bottom-[calc(100%+10px)] left-4 p-3 futuristic-card-dark backdrop-blur-xl 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 futuristic-card-dark 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 badge-futuristic 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-700/50 text-slate-400 hover:text-amber-400 hover:bg-amber-500/20 transition-colors flex items-center justify-center shadow-sm border border-white/10" title="表情">
😀
</button>
<label class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-emerald-400 hover:bg-emerald-500/20 transition-colors flex items-center justify-center shadow-sm cursor-pointer border border-white/10" 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-700/50 text-slate-400 hover:text-blue-400 hover:bg-blue-500/20 transition-colors flex items-center justify-center shadow-sm cursor-pointer border border-white/10" 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-700/50 text-slate-400 hover:text-cyan-400 hover:bg-cyan-500/20 transition-colors flex items-center justify-center shadow-sm font-bold text-sm border border-white/10" title="@提及">
@
</button>
<button id="voiceBtn" onmousedown="startRecording()" onmouseup="stopRecording()" ontouchstart="startRecording()" ontouchend="stopRecording()" class="w-9 h-9 rounded-xl bg-slate-700/50 text-slate-400 hover:text-rose-400 hover:bg-rose-500/20 transition-colors flex items-center justify-center shadow-sm border border-white/10" 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 futuristic-card-dark rounded-2xl shadow-sm focus-within:ring-2 focus-within:ring-cyan-500/30 focus-within:border-cyan-400/50 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 text-white placeholder-slate-500 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';" onkeydown="handleInputKey(event)"></textarea>
</div>
<button onclick="sendMessage()" class="btn-futuristic w-12 h-12 rounded-2xl flex items-center justify-center hover:shadow-lg hover:shadow-cyan-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 items-center justify-center">
<div class="futuristic-card-dark rounded-xl shadow-xl w-96 max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-white/10 flex justify-between items-center">
<h3 class="font-semibold text-white">创建群聊</h3>
<button onclick="hideCreateGroup()" class="text-slate-400 hover:text-slate-300">&times;</button>
</div>
<div class="p-4 space-y-3">
<input id="groupName" type="text" placeholder="群聊名称" class="input-futuristic w-full px-3 py-2 text-sm">
<p class="text-sm text-slate-400">选择好友加入群聊:</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-white/10">
<button id="groupActionBtn" onclick="createGroup()" class="btn-futuristic w-full px-4 py-2 text-sm">创建</button>
</div>
</div>
</div>
<!-- 成员列表弹窗 -->
<div id="membersModal" class="fixed inset-0 bg-black/50 z-[9990] hidden items-center justify-center">
<div class="futuristic-card-dark rounded-xl shadow-xl w-[420px] max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-white/10 flex justify-between items-center">
<h3 class="font-semibold text-white">群成员</h3>
<div class="flex items-center gap-2">
<button onclick="showNicknameModal()" class="text-xs text-cyan-400 hover:text-cyan-300" title="设置群昵称">我的昵称</button>
<button onclick="hideMembers()" class="text-slate-400 hover:text-slate-300 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-white/10 hidden space-y-2">
<button onclick="showInvite()" class="btn-futuristic w-full px-4 py-2 text-sm">邀请好友</button>
<button id="dissolveBtn" onclick="dissolveGroup()" class="w-full px-4 py-2 text-sm rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/30 transition-colors hidden">解散群聊</button>
</div>
</div>
</div>
<!-- 群公告弹窗 -->
<div id="announcementModal" class="fixed inset-0 bg-black/50 z-[9990] hidden 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 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 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 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>
<!-- 轻提示 -->
<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 %}
<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 = [];
let peerExamStatus = {}; // {user_id: {in_exam, exam_title, end_time}}
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();
// 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');
if (badge) {
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 (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');
}
}
}
}
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 => {
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">` :
`<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>
${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">
<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}人)` : '';
// 私聊:获取对方考试状态
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`;
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)));
// 确保滚动到底部
setTimeout(() => {
area.scrollTop = area.scrollHeight;
}, 100);
} 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 = '';
input.style.height = 'auto';
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() {
const modal = document.getElementById('createGroupModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
// 重置弹窗标题和按钮(防止被邀请好友功能覆盖)
document.querySelector('#createGroupModal h3').textContent = '创建群聊';
const createBtn = document.getElementById('groupActionBtn');
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() {
const modal = document.getElementById('createGroupModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
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;
const modal = document.getElementById('membersModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
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);
document.getElementById('dissolveBtn').classList.toggle('hidden', !isCreator);
}
function hideMembers() {
const modal = document.getElementById('membersModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
async function dissolveGroup() {
if (!confirm('确定要解散该群聊?所有聊天记录将被删除,此操作不可撤销。')) return;
const res = await fetch(`/api/chat/rooms/${currentRoomId}/dissolve`, {method:'POST'});
const data = await res.json();
if (data.success) {
hideMembers();
currentRoomId = null;
document.getElementById('chat-header-info').innerHTML = '';
document.getElementById('messages').innerHTML = '';
document.getElementById('chat-input-area').classList.add('hidden');
await loadRooms();
} else {
alert(data.message || '操作失败');
}
}
async function showInvite() {
hideMembers();
showCreateGroup();
document.querySelector('#createGroupModal h3').textContent = '邀请好友';
const btn = document.getElementById('groupActionBtn');
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;
const modal = document.getElementById('announcementModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
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() {
const modal = document.getElementById('announcementModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
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;
const modal = document.getElementById('searchModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
document.getElementById('msgSearchInput').value = '';
document.getElementById('searchResults').innerHTML = '';
document.getElementById('msgSearchInput').focus();
}
function hideSearchPanel() {
const modal = document.getElementById('searchModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
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;
const modal = document.getElementById('fileListModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
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() {
const modal = document.getElementById('fileListModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
// ========== 群昵称 ==========
function showNicknameModal() {
const modal = document.getElementById('nicknameModal');
modal.classList.remove('hidden');
modal.classList.add('flex');
const me = currentRoomMembers.find(m => m.id === currentUser.id);
document.getElementById('nicknameInput').value = me?.nickname || '';
}
function hideNicknameModal() {
const modal = document.getElementById('nicknameModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
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');
const wasAtBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 100;
area.appendChild(createMsgEl(msg));
// 如果用户在底部或者是自己发的消息,自动滚动到底部
if (wasAtBottom || msg.sender_id === currentUser.id) {
setTimeout(() => {
area.scrollTop = area.scrollHeight;
}, 50);
}
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);
});
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 '';
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 %}