collabdocchat 1.0.0

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,906 @@
1
+ import { ApiService } from '../services/api.js';
2
+ import { AuthService } from '../services/auth.js';
3
+ import Quill from 'quill';
4
+ import 'quill/dist/quill.snow.css';
5
+ import 'emoji-picker-element';
6
+
7
+ export function renderUserDashboard(user, wsService) {
8
+ const app = document.getElementById('app');
9
+ const apiService = new ApiService();
10
+ const authService = new AuthService();
11
+ const currentUserId = user.id || user._id;
12
+
13
+ let currentGroup = null;
14
+ let groups = [];
15
+
16
+ app.innerHTML = `
17
+ <div class="dashboard">
18
+ <aside class="sidebar">
19
+ <div class="sidebar-header">
20
+ <h2>CollabDocChat</h2>
21
+ <span class="badge-user">用户</span>
22
+ </div>
23
+
24
+ <div class="user-info">
25
+ <div class="avatar">${user.username[0].toUpperCase()}</div>
26
+ <div>
27
+ <div class="username">${user.username}</div>
28
+ <div class="user-role">普通用户</div>
29
+ </div>
30
+ </div>
31
+
32
+ <nav class="nav-menu">
33
+ <button class="nav-item active" data-view="groups">
34
+ <span class="icon">👥</span> 我的群组
35
+ </button>
36
+ <button class="nav-item" data-view="allgroups">
37
+ <span class="icon">🌐</span> 所有群组
38
+ </button>
39
+ <button class="nav-item" data-view="tasks">
40
+ <span class="icon">📋</span> 我的任务
41
+ </button>
42
+ <button class="nav-item" data-view="documents">
43
+ <span class="icon">📄</span> 共享文档
44
+ </button>
45
+ <button class="nav-item" data-view="files">
46
+ <span class="icon">📎</span> 文件共享
47
+ </button>
48
+ <button class="nav-item" data-view="chat">
49
+ <span class="icon">💬</span> 群聊
50
+ </button>
51
+ <button class="nav-item" data-view="search">
52
+ <span class="icon">🔍</span> 搜索
53
+ </button>
54
+ </nav>
55
+
56
+ <button class="btn-logout" id="logoutBtn">退出登录</button>
57
+ </aside>
58
+
59
+ <main class="main-content">
60
+ <div id="contentArea"></div>
61
+ </main>
62
+ </div>
63
+ `;
64
+
65
+ // 导航切换
66
+ document.querySelectorAll('.nav-item').forEach(item => {
67
+ item.addEventListener('click', () => {
68
+ document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
69
+ item.classList.add('active');
70
+ const view = item.dataset.view;
71
+ renderView(view);
72
+ });
73
+ });
74
+
75
+ // 退出登录
76
+ document.getElementById('logoutBtn').addEventListener('click', () => {
77
+ authService.logout();
78
+ });
79
+
80
+ async function renderView(view) {
81
+ const contentArea = document.getElementById('contentArea');
82
+
83
+ switch(view) {
84
+ case 'groups':
85
+ await renderGroupsView(contentArea);
86
+ break;
87
+ case 'allgroups':
88
+ await renderAllGroupsView(contentArea);
89
+ break;
90
+ case 'tasks':
91
+ await renderTasksView(contentArea);
92
+ break;
93
+ case 'documents':
94
+ await renderDocumentsView(contentArea);
95
+ break;
96
+ case 'files':
97
+ await renderFilesView(contentArea);
98
+ break;
99
+ case 'chat':
100
+ await renderChatView(contentArea);
101
+ break;
102
+ case 'search':
103
+ await renderSearchView(contentArea);
104
+ break;
105
+ }
106
+ }
107
+
108
+ async function renderGroupsView(container) {
109
+ const result = await apiService.getGroups();
110
+ groups = result.groups;
111
+
112
+ container.innerHTML = `
113
+ <div class="view-header">
114
+ <h2>我的群组</h2>
115
+ </div>
116
+ <div class="groups-grid" id="groupsList"></div>
117
+ `;
118
+
119
+ const groupsList = document.getElementById('groupsList');
120
+ if (groups.length === 0) {
121
+ groupsList.innerHTML = '<div class="empty-state">您还没有加入任何群组<br>请前往"所有群组"查看并加入</div>';
122
+ return;
123
+ }
124
+
125
+ groups.forEach(group => {
126
+ const groupCard = document.createElement('div');
127
+ groupCard.className = 'group-card';
128
+ groupCard.innerHTML = `
129
+ <h3>${group.name}</h3>
130
+ <p>${group.description || '暂无描述'}</p>
131
+ <div class="group-stats">
132
+ <span>👥 ${group.members.length} 成员</span>
133
+ <span>📄 ${group.documents.length} 文档</span>
134
+ <span>📋 ${group.tasks.length} 任务</span>
135
+ </div>
136
+ <div style="display: flex; gap: 10px; margin-top: 10px;">
137
+ <button class="btn-select" data-id="${group._id}">进入群组</button>
138
+ <button class="btn-secondary" data-id="${group._id}" data-action="leave">退出群组</button>
139
+ </div>
140
+ `;
141
+ groupsList.appendChild(groupCard);
142
+ });
143
+
144
+ document.querySelectorAll('.btn-select').forEach(btn => {
145
+ btn.addEventListener('click', () => {
146
+ currentGroup = groups.find(g => g._id === btn.dataset.id);
147
+ wsService.joinGroup(currentGroup._id);
148
+ alert(`已进入群组: ${currentGroup.name}`);
149
+ });
150
+ });
151
+
152
+ document.querySelectorAll('[data-action="leave"]').forEach(btn => {
153
+ btn.addEventListener('click', async () => {
154
+ if (confirm('确定要退出该群组吗?')) {
155
+ try {
156
+ await apiService.leaveGroup(btn.dataset.id);
157
+ alert('已退出群组');
158
+ await renderGroupsView(container);
159
+ } catch (error) {
160
+ alert('退出失败: ' + error.message);
161
+ }
162
+ }
163
+ });
164
+ });
165
+ }
166
+
167
+ async function renderAllGroupsView(container) {
168
+ const allGroupsResult = await apiService.getAllGroups();
169
+ const myGroupsResult = await apiService.getGroups();
170
+ const myGroupIds = myGroupsResult.groups.map(g => g._id);
171
+
172
+ container.innerHTML = `
173
+ <div class="view-header">
174
+ <h2>所有群组</h2>
175
+ </div>
176
+ <div class="groups-grid" id="allGroupsList"></div>
177
+ `;
178
+
179
+ const allGroupsList = document.getElementById('allGroupsList');
180
+ allGroupsResult.groups.forEach(group => {
181
+ const isJoined = myGroupIds.includes(group._id);
182
+ const groupCard = document.createElement('div');
183
+ groupCard.className = 'group-card';
184
+ groupCard.innerHTML = `
185
+ <h3>${group.name}</h3>
186
+ <p>${group.description || '暂无描述'}</p>
187
+ <div class="group-stats">
188
+ <span>👥 ${group.members.length} 成员</span>
189
+ <span>📄 ${group.documents.length} 文档</span>
190
+ </div>
191
+ ${isJoined ?
192
+ '<div style="color: var(--success); margin-top: 10px;">✓ 已加入</div>' :
193
+ `<button class="btn-primary" data-id="${group._id}" data-action="join">加入群组</button>`
194
+ }
195
+ `;
196
+ allGroupsList.appendChild(groupCard);
197
+ });
198
+
199
+ document.querySelectorAll('[data-action="join"]').forEach(btn => {
200
+ btn.addEventListener('click', async () => {
201
+ try {
202
+ await apiService.joinGroup(btn.dataset.id);
203
+ alert('加入成功!');
204
+ await renderAllGroupsView(container);
205
+ } catch (error) {
206
+ alert('加入失败: ' + error.message);
207
+ }
208
+ });
209
+ });
210
+ }
211
+
212
+ async function renderTasksView(container) {
213
+ try {
214
+ const result = await apiService.getMyTasks();
215
+
216
+ container.innerHTML = `
217
+ <div class="view-header">
218
+ <h2>我的任务</h2>
219
+ </div>
220
+ <div class="tasks-list" id="tasksList"></div>
221
+ `;
222
+
223
+ const tasksList = document.getElementById('tasksList');
224
+
225
+ if (result.tasks.length === 0) {
226
+ tasksList.innerHTML = '<div class="empty-state">暂无任务</div>';
227
+ return;
228
+ }
229
+
230
+ result.tasks.forEach(task => {
231
+ const taskCard = document.createElement('div');
232
+ taskCard.className = `task-card status-${task.status}`;
233
+ taskCard.innerHTML = `
234
+ <h3>${task.title}</h3>
235
+ <p>${task.description}</p>
236
+ <div class="task-meta">
237
+ <span class="status-badge">${getStatusText(task.status)}</span>
238
+ <span>群组: ${task.group.name}</span>
239
+ ${task.deadline ? `<span>截止: ${new Date(task.deadline).toLocaleDateString()}</span>` : ''}
240
+ </div>
241
+ ${task.relatedDocument ? `<a href="#" class="doc-link" data-id="${task.relatedDocument._id}">📄 查看相关文档</a>` : ''}
242
+ <div class="task-actions">
243
+ ${task.status === 'pending' ? `<button class="btn-primary btn-sm" data-id="${task._id}" data-action="start">开始任务</button>` : ''}
244
+ ${task.status === 'in_progress' ? `<button class="btn-success btn-sm" data-id="${task._id}" data-action="complete">完成任务</button>` : ''}
245
+ </div>
246
+ `;
247
+ tasksList.appendChild(taskCard);
248
+ });
249
+
250
+ // 任务操作
251
+ document.querySelectorAll('[data-action]').forEach(btn => {
252
+ btn.addEventListener('click', async () => {
253
+ const taskId = btn.dataset.id;
254
+ const action = btn.dataset.action;
255
+ const status = action === 'start' ? 'in_progress' : 'completed';
256
+
257
+ try {
258
+ await apiService.updateTaskStatus(taskId, status);
259
+ await renderTasksView(container);
260
+ } catch (error) {
261
+ alert('操作失败: ' + error.message);
262
+ }
263
+ });
264
+ });
265
+ } catch (error) {
266
+ console.error('获取任务失败:', error);
267
+ container.innerHTML = `
268
+ <div class="view-header">
269
+ <h2>我的任务</h2>
270
+ </div>
271
+ <div class="empty-state">加载任务失败: ${error.message}</div>
272
+ `;
273
+ }
274
+ }
275
+
276
+ async function renderDocumentsView(container) {
277
+ if (!currentGroup) {
278
+ container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
279
+ return;
280
+ }
281
+
282
+ const result = await apiService.getDocuments(currentGroup._id);
283
+
284
+ container.innerHTML = `
285
+ <div class="view-header">
286
+ <h2>共享文档 - ${currentGroup.name}</h2>
287
+ </div>
288
+ <div class="documents-list" id="docsList"></div>
289
+ `;
290
+
291
+ const docsList = document.getElementById('docsList');
292
+
293
+ if (result.documents.length === 0) {
294
+ docsList.innerHTML = '<div class="empty-state">暂无文档</div>';
295
+ return;
296
+ }
297
+
298
+ result.documents.forEach(doc => {
299
+ const docCard = document.createElement('div');
300
+ docCard.className = 'document-card';
301
+ docCard.innerHTML = `
302
+ <h3>📄 ${doc.title}</h3>
303
+ <div class="doc-meta">
304
+ <span>创建者: ${doc.creator.username}</span>
305
+ <span>${doc.permission === 'readonly' ? '🔒 只读' : '✏️ 可编辑'}</span>
306
+ <span>更新: ${new Date(doc.updatedAt).toLocaleString()}</span>
307
+ </div>
308
+ <button class="btn-edit" data-id="${doc._id}">
309
+ ${doc.permission === 'readonly' ? '查看' : '编辑'}
310
+ </button>
311
+ `;
312
+ docsList.appendChild(docCard);
313
+ });
314
+
315
+ document.querySelectorAll('.btn-edit').forEach(btn => {
316
+ btn.addEventListener('click', () => {
317
+ renderDocumentEditor(container, btn.dataset.id);
318
+ });
319
+ });
320
+ }
321
+
322
+ async function renderDocumentEditor(container, documentId) {
323
+ const result = await apiService.getDocument(documentId);
324
+ const doc = result.document;
325
+
326
+ container.innerHTML = `
327
+ <div class="view-header">
328
+ <button class="btn-back" id="backBtn">← 返回</button>
329
+ <h2>${doc.title}</h2>
330
+ <span class="doc-status">${doc.permission === 'readonly' ? '🔒 只读模式' : '✏️ 编辑模式'}</span>
331
+ </div>
332
+ <div class="editor-container">
333
+ <div class="editor-toolbar">
334
+ <div class="online-users" id="onlineUsers">
335
+ <span class="user-badge">👤 ${user.username}</span>
336
+ </div>
337
+ ${doc.permission === 'editable' ? '<button class="btn-primary" id="saveBtn">保存</button>' : ''}
338
+ </div>
339
+ <div id="editor" ${doc.permission === 'readonly' ? 'class="readonly"' : ''}></div>
340
+ <div class="editor-footer">
341
+ <span>最后编辑: ${new Date(doc.updatedAt).toLocaleString()}</span>
342
+ </div>
343
+ </div>
344
+ `;
345
+
346
+ // 初始化 Quill 编辑器
347
+ const quill = new Quill('#editor', {
348
+ theme: 'snow',
349
+ modules: {
350
+ toolbar: doc.permission === 'readonly' ? false : [
351
+ [{ 'header': [1, 2, 3, false] }],
352
+ ['bold', 'italic', 'underline', 'strike'],
353
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }],
354
+ [{ 'color': [] }, { 'background': [] }],
355
+ ['link', 'image', 'code-block'],
356
+ ['clean']
357
+ ]
358
+ },
359
+ readOnly: doc.permission === 'readonly'
360
+ });
361
+
362
+ // 设置初始内容
363
+ quill.root.innerHTML = doc.content || '';
364
+
365
+ // 实时同步
366
+ if (doc.permission === 'editable') {
367
+ let typingTimeout;
368
+ let saveTimeout;
369
+
370
+ quill.on('text-change', () => {
371
+ clearTimeout(typingTimeout);
372
+ clearTimeout(saveTimeout);
373
+ wsService.sendTyping(documentId, user.username, true);
374
+
375
+ typingTimeout = setTimeout(() => {
376
+ wsService.sendTyping(documentId, user.username, false);
377
+ }, 1000);
378
+
379
+ // 自动保存
380
+ saveTimeout = setTimeout(async () => {
381
+ const content = quill.root.innerHTML;
382
+ try {
383
+ await apiService.updateDocument(documentId, content);
384
+ } catch (error) {
385
+ console.error('自动保存失败:', error);
386
+ }
387
+ }, 2000);
388
+ });
389
+
390
+ document.getElementById('saveBtn').addEventListener('click', async () => {
391
+ try {
392
+ const content = quill.root.innerHTML;
393
+ await apiService.updateDocument(documentId, content);
394
+ alert('保存成功!');
395
+ } catch (error) {
396
+ alert('保存失败: ' + error.message);
397
+ }
398
+ });
399
+ }
400
+
401
+ // 监听文档更新
402
+ wsService.on('document_update', (data) => {
403
+ if (data.documentId === documentId && data.userId !== user.id) {
404
+ const selection = quill.getSelection();
405
+ quill.root.innerHTML = data.content;
406
+ if (selection) {
407
+ quill.setSelection(selection);
408
+ }
409
+ }
410
+ });
411
+
412
+ // 监听打字状态
413
+ wsService.on('typing', (data) => {
414
+ if (data.documentId === documentId && data.userId !== user.id) {
415
+ const onlineUsers = document.getElementById('onlineUsers');
416
+ if (data.isTyping) {
417
+ onlineUsers.innerHTML += `<span class="user-badge typing" data-user="${data.userId}">✏️ ${data.username}</span>`;
418
+ } else {
419
+ const badge = onlineUsers.querySelector(`[data-user="${data.userId}"]`);
420
+ if (badge) badge.remove();
421
+ }
422
+ }
423
+ });
424
+
425
+ document.getElementById('backBtn').addEventListener('click', () => {
426
+ renderDocumentsView(container);
427
+ });
428
+ }
429
+
430
+ async function renderFilesView(container) {
431
+ if (!currentGroup) {
432
+ container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
433
+ return;
434
+ }
435
+
436
+ try {
437
+ const result = await apiService.getGroupFiles(currentGroup._id);
438
+
439
+ container.innerHTML = `
440
+ <div class="view-header">
441
+ <h2>文件共享 - ${currentGroup.name}</h2>
442
+ <button class="btn-primary" id="uploadFileBtn">📤 上传文件</button>
443
+ </div>
444
+ <div class="files-list" id="filesList"></div>
445
+
446
+ <!-- 文件上传模态框 -->
447
+ <div class="modal hidden" id="uploadFileModal">
448
+ <div class="modal-content">
449
+ <div class="modal-header">
450
+ <h3>上传文件</h3>
451
+ <button class="modal-close" id="closeUploadModal">&times;</button>
452
+ </div>
453
+ <form id="uploadFileForm">
454
+ <div class="form-group">
455
+ <label>选择文件</label>
456
+ <input type="file" id="fileInput" required>
457
+ <small>支持图片、PDF、Word、Excel等,最大10MB</small>
458
+ </div>
459
+ <div class="form-group">
460
+ <label>描述(可选)</label>
461
+ <textarea id="fileDescription" rows="3" placeholder="文件描述..."></textarea>
462
+ </div>
463
+ <div class="form-actions">
464
+ <button type="button" class="btn-secondary" id="cancelUpload">取消</button>
465
+ <button type="submit" class="btn-primary">上传</button>
466
+ </div>
467
+ </form>
468
+ </div>
469
+ </div>
470
+ `;
471
+
472
+ const filesList = document.getElementById('filesList');
473
+
474
+ if (!result.files || result.files.length === 0) {
475
+ filesList.innerHTML = '<div class="empty-state">暂无文件</div>';
476
+ } else {
477
+ result.files.forEach(file => {
478
+ const fileCard = document.createElement('div');
479
+ fileCard.className = 'file-card';
480
+
481
+ const fileIcon = getFileIcon(file.mimetype);
482
+ const fileSize = formatFileSize(file.size);
483
+
484
+ fileCard.innerHTML = `
485
+ <div class="file-icon">${fileIcon}</div>
486
+ <div class="file-info">
487
+ <h4>${file.originalName}</h4>
488
+ <div class="file-meta">
489
+ <span>上传者: ${file.uploader.username}</span>
490
+ <span>大小: ${fileSize}</span>
491
+ <span>时间: ${new Date(file.createdAt).toLocaleString()}</span>
492
+ </div>
493
+ ${file.description ? `<p class="file-description">${file.description}</p>` : ''}
494
+ </div>
495
+ <div class="file-actions">
496
+ <a href="${apiService.getFileDownloadUrl(file._id)}" class="btn-primary" download>下载</a>
497
+ ${file.uploader._id === currentUserId ? `<button class="btn-danger" data-id="${file._id}" data-action="delete-file">删除</button>` : ''}
498
+ </div>
499
+ `;
500
+ filesList.appendChild(fileCard);
501
+ });
502
+
503
+ // 删除文件事件
504
+ document.querySelectorAll('[data-action="delete-file"]').forEach(btn => {
505
+ btn.addEventListener('click', async () => {
506
+ if (confirm('确定要删除这个文件吗?')) {
507
+ try {
508
+ await apiService.deleteFile(btn.dataset.id);
509
+ alert('文件删除成功!');
510
+ await renderFilesView(container);
511
+ } catch (error) {
512
+ alert('删除失败: ' + error.message);
513
+ }
514
+ }
515
+ });
516
+ });
517
+ }
518
+
519
+ // 文件上传功能
520
+ document.getElementById('uploadFileBtn').addEventListener('click', () => {
521
+ document.getElementById('uploadFileModal').classList.remove('hidden');
522
+ });
523
+
524
+ document.getElementById('closeUploadModal').addEventListener('click', () => {
525
+ document.getElementById('uploadFileModal').classList.add('hidden');
526
+ document.getElementById('uploadFileForm').reset();
527
+ });
528
+
529
+ document.getElementById('cancelUpload').addEventListener('click', () => {
530
+ document.getElementById('uploadFileModal').classList.add('hidden');
531
+ document.getElementById('uploadFileForm').reset();
532
+ });
533
+
534
+ document.getElementById('uploadFileForm').addEventListener('submit', async (e) => {
535
+ e.preventDefault();
536
+ const fileInput = document.getElementById('fileInput');
537
+ const description = document.getElementById('fileDescription').value;
538
+
539
+ if (!fileInput.files[0]) {
540
+ alert('请选择文件');
541
+ return;
542
+ }
543
+
544
+ try {
545
+ await apiService.uploadFile(currentGroup._id, fileInput.files[0], description);
546
+ alert('文件上传成功!');
547
+ document.getElementById('uploadFileModal').classList.add('hidden');
548
+ document.getElementById('uploadFileForm').reset();
549
+ await renderFilesView(container);
550
+ } catch (error) {
551
+ alert('上传失败: ' + error.message);
552
+ }
553
+ });
554
+ } catch (error) {
555
+ console.error('获取文件列表失败:', error);
556
+ container.innerHTML = `
557
+ <div class="view-header">
558
+ <h2>文件共享</h2>
559
+ </div>
560
+ <div class="empty-state">加载文件失败: ${error.message}</div>
561
+ `;
562
+ }
563
+ }
564
+
565
+ function getFileIcon(mimetype) {
566
+ if (mimetype.startsWith('image/')) return '🖼️';
567
+ if (mimetype === 'application/pdf') return '📕';
568
+ if (mimetype.includes('word') || mimetype.includes('document')) return '📘';
569
+ if (mimetype.includes('excel') || mimetype.includes('spreadsheet')) return '📗';
570
+ if (mimetype.includes('zip') || mimetype.includes('compressed')) return '📦';
571
+ return '📄';
572
+ }
573
+
574
+ function formatFileSize(bytes) {
575
+ if (bytes === 0) return '0 Bytes';
576
+ const k = 1024;
577
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
578
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
579
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
580
+ }
581
+
582
+ async function renderChatView(container) {
583
+ if (!currentGroup) {
584
+ container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
585
+ return;
586
+ }
587
+
588
+ const groupResult = await apiService.getGroup(currentGroup._id);
589
+ const group = groupResult.group;
590
+ const isMutedAll = Boolean(group.mutedAll);
591
+ const isMutedMe = (group.mutedUsers || []).map(String).includes(String(currentUserId));
592
+ const canSpeak = !isMutedAll && !isMutedMe;
593
+
594
+ container.innerHTML = `
595
+ <div class="view-header">
596
+ <h2>群聊 - ${currentGroup.name}</h2>
597
+ </div>
598
+ <div class="chat-container">
599
+ <div class="messages" id="messages"></div>
600
+ <div class="chat-input">
601
+ <button class="btn-emoji" id="emojiBtn" ${canSpeak ? '' : 'disabled'}>😊</button>
602
+ <input type="text" id="messageInput" placeholder="${canSpeak ? '输入消息...' : (isMutedAll ? '全体禁言中,无法发言' : '你已被禁言')}" ${canSpeak ? '' : 'disabled'}>
603
+ <button class="btn-primary" id="sendBtn" ${canSpeak ? '' : 'disabled'}>发送</button>
604
+ </div>
605
+ <emoji-picker id="emojiPicker" class="hidden"></emoji-picker>
606
+ </div>
607
+ `;
608
+
609
+ const messagesDiv = document.getElementById('messages');
610
+ const messageInput = document.getElementById('messageInput');
611
+ const sendBtn = document.getElementById('sendBtn');
612
+
613
+ // 加载历史消息
614
+ try {
615
+ const messagesResult = await apiService.getGroupMessages(currentGroup._id);
616
+ if (messagesResult.messages) {
617
+ messagesResult.messages.forEach(msg => {
618
+ const messageEl = document.createElement('div');
619
+ messageEl.className = `message ${msg.sender === currentUserId ? 'own' : ''}`;
620
+ messageEl.innerHTML = `
621
+ <div class="message-header">
622
+ <span class="message-user">${msg.username}</span>
623
+ <span class="message-time">${new Date(msg.timestamp).toLocaleTimeString()}</span>
624
+ </div>
625
+ <div class="message-content">${msg.content}</div>
626
+ `;
627
+ messagesDiv.appendChild(messageEl);
628
+ });
629
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
630
+ }
631
+ } catch (err) {
632
+ console.error('加载历史消息失败:', err);
633
+ }
634
+
635
+ // 表情包功能
636
+ const emojiBtn = document.getElementById('emojiBtn');
637
+ const emojiPicker = document.getElementById('emojiPicker');
638
+
639
+ emojiBtn.addEventListener('click', () => {
640
+ emojiPicker.classList.toggle('hidden');
641
+ });
642
+
643
+ emojiPicker.addEventListener('emoji-click', (event) => {
644
+ messageInput.value += event.detail.unicode;
645
+ messageInput.focus();
646
+ emojiPicker.classList.add('hidden');
647
+ });
648
+
649
+ // 点击外部关闭表情选择器
650
+ document.addEventListener('click', (e) => {
651
+ if (!emojiBtn.contains(e.target) && !emojiPicker.contains(e.target)) {
652
+ emojiPicker.classList.add('hidden');
653
+ }
654
+ });
655
+
656
+ // 消息通知系统
657
+ function showNotification(title, body, icon = '💬') {
658
+ if ('Notification' in window && Notification.permission === 'granted') {
659
+ new Notification(title, {
660
+ body: body,
661
+ icon: '/icon.png',
662
+ badge: '/icon.png',
663
+ tag: 'chat-message'
664
+ });
665
+ }
666
+ }
667
+
668
+ // 请求通知权限
669
+ if ('Notification' in window && Notification.permission === 'default') {
670
+ Notification.requestPermission();
671
+ }
672
+
673
+ // 监听消息
674
+ wsService.on('chat_message', (data) => {
675
+ if (data.groupId === currentGroup._id) {
676
+ const messageEl = document.createElement('div');
677
+ messageEl.className = `message ${data.userId === currentUserId ? 'own' : ''}`;
678
+ messageEl.innerHTML = `
679
+ <div class="message-header">
680
+ <span class="message-user">${data.username}</span>
681
+ <span class="message-time">${new Date(data.timestamp).toLocaleTimeString()}</span>
682
+ </div>
683
+ <div class="message-content">${data.content}</div>
684
+ `;
685
+ messagesDiv.appendChild(messageEl);
686
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
687
+
688
+ // 显示通知(如果不是自己发送的消息)
689
+ if (data.userId !== currentUserId) {
690
+ showNotification(`${data.username} 在 ${currentGroup.name}`, data.content);
691
+ }
692
+ }
693
+ });
694
+
695
+ // 服务端拦截提示(比如被禁言/未加入群组)
696
+ wsService.on('chat_blocked', (data) => {
697
+ if (data.groupId === currentGroup._id) {
698
+ const notificationEl = document.createElement('div');
699
+ notificationEl.className = 'notification';
700
+ notificationEl.textContent = data.message || '消息发送失败';
701
+ messagesDiv.appendChild(notificationEl);
702
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
703
+ }
704
+ });
705
+
706
+ // 监听点名通知
707
+ wsService.on('call_response', (data) => {
708
+ if (data.groupId === currentGroup._id) {
709
+ const notificationEl = document.createElement('div');
710
+ notificationEl.className = 'notification';
711
+ notificationEl.textContent = `${data.username} 已响应点名`;
712
+ messagesDiv.appendChild(notificationEl);
713
+ }
714
+ });
715
+
716
+ // 发送消息
717
+ const sendMessage = () => {
718
+ const content = messageInput.value.trim();
719
+ if (content) {
720
+ wsService.sendChatMessage(currentGroup._id, user.username, content);
721
+ messageInput.value = '';
722
+ }
723
+ };
724
+
725
+ sendBtn.addEventListener('click', sendMessage);
726
+ messageInput.addEventListener('keypress', (e) => {
727
+ if (e.key === 'Enter') sendMessage();
728
+ });
729
+ }
730
+
731
+ async function renderSearchView(container) {
732
+ container.innerHTML = `
733
+ <div class="view-header">
734
+ <h2>🔍 搜索</h2>
735
+ </div>
736
+ <div class="search-container">
737
+ <div class="search-box">
738
+ <input type="text" id="searchInput" placeholder="搜索消息、文档、任务...">
739
+ <button class="btn-primary" id="searchBtn">搜索</button>
740
+ </div>
741
+ <div class="search-filters">
742
+ <label>
743
+ <input type="checkbox" id="filterMessages" checked> 消息
744
+ </label>
745
+ <label>
746
+ <input type="checkbox" id="filterDocuments" checked> 文档
747
+ </label>
748
+ <label>
749
+ <input type="checkbox" id="filterTasks" checked> 任务
750
+ </label>
751
+ </div>
752
+ <div class="search-results" id="searchResults"></div>
753
+ </div>
754
+ `;
755
+
756
+ const searchInput = document.getElementById('searchInput');
757
+ const searchBtn = document.getElementById('searchBtn');
758
+ const searchResults = document.getElementById('searchResults');
759
+
760
+ const performSearch = async () => {
761
+ const query = searchInput.value.trim();
762
+ if (!query) {
763
+ searchResults.innerHTML = '<div class="empty-state">请输入搜索关键词</div>';
764
+ return;
765
+ }
766
+
767
+ const filters = {
768
+ messages: document.getElementById('filterMessages').checked,
769
+ documents: document.getElementById('filterDocuments').checked,
770
+ tasks: document.getElementById('filterTasks').checked
771
+ };
772
+
773
+ searchResults.innerHTML = '<div class="loading">搜索中...</div>';
774
+
775
+ try {
776
+ const results = [];
777
+
778
+ // 搜索消息
779
+ if (filters.messages && currentGroup) {
780
+ try {
781
+ const messagesResult = await apiService.getGroupMessages(currentGroup._id);
782
+ if (messagesResult.messages) {
783
+ const matchedMessages = messagesResult.messages.filter(msg =>
784
+ msg.content.toLowerCase().includes(query.toLowerCase())
785
+ );
786
+ matchedMessages.forEach(msg => {
787
+ results.push({
788
+ type: 'message',
789
+ title: `消息 - ${msg.username}`,
790
+ content: msg.content,
791
+ time: msg.timestamp,
792
+ group: currentGroup.name
793
+ });
794
+ });
795
+ }
796
+ } catch (err) {
797
+ console.error('搜索消息失败:', err);
798
+ }
799
+ }
800
+
801
+ // 搜索文档
802
+ if (filters.documents) {
803
+ try {
804
+ if (currentGroup) {
805
+ const docsResult = await apiService.getDocuments(currentGroup._id);
806
+ if (docsResult.documents) {
807
+ const matchedDocs = docsResult.documents.filter(doc =>
808
+ doc.title.toLowerCase().includes(query.toLowerCase()) ||
809
+ doc.content.toLowerCase().includes(query.toLowerCase())
810
+ );
811
+ matchedDocs.forEach(doc => {
812
+ results.push({
813
+ type: 'document',
814
+ title: doc.title,
815
+ content: doc.content.substring(0, 200),
816
+ time: doc.updatedAt,
817
+ id: doc._id,
818
+ group: currentGroup.name
819
+ });
820
+ });
821
+ }
822
+ }
823
+ } catch (err) {
824
+ console.error('搜索文档失败:', err);
825
+ }
826
+ }
827
+
828
+ // 搜索任务
829
+ if (filters.tasks) {
830
+ try {
831
+ const tasksResult = await apiService.getMyTasks();
832
+ if (tasksResult.tasks) {
833
+ const matchedTasks = tasksResult.tasks.filter(task =>
834
+ task.title.toLowerCase().includes(query.toLowerCase()) ||
835
+ (task.description && task.description.toLowerCase().includes(query.toLowerCase()))
836
+ );
837
+ matchedTasks.forEach(task => {
838
+ results.push({
839
+ type: 'task',
840
+ title: task.title,
841
+ content: task.description || '',
842
+ time: task.updatedAt,
843
+ id: task._id,
844
+ status: task.status
845
+ });
846
+ });
847
+ }
848
+ } catch (err) {
849
+ console.error('搜索任务失败:', err);
850
+ }
851
+ }
852
+
853
+ // 显示结果
854
+ if (results.length === 0) {
855
+ searchResults.innerHTML = '<div class="empty-state">未找到相关结果</div>';
856
+ } else {
857
+ searchResults.innerHTML = results.map(result => {
858
+ const typeIcon = {
859
+ message: '💬',
860
+ document: '📄',
861
+ task: '📋'
862
+ };
863
+ return `
864
+ <div class="search-result-item">
865
+ <div class="result-header">
866
+ <span class="result-type">${typeIcon[result.type]} ${result.type === 'message' ? '消息' : result.type === 'document' ? '文档' : '任务'}</span>
867
+ <span class="result-time">${new Date(result.time).toLocaleString()}</span>
868
+ </div>
869
+ <h4>${highlightText(result.title, query)}</h4>
870
+ <p>${highlightText(result.content, query)}</p>
871
+ ${result.group ? `<span class="result-group">群组: ${result.group}</span>` : ''}
872
+ ${result.status ? `<span class="result-status">状态: ${getStatusText(result.status)}</span>` : ''}
873
+ </div>
874
+ `;
875
+ }).join('');
876
+ }
877
+ } catch (error) {
878
+ searchResults.innerHTML = `<div class="empty-state">搜索失败: ${error.message}</div>`;
879
+ }
880
+ };
881
+
882
+ searchBtn.addEventListener('click', performSearch);
883
+ searchInput.addEventListener('keypress', (e) => {
884
+ if (e.key === 'Enter') performSearch();
885
+ });
886
+ }
887
+
888
+ function highlightText(text, query) {
889
+ if (!query) return text;
890
+ const regex = new RegExp(`(${query})`, 'gi');
891
+ return text.replace(regex, '<mark>$1</mark>');
892
+ }
893
+
894
+ function getStatusText(status) {
895
+ const statusMap = {
896
+ 'pending': '待处理',
897
+ 'in_progress': '进行中',
898
+ 'completed': '已完成',
899
+ 'terminated': '已终止'
900
+ };
901
+ return statusMap[status] || status;
902
+ }
903
+
904
+ renderView('groups');
905
+ }
906
+