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.
@@ -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>