vue-chat-kit 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -0
- package/package.json +59 -0
- package/src/components/AvatarCrop.vue +229 -0
- package/src/components/ChatWindow.vue +1326 -0
- package/src/composables/useChat.js +588 -0
- package/src/config/index.js +111 -0
- package/src/core/api.js +189 -0
- package/src/core/request.js +174 -0
- package/src/core/websocket.js +159 -0
- package/src/index.js +27 -0
|
@@ -0,0 +1,1326 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-dialog
|
|
3
|
+
v-model="visible"
|
|
4
|
+
:width="width"
|
|
5
|
+
:close-on-click-modal="false"
|
|
6
|
+
class="chat-dialog"
|
|
7
|
+
append-to-body
|
|
8
|
+
@closed="handleClosed"
|
|
9
|
+
@open="handleOpen"
|
|
10
|
+
>
|
|
11
|
+
<div class="chat-container flex h-[680px] bg-white overflow-hidden">
|
|
12
|
+
<!-- 左侧图标导航栏 -->
|
|
13
|
+
<div class="w-16 theme-white flex flex-col items-center gap-2 bg-gray-50 border-r">
|
|
14
|
+
<div class="mb-4 cursor-pointer mt-4" @click="handleAvatarClick">
|
|
15
|
+
<img :src="myAvatar" alt="头像" class="w-10 h-10 rounded-full border-2 border-gray-200" />
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div
|
|
19
|
+
v-for="tab in navTabs"
|
|
20
|
+
:key="tab.id"
|
|
21
|
+
:class="[
|
|
22
|
+
'w-10 h-10 flex items-center justify-center cursor-pointer rounded-lg transition-all relative',
|
|
23
|
+
currentNavTab === tab.id ? 'bg-green-50 text-green-600' : 'text-gray-500 hover:bg-gray-100'
|
|
24
|
+
]"
|
|
25
|
+
@click="currentNavTab = tab.id"
|
|
26
|
+
>
|
|
27
|
+
<el-icon :size="24">
|
|
28
|
+
<component :is="tab.icon" />
|
|
29
|
+
</el-icon>
|
|
30
|
+
<span
|
|
31
|
+
v-if="tab.badge"
|
|
32
|
+
class="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-xs text-white flex items-center justify-center"
|
|
33
|
+
>
|
|
34
|
+
{{ tab.badge > 99 ? '99+' : tab.badge }}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="flex-1"></div>
|
|
39
|
+
|
|
40
|
+
<div
|
|
41
|
+
v-if="config.modules.settings"
|
|
42
|
+
class="w-10 h-10 flex items-center justify-center cursor-pointer rounded-lg hover:bg-gray-100 transition-all mb-4 text-gray-500"
|
|
43
|
+
@click="showSettingsDialog = true"
|
|
44
|
+
title="设置"
|
|
45
|
+
>
|
|
46
|
+
<el-icon :size="24"><Setting /></el-icon>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- 中间内容栏 -->
|
|
51
|
+
<div class="w-72 bg-[#f5f5f5] border-r border-gray-200 flex flex-col">
|
|
52
|
+
<!-- 搜索栏 -->
|
|
53
|
+
<div class="p-3">
|
|
54
|
+
<el-input
|
|
55
|
+
v-model="searchText"
|
|
56
|
+
placeholder="搜索"
|
|
57
|
+
:prefix-icon="Search"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<!-- 内容区域 -->
|
|
62
|
+
<div class="flex-1 overflow-y-auto min-h-0">
|
|
63
|
+
<!-- 聊天列表 -->
|
|
64
|
+
<div v-if="currentNavTab === 'chat'">
|
|
65
|
+
<div
|
|
66
|
+
v-for="chat in filteredUsers"
|
|
67
|
+
:key="chat.id"
|
|
68
|
+
:class="[
|
|
69
|
+
'flex items-center p-3 cursor-pointer hover:bg-[#e5e5e5] transition-colors',
|
|
70
|
+
currentChatId === chat.id ? 'bg-[#d6d6d6]' : ''
|
|
71
|
+
]"
|
|
72
|
+
@click="selectChat(chat)"
|
|
73
|
+
@contextmenu.prevent.stop="showContextMenu($event, chat)"
|
|
74
|
+
>
|
|
75
|
+
<div class="relative flex-shrink-0">
|
|
76
|
+
<img
|
|
77
|
+
:src="chat.avatar"
|
|
78
|
+
:alt="chat.name"
|
|
79
|
+
class="w-11 h-11 rounded-full object-cover"
|
|
80
|
+
/>
|
|
81
|
+
<span
|
|
82
|
+
v-if="chat.online"
|
|
83
|
+
class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white"
|
|
84
|
+
></span>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="ml-3 flex-1 overflow-hidden">
|
|
87
|
+
<div class="flex justify-between items-center">
|
|
88
|
+
<span class="font-medium text-gray-800 text-sm">{{ chat.name }}</span>
|
|
89
|
+
<span class="text-xs text-gray-400">{{ formatLastTime(chat.lastTime) }}</span>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="flex justify-between items-center mt-1">
|
|
92
|
+
<span class="text-xs text-gray-500 truncate pr-2">{{ chat.lastMsg }}</span>
|
|
93
|
+
<span
|
|
94
|
+
v-if="chat.unread > 0"
|
|
95
|
+
class="bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5 min-w-[18px] text-center"
|
|
96
|
+
>
|
|
97
|
+
{{ chat.unread > 99 ? '99+' : chat.unread }}
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- 好友列表 -->
|
|
105
|
+
<div v-if="currentNavTab === 'friends' && config.modules.friends">
|
|
106
|
+
<div class="p-3">
|
|
107
|
+
<div
|
|
108
|
+
class="flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-[#e5e5e5]"
|
|
109
|
+
@click="openAddFriendDialog"
|
|
110
|
+
>
|
|
111
|
+
<div
|
|
112
|
+
class="w-11 h-11 bg-green-500 rounded-lg flex items-center justify-center"
|
|
113
|
+
>
|
|
114
|
+
<el-icon class="text-white" :size="20"><Plus /></el-icon>
|
|
115
|
+
</div>
|
|
116
|
+
<span class="text-sm text-gray-800">添加好友</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div
|
|
120
|
+
v-for="friend in filteredFriendList"
|
|
121
|
+
:key="friend.id"
|
|
122
|
+
class="flex items-center p-3 cursor-pointer hover:bg-[#e5e5e5] transition-colors"
|
|
123
|
+
@click="selectFriend(friend)"
|
|
124
|
+
>
|
|
125
|
+
<div class="relative flex-shrink-0">
|
|
126
|
+
<img
|
|
127
|
+
:src="friend.avatar"
|
|
128
|
+
:alt="friend.name"
|
|
129
|
+
class="w-11 h-11 rounded-full object-cover"
|
|
130
|
+
/>
|
|
131
|
+
<span
|
|
132
|
+
:class="[
|
|
133
|
+
'absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-white',
|
|
134
|
+
friend.online ? 'bg-green-500' : 'bg-gray-400'
|
|
135
|
+
]"
|
|
136
|
+
></span>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="ml-3">
|
|
139
|
+
<span class="font-medium text-gray-800 text-sm">{{ friend.name }}</span>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- 申请列表 -->
|
|
145
|
+
<div v-if="currentNavTab === 'apply' && config.modules.apply">
|
|
146
|
+
<el-empty v-if="loadingFriendApply" description="加载中..." />
|
|
147
|
+
<el-empty
|
|
148
|
+
v-else-if="friendApplyList.length === 0"
|
|
149
|
+
description="暂无好友申请"
|
|
150
|
+
/>
|
|
151
|
+
<div
|
|
152
|
+
v-else
|
|
153
|
+
v-for="apply in friendApplyList"
|
|
154
|
+
:key="apply.applyUser || apply.id"
|
|
155
|
+
class="flex items-center justify-between p-3 hover:bg-[#e5e5e5]"
|
|
156
|
+
>
|
|
157
|
+
<div class="flex items-center">
|
|
158
|
+
<img
|
|
159
|
+
:src="`https://api.dicebear.com/7.x/avataaars/svg?seed=${apply.applyUser}`"
|
|
160
|
+
:alt="apply.applyUser"
|
|
161
|
+
class="w-11 h-11 rounded-full object-cover"
|
|
162
|
+
/>
|
|
163
|
+
<div class="ml-3">
|
|
164
|
+
<div class="font-medium text-gray-800 text-sm">{{ apply.applyUser }}</div>
|
|
165
|
+
<div class="text-xs text-gray-500 mt-1">请求添加你为好友</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
<el-button
|
|
169
|
+
type="primary"
|
|
170
|
+
size="small"
|
|
171
|
+
@click="agreeFriend(apply.applyUser)"
|
|
172
|
+
>同意</el-button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<!-- 右侧聊天/详情区域 -->
|
|
179
|
+
<div class="flex-1 flex flex-col min-w-0 bg-white">
|
|
180
|
+
<!-- 好友信息展示区域 -->
|
|
181
|
+
<div v-if="currentSelectedFriend && !currentChat" class="flex-1 flex flex-col min-h-0">
|
|
182
|
+
<div
|
|
183
|
+
class="flex-1 flex flex-col items-center justify-center p-8 bg-[#f5f5f5]"
|
|
184
|
+
>
|
|
185
|
+
<img
|
|
186
|
+
:src="currentSelectedFriend.avatar"
|
|
187
|
+
:alt="currentSelectedFriend.name"
|
|
188
|
+
class="w-24 h-24 rounded-full object-cover mb-6"
|
|
189
|
+
/>
|
|
190
|
+
<div class="text-xl font-medium text-gray-800 mb-2">{{ currentSelectedFriend.name }}</div>
|
|
191
|
+
<div class="flex items-center gap-2 mb-8">
|
|
192
|
+
<span
|
|
193
|
+
:class="[
|
|
194
|
+
'w-2 h-2 rounded-full',
|
|
195
|
+
currentSelectedFriend.online ? 'bg-green-500' : 'bg-gray-400'
|
|
196
|
+
]"
|
|
197
|
+
></span>
|
|
198
|
+
<span class="text-sm text-gray-500">{{ currentSelectedFriend.online ? '在线' : '离线' }}</span>
|
|
199
|
+
</div>
|
|
200
|
+
<el-button
|
|
201
|
+
type="primary"
|
|
202
|
+
size="large"
|
|
203
|
+
@click="handleStartChat"
|
|
204
|
+
class="w-40"
|
|
205
|
+
>
|
|
206
|
+
<el-icon><ChatDotRound /></el-icon>
|
|
207
|
+
<span>发消息</span>
|
|
208
|
+
</el-button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- 聊天窗口 -->
|
|
213
|
+
<div v-if="currentChat" class="flex-1 flex flex-col min-h-0">
|
|
214
|
+
<!-- 顶部标题栏 -->
|
|
215
|
+
<div class="h-14 border-b border-gray-200 flex items-center justify-between px-4 bg-white">
|
|
216
|
+
<div class="flex items-center gap-3">
|
|
217
|
+
<span class="font-medium text-gray-800">{{ currentChat.name }}</span>
|
|
218
|
+
<span
|
|
219
|
+
:class="[
|
|
220
|
+
'text-xs px-2 py-0.5 rounded',
|
|
221
|
+
currentChat.online ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500'
|
|
222
|
+
]"
|
|
223
|
+
>
|
|
224
|
+
{{ currentChat.online ? '在线' : '离线' }}
|
|
225
|
+
</span>
|
|
226
|
+
</div>
|
|
227
|
+
<div class="flex items-center gap-3 text-gray-500">
|
|
228
|
+
<el-icon class="cursor-pointer hover:text-gray-700"><Search /></el-icon>
|
|
229
|
+
<el-icon
|
|
230
|
+
class="cursor-pointer hover:text-gray-700"
|
|
231
|
+
@click="showChatDetail = !showChatDetail"
|
|
232
|
+
><MoreFilled /></el-icon>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- 聊天消息区域 -->
|
|
237
|
+
<div
|
|
238
|
+
ref="messagesContainer"
|
|
239
|
+
class="flex-1 overflow-y-auto p-4 bg-[#f5f5f5] min-h-0 message-list"
|
|
240
|
+
>
|
|
241
|
+
<div
|
|
242
|
+
v-for="(msg, index) in currentMessages"
|
|
243
|
+
:key="index"
|
|
244
|
+
:class="[
|
|
245
|
+
'flex mb-6 items-start',
|
|
246
|
+
msg.isSelf ? 'flex-row-reverse' : 'flex-row'
|
|
247
|
+
]"
|
|
248
|
+
>
|
|
249
|
+
<!-- 头像 -->
|
|
250
|
+
<div class="flex-shrink-0">
|
|
251
|
+
<img
|
|
252
|
+
:src="msg.isSelf ? myAvatar : currentChat.avatar"
|
|
253
|
+
class="w-10 h-10 rounded-lg object-cover"
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<!-- 消息内容 -->
|
|
258
|
+
<div
|
|
259
|
+
:class="[
|
|
260
|
+
'flex flex-col max-w-[75%]',
|
|
261
|
+
msg.isSelf ? 'mr-3 items-end' : 'ml-3 items-start'
|
|
262
|
+
]"
|
|
263
|
+
>
|
|
264
|
+
<div v-if="!msg.isSelf" class="text-xs text-gray-500 mb-1 ml-1">{{ currentChat.name }}</div>
|
|
265
|
+
|
|
266
|
+
<div class="relative group">
|
|
267
|
+
<!-- 文本消息 -->
|
|
268
|
+
<div
|
|
269
|
+
v-if="msg.type === 'text'"
|
|
270
|
+
:class="[
|
|
271
|
+
'px-3 py-2 text-sm break-all whitespace-pre-wrap rounded-lg shadow-sm',
|
|
272
|
+
msg.isSelf ? 'bg-[#95ec69] text-gray-800 self-end message-bubble-self' : 'bg-white text-gray-800 message-bubble-other'
|
|
273
|
+
]"
|
|
274
|
+
>
|
|
275
|
+
{{ msg.text }}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
<!-- 图片文件消息 -->
|
|
279
|
+
<div
|
|
280
|
+
v-else-if="msg.type === 'file' && msg.fileType === 'image'"
|
|
281
|
+
:class="[
|
|
282
|
+
'rounded-lg relative shadow-sm cursor-pointer overflow-hidden max-w-[300px]',
|
|
283
|
+
msg.isSelf ? 'self-end' : 'self-start'
|
|
284
|
+
]"
|
|
285
|
+
@click="openFile(msg.fileUrl)"
|
|
286
|
+
>
|
|
287
|
+
<img
|
|
288
|
+
:src="msg.fileUrl"
|
|
289
|
+
:alt="msg.fileName"
|
|
290
|
+
class="w-full h-auto block"
|
|
291
|
+
@error="handleImageError"
|
|
292
|
+
/>
|
|
293
|
+
<div
|
|
294
|
+
v-if="msg.fileName || msg.fileSize"
|
|
295
|
+
:class="[
|
|
296
|
+
msg.isSelf ? 'bg-[#95ec69] text-gray-700' : 'bg-white text-gray-500'
|
|
297
|
+
]"
|
|
298
|
+
>
|
|
299
|
+
<div class="absolute left-1 bottom-0 text-white" v-if="msg.fileSize">{{ formatFileSize(msg.fileSize) }}</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<!-- 文档文件消息 -->
|
|
304
|
+
<div
|
|
305
|
+
v-else-if="msg.type === 'file'"
|
|
306
|
+
:class="[
|
|
307
|
+
'rounded-lg shadow-sm cursor-pointer overflow-hidden min-w-[200px]',
|
|
308
|
+
msg.isSelf ? 'self-end message-bubble-self' : 'message-bubble-other'
|
|
309
|
+
]"
|
|
310
|
+
@click="openFile(msg.fileUrl)"
|
|
311
|
+
>
|
|
312
|
+
<div
|
|
313
|
+
:class="[
|
|
314
|
+
'flex items-center gap-3 px-4 py-3',
|
|
315
|
+
msg.isSelf ? 'bg-[#95ec69]' : 'bg-white'
|
|
316
|
+
]"
|
|
317
|
+
>
|
|
318
|
+
<!-- 文件图标 -->
|
|
319
|
+
<div class="w-10 h-10 flex items-center justify-center rounded-lg flex-shrink-0">
|
|
320
|
+
<el-icon :size="28" :class="msg.isSelf ? 'text-gray-700' : 'text-gray-500'">
|
|
321
|
+
<Document />
|
|
322
|
+
</el-icon>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<!-- 文件信息 -->
|
|
326
|
+
<div class="flex-1 min-w-0">
|
|
327
|
+
<div
|
|
328
|
+
:class="[
|
|
329
|
+
'truncate text-sm font-medium leading-tight',
|
|
330
|
+
msg.isSelf ? 'text-gray-800' : 'text-gray-800'
|
|
331
|
+
]"
|
|
332
|
+
>
|
|
333
|
+
{{ msg.fileName || msg.text }}
|
|
334
|
+
</div>
|
|
335
|
+
<div
|
|
336
|
+
:class="[
|
|
337
|
+
'text-xs mt-1 flex items-center gap-2',
|
|
338
|
+
msg.isSelf ? 'text-gray-600' : 'text-gray-500'
|
|
339
|
+
]"
|
|
340
|
+
>
|
|
341
|
+
<el-icon :size="12"><Download /></el-icon>
|
|
342
|
+
<span>点击下载</span>
|
|
343
|
+
<span v-if="msg.fileSize">· {{ formatFileSize(msg.fileSize) }}</span>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<!-- 时间显示在气泡下方 -->
|
|
350
|
+
<div
|
|
351
|
+
:class="[
|
|
352
|
+
'text-[10px] text-gray-400 mt-1',
|
|
353
|
+
msg.isSelf ? 'text-right' : 'text-left'
|
|
354
|
+
]"
|
|
355
|
+
>
|
|
356
|
+
{{ formatTime(msg.time) }}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<!-- 底部输入区域 -->
|
|
364
|
+
<div class="bg-white border-t border-gray-200">
|
|
365
|
+
<!-- 待发送文件预览 -->
|
|
366
|
+
<div
|
|
367
|
+
v-if="pendingFiles.length > 0"
|
|
368
|
+
class="px-3 py-2 border-b border-gray-100 flex flex-wrap gap-2"
|
|
369
|
+
>
|
|
370
|
+
<div
|
|
371
|
+
v-for="(file, index) in pendingFiles"
|
|
372
|
+
:key="file.id"
|
|
373
|
+
class="relative group"
|
|
374
|
+
>
|
|
375
|
+
<!-- 图片预览 -->
|
|
376
|
+
<div
|
|
377
|
+
v-if="file.isImage"
|
|
378
|
+
class="relative w-20 h-20 rounded-lg overflow-hidden border border-gray-200"
|
|
379
|
+
>
|
|
380
|
+
<img
|
|
381
|
+
:src="file.previewUrl"
|
|
382
|
+
:alt="file.name"
|
|
383
|
+
class="w-full h-full object-cover"
|
|
384
|
+
/>
|
|
385
|
+
<button
|
|
386
|
+
@click="removePendingFile(index)"
|
|
387
|
+
class="absolute top-1 right-1 w-5 h-5 bg-black/50 text-white rounded-full flex items-center justify-center hover:bg-black/70 transition-colors text-xs"
|
|
388
|
+
>
|
|
389
|
+
×
|
|
390
|
+
</button>
|
|
391
|
+
</div>
|
|
392
|
+
<!-- 非图片文件预览 -->
|
|
393
|
+
<div
|
|
394
|
+
v-else
|
|
395
|
+
class="relative w-24 h-20 rounded-lg border border-gray-200 bg-gray-50 flex flex-col items-center justify-center p-1"
|
|
396
|
+
>
|
|
397
|
+
<el-icon class="text-gray-400 text-2xl mb-1"><Folder /></el-icon>
|
|
398
|
+
<span class="text-xs text-gray-500 truncate w-full text-center px-1">{{ file.name }}</span>
|
|
399
|
+
<button
|
|
400
|
+
@click="removePendingFile(index)"
|
|
401
|
+
class="absolute top-1 right-1 w-5 h-5 bg-black/50 text-white rounded-full flex items-center justify-center hover:bg-black/70 transition-colors text-xs"
|
|
402
|
+
>
|
|
403
|
+
×
|
|
404
|
+
</button>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<div v-if="config.modules.fileUpload" class="flex items-center p-3 gap-2">
|
|
410
|
+
<el-icon class="text-gray-500 cursor-pointer hover:text-gray-700"><ChatDotRound /></el-icon>
|
|
411
|
+
<el-icon
|
|
412
|
+
class="text-gray-500 cursor-pointer hover:text-gray-700"
|
|
413
|
+
@click="triggerFileSelect"
|
|
414
|
+
><Folder /></el-icon>
|
|
415
|
+
<el-icon class="text-gray-500 cursor-pointer hover:text-gray-700"><Picture /></el-icon>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="px-3 pb-3">
|
|
418
|
+
<textarea
|
|
419
|
+
v-model="inputText"
|
|
420
|
+
@keydown.enter.prevent="handleSend"
|
|
421
|
+
@paste="handlePaste"
|
|
422
|
+
placeholder="输入消息或粘贴文件..."
|
|
423
|
+
class="w-full resize-none border-0 outline-none text-sm h-[80px]"
|
|
424
|
+
rows="3"
|
|
425
|
+
/>
|
|
426
|
+
</div>
|
|
427
|
+
<div class="flex justify-end px-3 pb-3">
|
|
428
|
+
<el-button
|
|
429
|
+
type="primary"
|
|
430
|
+
:disabled="!inputText.trim() && pendingFiles.length === 0"
|
|
431
|
+
@click="handleSend"
|
|
432
|
+
class="bg-[#07c160] hover:bg-[#06ad56] border-0 text-sm px-6"
|
|
433
|
+
>
|
|
434
|
+
发送
|
|
435
|
+
</el-button>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<!-- 隐藏的文件 input -->
|
|
439
|
+
<input
|
|
440
|
+
ref="fileInputRef"
|
|
441
|
+
type="file"
|
|
442
|
+
multiple
|
|
443
|
+
class="hidden"
|
|
444
|
+
@change="handleFileSelect"
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<!-- 空状态 -->
|
|
450
|
+
<div
|
|
451
|
+
v-else-if="!currentSelectedFriend"
|
|
452
|
+
class="flex-1 flex items-center justify-center flex-col bg-[#f5f5f5]"
|
|
453
|
+
>
|
|
454
|
+
<el-icon :size="64" class="text-gray-300 mb-2"><ChatLineRound /></el-icon>
|
|
455
|
+
<div class="text-gray-400">
|
|
456
|
+
{{ currentNavTab === 'apply' ? '在左侧选择好友申请' : '在左侧选择好友开始聊天' }}
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
<!-- 右侧详情面板 -->
|
|
462
|
+
<div
|
|
463
|
+
v-if="showChatDetail"
|
|
464
|
+
class="w-64 bg-[#f5f5f5] border-l border-gray-200 flex flex-col"
|
|
465
|
+
>
|
|
466
|
+
<div class="h-14 flex items-center justify-center border-b border-gray-200">
|
|
467
|
+
<span class="font-medium text-gray-700">聊天详情</span>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="flex-1 p-4">
|
|
470
|
+
<div class="flex flex-col items-center">
|
|
471
|
+
<img
|
|
472
|
+
:src="currentChat?.avatar"
|
|
473
|
+
:alt="currentChat?.name"
|
|
474
|
+
class="w-20 h-20 rounded-full object-cover"
|
|
475
|
+
/>
|
|
476
|
+
<div class="mt-3 font-medium text-gray-800 text-lg">{{ currentChat?.name }}</div>
|
|
477
|
+
<div class="mt-6 w-full">
|
|
478
|
+
<div class="bg-white rounded-lg">
|
|
479
|
+
<div class="p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50">
|
|
480
|
+
<span class="text-sm text-gray-700">查找聊天记录</span>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="p-3 border-b border-gray-100 cursor-pointer hover:bg-gray-50">
|
|
483
|
+
<span class="text-sm text-gray-700">清空聊天记录</span>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<!-- 添加好友弹窗 -->
|
|
493
|
+
<el-dialog
|
|
494
|
+
v-model="addFriendDialogVisible"
|
|
495
|
+
title="添加好友"
|
|
496
|
+
width="500px"
|
|
497
|
+
append-to-body
|
|
498
|
+
>
|
|
499
|
+
<div class="mb-4">
|
|
500
|
+
<div class="relative">
|
|
501
|
+
<el-input
|
|
502
|
+
v-model="addFriendSearchText"
|
|
503
|
+
placeholder="搜索用户"
|
|
504
|
+
:prefix-icon="Search"
|
|
505
|
+
/>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
<div class="max-h-[400px] overflow-y-auto">
|
|
509
|
+
<el-empty v-if="loadingAvailableUsers" description="加载中..." />
|
|
510
|
+
<el-empty
|
|
511
|
+
v-else-if="filteredAvailableUsers.length === 0"
|
|
512
|
+
description="暂无用户"
|
|
513
|
+
/>
|
|
514
|
+
<div
|
|
515
|
+
v-else
|
|
516
|
+
v-for="user in filteredAvailableUsers"
|
|
517
|
+
:key="user.username"
|
|
518
|
+
class="flex items-center justify-between p-3 hover:bg-gray-50 rounded-lg mb-2 transition-colors"
|
|
519
|
+
>
|
|
520
|
+
<div class="flex items-center">
|
|
521
|
+
<img
|
|
522
|
+
:src="`https://api.dicebear.com/7.x/avataaars/svg?seed=${user.username}`"
|
|
523
|
+
:alt="user.username"
|
|
524
|
+
class="w-10 h-10 rounded-full object-cover"
|
|
525
|
+
/>
|
|
526
|
+
<div class="ml-3">
|
|
527
|
+
<div class="font-medium text-gray-800">{{ user.username }}</div>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
<el-button type="primary" size="small" @click="addFriend(user)">添加</el-button>
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
</el-dialog>
|
|
534
|
+
|
|
535
|
+
<!-- 用户设置弹窗 -->
|
|
536
|
+
<el-dialog
|
|
537
|
+
v-model="showSettingsDialog"
|
|
538
|
+
title="个人设置"
|
|
539
|
+
width="560px"
|
|
540
|
+
:close-on-click-modal="false"
|
|
541
|
+
append-to-body
|
|
542
|
+
class="settings-dialog"
|
|
543
|
+
>
|
|
544
|
+
<div class="flex flex-col">
|
|
545
|
+
<!-- 头像设置区域 -->
|
|
546
|
+
<div class="flex flex-col items-center mb-8 pb-6 border-b border-gray-100">
|
|
547
|
+
<div class="relative mb-4">
|
|
548
|
+
<img
|
|
549
|
+
:src="myAvatar"
|
|
550
|
+
alt="头像"
|
|
551
|
+
class="w-28 h-28 rounded-full object-cover border-4 border-white shadow-lg"
|
|
552
|
+
/>
|
|
553
|
+
<div
|
|
554
|
+
v-if="config.modules.avatarCrop"
|
|
555
|
+
class="absolute -bottom-1 -right-1 w-10 h-10 bg-green-500 rounded-full flex items-center justify-center cursor-pointer hover:bg-green-600 transition-all shadow-md"
|
|
556
|
+
@click="triggerAvatarUpload"
|
|
557
|
+
>
|
|
558
|
+
<el-icon :size="18" class="text-white"><Camera /></el-icon>
|
|
559
|
+
</div>
|
|
560
|
+
<input
|
|
561
|
+
ref="avatarInputRef"
|
|
562
|
+
type="file"
|
|
563
|
+
accept="image/*"
|
|
564
|
+
class="hidden"
|
|
565
|
+
@change="handleAvatarFileChange"
|
|
566
|
+
/>
|
|
567
|
+
</div>
|
|
568
|
+
<div class="text-center">
|
|
569
|
+
<div class="font-semibold text-gray-800 text-xl">{{ userInfo.nickname || myUsername }}</div>
|
|
570
|
+
<div class="text-sm text-gray-500 mt-1">@{{ myUsername }}</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
<!-- 用户信息表单 -->
|
|
575
|
+
<div class="space-y-5">
|
|
576
|
+
<div class="flex items-center justify-between mb-2">
|
|
577
|
+
<div class="text-gray-700 font-semibold flex items-center gap-2">
|
|
578
|
+
<el-icon><UserFilled /></el-icon>
|
|
579
|
+
个人信息
|
|
580
|
+
</div>
|
|
581
|
+
<el-button
|
|
582
|
+
v-if="!isEditingUserInfo"
|
|
583
|
+
type="primary"
|
|
584
|
+
size="small"
|
|
585
|
+
@click="startEditUserInfo"
|
|
586
|
+
class="rounded-full"
|
|
587
|
+
>
|
|
588
|
+
编辑
|
|
589
|
+
</el-button>
|
|
590
|
+
</div>
|
|
591
|
+
|
|
592
|
+
<div class="bg-gray-50 rounded-xl p-6 space-y-5">
|
|
593
|
+
<!-- 昵称 -->
|
|
594
|
+
<div>
|
|
595
|
+
<label class="block text-sm text-gray-600 mb-2 font-medium">昵称</label>
|
|
596
|
+
<el-input
|
|
597
|
+
v-if="isEditingUserInfo"
|
|
598
|
+
v-model="editingUserInfo.nickname"
|
|
599
|
+
placeholder="请输入昵称"
|
|
600
|
+
size="large"
|
|
601
|
+
/>
|
|
602
|
+
<div
|
|
603
|
+
v-else
|
|
604
|
+
class="text-gray-800 bg-white rounded-lg px-4 py-3 border border-gray-200"
|
|
605
|
+
>
|
|
606
|
+
{{ userInfo.nickname || '未设置' }}
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
<!-- 邮箱 -->
|
|
611
|
+
<div>
|
|
612
|
+
<label class="block text-sm text-gray-600 mb-2 font-medium">邮箱</label>
|
|
613
|
+
<el-input
|
|
614
|
+
v-if="isEditingUserInfo"
|
|
615
|
+
v-model="editingUserInfo.email"
|
|
616
|
+
placeholder="请输入邮箱"
|
|
617
|
+
size="large"
|
|
618
|
+
/>
|
|
619
|
+
<div
|
|
620
|
+
v-else
|
|
621
|
+
class="text-gray-800 bg-white rounded-lg px-4 py-3 border border-gray-200"
|
|
622
|
+
>
|
|
623
|
+
{{ userInfo.email || '未设置' }}
|
|
624
|
+
</div>
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
<!-- 手机号 -->
|
|
628
|
+
<div>
|
|
629
|
+
<label class="block text-sm text-gray-600 mb-2 font-medium">手机号</label>
|
|
630
|
+
<el-input
|
|
631
|
+
v-if="isEditingUserInfo"
|
|
632
|
+
v-model="editingUserInfo.phone"
|
|
633
|
+
placeholder="请输入手机号"
|
|
634
|
+
size="large"
|
|
635
|
+
/>
|
|
636
|
+
<div
|
|
637
|
+
v-else
|
|
638
|
+
class="text-gray-800 bg-white rounded-lg px-4 py-3 border border-gray-200"
|
|
639
|
+
>
|
|
640
|
+
{{ userInfo.phone || '未设置' }}
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
|
|
644
|
+
<!-- 个人简介 -->
|
|
645
|
+
<div>
|
|
646
|
+
<label class="block text-sm text-gray-600 mb-2 font-medium">个人简介</label>
|
|
647
|
+
<el-input
|
|
648
|
+
v-if="isEditingUserInfo"
|
|
649
|
+
v-model="editingUserInfo.bio"
|
|
650
|
+
type="textarea"
|
|
651
|
+
:rows="4"
|
|
652
|
+
placeholder="介绍一下自己吧..."
|
|
653
|
+
size="large"
|
|
654
|
+
/>
|
|
655
|
+
<div
|
|
656
|
+
v-else
|
|
657
|
+
class="text-gray-800 bg-white rounded-lg px-4 py-3 border border-gray-200 min-h-[80px]"
|
|
658
|
+
>
|
|
659
|
+
{{ userInfo.bio || '这个人很懒,什么都没写~' }}
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
<!-- 编辑按钮 -->
|
|
664
|
+
<div v-if="isEditingUserInfo" class="flex gap-3 justify-end pt-2">
|
|
665
|
+
<el-button size="default" @click="cancelEditUserInfo">取消</el-button>
|
|
666
|
+
<el-button
|
|
667
|
+
type="primary"
|
|
668
|
+
size="default"
|
|
669
|
+
:loading="savingUserInfo"
|
|
670
|
+
@click="saveUserInfo"
|
|
671
|
+
>保存更改</el-button>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
675
|
+
</div>
|
|
676
|
+
</el-dialog>
|
|
677
|
+
|
|
678
|
+
<!-- 头像裁剪弹窗 -->
|
|
679
|
+
<AvatarCrop
|
|
680
|
+
v-model="showAvatarEditor"
|
|
681
|
+
:src="avatarImageSrc"
|
|
682
|
+
@confirm="handleAvatarCropConfirm"
|
|
683
|
+
/>
|
|
684
|
+
|
|
685
|
+
<!-- 右键菜单 -->
|
|
686
|
+
<div
|
|
687
|
+
v-if="contextMenu.visible"
|
|
688
|
+
class="context-menu fixed bg-white rounded-lg shadow-lg border py-1 z-50"
|
|
689
|
+
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
|
690
|
+
>
|
|
691
|
+
<div class="px-4 py-2 hover:bg-gray-100 cursor-pointer" @click="handleRemoveChat">删除聊天</div>
|
|
692
|
+
</div>
|
|
693
|
+
</el-dialog>
|
|
694
|
+
</template>
|
|
695
|
+
|
|
696
|
+
<script setup>
|
|
697
|
+
import { computed, watch, nextTick, ref, onMounted, onUnmounted, inject } from 'vue'
|
|
698
|
+
import {
|
|
699
|
+
Camera,
|
|
700
|
+
ChatDotRound,
|
|
701
|
+
Folder,
|
|
702
|
+
Picture,
|
|
703
|
+
ChatLineRound,
|
|
704
|
+
MoreFilled,
|
|
705
|
+
Search,
|
|
706
|
+
Plus,
|
|
707
|
+
UserFilled,
|
|
708
|
+
Bell,
|
|
709
|
+
Setting,
|
|
710
|
+
Document,
|
|
711
|
+
Download
|
|
712
|
+
} from '@element-plus/icons-vue'
|
|
713
|
+
import { useChat } from '../composables/useChat.js'
|
|
714
|
+
import AvatarCrop from './AvatarCrop.vue'
|
|
715
|
+
import { ElMessage } from 'element-plus'
|
|
716
|
+
|
|
717
|
+
const props = defineProps({
|
|
718
|
+
modelValue: { type: Boolean, default: false },
|
|
719
|
+
config: { type: Object, required: true },
|
|
720
|
+
width: { type: [String, Number], default: '1100px' }
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
const emit = defineEmits(['update:modelValue', 'open', 'close', 'message', 'send', 'error'])
|
|
724
|
+
|
|
725
|
+
const visible = computed({
|
|
726
|
+
get: () => props.modelValue,
|
|
727
|
+
set: (val) => emit('update:modelValue', val)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// 使用聊天 hook
|
|
731
|
+
const {
|
|
732
|
+
myUsername,
|
|
733
|
+
myAvatar,
|
|
734
|
+
userInfo,
|
|
735
|
+
loadingUserInfo,
|
|
736
|
+
friendList,
|
|
737
|
+
filteredFriendList,
|
|
738
|
+
searchText,
|
|
739
|
+
inputText,
|
|
740
|
+
messagesContainer,
|
|
741
|
+
filteredUsers,
|
|
742
|
+
filteredAvailableUsers,
|
|
743
|
+
currentUser,
|
|
744
|
+
currentMessages,
|
|
745
|
+
addFriendDialogVisible,
|
|
746
|
+
addFriendSearchText,
|
|
747
|
+
availableUsers,
|
|
748
|
+
loadingAvailableUsers,
|
|
749
|
+
friendApplyList,
|
|
750
|
+
loadingFriendApply,
|
|
751
|
+
formatTime,
|
|
752
|
+
formatLastTime,
|
|
753
|
+
scrollToBottom,
|
|
754
|
+
getFriendList,
|
|
755
|
+
getChatHistory,
|
|
756
|
+
setFriendToChatStatus,
|
|
757
|
+
selectUser,
|
|
758
|
+
sendMessage,
|
|
759
|
+
sendFile,
|
|
760
|
+
sendFilesAndText,
|
|
761
|
+
initWebSocket,
|
|
762
|
+
closeWebSocket,
|
|
763
|
+
reset,
|
|
764
|
+
openAddFriendDialog,
|
|
765
|
+
addFriend,
|
|
766
|
+
loadFriendApplyList,
|
|
767
|
+
agreeFriend,
|
|
768
|
+
updateMyAvatar,
|
|
769
|
+
getUserInfo,
|
|
770
|
+
updateUserInfo
|
|
771
|
+
} = useChat(props.config)
|
|
772
|
+
|
|
773
|
+
// 导航 tab
|
|
774
|
+
const navTabs = computed(() => {
|
|
775
|
+
const tabs = [{ id: 'chat', icon: ChatDotRound, badge: 0 }]
|
|
776
|
+
|
|
777
|
+
if (props.config.modules.friends) {
|
|
778
|
+
tabs.push({ id: 'friends', icon: UserFilled, badge: 0 })
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (props.config.modules.apply) {
|
|
782
|
+
tabs.push({ id: 'apply', icon: Bell, badge: friendApplyList.value?.length || 0 })
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return tabs
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
const currentNavTab = ref('chat')
|
|
789
|
+
const currentChatId = ref(null)
|
|
790
|
+
const currentChat = ref(null)
|
|
791
|
+
const currentSelectedFriend = ref(null)
|
|
792
|
+
const showChatDetail = ref(false)
|
|
793
|
+
|
|
794
|
+
// 用户信息编辑
|
|
795
|
+
const isEditingUserInfo = ref(false)
|
|
796
|
+
const editingUserInfo = ref({ nickname: '', email: '', phone: '', bio: '' })
|
|
797
|
+
const savingUserInfo = ref(false)
|
|
798
|
+
|
|
799
|
+
// 设置弹窗
|
|
800
|
+
const showSettingsDialog = ref(false)
|
|
801
|
+
|
|
802
|
+
// 头像相关
|
|
803
|
+
const showAvatarEditor = ref(false)
|
|
804
|
+
const avatarUploading = ref(false)
|
|
805
|
+
const avatarInputRef = ref(null)
|
|
806
|
+
const avatarImageSrc = ref('')
|
|
807
|
+
|
|
808
|
+
// 文件上传相关
|
|
809
|
+
const fileInputRef = ref(null)
|
|
810
|
+
const pendingFiles = ref([])
|
|
811
|
+
|
|
812
|
+
// 右键菜单
|
|
813
|
+
const contextMenu = ref({ visible: false, x: 0, y: 0, chat: null })
|
|
814
|
+
|
|
815
|
+
// 显示右键菜单
|
|
816
|
+
const showContextMenu = (e, chat) => {
|
|
817
|
+
e.preventDefault()
|
|
818
|
+
e.stopPropagation()
|
|
819
|
+
contextMenu.value = { visible: true, x: e.clientX, y: e.clientY, chat }
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// 隐藏右键菜单
|
|
823
|
+
const hideContextMenu = () => {
|
|
824
|
+
contextMenu.value.visible = false
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// 删除聊天
|
|
828
|
+
const handleRemoveChat = async () => {
|
|
829
|
+
if (!contextMenu.value.chat) return
|
|
830
|
+
const success = await setFriendToChatStatus(contextMenu.value.chat.id, 0)
|
|
831
|
+
if (success) {
|
|
832
|
+
if (currentChatId.value === contextMenu.value.chat.id) {
|
|
833
|
+
currentChatId.value = null
|
|
834
|
+
currentChat.value = null
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
hideContextMenu()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// 选择聊天
|
|
841
|
+
const selectChat = (chat) => {
|
|
842
|
+
currentChatId.value = chat.id
|
|
843
|
+
currentChat.value = chat
|
|
844
|
+
currentSelectedFriend.value = null
|
|
845
|
+
showChatDetail.value = false
|
|
846
|
+
selectUser({
|
|
847
|
+
id: chat.id,
|
|
848
|
+
name: chat.name,
|
|
849
|
+
avatar: chat.avatar,
|
|
850
|
+
online: chat.online
|
|
851
|
+
})
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// 选择好友
|
|
855
|
+
const selectFriend = (friend) => {
|
|
856
|
+
currentSelectedFriend.value = friend
|
|
857
|
+
currentChatId.value = null
|
|
858
|
+
currentChat.value = null
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// 开始聊天
|
|
862
|
+
const handleStartChat = async () => {
|
|
863
|
+
if (!currentSelectedFriend.value) return
|
|
864
|
+
|
|
865
|
+
const success = await setFriendToChatStatus(currentSelectedFriend.value.id)
|
|
866
|
+
if (success) {
|
|
867
|
+
currentNavTab.value = 'chat'
|
|
868
|
+
await nextTick()
|
|
869
|
+
const chatItem = filteredUsers.value.find(u => u.id === currentSelectedFriend.value.id)
|
|
870
|
+
if (chatItem) {
|
|
871
|
+
selectChat(chatItem)
|
|
872
|
+
}
|
|
873
|
+
currentSelectedFriend.value = null
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// 头像点击
|
|
878
|
+
const handleAvatarClick = () => {
|
|
879
|
+
showSettingsDialog.value = true
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 触发头像上传
|
|
883
|
+
const triggerAvatarUpload = () => {
|
|
884
|
+
avatarInputRef.value?.click()
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// 文件选择
|
|
888
|
+
const triggerFileSelect = () => {
|
|
889
|
+
fileInputRef.value?.click()
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const handleFileSelect = (e) => {
|
|
893
|
+
const files = Array.from(e.target.files || [])
|
|
894
|
+
if (files.length === 0) return
|
|
895
|
+
|
|
896
|
+
for (const file of files) {
|
|
897
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
898
|
+
ElMessage.warning(`文件 ${file.name} 超过50MB,已跳过`)
|
|
899
|
+
continue
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const previewUrl = URL.createObjectURL(file)
|
|
903
|
+
pendingFiles.value.push({
|
|
904
|
+
id: Date.now() + Math.random(),
|
|
905
|
+
file,
|
|
906
|
+
name: file.name,
|
|
907
|
+
size: file.size,
|
|
908
|
+
type: file.type,
|
|
909
|
+
previewUrl,
|
|
910
|
+
isImage: file.type.startsWith('image/')
|
|
911
|
+
})
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (fileInputRef.value) {
|
|
915
|
+
fileInputRef.value.value = ''
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// 移除待发送文件
|
|
920
|
+
const removePendingFile = (index) => {
|
|
921
|
+
const file = pendingFiles.value[index]
|
|
922
|
+
if (file.previewUrl) {
|
|
923
|
+
URL.revokeObjectURL(file.previewUrl)
|
|
924
|
+
}
|
|
925
|
+
pendingFiles.value.splice(index, 1)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 格式化文件大小
|
|
929
|
+
const formatFileSize = (bytes) => {
|
|
930
|
+
if (bytes === 0) return '0 B'
|
|
931
|
+
const k = 1024
|
|
932
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
933
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
934
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 发送消息
|
|
938
|
+
const handleSend = async () => {
|
|
939
|
+
if (!inputText.value.trim() && pendingFiles.value.length === 0) return
|
|
940
|
+
|
|
941
|
+
const filesToSend = [...pendingFiles.value]
|
|
942
|
+
const textToSend = inputText.value
|
|
943
|
+
|
|
944
|
+
inputText.value = ''
|
|
945
|
+
pendingFiles.value.forEach((file) => {
|
|
946
|
+
if (file.previewUrl) {
|
|
947
|
+
URL.revokeObjectURL(file.previewUrl)
|
|
948
|
+
}
|
|
949
|
+
})
|
|
950
|
+
pendingFiles.value = []
|
|
951
|
+
|
|
952
|
+
await sendFilesAndText(filesToSend, textToSend)
|
|
953
|
+
emit('send', { text: textToSend, files: filesToSend })
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 处理粘贴
|
|
957
|
+
const handlePaste = (e) => {
|
|
958
|
+
const items = e.clipboardData?.items
|
|
959
|
+
if (!items) return
|
|
960
|
+
|
|
961
|
+
for (const item of items) {
|
|
962
|
+
if (item.kind === 'file') {
|
|
963
|
+
const file = item.getAsFile()
|
|
964
|
+
if (file) {
|
|
965
|
+
if (file.size > 50 * 1024 * 1024) {
|
|
966
|
+
ElMessage.warning(`文件 ${file.name} 超过50MB,已跳过`)
|
|
967
|
+
continue
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const previewUrl = URL.createObjectURL(file)
|
|
971
|
+
pendingFiles.value.push({
|
|
972
|
+
id: Date.now() + Math.random(),
|
|
973
|
+
file,
|
|
974
|
+
name: file.name,
|
|
975
|
+
size: file.size,
|
|
976
|
+
type: file.type,
|
|
977
|
+
previewUrl,
|
|
978
|
+
isImage: file.type.startsWith('image/')
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 打开文件
|
|
986
|
+
const openFile = (fileUrl) => {
|
|
987
|
+
if (!fileUrl) {
|
|
988
|
+
ElMessage.warning('文件地址无效')
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
window.open(fileUrl, '_blank')
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// 图片加载错误
|
|
995
|
+
const handleImageError = (e) => {
|
|
996
|
+
console.warn('图片加载失败', e)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// 处理头像文件选择
|
|
1000
|
+
const handleAvatarFileChange = (e) => {
|
|
1001
|
+
const file = e.target.files[0]
|
|
1002
|
+
if (!file) return
|
|
1003
|
+
|
|
1004
|
+
if (!file.type.startsWith('image/')) {
|
|
1005
|
+
ElMessage.error('只能上传图片文件')
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
1010
|
+
ElMessage.error('图片大小不能超过 5MB')
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const reader = new FileReader()
|
|
1015
|
+
reader.onload = (event) => {
|
|
1016
|
+
avatarImageSrc.value = event.target.result
|
|
1017
|
+
showAvatarEditor.value = true
|
|
1018
|
+
}
|
|
1019
|
+
reader.readAsDataURL(file)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// 处理头像裁剪确认
|
|
1023
|
+
const handleAvatarCropConfirm = async ({ file }) => {
|
|
1024
|
+
if (!file) return
|
|
1025
|
+
|
|
1026
|
+
avatarUploading.value = true
|
|
1027
|
+
try {
|
|
1028
|
+
const api = new (require('../core/api.js').ChatApi)(props.config)
|
|
1029
|
+
const res = await api.uploadAvatar(file, myUsername)
|
|
1030
|
+
if (res.code === 200) {
|
|
1031
|
+
ElMessage.success('头像上传成功')
|
|
1032
|
+
updateMyAvatar(res.data)
|
|
1033
|
+
resetAvatar()
|
|
1034
|
+
} else {
|
|
1035
|
+
ElMessage.error(res.msg || '头像上传失败')
|
|
1036
|
+
}
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
console.error(error)
|
|
1039
|
+
ElMessage.error('头像上传失败')
|
|
1040
|
+
} finally {
|
|
1041
|
+
avatarUploading.value = false
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const resetAvatar = () => {
|
|
1046
|
+
avatarImageSrc.value = ''
|
|
1047
|
+
showAvatarEditor.value = false
|
|
1048
|
+
if (avatarInputRef.value) {
|
|
1049
|
+
avatarInputRef.value.value = ''
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// 开始编辑用户信息
|
|
1054
|
+
const startEditUserInfo = () => {
|
|
1055
|
+
editingUserInfo.value = {
|
|
1056
|
+
nickname: userInfo.value.nickname || '',
|
|
1057
|
+
email: userInfo.value.email || '',
|
|
1058
|
+
phone: userInfo.value.phone || '',
|
|
1059
|
+
bio: userInfo.value.bio || ''
|
|
1060
|
+
}
|
|
1061
|
+
isEditingUserInfo.value = true
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// 取消编辑
|
|
1065
|
+
const cancelEditUserInfo = () => {
|
|
1066
|
+
isEditingUserInfo.value = false
|
|
1067
|
+
editingUserInfo.value = { nickname: '', email: '', phone: '', bio: '' }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// 保存用户信息
|
|
1071
|
+
const saveUserInfo = async () => {
|
|
1072
|
+
savingUserInfo.value = true
|
|
1073
|
+
try {
|
|
1074
|
+
const success = await updateUserInfo(editingUserInfo.value)
|
|
1075
|
+
if (success) {
|
|
1076
|
+
ElMessage.success('保存成功')
|
|
1077
|
+
isEditingUserInfo.value = false
|
|
1078
|
+
} else {
|
|
1079
|
+
ElMessage.error('保存失败')
|
|
1080
|
+
}
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
console.error(error)
|
|
1083
|
+
ElMessage.error('保存失败')
|
|
1084
|
+
} finally {
|
|
1085
|
+
savingUserInfo.value = false
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// 处理弹窗关闭
|
|
1090
|
+
const handleClosed = () => {
|
|
1091
|
+
reset()
|
|
1092
|
+
closeWebSocket()
|
|
1093
|
+
showChatDetail.value = false
|
|
1094
|
+
showSettingsDialog.value = false
|
|
1095
|
+
resetAvatar()
|
|
1096
|
+
isEditingUserInfo.value = false
|
|
1097
|
+
emit('close')
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// 处理弹窗打开
|
|
1101
|
+
const handleOpen = async () => {
|
|
1102
|
+
await Promise.all([getFriendList(), loadFriendApplyList(), getUserInfo()])
|
|
1103
|
+
initWebSocket()
|
|
1104
|
+
if (filteredUsers.value.length > 0) {
|
|
1105
|
+
selectChat(filteredUsers.value[0])
|
|
1106
|
+
}
|
|
1107
|
+
emit('open')
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// 生命周期
|
|
1111
|
+
onMounted(() => {
|
|
1112
|
+
document.addEventListener('click', hideContextMenu)
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
onUnmounted(() => {
|
|
1116
|
+
document.removeEventListener('click', hideContextMenu)
|
|
1117
|
+
closeWebSocket()
|
|
1118
|
+
})
|
|
1119
|
+
</script>
|
|
1120
|
+
|
|
1121
|
+
<style scoped>
|
|
1122
|
+
.chat-dialog :deep(.el-dialog) {
|
|
1123
|
+
padding: 0;
|
|
1124
|
+
border-radius: 12px;
|
|
1125
|
+
overflow: hidden;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.chat-dialog :deep(.el-dialog__header) {
|
|
1129
|
+
display: none;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
.chat-dialog :deep(.el-dialog__body) {
|
|
1133
|
+
padding: 0;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/* 微信样式消息气泡 */
|
|
1137
|
+
.message-bubble-self {
|
|
1138
|
+
position: relative;
|
|
1139
|
+
background-color: #95ec69 !important;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
.message-bubble-self::after {
|
|
1143
|
+
content: '';
|
|
1144
|
+
position: absolute;
|
|
1145
|
+
right: -5px;
|
|
1146
|
+
top: 10px;
|
|
1147
|
+
width: 10px;
|
|
1148
|
+
height: 10px;
|
|
1149
|
+
background-color: #95ec69;
|
|
1150
|
+
transform: rotate(45deg);
|
|
1151
|
+
box-shadow: 2px -2px 2px 0 rgba(0, 0, 0, 0.05);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
.message-bubble-other {
|
|
1155
|
+
position: relative;
|
|
1156
|
+
background-color: white !important;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.message-bubble-other::after {
|
|
1160
|
+
content: '';
|
|
1161
|
+
position: absolute;
|
|
1162
|
+
left: -5px;
|
|
1163
|
+
top: 10px;
|
|
1164
|
+
width: 10px;
|
|
1165
|
+
height: 10px;
|
|
1166
|
+
background-color: white;
|
|
1167
|
+
transform: rotate(45deg);
|
|
1168
|
+
box-shadow: -2px 2px 2px 0 rgba(0, 0, 0, 0.05);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/* 消息列表滚动条 */
|
|
1172
|
+
.message-list::-webkit-scrollbar {
|
|
1173
|
+
width: 6px;
|
|
1174
|
+
}
|
|
1175
|
+
.message-list::-webkit-scrollbar-thumb {
|
|
1176
|
+
background: #ccc;
|
|
1177
|
+
border-radius: 3px;
|
|
1178
|
+
}
|
|
1179
|
+
.message-list::-webkit-scrollbar-track {
|
|
1180
|
+
background: transparent;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/* 设置弹窗样式 */
|
|
1184
|
+
.settings-dialog :deep(.el-dialog) {
|
|
1185
|
+
border-radius: 16px;
|
|
1186
|
+
overflow: hidden;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
.settings-dialog :deep(.el-dialog__header) {
|
|
1190
|
+
padding: 24px 24px 0;
|
|
1191
|
+
margin: 0;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
.settings-dialog :deep(.el-dialog__title) {
|
|
1195
|
+
font-size: 20px;
|
|
1196
|
+
font-weight: 600;
|
|
1197
|
+
color: #1f2937;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/* 简单的 flex 工具类 */
|
|
1201
|
+
.flex { display: flex; }
|
|
1202
|
+
.flex-col { flex-direction: column; }
|
|
1203
|
+
.flex-1 { flex: 1; }
|
|
1204
|
+
.items-center { align-items: center; }
|
|
1205
|
+
.justify-center { justify-content: center; }
|
|
1206
|
+
.justify-between { justify-content: space-between; }
|
|
1207
|
+
.gap-2 { gap: 0.5rem; }
|
|
1208
|
+
.gap-3 { gap: 0.75rem; }
|
|
1209
|
+
.p-3 { padding: 0.75rem; }
|
|
1210
|
+
.p-4 { padding: 1rem; }
|
|
1211
|
+
.p-8 { padding: 2rem; }
|
|
1212
|
+
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
|
1213
|
+
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
|
1214
|
+
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
|
1215
|
+
.pb-3 { padding-bottom: 0.75rem; }
|
|
1216
|
+
.mb-2 { margin-bottom: 0.5rem; }
|
|
1217
|
+
.mb-4 { margin-bottom: 1rem; }
|
|
1218
|
+
.mb-6 { margin-bottom: 1.5rem; }
|
|
1219
|
+
.mb-8 { margin-bottom: 2rem; }
|
|
1220
|
+
.mt-1 { margin-top: 0.25rem; }
|
|
1221
|
+
.mt-3 { margin-top: 0.75rem; }
|
|
1222
|
+
.mt-4 { margin-top: 1rem; }
|
|
1223
|
+
.mt-6 { margin-top: 1.5rem; }
|
|
1224
|
+
.mr-3 { margin-right: 0.75rem; }
|
|
1225
|
+
.ml-3 { margin-left: 0.75rem; }
|
|
1226
|
+
.w-10 { width: 2.5rem; }
|
|
1227
|
+
.w-11 { width: 2.75rem; }
|
|
1228
|
+
.w-16 { width: 4rem; }
|
|
1229
|
+
.w-20 { width: 5rem; }
|
|
1230
|
+
.w-24 { width: 6rem; }
|
|
1231
|
+
.w-28 { width: 7rem; }
|
|
1232
|
+
.w-64 { width: 16rem; }
|
|
1233
|
+
.w-72 { width: 18rem; }
|
|
1234
|
+
.w-full { width: 100%; }
|
|
1235
|
+
.h-10 { height: 2.5rem; }
|
|
1236
|
+
.h-11 { height: 2.75rem; }
|
|
1237
|
+
.h-14 { height: 3.5rem; }
|
|
1238
|
+
.h-20 { height: 5rem; }
|
|
1239
|
+
.h-24 { height: 6rem; }
|
|
1240
|
+
.h-28 { height: 7rem; }
|
|
1241
|
+
.h-[680px] { height: 680px; }
|
|
1242
|
+
.h-[80px] { height: 80px; }
|
|
1243
|
+
.h-auto { height: auto; }
|
|
1244
|
+
.h-full { height: 100%; }
|
|
1245
|
+
.min-h-0 { min-height: 0; }
|
|
1246
|
+
.max-w-\[300px\] { max-width: 300px; }
|
|
1247
|
+
.max-w-\[75\%\] { max-width: 75%; }
|
|
1248
|
+
.min-w-\[200px\] { min-width: 200px; }
|
|
1249
|
+
.min-w-0 { min-width: 0; }
|
|
1250
|
+
.rounded-full { border-radius: 9999px; }
|
|
1251
|
+
.rounded-lg { border-radius: 0.5rem; }
|
|
1252
|
+
.rounded-xl { border-radius: 0.75rem; }
|
|
1253
|
+
.border { border-width: 1px; }
|
|
1254
|
+
.border-0 { border-width: 0; }
|
|
1255
|
+
.border-2 { border-width: 2px; }
|
|
1256
|
+
.border-4 { border-width: 4px; }
|
|
1257
|
+
.border-b { border-bottom-width: 1px; }
|
|
1258
|
+
.border-l { border-left-width: 1px; }
|
|
1259
|
+
.border-r { border-right-width: 1px; }
|
|
1260
|
+
.border-gray-100 { border-color: #f3f4f6; }
|
|
1261
|
+
.border-gray-200 { border-color: #e5e7eb; }
|
|
1262
|
+
.border-white { border-color: #fff; }
|
|
1263
|
+
.bg-\[\#07c160\] { background-color: #07c160; }
|
|
1264
|
+
.bg-\[\#95ec69\] { background-color: #95ec69; }
|
|
1265
|
+
.bg-\[\#f5f5f5\] { background-color: #f5f5f5; }
|
|
1266
|
+
.bg-gray-50 { background-color: #f9fafb; }
|
|
1267
|
+
.bg-gray-100 { background-color: #f3f4f6; }
|
|
1268
|
+
.bg-green-100 { background-color: #dcfce7; }
|
|
1269
|
+
.bg-green-50 { background-color: #f0fdf4; }
|
|
1270
|
+
.bg-green-500 { background-color: #22c55e; }
|
|
1271
|
+
.bg-red-500 { background-color: #ef4444; }
|
|
1272
|
+
.bg-white { background-color: #fff; }
|
|
1273
|
+
.hover\:bg-\[\#06ad56\]:hover { background-color: #06ad56; }
|
|
1274
|
+
.hover\:bg-\[\#e5e5e5\]:hover { background-color: #e5e5e5; }
|
|
1275
|
+
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
|
1276
|
+
.hover\:bg-gray-50:hover { background-color: #f9fafb; }
|
|
1277
|
+
.object-cover { object-fit: cover; }
|
|
1278
|
+
.overflow-hidden { overflow: hidden; }
|
|
1279
|
+
.overflow-y-auto { overflow-y: auto; }
|
|
1280
|
+
.text-center { text-align: center; }
|
|
1281
|
+
.text-right { text-align: right; }
|
|
1282
|
+
.text-left { text-align: left; }
|
|
1283
|
+
.text-xs { font-size: 0.75rem; }
|
|
1284
|
+
.text-sm { font-size: 0.875rem; }
|
|
1285
|
+
.text-lg { font-size: 1.125rem; }
|
|
1286
|
+
.text-xl { font-size: 1.25rem; }
|
|
1287
|
+
.font-medium { font-weight: 500; }
|
|
1288
|
+
.font-semibold { font-weight: 600; }
|
|
1289
|
+
.text-gray-300 { color: #d1d5db; }
|
|
1290
|
+
.text-gray-400 { color: #9ca3af; }
|
|
1291
|
+
.text-gray-500 { color: #6b7280; }
|
|
1292
|
+
.text-gray-600 { color: #4b5563; }
|
|
1293
|
+
.text-gray-700 { color: #374151; }
|
|
1294
|
+
.text-gray-800 { color: #1f2937; }
|
|
1295
|
+
.text-green-600 { color: #16a34a; }
|
|
1296
|
+
.text-white { color: #fff; }
|
|
1297
|
+
.cursor-pointer { cursor: pointer; }
|
|
1298
|
+
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); }
|
|
1299
|
+
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
|
|
1300
|
+
.flex-wrap { flex-wrap: wrap; }
|
|
1301
|
+
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1302
|
+
.break-all { word-break: break-all; }
|
|
1303
|
+
.whitespace-pre-wrap { white-space: pre-wrap; }
|
|
1304
|
+
.select-none { user-select: none; }
|
|
1305
|
+
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
|
1306
|
+
.transition-colors { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
|
|
1307
|
+
.self-end { align-self: flex-end; }
|
|
1308
|
+
.self-start { align-self: flex-start; }
|
|
1309
|
+
.flex-shrink-0 { flex-shrink: 0; }
|
|
1310
|
+
.flex-row-reverse { flex-direction: row-reverse; }
|
|
1311
|
+
.flex-row { flex-direction: row; }
|
|
1312
|
+
.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
|
|
1313
|
+
.absolute { position: absolute; }
|
|
1314
|
+
.relative { position: relative; }
|
|
1315
|
+
.fixed { position: fixed; }
|
|
1316
|
+
.top-0 { top: 0; }
|
|
1317
|
+
.bottom-0 { bottom: 0; }
|
|
1318
|
+
.left-0 { left: 0; }
|
|
1319
|
+
.right-0 { right: 0; }
|
|
1320
|
+
.top-1 { top: 0.25rem; }
|
|
1321
|
+
.right-1 { right: 0.25rem; }
|
|
1322
|
+
.-top-1 { top: -0.25rem; }
|
|
1323
|
+
.-right-1 { right: -0.25rem; }
|
|
1324
|
+
.-bottom-1 { bottom: -0.25rem; }
|
|
1325
|
+
.z-50 { z-index: 50; }
|
|
1326
|
+
</style>
|