1308 lines
71 KiB
HTML
1308 lines
71 KiB
HTML
{% 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 class="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 class="flex-1 flex flex-col 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">
|
||
<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">
|
||
<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">×</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">×</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">×</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">×</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">×</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">×</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 = [];
|
||
|
||
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('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 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';
|
||
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>';
|
||
const icon = isContestApp ? '📋' : isTeacherApp ? '👨🏫' : isResult ? '📢' : isNewExam ? '📝' : isGraded ? '✅' : isSystem ? '📢' : '🔔';
|
||
const iconBg = isContestApp ? 'bg-orange-100' : isTeacherApp ? 'bg-purple-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>`;
|
||
}
|
||
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 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');
|
||
|
||
const typeLabels = {
|
||
'teacher_application': '教师申请', 'teacher_result': '教师审核结果',
|
||
'contest_application': '杯赛申请', 'contest_result': '杯赛通知',
|
||
'contest_new_exam': '新考试', 'exam_graded': '成绩通知',
|
||
'system_announcement': '系统通知'
|
||
};
|
||
const typeIcons = {
|
||
'teacher_application': '👨🏫', 'teacher_result': '🎓',
|
||
'contest_application': '📋', 'contest_result': '🏅',
|
||
'contest_new_exam': '📝', 'exam_graded': '✅',
|
||
'system_announcement': '📢'
|
||
};
|
||
|
||
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 === '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');
|
||
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');
|
||
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 %}
|
||
|