AI聊天室(vue.js)
<!DOCTYPE html>
<html lang="zh-CN" class="h-full bg-slate-900 text-slate-100">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhisperVerse - 匿名兴趣在线聊天室</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Vue 3 CDN -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- FontAwesome Icons CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Tailwind Custom Config -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
brand: {
50: '#f0f5ff',
100: '#e5edff',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
}
}
}
}
}
</script>
<style>
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.6);
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.chat-bubble-self {
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
}
.chat-bubble-other {
background: rgba(51, 65, 85, 0.9);
}
</style>
</head>
<body class="h-full font-sans antialiased selection:bg-brand-500 selection:text-white">
<div id="app" class="h-full flex flex-col overflow-hidden">
<!-- Navbar -->
<header class="h-16 glass-panel border-b border-slate-800 px-4 md:px-6 flex items-center justify-between z-20 shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-500 via-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-lg shadow-indigo-500/30">
<i class="fa-solid fa-user-ninja"></i>
</div>
<div>
<h1 class="text-lg font-bold bg-gradient-to-r from-white via-slate-200 to-indigo-300 bg-clip-text text-transparent">WhisperVerse</h1>
<p class="text-xs text-slate-400 hidden sm:block">匿名 · 门类房间 · 兴趣私聊</p>
</div>
</div>
<!-- Test Mode Tip -->
<div class="hidden lg:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/80 border border-slate-700/60 text-xs text-slate-300">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span>提示:在<b>另一浏览器标签页</b>打开本页面,可体验跨标签实时交互!</span>
</div>
<!-- User Info / Profile Switch Button -->
<div class="flex items-center gap-3">
<button v-if="currentUser" @click="showProfileModal = true" class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800/80 hover:bg-slate-700/80 transition-all border border-slate-700/50">
<img :src="currentUser.avatar" class="w-7 h-7 rounded-full bg-slate-700" alt="avatar">
<span class="text-sm font-medium text-slate-200 max-w-[100px] truncate">{{ currentUser.nickname }}</span>
<span :class="genderBadgeClass(currentUser.gender)" class="text-xs px-1.5 py-0.5 rounded-full font-bold">
{{ genderSymbol(currentUser.gender) }}
</span>
</button>
</div>
</header>
<!-- Main Content Area -->
<main class="flex-1 overflow-hidden relative flex">
<!-- VIEW 1: LOBBY VIEW (Room Selection & Filtering) -->
<div v-if="currentView === 'lobby'" class="flex-1 flex flex-col md:flex-row h-full overflow-hidden">
<!-- Left Sidebar: Categories & Filters -->
<aside class="w-full md:w-72 glass-panel border-r border-slate-800 p-4 flex flex-col gap-5 overflow-y-auto shrink-0">
<div>
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">聊天门类</h2>
<div class="space-y-1">
<button
@click="selectedCategory = 'all'"
:class="selectedCategory === 'all' ? 'bg-indigo-600 text-white font-medium' : 'text-slate-300 hover:bg-slate-800/60'"
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between">
<span><i class="fa-solid fa-compass w-5 text-center mr-1"></i> 全部大厅</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-900/50 text-slate-300">{{ rooms.length }}</span>
</button>
<button
v-for="cat in categories"
:key="cat.id"
@click="selectedCategory = cat.id"
:class="selectedCategory === cat.id ? 'bg-indigo-600 text-white font-medium' : 'text-slate-300 hover:bg-slate-800/60'"
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors flex items-center justify-between">
<span><i :class="cat.icon" class="w-5 text-center mr-1"></i> {{ cat.name }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-900/50 text-slate-300">{{ getCategoryRoomCount(cat.id) }}</span>
</button>
</div>
</div>
<!-- Hobby Filter -->
<div>
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">兴趣标签筛选</h2>
<div class="flex flex-wrap gap-1.5">
<button
v-for="hobby in allHobbies"
:key="hobby"
@click="toggleFilterHobby(hobby)"
:class="selectedHobbies.includes(hobby) ? 'bg-indigo-500/20 text-indigo-300 border-indigo-500/50' : 'bg-slate-800/40 text-slate-400 border-slate-700/40 hover:border-slate-600'"
class="text-xs px-2.5 py-1 rounded-full border transition-all">
#{{ hobby }}
</button>
</div>
</div>
<!-- Create Room Action -->
<div class="mt-auto pt-4 border-t border-slate-800">
<button @click="showCreateRoomModal = true" class="w-full py-2.5 px-4 rounded-xl bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white font-medium text-sm shadow-lg shadow-indigo-500/20 transition-all flex items-center justify-center gap-2">
<i class="fa-solid fa-plus"></i>
<span>创建匿名房间</span>
</button>
</div>
</aside>
<!-- Center Lobby: Room Cards Grid -->
<section class="flex-1 p-4 md:p-6 overflow-y-auto">
<!-- Filter Header Bar -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div>
<h2 class="text-xl font-bold text-white">房间探索</h2>
<p class="text-xs text-slate-400 mt-1">选择感兴趣的门类与主题,加入即刻开启匿名对谈</p>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-xs"></i>
<input
v-model="searchQuery"
type="text"
placeholder="搜索房间主题或标签..."
class="bg-slate-800/80 text-xs text-slate-200 placeholder-slate-500 pl-8 pr-3 py-2 rounded-xl border border-slate-700/60 focus:outline-none focus:border-indigo-500 w-48 sm:w-64 transition-all"
>
</div>
</div>
</div>
<!-- Rooms Grid -->
<div v-if="filteredRooms.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="room in filteredRooms"
:key="room.id"
class="glass-panel rounded-2xl p-4 hover:border-indigo-500/50 hover:shadow-xl hover:shadow-indigo-500/10 transition-all duration-300 flex flex-col justify-between group"
>
<div>
<div class="flex items-start justify-between gap-2 mb-2">
<span class="text-xs font-medium px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 flex items-center gap-1.5">
<i :class="getCategoryIcon(room.category)"></i>
{{ getCategoryName(room.category) }}
</span>
<span class="text-xs text-slate-400 flex items-center gap-1 bg-slate-800/80 px-2 py-0.5 rounded-full">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
{{ room.members.length }} 人在线
</span>
</div>
<h3 class="font-bold text-slate-100 group-hover:text-indigo-300 transition-colors text-base mb-1.5">{{ room.title }}</h3>
<p class="text-xs text-slate-400 line-clamp-2 mb-3 leading-relaxed">{{ room.description }}</p>
<!-- Tags -->
<div class="flex flex-wrap gap-1 mb-4">
<span v-for="tag in room.tags" :key="tag" class="text-[10px] px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700/40">
#{{ tag }}
</span>
</div>
</div>
<div class="pt-3 border-t border-slate-800/60 flex items-center justify-between">
<div class="flex -space-x-2 overflow-hidden">
<img v-for="(m, idx) in room.members.slice(0, 4)" :key="idx" :src="m.avatar" class="inline-block h-6 w-6 rounded-full ring-2 ring-slate-800 object-cover bg-slate-700" :title="m.nickname" />
<div v-if="room.members.length > 4" class="h-6 w-6 rounded-full bg-slate-800 text-[10px] font-bold text-slate-400 flex items-center justify-center ring-2 ring-slate-800">
+{{ room.members.length - 4 }}
</div>
</div>
<button
@click="joinRoom(room)"
class="px-3.5 py-1.5 rounded-xl bg-slate-800 group-hover:bg-indigo-600 text-slate-200 group-hover:text-white text-xs font-semibold transition-all flex items-center gap-1.5">
<span>进入房间</span>
<i class="fa-solid fa-arrow-right text-[10px]"></i>
</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="glass-panel rounded-2xl p-12 text-center max-w-md mx-auto my-12">
<i class="fa-solid fa-ghost text-4xl text-slate-600 mb-3"></i>
<h3 class="text-base font-bold text-slate-300 mb-1">未找到匹配的聊天房间</h3>
<p class="text-xs text-slate-500 mb-4">尝试更改筛选分类,或者亲自动手创建一个新房间吧!</p>
<button @click="showCreateRoomModal = true" class="px-4 py-2 rounded-xl bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-500 transition-all">
创建该门类房间
</button>
</div>
</section>
</div>
<!-- VIEW 2: CHAT ROOM VIEW -->
<div v-else-if="currentView === 'room' && activeRoom" class="flex-1 flex overflow-hidden">
<!-- Main Chat Stream Section -->
<div class="flex-1 flex flex-col h-full bg-slate-950/40 relative">
<!-- Room Sub-Header -->
<div class="h-14 glass-panel border-b border-slate-800/80 px-4 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<button @click="leaveRoom" class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-400 hover:text-white flex items-center justify-center transition-all">
<i class="fa-solid fa-chevron-left text-xs"></i>
</button>
<div>
<div class="flex items-center gap-2">
<h2 class="font-bold text-slate-100 text-sm md:text-base">{{ activeRoom.title }}</h2>
<span class="text-[10px] px-2 py-0.5 rounded bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
{{ getCategoryName(activeRoom.category) }}
</span>
</div>
<p class="text-[11px] text-slate-400 truncate max-w-xs md:max-w-md">{{ activeRoom.description }}</p>
</div>
</div>
<!-- Mobile Toggle Sidebar & Quick Create Button -->
<div class="flex items-center gap-2">
<button @click="showCreateRoomModal = true" class="px-2.5 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-xs text-white font-medium flex items-center gap-1.5 shadow transition-all" title="新建并切换到新房间">
<i class="fa-solid fa-plus text-xs"></i>
<span class="hidden sm:inline">新建房间</span>
</button>
<button @click="showMobileMembers = !showMobileMembers" class="md:hidden px-3 py-1.5 rounded-lg bg-slate-800 text-xs text-slate-300 flex items-center gap-1.5">
<i class="fa-solid fa-users text-indigo-400"></i>
<span>{{ activeRoom.members.length }}</span>
</button>
</div>
</div>
<!-- Icebreaker Banner / Prompt Generator -->
<div class="bg-indigo-950/30 border-b border-indigo-900/30 px-4 py-2 flex items-center justify-between text-xs text-indigo-200">
<div class="flex items-center gap-2 overflow-hidden">
<i class="fa-solid fa-lightbulb text-amber-400 animate-pulse"></i>
<span class="font-semibold text-amber-300 shrink-0">破冰灵感:</span>
<span class="truncate text-indigo-200/90 font-light">{{ currentIcebreaker }}</span>
</div>
<button @click="nextIcebreaker" class="text-indigo-400 hover:text-indigo-200 shrink-0 ml-2 text-[11px] flex items-center gap-1">
<i class="fa-solid fa-arrows-rotate"></i> 换一个
</button>
</div>
<!-- Messages Stream Container -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-4">
<div v-for="msg in activeRoomMessages" :key="msg.id" class="flex flex-col">
<!-- System Message -->
<div v-if="msg.isSystem" class="my-2 flex justify-center">
<span class="text-[11px] px-3 py-1 rounded-full bg-slate-800/80 text-slate-400 border border-slate-700/50">
<i class="fa-solid fa-circle-info mr-1 text-indigo-400"></i>
{{ msg.text }}
</span>
</div>
<!-- User Message -->
<div v-else :class="msg.sender.id === currentUser.id ? 'items-end' : 'items-start'" class="flex flex-col group">
<div class="flex items-end gap-2 max-w-[85%] sm:max-w-[70%]" :class="msg.sender.id === currentUser.id ? 'flex-row-reverse' : 'flex-row'">
<!-- Avatar -->
<img
@click="openUserProfileModal(msg.sender)"
:src="msg.sender.avatar"
class="w-8 h-8 rounded-full bg-slate-700 cursor-pointer hover:opacity-80 transition-opacity ring-1 ring-slate-700 shrink-0 mb-1"
title="点击查看详细或发起私聊"
/>
<div>
<!-- Sender Name + Time -->
<div class="flex items-center gap-1.5 mb-1 text-[11px] text-slate-400" :class="msg.sender.id === currentUser.id ? 'justify-end' : 'justify-start'">
<span class="font-medium text-slate-300">{{ msg.sender.nickname }}</span>
<span :class="genderBadgeClass(msg.sender.gender)" class="text-[9px] px-1 rounded font-bold">
{{ genderSymbol(msg.sender.gender) }}
</span>
<span class="text-[10px] text-slate-500">{{ formatTime(msg.timestamp) }}</span>
</div>
<!-- Bubble Text or Image -->
<div
:class="msg.sender.id === currentUser.id ? 'chat-bubble-self text-white rounded-2xl rounded-br-none' : 'chat-bubble-other text-slate-200 rounded-2xl rounded-bl-none border border-slate-700/50'"
class="px-4 py-2.5 text-sm shadow-md break-words"
>
<p v-if="msg.text" class="whitespace-pre-wrap leading-relaxed">{{ msg.text }}</p>
<img v-if="msg.image" :src="msg.image" class="max-w-xs max-h-60 rounded-lg mt-1 border border-slate-700/50 object-cover" />
</div>
</div>
</div>
<!-- Quick Action on Message Hover (Private Chat trigger) -->
<button
v-if="msg.sender.id !== currentUser.id"
@click="startPrivateChat(msg.sender)"
class="opacity-0 group-hover:opacity-100 transition-opacity text-[10px] text-indigo-400 hover:underline mt-0.5 ml-10">
<i class="fa-regular fa-paper-plane mr-0.5"></i> 私聊TA
</button>
</div>
</div>
</div>
<!-- Message Input Bar -->
<div class="p-3 md:p-4 glass-panel border-t border-slate-800 shrink-0">
<!-- Image Preview if attached -->
<div v-if="pendingImage" class="mb-2 relative inline-block">
<img :src="pendingImage" class="h-16 rounded-lg border border-indigo-500/50" />
<button @click="pendingImage = null" class="absolute -top-2 -right-2 bg-red-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Emoji Picker Popover -->
<div v-if="showEmojiPicker" class="absolute bottom-20 left-4 bg-slate-800 border border-slate-700 rounded-2xl p-3 shadow-2xl z-30 w-64 grid grid-cols-6 gap-2 text-xl">
<button v-for="emoji in emojis" :key="emoji" @click="addEmoji(emoji)" class="hover:bg-slate-700 rounded p-1 text-center transition-colors">
{{ emoji }}
</button>
</div>
<form @submit.prevent="sendPublicMessage" class="flex items-center gap-2">
<button type="button" @click="showEmojiPicker = !showEmojiPicker" class="text-slate-400 hover:text-indigo-400 p-2 rounded-xl hover:bg-slate-800 transition-all">
<i class="fa-regular fa-face-smile text-lg"></i>
</button>
<label class="text-slate-400 hover:text-indigo-400 p-2 rounded-xl hover:bg-slate-800 transition-all cursor-pointer">
<i class="fa-regular fa-image text-lg"></i>
<input type="file" accept="image/*" @change="handleImageUpload" class="hidden" />
</label>
<input
v-model="newMessageText"
type="text"
placeholder="发送公开消息... (支持回车直接发送)"
class="flex-1 bg-slate-800/80 text-sm text-slate-100 placeholder-slate-500 px-4 py-2.5 rounded-xl border border-slate-700/60 focus:outline-none focus:border-indigo-500 transition-all"
/>
<button
type="submit"
:disabled="!newMessageText.trim() && !pendingImage"
class="px-4 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-semibold transition-all flex items-center gap-1.5 shadow-lg shadow-indigo-600/20">
<span>发送</span>
<i class="fa-solid fa-paper-plane text-xs"></i>
</button>
</form>
</div>
</div>
<!-- Right Sidebar: Room Members & Interests -->
<aside
:class="showMobileMembers ? 'translate-x-0' : 'translate-x-full md:translate-x-0'"
class="fixed md:static right-0 top-16 bottom-0 w-72 glass-panel border-l border-slate-800 p-4 flex flex-col z-20 transition-transform duration-300 shrink-0"
>
<div class="flex items-center justify-between pb-3 border-b border-slate-800 mb-3">
<h3 class="text-sm font-bold text-slate-200 flex items-center gap-2">
<i class="fa-solid fa-users text-indigo-400"></i>
<span>在线成员 ({{ activeRoom.members.length }})</span>
</h3>
<button @click="showMobileMembers = false" class="md:hidden text-slate-400 hover:text-white">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Members List -->
<div class="flex-1 overflow-y-auto space-y-2 pr-1">
<div
v-for="member in activeRoom.members"
:key="member.id"
class="p-2.5 rounded-xl bg-slate-800/50 hover:bg-slate-800 border border-slate-700/30 transition-all flex items-center justify-between group"
>
<div class="flex items-center gap-2.5 overflow-hidden">
<div class="relative shrink-0">
<img :src="member.avatar" class="w-8 h-8 rounded-full bg-slate-700 object-cover" />
<span class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-500 ring-2 ring-slate-800"></span>
</div>
<div class="overflow-hidden">
<div class="flex items-center gap-1">
<span class="text-xs font-semibold text-slate-200 truncate">{{ member.nickname }}</span>
<span :class="genderBadgeClass(member.gender)" class="text-[9px] px-1 rounded font-bold shrink-0">
{{ genderSymbol(member.gender) }}
</span>
</div>
<!-- Shared Interests Tags -->
<div class="flex items-center gap-1 mt-0.5">
<span class="text-[9px] text-indigo-300 font-medium bg-indigo-500/10 px-1.5 py-0.2 rounded truncate">
{{ getMatchScoreText(member) }}
</span>
</div>
</div>
</div>
<!-- Private Chat Button -->
<button
v-if="member.id !== currentUser.id"
@click="startPrivateChat(member)"
title="发起一对一私聊"
class="w-7 h-7 rounded-lg bg-indigo-600/20 hover:bg-indigo-600 text-indigo-300 hover:text-white flex items-center justify-center transition-all opacity-80 group-hover:opacity-100 shrink-0">
<i class="fa-regular fa-comment-dots text-xs"></i>
</button>
</div>
</div>
</aside>
</div>
</main>
<!-- PRIVATE CHAT DRAWER / FLOATING MESSAGES BAR & DOCK -->
<div v-if="activePrivateChats.length > 0" class="fixed bottom-4 right-4 z-40 flex flex-col items-end gap-3 pointer-events-none">
<!-- POPUP: PRIVATE CHAT LIST DRAWER -->
<div
v-if="showPrivateChatList"
class="w-80 sm:w-86 glass-panel rounded-2xl shadow-2xl border border-slate-700/80 pointer-events-auto overflow-hidden flex flex-col max-h-[420px] transition-all duration-200 mb-1"
>
<div class="p-3 bg-slate-800/90 border-b border-slate-700/60 flex items-center justify-between shrink-0">
<div class="flex items-center gap-2">
<i class="fa-solid fa-comments text-indigo-400"></i>
<span class="text-xs font-bold text-slate-100">私聊会话列表</span>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300 font-semibold">
{{ activePrivateChats.length }}
</span>
</div>
<div class="flex items-center gap-2">
<button @click="markAllPrivateAsRead" title="全部标为已读" class="text-[11px] text-slate-400 hover:text-indigo-300 transition-colors">
<i class="fa-solid fa-check-double mr-1"></i>已读
</button>
<button @click="showPrivateChatList = false" class="text-slate-400 hover:text-white p-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<!-- Conversation Item List -->
<div class="overflow-y-auto flex-1 divide-y divide-slate-800/60 bg-slate-950/70">
<div
v-for="chat in activePrivateChats"
:key="chat.targetUser.id"
@click="openPrivateChat(chat.targetUser.id)"
:class="focusedPrivateChatId === chat.targetUser.id ? 'bg-indigo-950/40 border-l-2 border-indigo-500' : 'hover:bg-slate-800/40'"
class="p-3 flex items-center justify-between cursor-pointer transition-colors group"
>
<div class="flex items-center gap-2.5 overflow-hidden flex-1 mr-2">
<div class="relative shrink-0">
<img :src="chat.targetUser.avatar" class="w-9 h-9 rounded-full bg-slate-800 object-cover" />
<span v-if="chat.unreadCount > 0" class="absolute -top-1 -right-1 bg-pink-500 text-white text-[9px] font-bold min-w-[16px] h-4 px-1 rounded-full flex items-center justify-center animate-pulse">
{{ chat.unreadCount }}
</span>
</div>
<div class="overflow-hidden flex-1">
<div class="flex items-center justify-between">
<span class="text-xs font-bold text-slate-200 truncate">{{ chat.targetUser.nickname }}</span>
<span :class="genderBadgeClass(chat.targetUser.gender)" class="text-[9px] px-1 rounded font-bold shrink-0 ml-1">
{{ genderSymbol(chat.targetUser.gender) }}
</span>
</div>
<p class="text-[11px] text-slate-400 truncate mt-0.5">
{{ getLastMessage(chat) }}
</p>
</div>
</div>
<button
@click.stop="closePrivateChat(chat.targetUser.id)"
title="关闭会话"
class="opacity-0 group-hover:opacity-100 p-1.5 hover:bg-slate-700/60 rounded-lg text-slate-400 hover:text-red-400 transition-all shrink-0">
<i class="fa-solid fa-xmark text-xs"></i>
</button>
</div>
</div>
</div>
<!-- FOCUSED PRIVATE CHAT WINDOW (Only shows selected/active chat) -->
<div
v-if="activeFocusedChat"
class="w-72 sm:w-84 glass-panel rounded-2xl shadow-2xl border border-slate-700/80 pointer-events-auto flex flex-col transition-all overflow-hidden"
:style="{ height: activeFocusedChat.isMinimized ? '44px' : '390px' }"
>
<!-- DM Header Bar -->
<div
@click="activeFocusedChat.isMinimized = !activeFocusedChat.isMinimized"
class="h-11 bg-slate-800/90 px-3 flex items-center justify-between cursor-pointer border-b border-slate-700/60 shrink-0"
>
<div class="flex items-center gap-2 overflow-hidden">
<div class="relative shrink-0">
<img :src="activeFocusedChat.targetUser.avatar" class="w-6 h-6 rounded-full bg-slate-700" />
</div>
<span class="text-xs font-bold text-slate-200 truncate">{{ activeFocusedChat.targetUser.nickname }}</span>
<span :class="genderBadgeClass(activeFocusedChat.targetUser.gender)" class="text-[9px] px-1 rounded font-bold">
{{ genderSymbol(activeFocusedChat.targetUser.gender) }}
</span>
</div>
<div class="flex items-center gap-1.5 text-slate-400 text-xs">
<button @click.stop="activeFocusedChat.isMinimized = !activeFocusedChat.isMinimized" class="hover:text-white p-1">
<i :class="activeFocusedChat.isMinimized ? 'fa-square-plus' : 'fa-minus'" class="fa-solid"></i>
</button>
<button @click.stop="closePrivateChat(activeFocusedChat.targetUser.id)" class="hover:text-red-400 p-1">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<!-- DM Quick Switcher Tabs (if multiple chats) -->
<div v-if="!activeFocusedChat.isMinimized && activePrivateChats.length > 1" class="bg-slate-900/80 border-b border-slate-800 px-2 py-1 flex items-center gap-1 overflow-x-auto shrink-0">
<button
v-for="chat in activePrivateChats"
:key="'tab_' + chat.targetUser.id"
@click="openPrivateChat(chat.targetUser.id)"
:class="focusedPrivateChatId === chat.targetUser.id ? 'bg-indigo-600 text-white font-semibold' : 'bg-slate-800/60 text-slate-400 hover:text-slate-200'"
class="text-[10px] px-2 py-0.5 rounded-md flex items-center gap-1 shrink-0 transition-all max-w-[100px]"
>
<span class="truncate">{{ chat.targetUser.nickname }}</span>
<span v-if="chat.unreadCount > 0" class="w-1.5 h-1.5 rounded-full bg-pink-400"></span>
</button>
</div>
<!-- DM Body (if expanded) -->
<template v-if="!activeFocusedChat.isMinimized">
<div :ref="'dmBox_' + activeFocusedChat.targetUser.id" class="flex-1 overflow-y-auto p-3 space-y-3 bg-slate-950/60">
<div class="text-center my-1">
<span class="text-[10px] text-slate-500 bg-slate-900/80 px-2 py-0.5 rounded-full">
🔒 正在与 {{ activeFocusedChat.targetUser.nickname }} 进行端到端匿名私聊
</span>
</div>
<div v-for="(msg, idx) in activeFocusedChat.messages" :key="idx" :class="msg.senderId === currentUser.id ? 'items-end' : 'items-start'" class="flex flex-col">
<div
:class="msg.senderId === currentUser.id ? 'bg-indigo-600 text-white rounded-xl rounded-br-none' : 'bg-slate-800 text-slate-200 rounded-xl rounded-bl-none border border-slate-700/50'"
class="px-3 py-1.5 text-xs max-w-[85%] break-words shadow"
>
{{ msg.text }}
</div>
<span class="text-[9px] text-slate-500 mt-0.5">{{ formatTime(msg.timestamp) }}</span>
</div>
</div>
<!-- DM Input -->
<form @submit.prevent="sendPrivateMessage(activeFocusedChat)" class="p-2 bg-slate-900 border-t border-slate-800 flex items-center gap-1.5">
<input
v-model="activeFocusedChat.inputText"
type="text"
placeholder="发送私密消息..."
class="flex-1 bg-slate-800 text-xs text-slate-100 placeholder-slate-500 px-3 py-1.5 rounded-lg border border-slate-700/60 focus:outline-none focus:border-indigo-500"
/>
<button type="submit" :disabled="!activeFocusedChat.inputText.trim()" class="px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-medium disabled:opacity-40">
<i class="fa-solid fa-paper-plane"></i>
</button>
</form>
</template>
</div>
<!-- BOTTOM CONTROL BAR BUTTON: TOGGLE PRIVATE CHAT LIST -->
<button
@click="showPrivateChatList = !showPrivateChatList"
class="pointer-events-auto px-3.5 py-2 rounded-full bg-slate-800/90 hover:bg-slate-700 border border-slate-700 shadow-xl text-slate-200 text-xs font-semibold flex items-center gap-2 backdrop-blur-md transition-all group"
>
<div class="relative flex items-center justify-center">
<i class="fa-regular fa-comments text-indigo-400 group-hover:scale-110 transition-transform"></i>
<span v-if="totalUnreadCount > 0" class="absolute -top-2 -right-2 bg-pink-500 text-white text-[9px] font-bold min-w-[16px] h-4 px-1 rounded-full flex items-center justify-center animate-bounce">
{{ totalUnreadCount }}
</span>
</div>
<span>私聊列表 ({{ activePrivateChats.length }})</span>
<i :class="showPrivateChatList ? 'fa-chevron-down' : 'fa-chevron-up'" class="fa-solid text-[10px] text-slate-400"></i>
</button>
</div>
<!-- MODAL 1: User Anonymous Profile Setup / Edit -->
<div v-if="showProfileModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="glass-panel rounded-2xl w-full max-w-md p-6 border border-slate-700 shadow-2xl relative">
<h3 class="text-lg font-bold text-slate-100 mb-1 flex items-center gap-2">
<i class="fa-solid fa-user-gear text-indigo-400"></i>
<span>设置你的匿名卡片</span>
</h3>
<p class="text-xs text-slate-400 mb-5">其他聊天成员将通过此卡片认识你,无需真实手机号或社交账号</p>
<!-- Avatar Selector -->
<div class="mb-4">
<label class="block text-xs font-semibold text-slate-300 mb-2">选择匿名头像</label>
<div class="flex items-center gap-3">
<img :src="profileForm.avatar" class="w-14 h-14 rounded-full bg-slate-800 ring-2 ring-indigo-500 object-cover" />
<button @click="randomizeAvatar" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 flex items-center gap-1.5 border border-slate-700">
<i class="fa-solid fa-dice"></i> 随机更换
</button>
</div>
</div>
<!-- Nickname -->
<div class="mb-4">
<label class="block text-xs font-semibold text-slate-300 mb-1">匿名代号/昵称</label>
<div class="flex gap-2">
<input v-model="profileForm.nickname" type="text" class="flex-1 bg-slate-800 text-sm text-slate-100 px-3 py-2 rounded-xl border border-slate-700 focus:outline-none focus:border-indigo-500" placeholder="例如: 夜航船人" />
<button @click="randomizeNickname" class="px-3 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs text-slate-300 border border-slate-700">
随机生成
</button>
</div>
</div>
<!-- Gender selection -->
<div class="mb-4">
<label class="block text-xs font-semibold text-slate-300 mb-1">性别/偏好标识</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="g in genders"
:key="g.value"
@click="profileForm.gender = g.value"
:class="profileForm.gender === g.value ? 'bg-indigo-600 text-white border-indigo-500' : 'bg-slate-800/60 text-slate-400 border-slate-700'"
class="py-2 rounded-xl text-xs font-medium border text-center transition-all">
{{ g.label }}
</button>
</div>
</div>
<!-- Hobbies Selection -->
<div class="mb-6">
<label class="block text-xs font-semibold text-slate-300 mb-1">兴趣爱好标签 (多选)</label>
<div class="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto p-1">
<button
v-for="hobby in allHobbies"
:key="hobby"
@click="toggleProfileHobby(hobby)"
:class="profileForm.hobbies.includes(hobby) ? 'bg-indigo-500 text-white font-medium' : 'bg-slate-800/80 text-slate-400 border border-slate-700'"
class="text-xs px-2.5 py-1 rounded-full transition-all">
#{{ hobby }}
</button>
</div>
</div>
<button @click="saveProfile" class="w-full py-2.5 rounded-xl bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-semibold text-sm shadow-lg shadow-indigo-500/20 hover:opacity-95 transition-all">
保存并进入聊天
</button>
</div>
</div>
<!-- MODAL 2: Create Custom Room -->
<div v-if="showCreateRoomModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="glass-panel rounded-2xl w-full max-w-md p-6 border border-slate-700 shadow-2xl">
<div class="flex justify-between items-center mb-4">
<h3 class="text-base font-bold text-slate-100 flex items-center gap-2">
<i class="fa-solid fa-comments text-indigo-400"></i>
<span>创建自定义匿名房间</span>
</h3>
<button @click="showCreateRoomModal = false" class="text-slate-400 hover:text-white">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1">房间主题名称</label>
<input v-model="newRoomForm.title" type="text" placeholder="例如: 凌晨三点听歌俱乐部" class="w-full bg-slate-800 text-sm text-slate-100 px-3 py-2 rounded-xl border border-slate-700 focus:outline-none focus:border-indigo-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1">所属分类门类</label>
<select v-model="newRoomForm.category" class="w-full bg-slate-800 text-sm text-slate-100 px-3 py-2 rounded-xl border border-slate-700 focus:outline-none focus:border-indigo-500">
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1">房间简介/聊天规则</label>
<textarea v-model="newRoomForm.description" rows="2" placeholder="简单说明大家可以在这里聊些什么..." class="w-full bg-slate-800 text-xs text-slate-100 px-3 py-2 rounded-xl border border-slate-700 focus:outline-none focus:border-indigo-500"></textarea>
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 mb-1">关联标签 (以逗号或空格分隔)</label>
<input v-model="newRoomForm.tagsInput" type="text" placeholder="音乐, 摇滚, 夜宵" class="w-full bg-slate-800 text-sm text-slate-100 px-3 py-2 rounded-xl border border-slate-700 focus:outline-none focus:border-indigo-500" />
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateRoomModal = false" class="flex-1 py-2 rounded-xl bg-slate-800 text-slate-300 text-xs font-medium">取消</button>
<button @click="createNewRoom" class="flex-1 py-2 rounded-xl bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-500">确认创建</button>
</div>
</div>
</div>
<!-- MODAL 3: View User Profile Card -->
<div v-if="inspectedUser" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div class="glass-panel rounded-2xl w-full max-w-xs p-5 border border-slate-700 shadow-2xl text-center relative">
<button @click="inspectedUser = null" class="absolute top-3 right-3 text-slate-400 hover:text-white">
<i class="fa-solid fa-xmark"></i>
</button>
<img :src="inspectedUser.avatar" class="w-20 h-20 rounded-full mx-auto bg-slate-800 ring-4 ring-indigo-500/30 object-cover mb-3" />
<h4 class="font-bold text-slate-100 text-base flex items-center justify-center gap-1.5">
<span>{{ inspectedUser.nickname }}</span>
<span :class="genderBadgeClass(inspectedUser.gender)" class="text-xs px-1.5 py-0.5 rounded font-bold">
{{ genderSymbol(inspectedUser.gender) }}
</span>
</h4>
<!-- Match Score -->
<div class="my-3 inline-block px-3 py-1 rounded-full bg-indigo-500/10 text-indigo-300 text-xs border border-indigo-500/20">
契合度匹配: {{ calculateMatchScore(inspectedUser) }}%
</div>
<!-- Hobbies list -->
<div class="mb-5">
<p class="text-[11px] text-slate-400 mb-1">兴趣爱好</p>
<div class="flex flex-wrap justify-center gap-1">
<span v-for="h in inspectedUser.hobbies" :key="h" class="text-[10px] px-2 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
#{{ h }}
</span>
</div>
</div>
<button
v-if="inspectedUser.id !== currentUser.id"
@click="startPrivateChat(inspectedUser); inspectedUser = null"
class="w-full py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold flex items-center justify-center gap-2">
<i class="fa-regular fa-paper-plane"></i>
<span>发起一对一私聊</span>
</button>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, nextTick, watch } = Vue;
createApp({
setup() {
// --- DATA & STATE ---
const currentView = ref('lobby'); // 'lobby' or 'room'
const showProfileModal = ref(false);
const showCreateRoomModal = ref(false);
const showMobileMembers = ref(false);
const showEmojiPicker = ref(false);
const inspectedUser = ref(null);
const searchQuery = ref('');
const selectedCategory = ref('all');
const selectedHobbies = ref([]);
// Form models
const profileForm = ref({
nickname: '',
avatar: '',
gender: 'secret',
hobbies: []
});
const newRoomForm = ref({
title: '',
category: 'casual',
description: '',
tagsInput: ''
});
const currentUser = ref({
id: 'user_' + Math.random().toString(36).substr(2, 9),
nickname: '极客风筝',
avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=Fkite',
gender: 'secret',
hobbies: ['二次元', '技术', '游戏']
});
// Simplified Categories
const categories = [
{ id: 'casual', name: '综合闲聊', icon: 'fa-solid fa-comments' },
{ id: 'gaming', name: '游戏娱乐', icon: 'fa-solid fa-gamepad' },
{ id: 'tech', name: '技术极客', icon: 'fa-solid fa-code' }
];
// Genders
const genders = [
{ value: 'male', label: '男 ♂' },
{ value: 'female', label: '女 ♀' },
{ value: 'secret', label: '保密 🎭' }
];
// Hobbies catalog
const allHobbies = ['游戏', '二次元', '音乐', '电影', '美食', '编程', '夜跑', '摄影', '阅读', '星象', '宠物', '露营'];
// Emojis list
const emojis = ['😀', '😂', '🤣', '😍', '😎', '🤔', '👍', '🔥', '🎉', '🎧', '🎮', '☕'];
// Icebreakers generator
const icebreakers = [
"你最近看过最惊艳的一部电影或动漫是什么?",
"如果现在能去世界上任何地方旅行,你想去哪里?",
"推荐一首你最近循环播放了20遍的歌曲吧!",
"深夜不睡觉的时候,你一般喜欢干什么?",
"你吃过最奇葩但意外好吃的食物是什么?"
];
const currentIcebreakerIndex = ref(0);
const currentIcebreaker = computed(() => icebreakers[currentIcebreakerIndex.value]);
// Rooms Database (Initial Mock Data + Sync)
const rooms = ref([
{
id: 'room_1',
category: 'casual',
title: '夜猫子深夜解忧杂货铺',
description: '倾听属于你的故事,深夜不打烊,欢迎任何话题',
tags: ['深夜', '解忧', '听歌'],
members: [
{ id: 'bot_1', nickname: '月光漫步者', avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=Moon', gender: 'female', hobbies: ['音乐', '电影'] },
{ id: 'bot_2', nickname: '不睡星人', avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=Star', gender: 'male', hobbies: ['夜跑', '游戏'] }
]
},
{
id: 'room_2',
category: 'gaming',
title: 'Steam/主机游戏开黑大堂',
description: '无缝找队友,独立游戏、3A大作沟通交流区',
tags: ['游戏', '开黑', 'Steam'],
members: [
{ id: 'bot_3', nickname: '像素达人', avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=Pixel', gender: 'male', hobbies: ['游戏', '编程'] }
]
},
{
id: 'room_3',
category: 'emotion',
title: '树洞:说出不敢公开的烦恼',
description: '这里没有人认识你,尽情倾诉与释放压力吧',
tags: ['倾诉', '树洞', '温暖'],
members: [
{ id: 'bot_4', nickname: '倾听小熊', avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=Bear', gender: 'female', hobbies: ['宠物', '阅读'] }
]
},
{
id: 'room_4',
category: 'tech',
title: '前端 & AI 探索爱好者聚会',
description: '分享最新的 Web 技术、AI 玩法与独立开发灵感',
tags: ['编程', 'AI', '独立开发'],
members: [
{ id: 'bot_5', nickname: 'CodeKnight', avatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=Knight', gender: 'male', hobbies: ['编程', '阅读'] }
]
}
]);
// Active Room state & messages
const activeRoom = ref(null);
const activeRoomMessages = ref([]);
const newMessageText = ref('');
const pendingImage = ref(null);
const messagesContainer = ref(null);
// Private Messaging State
const activePrivateChats = ref([]);
const focusedPrivateChatId = ref(null);
const showPrivateChatList = ref(false);
// Computed Private Chat Helpers
const totalUnreadCount = computed(() => {
return activePrivateChats.value.reduce((sum, c) => sum + (c.unreadCount || 0), 0);
});
const activeFocusedChat = computed(() => {
if (!activePrivateChats.value.length) return null;
return activePrivateChats.value.find(c => c.targetUser.id === focusedPrivateChatId.value) || activePrivateChats.value[0];
});
// --- BROADCAST CHANNEL FOR CROSS-TAB MULTI-USER REALTIME SIMULATION ---
const broadcastChannel = new BroadcastChannel('whisper_verse_channel');
// Helper to safely send messages through BroadcastChannel by stripping Vue reactive proxies
const safePostMessage = (data) => {
try {
const plainData = JSON.parse(JSON.stringify(data));
broadcastChannel.postMessage(plainData);
} catch (e) {
console.error('BroadcastChannel postMessage error:', e);
}
};
broadcastChannel.onmessage = (event) => {
const { type, payload } = event.data;
if (type === 'PUBLIC_MESSAGE') {
if (activeRoom.value && activeRoom.value.id === payload.roomId) {
activeRoomMessages.value.push(payload.message);
scrollToBottom();
}
} else if (type === 'PRIVATE_MESSAGE') {
if (payload.toUserId === currentUser.value.id) {
receivePrivateMessage(payload.sender, payload.text, payload.timestamp);
}
} else if (type === 'USER_JOINED_ROOM') {
if (activeRoom.value && activeRoom.value.id === payload.roomId) {
if (!activeRoom.value.members.some(m => m.id === payload.user.id)) {
activeRoom.value.members.push(payload.user);
activeRoomMessages.value.push({
id: 'sys_' + Date.now(),
isSystem: true,
text: `用户 "${payload.user.nickname}" 加入了房间`
});
}
}
} else if (type === 'USER_LEFT_ROOM') {
if (activeRoom.value && activeRoom.value.id === payload.roomId) {
activeRoom.value.members = activeRoom.value.members.filter(m => m.id !== payload.userId);
activeRoomMessages.value.push({
id: 'sys_' + Date.now(),
isSystem: true,
text: `一位匿名成员退出了房间`
});
}
} else if (type === 'NEW_ROOM_CREATED') {
if (!rooms.value.some(r => r.id === payload.room.id)) {
rooms.value.unshift(payload.room);
}
} else if (type === 'ROOM_DELETED') {
rooms.value = rooms.value.filter(r => r.id !== payload.roomId);
if (activeRoom.value && activeRoom.value.id === payload.roomId) {
activeRoom.value = null;
currentView.value = 'lobby';
}
}
};
// --- COMPUTED PROPERTIES ---
const filteredRooms = computed(() => {
return rooms.value.filter(room => {
const matchesCategory = selectedCategory.value === 'all' || room.category === selectedCategory.value;
const matchesSearch = !searchQuery.value.trim() ||
room.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
room.tags.some(t => t.toLowerCase().includes(searchQuery.value.toLowerCase()));
const matchesHobbies = selectedHobbies.value.length === 0 ||
selectedHobbies.value.some(h => room.tags.includes(h));
return matchesCategory && matchesSearch && matchesHobbies;
});
});
// --- HELPER METHODS ---
const genderSymbol = (g) => {
if (g === 'male') return '♂';
if (g === 'female') return '♀';
return '🎭';
};
const genderBadgeClass = (g) => {
if (g === 'male') return 'bg-blue-500/20 text-blue-300 border border-blue-500/30';
if (g === 'female') return 'bg-pink-500/20 text-pink-300 border border-pink-500/30';
return 'bg-purple-500/20 text-purple-300 border border-purple-500/30';
};
const getCategoryName = (catId) => {
const cat = categories.find(c => c.id === catId);
return cat ? cat.name : '综合';
};
const getCategoryIcon = (catId) => {
const cat = categories.find(c => c.id === catId);
return cat ? cat.icon : 'fa-solid fa-comments';
};
const getCategoryRoomCount = (catId) => {
return rooms.value.filter(r => r.category === catId).length;
};
const formatTime = (ts) => {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const calculateMatchScore = (user) => {
if (!user || !user.hobbies) return 50;
const common = user.hobbies.filter(h => currentUser.value.hobbies.includes(h));
const score = Math.min(99, 40 + common.length * 25);
return score;
};
const getMatchScoreText = (user) => {
const score = calculateMatchScore(user);
return `契合度 ${score}%`;
};
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
});
};
const nextIcebreaker = () => {
currentIcebreakerIndex.value = (currentIcebreakerIndex.value + 1) % icebreakers.length;
};
// --- PROFILE ACTIONS ---
const randomizeAvatar = () => {
const seeds = ['Alex', 'Mimi', 'Spark', 'Zen', 'Nova', 'Cyber', 'Panda', 'Shadow'];
const randomSeed = seeds[Math.floor(Math.random() * seeds.length)] + Math.floor(Math.random() * 100);
profileForm.value.avatar = `https://api.dicebear.com/7.x/bottts/svg?seed=${randomSeed}`;
};
const randomizeNickname = () => {
const prefixes = ['沉思的', '迷茫的', '快乐的', '赛博', '星空', '隐身', '深夜'];
const nouns = ['猫咪', '风筝', '极客', '旅人', '浪人', '代码', '影子'];
profileForm.value.nickname = prefixes[Math.floor(Math.random() * prefixes.length)] + nouns[Math.floor(Math.random() * nouns.length)];
};
const toggleProfileHobby = (hobby) => {
const idx = profileForm.value.hobbies.indexOf(hobby);
if (idx >= 0) {
profileForm.value.hobbies.splice(idx, 1);
} else {
profileForm.value.hobbies.push(hobby);
}
};
const toggleFilterHobby = (hobby) => {
const idx = selectedHobbies.value.indexOf(hobby);
if (idx >= 0) {
selectedHobbies.value.splice(idx, 1);
} else {
selectedHobbies.value.push(hobby);
}
};
const saveProfile = () => {
if (!profileForm.value.nickname.trim()) randomizeNickname();
currentUser.value.nickname = profileForm.value.nickname;
currentUser.value.avatar = profileForm.value.avatar;
currentUser.value.gender = profileForm.value.gender;
currentUser.value.hobbies = [...profileForm.value.hobbies];
showProfileModal.value = false;
};
const openUserProfileModal = (user) => {
inspectedUser.value = user;
};
// --- ROOM NAVIGATION & ACTIONS ---
const checkAndDeleteEmptyRoom = (roomToLeave) => {
if (!roomToLeave) return;
roomToLeave.members = roomToLeave.members.filter(m => m.id !== currentUser.value.id);
safePostMessage({
type: 'USER_LEFT_ROOM',
payload: { roomId: roomToLeave.id, userId: currentUser.value.id }
});
// 如果离去后该房间无任何成员,自动销毁该房间
if (roomToLeave.members.length === 0) {
rooms.value = rooms.value.filter(r => r.id !== roomToLeave.id);
safePostMessage({
type: 'ROOM_DELETED',
payload: { roomId: roomToLeave.id }
});
}
};
const joinRoom = (room) => {
if (activeRoom.value && activeRoom.value.id !== room.id) {
checkAndDeleteEmptyRoom(activeRoom.value);
}
activeRoom.value = room;
currentView.value = 'room';
// Add user if not in members
if (!room.members.some(m => m.id === currentUser.value.id)) {
room.members.push({ ...currentUser.value });
}
// Initial System Message
activeRoomMessages.value = [
{ id: 'sys_1', isSystem: true, text: `欢迎来到「${room.title}」,请保持文明友善发言` }
];
// Notify other tabs
safePostMessage({
type: 'USER_JOINED_ROOM',
payload: { roomId: room.id, user: currentUser.value }
});
scrollToBottom();
};
const leaveRoom = () => {
if (activeRoom.value) {
checkAndDeleteEmptyRoom(activeRoom.value);
activeRoom.value = null;
}
currentView.value = 'lobby';
};
const createNewRoom = () => {
if (!newRoomForm.value.title.trim()) return;
const tags = newRoomForm.value.tagsInput
.split(/[,,\s]+/)
.filter(t => t.trim().length > 0);
const createdRoom = {
id: 'room_' + Date.now(),
category: newRoomForm.value.category,
title: newRoomForm.value.title,
description: newRoomForm.value.description || '暂无描述',
tags: tags.length ? tags : ['自定义'],
members: [{ ...currentUser.value }]
};
rooms.value.unshift(createdRoom);
// Notify other tabs
safePostMessage({
type: 'NEW_ROOM_CREATED',
payload: { room: createdRoom }
});
showCreateRoomModal.value = false;
// Reset form
newRoomForm.value = { title: '', category: 'casual', description: '', tagsInput: '' };
// Auto Join and handle leaving old room automatically
joinRoom(createdRoom);
};
// --- PUBLIC MESSAGING ---
const addEmoji = (emoji) => {
newMessageText.value += emoji;
showEmojiPicker.value = false;
};
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
pendingImage.value = evt.target.result;
};
reader.readAsDataURL(file);
}
};
const sendPublicMessage = () => {
if (!newMessageText.value.trim() && !pendingImage.value) return;
const msg = {
id: 'msg_' + Date.now() + Math.random().toString(36).substr(2, 4),
sender: { ...currentUser.value },
text: newMessageText.value.trim(),
image: pendingImage.value,
timestamp: Date.now()
};
activeRoomMessages.value.push(msg);
// Sync with other tabs
safePostMessage({
type: 'PUBLIC_MESSAGE',
payload: { roomId: activeRoom.value.id, message: msg }
});
// Reset inputs
newMessageText.value = '';
pendingImage.value = null;
showEmojiPicker.value = false;
scrollToBottom();
// Mock Bot auto-reply trigger if in single tab
triggerMockBotReply();
};
const triggerMockBotReply = () => {
if (!activeRoom.value) return;
const bots = activeRoom.value.members.filter(m => m.id.startsWith('bot_'));
if (bots.length === 0) return;
// 30% chance for bot to reply after 1.5 seconds
if (Math.random() < 0.4) {
setTimeout(() => {
const randomBot = bots[Math.floor(Math.random() * bots.length)];
const botReplies = [
"哈哈,有同感!也是我之前思考过的。",
"这个观点有意思,加个朋友?",
"对对对!大家都很认同这个话题。",
"哇,看来大家都是同道中人呀 ~",
"确实,我也超喜欢这个!"
];
const replyMsg = {
id: 'msg_bot_' + Date.now(),
sender: randomBot,
text: botReplies[Math.floor(Math.random() * botReplies.length)],
timestamp: Date.now()
};
if (activeRoom.value) {
activeRoomMessages.value.push(replyMsg);
scrollToBottom();
}
}, 1200);
}
};
// --- PRIVATE MESSAGING (DM) METHODS ---
const startPrivateChat = (targetUser) => {
if (targetUser.id === currentUser.value.id) return;
let existingChat = activePrivateChats.value.find(c => c.targetUser.id === targetUser.id);
if (!existingChat) {
existingChat = {
targetUser: { ...targetUser },
isMinimized: false,
unreadCount: 0,
messages: [],
inputText: ''
};
activePrivateChats.value.push(existingChat);
} else {
existingChat.isMinimized = false;
existingChat.unreadCount = 0;
}
focusedPrivateChatId.value = targetUser.id;
showPrivateChatList.value = false;
};
const openPrivateChat = (userId) => {
focusedPrivateChatId.value = userId;
const chat = activePrivateChats.value.find(c => c.targetUser.id === userId);
if (chat) {
chat.isMinimized = false;
chat.unreadCount = 0;
}
};
const closePrivateChat = (userId) => {
activePrivateChats.value = activePrivateChats.value.filter(c => c.targetUser.id !== userId);
if (focusedPrivateChatId.value === userId) {
const remaining = activePrivateChats.value;
focusedPrivateChatId.value = remaining.length ? remaining[remaining.length - 1].targetUser.id : null;
}
};
const getLastMessage = (chat) => {
if (!chat.messages || chat.messages.length === 0) return '暂无消息,点击发起聊天';
const last = chat.messages[chat.messages.length - 1];
return (last.senderId === currentUser.value.id ? '我: ' : '') + last.text;
};
const markAllPrivateAsRead = () => {
activePrivateChats.value.forEach(c => c.unreadCount = 0);
};
const sendPrivateMessage = (chat) => {
if (!chat.inputText.trim()) return;
const text = chat.inputText.trim();
const timestamp = Date.now();
chat.messages.push({
senderId: currentUser.value.id,
text,
timestamp
});
// Post message to broadcast channel for other tabs
safePostMessage({
type: 'PRIVATE_MESSAGE',
payload: {
sender: currentUser.value,
toUserId: chat.targetUser.id,
text,
timestamp
}
});
chat.inputText = '';
// Scroll DM container to bottom
nextTick(() => {
const el = document.querySelector(`[ref="dmBox_${chat.targetUser.id}"]`);
if (el) el.scrollTop = el.scrollHeight;
});
// If chatting with a bot, simulate bot DM reply
if (chat.targetUser.id.startsWith('bot_')) {
setTimeout(() => {
const botReplies = [
"你好呀!很高兴认识你~ 匿名聊天感觉挺轻松的。",
"刚才在房间看到你的发言就觉得很有共鸣!",
"哈哈,你平时也喜欢这些兴趣标签吗?",
"嗯嗯,今天聊得很开心!"
];
chat.messages.push({
senderId: chat.targetUser.id,
text: botReplies[Math.floor(Math.random() * botReplies.length)],
timestamp: Date.now()
});
}, 1000);
}
};
const receivePrivateMessage = (sender, text, timestamp) => {
let chat = activePrivateChats.value.find(c => c.targetUser.id === sender.id);
if (!chat) {
chat = {
targetUser: sender,
isMinimized: false,
unreadCount: 1,
messages: [],
inputText: ''
};
activePrivateChats.value.push(chat);
} else {
if (chat.isMinimized || focusedPrivateChatId.value !== sender.id) {
chat.unreadCount++;
}
}
chat.messages.push({
senderId: sender.id,
text,
timestamp
});
};
// --- INITIALIZATION ---
onMounted(() => {
// Initialize profile form
randomizeAvatar();
randomizeNickname();
profileForm.value.gender = 'secret';
profileForm.value.hobbies = ['二次元', '音乐', '游戏'];
saveProfile();
});
return {
currentView,
showProfileModal,
showCreateRoomModal,
showMobileMembers,
showEmojiPicker,
inspectedUser,
searchQuery,
selectedCategory,
selectedHobbies,
profileForm,
newRoomForm,
currentUser,
categories,
genders,
allHobbies,
emojis,
rooms,
filteredRooms,
activeRoom,
activeRoomMessages,
newMessageText,
pendingImage,
messagesContainer,
activePrivateChats,
focusedPrivateChatId,
showPrivateChatList,
totalUnreadCount,
activeFocusedChat,
currentIcebreaker,
nextIcebreaker,
genderSymbol,
genderBadgeClass,
getCategoryName,
getCategoryIcon,
getCategoryRoomCount,
formatTime,
calculateMatchScore,
getMatchScoreText,
randomizeAvatar,
randomizeNickname,
toggleProfileHobby,
toggleFilterHobby,
saveProfile,
openUserProfileModal,
joinRoom,
leaveRoom,
createNewRoom,
addEmoji,
handleImageUpload,
sendPublicMessage,
startPrivateChat,
openPrivateChat,
closePrivateChat,
getLastMessage,
markAllPrivateAsRead,
sendPrivateMessage
};
}
}).mount('#app');
</script>
</body>
</html>
有什么不同见解可以在评论区共同讨论

浙公网安备 33010602011771号