collabdocchat 2.4.4 → 2.4.6
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/bin/cli.js +1 -1
- package/package.json +4 -2
- package/scripts/cleanup-scripts.js +140 -0
- package/scripts/fix-startup-issues.js +136 -0
- package/scripts/start-app.js +11 -5
- package/scripts/start-simple.js +96 -0
- package/server/index.js +4 -0
- package/server/index.js.bak +97 -0
- package/server/models/Document.js +5 -0
- package/server/models/KnowledgeBase.js +259 -254
- package/server/models/Poll.js +97 -0
- package/server/routes/ai.js +391 -327
- package/server/routes/audit.js +61 -0
- package/server/routes/documents.js +74 -5
- package/server/routes/export.js +171 -10
- package/server/routes/files.js +27 -4
- package/server/routes/knowledge.js +31 -22
- package/server/routes/messages.js +142 -0
- package/server/routes/polls.js +241 -0
- package/server/routes/tasks.js +1 -0
- package/server/routes/workflows.js +27 -0
- package/server/utils/auditLogger.js +268 -238
- package/src/pages/admin-dashboard.js +1431 -335
- package/src/pages/admin-dashboard.js.audit-optimize.bak +4134 -0
- package/src/pages/admin-dashboard.js.bak +4041 -0
- package/src/pages/admin-dashboard.js.broken.bak +4099 -0
- package/src/pages/admin-dashboard.js.comprehensive.bak +4099 -0
- package/src/pages/admin-dashboard.js.escape.bak +4099 -0
- package/src/pages/admin-dashboard.js.final-final-fix.bak +4099 -0
- package/src/pages/admin-dashboard.js.final-fix.bak +4099 -0
- package/src/pages/admin-dashboard.js.final.bak +4099 -0
- package/src/pages/admin-dashboard.js.indent-fix.bak +4099 -0
- package/src/pages/admin-dashboard.js.last-fix.bak +4099 -0
- package/src/pages/admin-dashboard.js.line595-fix.bak +4099 -0
- package/src/pages/admin-dashboard.js.pre-manual-fix.bak +4099 -0
- package/src/pages/admin-dashboard.js.syntax.bak +4099 -0
- package/src/pages/admin-dashboard.js.test.bak +4099 -0
- package/src/pages/optimized-task-detail-original.js +838 -0
- package/src/pages/optimized-task-detail.js +324 -22
- package/src/pages/optimized-task-detail.js.bak +1162 -0
- package/src/pages/poll-detail-enhanced.js +394 -0
- package/src/pages/update-poll-display.js +380 -0
- package/src/pages/user-dashboard.js +1860 -1006
- package/src/services/api.js +326 -265
- package/src/services/auth.js +54 -54
- package/src/services/websocket.js +88 -80
- package/scripts/add-button-hover.js +0 -56
- package/scripts/add-missing-functions.js +0 -66
- package/scripts/add-more-features.js +0 -427
- package/scripts/add-user-functions.js +0 -201
- package/scripts/auto-publish.js +0 -63
- package/scripts/beautify-buttons.js +0 -45
- package/scripts/beautify-ui.js +0 -267
- package/scripts/check-encoding.js +0 -41
- package/scripts/check-syntax.js +0 -54
- package/scripts/find-buttons.js +0 -20
- package/scripts/find-duplicate.js +0 -35
- package/scripts/find-sidebar-buttons.js +0 -21
- package/scripts/fix-help.js +0 -274
- package/scripts/fix-issues-step1.js +0 -73
- package/scripts/fix-issues-step2.js +0 -93
- package/scripts/fix-issues-step3.js +0 -155
- package/scripts/fix-issues-step4.js +0 -150
- package/scripts/fix-optimized-views.js +0 -37
- package/scripts/fix-ports.js +0 -77
- package/scripts/fix-settings.js +0 -258
- package/scripts/fix-user-dashboard.js +0 -62
- package/scripts/fix-workflow.js +0 -110
- package/scripts/refactor-step1.js +0 -32
- package/scripts/refactor-step2.js +0 -255
- package/scripts/refactor-step3.js +0 -137
- package/scripts/refactor-step4.js +0 -183
- package/scripts/refactor-step5.js +0 -181
- package/scripts/refactor-step6.js +0 -254
- package/scripts/refactor-step7.js +0 -291
- package/scripts/remove-bom.js +0 -69
- package/scripts/remove-quill-from-user-dashboard.js +0 -49
- package/scripts/remove-quill-imports-only.js +0 -32
- package/scripts/update-port-user.js +0 -21
- package/scripts/update-port.js +0 -22
- package/src/pages/simplified-workflows.js +0 -652
- package/src/utils/ai-assistant.js +0 -1384
|
@@ -1,1058 +1,1912 @@
|
|
|
1
|
-
import { ApiService } from '../services/api.js';
|
|
2
|
-
import { AuthService } from '../services/auth.js';
|
|
3
|
-
import 'emoji-picker-element';
|
|
4
|
-
|
|
5
|
-
export function renderUserDashboard(user, wsService) {
|
|
6
|
-
const app = document.getElementById('app');
|
|
7
|
-
const apiService = new ApiService();
|
|
8
|
-
const authService = new AuthService();
|
|
9
|
-
const currentUserId = user.id || user._id;
|
|
10
|
-
|
|
11
|
-
let currentGroup = null;
|
|
12
|
-
let groups = [];
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
<span
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<
|
|
1
|
+
import { ApiService } from '../services/api.js';
|
|
2
|
+
import { AuthService } from '../services/auth.js';
|
|
3
|
+
import 'emoji-picker-element';
|
|
4
|
+
|
|
5
|
+
export function renderUserDashboard(user, wsService) {
|
|
6
|
+
const app = document.getElementById('app');
|
|
7
|
+
const apiService = new ApiService();
|
|
8
|
+
const authService = new AuthService();
|
|
9
|
+
const currentUserId = user.id || user._id;
|
|
10
|
+
|
|
11
|
+
let currentGroup = null;
|
|
12
|
+
let groups = [];
|
|
13
|
+
|
|
14
|
+
// 辅助函数:处理消息内容(白板、投票等特殊格式)
|
|
15
|
+
function formatMessageContent(content) {
|
|
16
|
+
// 处理白板作品
|
|
17
|
+
if (content.startsWith('[白板作品]')) {
|
|
18
|
+
const imageUrl = content.replace('[白板作品]', '').trim();
|
|
19
|
+
|
|
20
|
+
// 检查是否是文件URL(需要token)
|
|
21
|
+
const isFileUrl = imageUrl.includes('/api/files/') && imageUrl.includes('/download');
|
|
22
|
+
|
|
23
|
+
// 如果是文件URL但没有token,添加token
|
|
24
|
+
let finalUrl = imageUrl;
|
|
25
|
+
if (isFileUrl && !imageUrl.includes('token=')) {
|
|
26
|
+
const token = localStorage.getItem('token');
|
|
27
|
+
finalUrl = imageUrl.includes('?')
|
|
28
|
+
? `${imageUrl}&token=${token}`
|
|
29
|
+
: `${imageUrl}?token=${token}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return `
|
|
33
|
+
<div style="background: linear-gradient(135deg, rgb(99, 102, 241) 0%, rgb(139, 92, 246) 100%); padding: 16px; border-radius: 12px; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);">
|
|
34
|
+
<div style="margin-bottom: 12px; font-weight: 600; color: white; display: flex; align-items: center; gap: 6px; font-size: 15px;">
|
|
35
|
+
<span style="font-size: 18px;">🎨</span>
|
|
36
|
+
<span>白板作品</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div style="position: relative; display: inline-block;">
|
|
39
|
+
<img src="${finalUrl}" alt="白板作品"
|
|
40
|
+
style="max-width: 400px; max-height: 400px; border-radius: 8px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2); background: rgba(255,255,255,0.1);"
|
|
41
|
+
onclick="window.open('${finalUrl}', '_blank')"
|
|
42
|
+
onerror="this.style.display='none'; this.nextElementSibling.style.display='block'; console.error('白板图片加载失败:', '${finalUrl}');"
|
|
43
|
+
onload="console.log('白板图片加载成功:', '${finalUrl}');">
|
|
44
|
+
<div style="display: none; padding: 20px; background: rgba(255,255,255,0.1); border: 2px dashed rgba(255,255,255,0.3); border-radius: 8px; text-align: center; color: white;">
|
|
45
|
+
<div style="font-size: 48px; margin-bottom: 10px;">⚠️</div>
|
|
46
|
+
<div style="font-weight: 600; margin-bottom: 5px;">图片加载失败</div>
|
|
47
|
+
<div style="font-size: 12px;">图片可能已被删除或URL无效</div>
|
|
48
|
+
<button onclick="navigator.clipboard.writeText('${imageUrl}'); alert('图片URL已复制到剪贴板');" style="margin-top: 10px; padding: 6px 12px; background: rgba(255,255,255,0.2); color: white; border: none; border-radius: 6px; cursor: pointer;">复制图片URL</button>
|
|
49
|
+
</div>
|
|
50
|
+
<div style="margin-top: 10px; font-size: 12px; color: rgba(255,255,255,0.8);">点击图片查看大图</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 处理投票
|
|
57
|
+
if (content.startsWith('[投票]')) {
|
|
58
|
+
const pollId = content.replace('[投票]', '').trim();
|
|
59
|
+
return `
|
|
60
|
+
<div class="poll-card" data-poll-id="${pollId}" style="background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(168, 85, 247, 0.1) 100%); border: 2px solid rgba(99, 102, 241, 0.3); border-radius: 12px; padding: 16px; cursor: pointer; transition: all 0.3s;" onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 4px 12px rgba(99, 102, 241, 0.2)'" onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='none'" onclick="viewPollDetail('${pollId}')">
|
|
61
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
|
|
62
|
+
<span style="font-size: 32px;">📊</span>
|
|
63
|
+
<div style="flex: 1;">
|
|
64
|
+
<div style="font-weight: 600; font-size: 16px; color: var(--text-primary); margin-bottom: 4px;">投票</div>
|
|
65
|
+
<div style="font-size: 13px; color: var(--text-secondary);">点击查看详情并参与投票</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div style="padding: 8px 12px; background: rgba(99, 102, 241, 0.1); border-radius: 6px; font-size: 12px; color: var(--primary); text-align: center;">
|
|
69
|
+
📋 查看投票详情
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 普通消息
|
|
76
|
+
return content;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 全局函数:查看投票详情
|
|
80
|
+
window.viewPollDetail = async (pollId) => {
|
|
81
|
+
try {
|
|
82
|
+
const result = await apiService.getPoll(pollId);
|
|
83
|
+
const poll = result.poll;
|
|
84
|
+
|
|
85
|
+
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes.length, 0);
|
|
86
|
+
const hasVoted = poll.options.some(opt => opt.votes.includes(currentUserId));
|
|
87
|
+
const isEnded = poll.status === 'ended' || (poll.endTime && new Date(poll.endTime) < new Date());
|
|
88
|
+
|
|
89
|
+
let optionsHtml = '';
|
|
90
|
+
poll.options.forEach((option, index) => {
|
|
91
|
+
const percentage = totalVotes > 0 ? (option.votes.length / totalVotes * 100).toFixed(1) : 0;
|
|
92
|
+
const isSelected = option.votes.includes(currentUserId);
|
|
93
|
+
|
|
94
|
+
optionsHtml += `
|
|
95
|
+
<div class="poll-option" style="margin-bottom: 12px; padding: 12px; background: var(--bg-tertiary); border-radius: 8px; border: 2px solid ${isSelected ? 'var(--primary)' : 'var(--border)'};">
|
|
96
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
97
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
98
|
+
<span style="font-weight: 500; color: var(--text-primary);">${option.text}</span>
|
|
99
|
+
${isSelected ? '<span style="color: var(--primary); font-size: 12px;">✓ 已投票</span>' : ''}
|
|
100
|
+
</div>
|
|
101
|
+
<span style="font-weight: 600; color: var(--primary);">${option.votes.length} 票 (${percentage}%)</span>
|
|
102
|
+
</div>
|
|
103
|
+
<div style="height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden;">
|
|
104
|
+
<div style="height: 100%; background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%); width: ${percentage}%; transition: width 0.3s;"></div>
|
|
105
|
+
</div>
|
|
106
|
+
${!poll.anonymous && option.votes.length > 0 ? `
|
|
107
|
+
<div style="margin-top: 8px; font-size: 12px; color: var(--text-secondary);">
|
|
108
|
+
投票者: ${option.voterNames ? option.voterNames.join(', ') : ''}
|
|
109
|
+
</div>
|
|
110
|
+
` : ''}
|
|
111
|
+
</div>
|
|
112
|
+
`;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const modalHtml = `
|
|
116
|
+
<div id="pollDetailModal" class="modal" style="display: flex;">
|
|
117
|
+
<div class="modal-content" style="max-width: 700px; max-height: 90vh; overflow-y: auto;">
|
|
118
|
+
<div class="modal-header">
|
|
119
|
+
<h3>📊 投票详情</h3>
|
|
120
|
+
<button class="modal-close" id="closePollDetailModal">×</button>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="modal-body" style="padding: 24px;">
|
|
123
|
+
<div style="margin-bottom: 24px;">
|
|
124
|
+
<h2 style="margin: 0 0 12px 0; color: var(--text-primary);">${poll.title}</h2>
|
|
125
|
+
${poll.description ? `<p style="color: var(--text-secondary); margin: 0 0 16px 0;">${poll.description}</p>` : ''}
|
|
126
|
+
|
|
127
|
+
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px;">
|
|
128
|
+
<span style="font-size: 13px; padding: 6px 12px; background: var(--bg-tertiary); border-radius: 14px; color: var(--text-secondary);">
|
|
129
|
+
${poll.allowMultiple ? '✓ 多选投票' : '○ 单选投票'}
|
|
130
|
+
</span>
|
|
131
|
+
<span style="font-size: 13px; padding: 6px 12px; background: var(--bg-tertiary); border-radius: 14px; color: var(--text-secondary);">
|
|
132
|
+
${poll.anonymous ? '🔒 匿名投票' : '👤 实名投票'}
|
|
133
|
+
</span>
|
|
134
|
+
${isEnded ? '<span style="font-size: 13px; padding: 6px 12px; background: var(--danger); border-radius: 14px; color: white;">已结束</span>' : '<span style="font-size: 13px; padding: 6px 12px; background: var(--success); border-radius: 14px; color: white;">进行中</span>'}
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div style="padding: 16px; background: var(--bg-secondary); border-radius: 12px; border-left: 4px solid var(--primary); margin-bottom: 24px;">
|
|
138
|
+
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;">
|
|
139
|
+
<div>
|
|
140
|
+
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">创建者</div>
|
|
141
|
+
<div style="font-weight: 600; color: var(--text-primary);">👤 ${poll.creatorName}</div>
|
|
142
|
+
</div>
|
|
143
|
+
<div>
|
|
144
|
+
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">总投票数</div>
|
|
145
|
+
<div style="font-weight: 600; color: var(--primary);">👥 ${totalVotes} 人</div>
|
|
146
|
+
</div>
|
|
147
|
+
<div>
|
|
148
|
+
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">创建时间</div>
|
|
149
|
+
<div style="font-weight: 600; color: var(--text-primary);">⏰ ${new Date(poll.createdAt).toLocaleString('zh-CN')}</div>
|
|
150
|
+
</div>
|
|
151
|
+
${poll.endTime ? `
|
|
152
|
+
<div>
|
|
153
|
+
<div style="font-size: 12px; color: var(--text-secondary); margin-bottom: 4px;">截止时间</div>
|
|
154
|
+
<div style="font-weight: 600; color: var(--text-primary);">⏰ ${new Date(poll.endTime).toLocaleString('zh-CN')}</div>
|
|
155
|
+
</div>
|
|
156
|
+
` : ''}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div style="margin-bottom: 24px;">
|
|
162
|
+
<h3 style="margin-bottom: 16px; color: var(--text-primary);">投票选项</h3>
|
|
163
|
+
${!isEnded && !hasVoted ? `
|
|
164
|
+
<form id="voteForm">
|
|
165
|
+
${poll.options.map((option, index) => {
|
|
166
|
+
const percentage = totalVotes > 0 ? (option.votes.length / totalVotes * 100).toFixed(1) : 0;
|
|
167
|
+
return `
|
|
168
|
+
<div class="poll-option" style="margin-bottom: 12px; padding: 12px; background: var(--bg-tertiary); border-radius: 8px; border: 2px solid var(--border); cursor: pointer;" onmouseover="this.style.borderColor='var(--primary)'" onmouseout="this.style.borderColor='var(--border)'">
|
|
169
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
|
170
|
+
<div style="display: flex; align-items: center; gap: 8px;">
|
|
171
|
+
<input type="${poll.allowMultiple ? 'checkbox' : 'radio'}" name="poll-option" value="${index}" style="width: 18px; height: 18px; cursor: pointer;">
|
|
172
|
+
<span style="font-weight: 500; color: var(--text-primary);">${option.text}</span>
|
|
173
|
+
</div>
|
|
174
|
+
<span style="font-weight: 600; color: var(--primary);">${option.votes.length} 票 (${percentage}%)</span>
|
|
175
|
+
</div>
|
|
176
|
+
<div style="height: 8px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden;">
|
|
177
|
+
<div style="height: 100%; background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%); width: ${percentage}%; transition: width 0.3s;"></div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
}).join('')}
|
|
182
|
+
<button type="submit" class="btn-primary" style="width: 100%; padding: 12px; margin-top: 16px;">提交投票</button>
|
|
183
|
+
<div style="text-align: center; color: var(--warning); margin-top: 12px; font-size: 13px;">⚠️ 提交后不可修改</div>
|
|
184
|
+
</form>
|
|
185
|
+
` : optionsHtml}
|
|
186
|
+
${hasVoted ? '<div style="text-align: center; color: var(--success); margin-top: 16px; padding: 12px; background: rgba(34, 197, 94, 0.1); border-radius: 8px; font-weight: 600;">✓ 您已参与投票,投票后不可修改</div>' : ''}
|
|
187
|
+
${isEnded ? '<div style="text-align: center; color: var(--text-secondary); margin-top: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 8px;">投票已结束</div>' : ''}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div style="display: flex; gap: 10px;">
|
|
191
|
+
<button class="btn-secondary" id="closePollDetailBtn" style="flex: 1;">关闭</button>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
`;
|
|
197
|
+
|
|
198
|
+
// 移除旧的模态框
|
|
199
|
+
const oldModal = document.getElementById('pollDetailModal');
|
|
200
|
+
if (oldModal) oldModal.remove();
|
|
201
|
+
|
|
202
|
+
// 添加新模态框
|
|
203
|
+
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
204
|
+
|
|
205
|
+
// 绑定事件
|
|
206
|
+
document.getElementById('closePollDetailModal').addEventListener('click', () => {
|
|
207
|
+
document.getElementById('pollDetailModal').remove();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
document.getElementById('closePollDetailBtn').addEventListener('click', () => {
|
|
211
|
+
document.getElementById('pollDetailModal').remove();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// 投票表单提交
|
|
215
|
+
const voteForm = document.getElementById('voteForm');
|
|
216
|
+
if (voteForm && !isEnded && !hasVoted) {
|
|
217
|
+
voteForm.addEventListener('submit', async (e) => {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
|
|
220
|
+
const selectedOptions = Array.from(document.querySelectorAll('input[name="poll-option"]:checked'))
|
|
221
|
+
.map(input => parseInt(input.value));
|
|
222
|
+
|
|
223
|
+
if (selectedOptions.length === 0) {
|
|
224
|
+
alert('请选择至少一个选项!');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
await apiService.vote(pollId, selectedOptions);
|
|
230
|
+
alert('投票成功!');
|
|
231
|
+
document.getElementById('pollDetailModal').remove();
|
|
232
|
+
// 刷新当前视图以更新投票状态
|
|
233
|
+
const activeView = document.querySelector('.nav-item.active');
|
|
234
|
+
if (activeView && activeView.dataset.view === 'tasks') {
|
|
235
|
+
const contentArea = document.getElementById('contentArea');
|
|
236
|
+
await renderTasksView(contentArea);
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error('投票失败:', error);
|
|
240
|
+
alert('投票失败:' + error.message);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
} catch (error) {
|
|
246
|
+
console.error('加载投票详情失败:', error);
|
|
247
|
+
alert('加载投票详情失败:' + error.message);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
app.innerHTML = `
|
|
252
|
+
<div class="dashboard">
|
|
253
|
+
<aside class="sidebar">
|
|
254
|
+
<div class="sidebar-header">
|
|
255
|
+
<h2>CollabDocChat</h2>
|
|
256
|
+
<span class="badge-user">用户</span>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="user-info">
|
|
260
|
+
<div class="avatar">${user.username[0].toUpperCase()}</div>
|
|
261
|
+
<div>
|
|
262
|
+
<div class="username">${user.username}</div>
|
|
263
|
+
<div class="user-role">普通用户</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<nav class="nav-menu">
|
|
268
|
+
<button class="nav-item active" data-view="groups">
|
|
269
|
+
<span class="icon">👥</span> 我的群组
|
|
270
|
+
</button>
|
|
271
|
+
<button class="nav-item" data-view="allgroups">
|
|
272
|
+
<span class="icon">🌐</span> 所有群组
|
|
273
|
+
</button>
|
|
274
|
+
<button class="nav-item" data-view="tasks">
|
|
275
|
+
<span class="icon">📋</span> 我的任务
|
|
276
|
+
</button>
|
|
277
|
+
<button class="nav-item" data-view="documents">
|
|
278
|
+
<span class="icon">📄</span> 共享文档
|
|
279
|
+
</button>
|
|
280
|
+
<button class="nav-item" data-view="files">
|
|
281
|
+
<span class="icon">📎</span> 文件共享
|
|
282
|
+
</button>
|
|
283
|
+
<button class="nav-item" data-view="chat">
|
|
284
|
+
<span class="icon">💬</span> 群聊
|
|
285
|
+
</button>
|
|
286
|
+
<button class="nav-item" data-view="search">
|
|
287
|
+
<span class="icon">🔍</span> 搜索
|
|
51
288
|
</button>
|
|
52
289
|
<button class="nav-item" data-view="knowledge">
|
|
53
290
|
<span class="icon">📚</span> 知识库
|
|
54
291
|
</button>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
case '
|
|
89
|
-
await
|
|
90
|
-
break;
|
|
91
|
-
case '
|
|
92
|
-
await
|
|
93
|
-
break;
|
|
94
|
-
case '
|
|
95
|
-
await
|
|
96
|
-
break;
|
|
97
|
-
case '
|
|
98
|
-
await
|
|
99
|
-
break;
|
|
100
|
-
case '
|
|
101
|
-
await
|
|
102
|
-
break;
|
|
103
|
-
case '
|
|
104
|
-
await
|
|
105
|
-
break;
|
|
106
|
-
case 'search':
|
|
107
|
-
await renderSearchView(contentArea);
|
|
292
|
+
</nav>
|
|
293
|
+
|
|
294
|
+
<button class="btn-logout" id="logoutBtn">退出登录</button>
|
|
295
|
+
</aside>
|
|
296
|
+
|
|
297
|
+
<main class="main-content">
|
|
298
|
+
<div id="contentArea"></div>
|
|
299
|
+
</main>
|
|
300
|
+
</div>
|
|
301
|
+
`;
|
|
302
|
+
|
|
303
|
+
// 导航切换
|
|
304
|
+
document.querySelectorAll('.nav-item').forEach(item => {
|
|
305
|
+
item.addEventListener('click', () => {
|
|
306
|
+
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
|
307
|
+
item.classList.add('active');
|
|
308
|
+
const view = item.dataset.view;
|
|
309
|
+
renderView(view);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// 退出登录
|
|
314
|
+
document.getElementById('logoutBtn').addEventListener('click', () => {
|
|
315
|
+
authService.logout();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
async function renderView(view) {
|
|
319
|
+
const contentArea = document.getElementById('contentArea');
|
|
320
|
+
|
|
321
|
+
switch(view) {
|
|
322
|
+
case 'groups':
|
|
323
|
+
await renderGroupsView(contentArea);
|
|
324
|
+
break;
|
|
325
|
+
case 'allgroups':
|
|
326
|
+
await renderAllGroupsView(contentArea);
|
|
327
|
+
break;
|
|
328
|
+
case 'tasks':
|
|
329
|
+
await renderTasksView(contentArea);
|
|
330
|
+
break;
|
|
331
|
+
case 'documents':
|
|
332
|
+
await renderDocumentsView(contentArea);
|
|
333
|
+
break;
|
|
334
|
+
case 'files':
|
|
335
|
+
await renderFilesView(contentArea);
|
|
336
|
+
break;
|
|
337
|
+
case 'chat':
|
|
338
|
+
await renderChatView(contentArea);
|
|
339
|
+
break;
|
|
340
|
+
case 'search':
|
|
341
|
+
await renderSearchView(contentArea);
|
|
108
342
|
break;
|
|
109
343
|
case 'knowledge':
|
|
110
344
|
await renderKnowledgeView(contentArea);
|
|
111
345
|
break;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
<div class="
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
alert('
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<div class="
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
alert('
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
tasksList.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
<span class="user-badge">👤 ${user.username}</span>
|
|
346
|
-
</div>
|
|
347
|
-
${doc.permission === 'editable' ? '<button class="btn-primary" id="saveBtn">保存</button>' : ''}
|
|
348
|
-
</div>
|
|
349
|
-
<div id="editor" ${doc.permission === 'readonly' ? 'class="readonly"' : ''}></div>
|
|
350
|
-
<div class="editor-footer">
|
|
351
|
-
<span>最后编辑: ${new Date(doc.updatedAt).toLocaleString()}</span>
|
|
352
|
-
</div>
|
|
353
|
-
</div>
|
|
354
|
-
`;
|
|
355
|
-
|
|
356
|
-
// 初始化 Quill 编辑器
|
|
357
|
-
const quill = new Quill('#editor', {
|
|
358
|
-
theme: 'snow',
|
|
359
|
-
modules: {
|
|
360
|
-
toolbar: doc.permission === 'readonly' ? false : [
|
|
361
|
-
[{ 'header': [1, 2, 3, false] }],
|
|
362
|
-
['bold', 'italic', 'underline', 'strike'],
|
|
363
|
-
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
364
|
-
[{ 'color': [] }, { 'background': [] }],
|
|
365
|
-
['link', 'image', 'code-block'],
|
|
366
|
-
['clean']
|
|
367
|
-
]
|
|
368
|
-
},
|
|
369
|
-
readOnly: doc.permission === 'readonly'
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// 设置初始内容
|
|
373
|
-
quill.root.innerHTML = doc.content || '';
|
|
374
|
-
|
|
375
|
-
// 实时同步
|
|
376
|
-
if (doc.permission === 'editable') {
|
|
377
|
-
let typingTimeout;
|
|
378
|
-
let saveTimeout;
|
|
379
|
-
|
|
380
|
-
quill.on('text-change', () => {
|
|
381
|
-
clearTimeout(typingTimeout);
|
|
382
|
-
clearTimeout(saveTimeout);
|
|
383
|
-
wsService.sendTyping(documentId, user.username, true);
|
|
384
|
-
|
|
385
|
-
typingTimeout = setTimeout(() => {
|
|
386
|
-
wsService.sendTyping(documentId, user.username, false);
|
|
387
|
-
}, 1000);
|
|
388
|
-
|
|
389
|
-
// 自动保存
|
|
390
|
-
saveTimeout = setTimeout(async () => {
|
|
391
|
-
const content = quill.root.innerHTML;
|
|
392
|
-
try {
|
|
393
|
-
await apiService.updateDocument(documentId, content);
|
|
394
|
-
} catch (error) {
|
|
395
|
-
console.error('自动保存失败:', error);
|
|
396
|
-
}
|
|
397
|
-
}, 2000);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
document.getElementById('saveBtn').addEventListener('click', async () => {
|
|
401
|
-
try {
|
|
402
|
-
const content = quill.root.innerHTML;
|
|
403
|
-
await apiService.updateDocument(documentId, content);
|
|
404
|
-
alert('保存成功!');
|
|
405
|
-
} catch (error) {
|
|
406
|
-
alert('保存失败: ' + error.message);
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// 监听文档更新
|
|
412
|
-
wsService.on('document_update', (data) => {
|
|
413
|
-
if (data.documentId === documentId && data.userId !== user.id) {
|
|
414
|
-
const selection = quill.getSelection();
|
|
415
|
-
quill.root.innerHTML = data.content;
|
|
416
|
-
if (selection) {
|
|
417
|
-
quill.setSelection(selection);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
// 监听打字状态
|
|
423
|
-
wsService.on('typing', (data) => {
|
|
424
|
-
if (data.documentId === documentId && data.userId !== user.id) {
|
|
425
|
-
const onlineUsers = document.getElementById('onlineUsers');
|
|
426
|
-
if (data.isTyping) {
|
|
427
|
-
onlineUsers.innerHTML += `<span class="user-badge typing" data-user="${data.userId}">✏️ ${data.username}</span>`;
|
|
428
|
-
} else {
|
|
429
|
-
const badge = onlineUsers.querySelector(`[data-user="${data.userId}"]`);
|
|
430
|
-
if (badge) badge.remove();
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
document.getElementById('backBtn').addEventListener('click', () => {
|
|
436
|
-
renderDocumentsView(container);
|
|
437
|
-
});
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
async function renderFilesView(container) {
|
|
441
|
-
if (!currentGroup) {
|
|
442
|
-
container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
|
|
443
|
-
return;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
try {
|
|
447
|
-
const result = await apiService.getGroupFiles(currentGroup._id);
|
|
448
|
-
|
|
449
|
-
container.innerHTML = `
|
|
450
|
-
<div class="view-header">
|
|
451
|
-
<h2>文件共享 - ${currentGroup.name}</h2>
|
|
452
|
-
<button class="btn-primary" id="uploadFileBtn">📤 上传文件</button>
|
|
453
|
-
</div>
|
|
454
|
-
<div class="files-list" id="filesList"></div>
|
|
455
|
-
|
|
456
|
-
<!-- 文件上传模态框 -->
|
|
457
|
-
<div class="modal hidden" id="uploadFileModal">
|
|
458
|
-
<div class="modal-content">
|
|
459
|
-
<div class="modal-header">
|
|
460
|
-
<h3>上传文件</h3>
|
|
461
|
-
<button class="modal-close" id="closeUploadModal">×</button>
|
|
462
|
-
</div>
|
|
463
|
-
<form id="uploadFileForm">
|
|
464
|
-
<div class="form-group">
|
|
465
|
-
<label>选择文件</label>
|
|
466
|
-
<input type="file" id="fileInput" required>
|
|
467
|
-
<small>支持图片、PDF、Word、Excel等,最大10MB</small>
|
|
468
|
-
</div>
|
|
469
|
-
<div class="form-group">
|
|
470
|
-
<label>描述(可选)</label>
|
|
471
|
-
<textarea id="fileDescription" rows="3" placeholder="文件描述..."></textarea>
|
|
472
|
-
</div>
|
|
473
|
-
<div class="form-actions">
|
|
474
|
-
<button type="button" class="btn-secondary" id="cancelUpload">取消</button>
|
|
475
|
-
<button type="submit" class="btn-primary">上传</button>
|
|
476
|
-
</div>
|
|
477
|
-
</form>
|
|
478
|
-
</div>
|
|
479
|
-
</div>
|
|
480
|
-
`;
|
|
481
|
-
|
|
482
|
-
const filesList = document.getElementById('filesList');
|
|
483
|
-
|
|
484
|
-
if (!result.files || result.files.length === 0) {
|
|
485
|
-
filesList.innerHTML = '<div class="empty-state">暂无文件</div>';
|
|
486
|
-
} else {
|
|
487
|
-
result.files.forEach(file => {
|
|
488
|
-
const fileCard = document.createElement('div');
|
|
489
|
-
fileCard.className = 'file-card';
|
|
490
|
-
|
|
491
|
-
const fileIcon = getFileIcon(file.mimetype);
|
|
492
|
-
const fileSize = formatFileSize(file.size);
|
|
493
|
-
|
|
494
|
-
fileCard.innerHTML = `
|
|
495
|
-
<div class="file-icon">${fileIcon}</div>
|
|
496
|
-
<div class="file-info">
|
|
497
|
-
<h4>${file.originalName}</h4>
|
|
498
|
-
<div class="file-meta">
|
|
499
|
-
<span>上传者: ${file.uploader.username}</span>
|
|
500
|
-
<span>大小: ${fileSize}</span>
|
|
501
|
-
<span>时间: ${new Date(file.createdAt).toLocaleString()}</span>
|
|
502
|
-
</div>
|
|
503
|
-
${file.description ? `<p class="file-description">${file.description}</p>` : ''}
|
|
504
|
-
</div>
|
|
505
|
-
<div class="file-actions">
|
|
506
|
-
<a href="${apiService.getFileDownloadUrl(file._id)}" class="btn-primary" download>下载</a>
|
|
507
|
-
${file.uploader._id === currentUserId ? `<button class="btn-danger" data-id="${file._id}" data-action="delete-file">删除</button>` : ''}
|
|
508
|
-
</div>
|
|
509
|
-
`;
|
|
510
|
-
filesList.appendChild(fileCard);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
// 删除文件事件
|
|
514
|
-
document.querySelectorAll('[data-action="delete-file"]').forEach(btn => {
|
|
515
|
-
btn.addEventListener('click', async () => {
|
|
516
|
-
if (confirm('确定要删除这个文件吗?')) {
|
|
517
|
-
try {
|
|
518
|
-
await apiService.deleteFile(btn.dataset.id);
|
|
519
|
-
alert('文件删除成功!');
|
|
520
|
-
await renderFilesView(container);
|
|
521
|
-
} catch (error) {
|
|
522
|
-
alert('删除失败: ' + error.message);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// 文件上传功能
|
|
530
|
-
document.getElementById('uploadFileBtn').addEventListener('click', () => {
|
|
531
|
-
document.getElementById('uploadFileModal').classList.remove('hidden');
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
document.getElementById('closeUploadModal').addEventListener('click', () => {
|
|
535
|
-
document.getElementById('uploadFileModal').classList.add('hidden');
|
|
536
|
-
document.getElementById('uploadFileForm').reset();
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
document.getElementById('cancelUpload').addEventListener('click', () => {
|
|
540
|
-
document.getElementById('uploadFileModal').classList.add('hidden');
|
|
541
|
-
document.getElementById('uploadFileForm').reset();
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
document.getElementById('uploadFileForm').addEventListener('submit', async (e) => {
|
|
545
|
-
e.preventDefault();
|
|
546
|
-
const fileInput = document.getElementById('fileInput');
|
|
547
|
-
const description = document.getElementById('fileDescription').value;
|
|
548
|
-
|
|
549
|
-
if (!fileInput.files[0]) {
|
|
550
|
-
alert('请选择文件');
|
|
551
|
-
return;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
try {
|
|
555
|
-
await apiService.uploadFile(currentGroup._id, fileInput.files[0], description);
|
|
556
|
-
alert('文件上传成功!');
|
|
557
|
-
document.getElementById('uploadFileModal').classList.add('hidden');
|
|
558
|
-
document.getElementById('uploadFileForm').reset();
|
|
559
|
-
await renderFilesView(container);
|
|
560
|
-
} catch (error) {
|
|
561
|
-
alert('上传失败: ' + error.message);
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
} catch (error) {
|
|
565
|
-
console.error('获取文件列表失败:', error);
|
|
566
|
-
container.innerHTML = `
|
|
567
|
-
<div class="view-header">
|
|
568
|
-
<h2>文件共享</h2>
|
|
569
|
-
</div>
|
|
570
|
-
<div class="empty-state">加载文件失败: ${error.message}</div>
|
|
571
|
-
`;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function getFileIcon(mimetype) {
|
|
576
|
-
if (mimetype.startsWith('image/')) return '🖼️';
|
|
577
|
-
if (mimetype === 'application/pdf') return '📕';
|
|
578
|
-
if (mimetype.includes('word') || mimetype.includes('document')) return '📘';
|
|
579
|
-
if (mimetype.includes('excel') || mimetype.includes('spreadsheet')) return '📗';
|
|
580
|
-
if (mimetype.includes('zip') || mimetype.includes('compressed')) return '📦';
|
|
581
|
-
return '📄';
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
function formatFileSize(bytes) {
|
|
585
|
-
if (bytes === 0) return '0 Bytes';
|
|
586
|
-
const k = 1024;
|
|
587
|
-
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
588
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
589
|
-
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
async function renderChatView(container) {
|
|
593
|
-
if (!currentGroup) {
|
|
594
|
-
container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
const groupResult = await apiService.getGroup(currentGroup._id);
|
|
599
|
-
const group = groupResult.group;
|
|
600
|
-
const isMutedAll = Boolean(group.mutedAll);
|
|
601
|
-
const isMutedMe = (group.mutedUsers || []).map(String).includes(String(currentUserId));
|
|
602
|
-
const canSpeak = !isMutedAll && !isMutedMe;
|
|
603
|
-
|
|
604
|
-
container.innerHTML = `
|
|
605
|
-
<div class="view-header">
|
|
606
|
-
<h2>群聊 - ${currentGroup.name}</h2>
|
|
607
|
-
</div>
|
|
608
|
-
<div class="chat-container">
|
|
609
|
-
<div class="messages" id="messages"></div>
|
|
610
|
-
<div class="chat-input">
|
|
611
|
-
<button class="btn-emoji" id="emojiBtn" ${canSpeak ? '' : 'disabled'}>😊</button>
|
|
612
|
-
<input type="text" id="messageInput" placeholder="${canSpeak ? '输入消息...' : (isMutedAll ? '全体禁言中,无法发言' : '你已被禁言')}" ${canSpeak ? '' : 'disabled'}>
|
|
613
|
-
<button class="btn-primary" id="sendBtn" ${canSpeak ? '' : 'disabled'}>发送</button>
|
|
614
|
-
</div>
|
|
615
|
-
<emoji-picker id="emojiPicker" class="hidden"></emoji-picker>
|
|
616
|
-
</div>
|
|
617
|
-
`;
|
|
618
|
-
|
|
619
|
-
const messagesDiv = document.getElementById('messages');
|
|
620
|
-
const messageInput = document.getElementById('messageInput');
|
|
621
|
-
const sendBtn = document.getElementById('sendBtn');
|
|
622
|
-
|
|
623
|
-
// 加载历史消息
|
|
624
|
-
try {
|
|
625
|
-
const messagesResult = await apiService.getGroupMessages(currentGroup._id);
|
|
626
|
-
if (messagesResult.messages) {
|
|
627
|
-
messagesResult.messages.forEach(msg => {
|
|
628
|
-
const messageEl = document.createElement('div');
|
|
629
|
-
messageEl.className = `message ${msg.sender === currentUserId ? 'own' : ''}`;
|
|
630
|
-
messageEl.innerHTML = `
|
|
631
|
-
<div class="message-header">
|
|
632
|
-
<span class="message-user">${msg.username}</span>
|
|
633
|
-
<span class="message-time">${new Date(msg.timestamp).toLocaleTimeString()}</span>
|
|
634
|
-
</div>
|
|
635
|
-
<div class="message-content">${msg.content}</div>
|
|
636
|
-
`;
|
|
637
|
-
messagesDiv.appendChild(messageEl);
|
|
638
|
-
});
|
|
639
|
-
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
640
|
-
}
|
|
641
|
-
} catch (err) {
|
|
642
|
-
console.error('加载历史消息失败:', err);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// 表情包功能
|
|
646
|
-
const emojiBtn = document.getElementById('emojiBtn');
|
|
647
|
-
const emojiPicker = document.getElementById('emojiPicker');
|
|
648
|
-
|
|
649
|
-
emojiBtn.addEventListener('click', () => {
|
|
650
|
-
emojiPicker.classList.toggle('hidden');
|
|
651
|
-
});
|
|
652
|
-
|
|
653
|
-
emojiPicker.addEventListener('emoji-click', (event) => {
|
|
654
|
-
messageInput.value += event.detail.unicode;
|
|
655
|
-
messageInput.focus();
|
|
656
|
-
emojiPicker.classList.add('hidden');
|
|
657
|
-
});
|
|
658
|
-
|
|
659
|
-
// 点击外部关闭表情选择器
|
|
660
|
-
document.addEventListener('click', (e) => {
|
|
661
|
-
if (!emojiBtn.contains(e.target) && !emojiPicker.contains(e.target)) {
|
|
662
|
-
emojiPicker.classList.add('hidden');
|
|
663
|
-
}
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
// 消息通知系统
|
|
667
|
-
function showNotification(title, body, icon = '💬') {
|
|
668
|
-
if ('Notification' in window && Notification.permission === 'granted') {
|
|
669
|
-
new Notification(title, {
|
|
670
|
-
body: body,
|
|
671
|
-
icon: '/icon.png',
|
|
672
|
-
badge: '/icon.png',
|
|
673
|
-
tag: 'chat-message'
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// 请求通知权限
|
|
679
|
-
if ('Notification' in window && Notification.permission === 'default') {
|
|
680
|
-
Notification.requestPermission();
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// 监听消息
|
|
684
|
-
wsService.on('chat_message', (data) => {
|
|
685
|
-
if (data.groupId === currentGroup._id) {
|
|
686
|
-
const messageEl = document.createElement('div');
|
|
687
|
-
messageEl.className = `message ${data.userId === currentUserId ? 'own' : ''}`;
|
|
688
|
-
messageEl.innerHTML = `
|
|
689
|
-
<div class="message-header">
|
|
690
|
-
<span class="message-user">${data.username}</span>
|
|
691
|
-
<span class="message-time">${new Date(data.timestamp).toLocaleTimeString()}</span>
|
|
692
|
-
</div>
|
|
693
|
-
<div class="message-content">${data.content}</div>
|
|
694
|
-
`;
|
|
695
|
-
messagesDiv.appendChild(messageEl);
|
|
696
|
-
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
697
|
-
|
|
698
|
-
// 显示通知(如果不是自己发送的消息)
|
|
699
|
-
if (data.userId !== currentUserId) {
|
|
700
|
-
showNotification(`${data.username} 在 ${currentGroup.name}`, data.content);
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
// 服务端拦截提示(比如被禁言/未加入群组)
|
|
706
|
-
wsService.on('chat_blocked', (data) => {
|
|
707
|
-
if (data.groupId === currentGroup._id) {
|
|
708
|
-
const notificationEl = document.createElement('div');
|
|
709
|
-
notificationEl.className = 'notification';
|
|
710
|
-
notificationEl.textContent = data.message || '消息发送失败';
|
|
711
|
-
messagesDiv.appendChild(notificationEl);
|
|
712
|
-
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
713
|
-
}
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
// 监听点名通知
|
|
717
|
-
wsService.on('call_response', (data) => {
|
|
718
|
-
if (data.groupId === currentGroup._id) {
|
|
719
|
-
const notificationEl = document.createElement('div');
|
|
720
|
-
notificationEl.className = 'notification';
|
|
721
|
-
notificationEl.textContent = `${data.username} 已响应点名`;
|
|
722
|
-
messagesDiv.appendChild(notificationEl);
|
|
723
|
-
}
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
// 发送消息
|
|
727
|
-
const sendMessage = () => {
|
|
728
|
-
const content = messageInput.value.trim();
|
|
729
|
-
if (content) {
|
|
730
|
-
wsService.sendChatMessage(currentGroup._id, user.username, content);
|
|
731
|
-
messageInput.value = '';
|
|
732
|
-
}
|
|
733
|
-
};
|
|
734
|
-
|
|
735
|
-
sendBtn.addEventListener('click', sendMessage);
|
|
736
|
-
messageInput.addEventListener('keypress', (e) => {
|
|
737
|
-
if (e.key === 'Enter') sendMessage();
|
|
738
|
-
});
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
async function renderSearchView(container) {
|
|
742
|
-
container.innerHTML = `
|
|
743
|
-
<div class="view-header">
|
|
744
|
-
<h2>🔍 搜索</h2>
|
|
745
|
-
</div>
|
|
746
|
-
<div class="search-container">
|
|
747
|
-
<div class="search-box">
|
|
748
|
-
<input type="text" id="searchInput" placeholder="搜索消息、文档、任务...">
|
|
749
|
-
<button class="btn-primary" id="searchBtn">搜索</button>
|
|
750
|
-
</div>
|
|
751
|
-
<div class="search-filters">
|
|
752
|
-
<label>
|
|
753
|
-
<input type="checkbox" id="filterMessages" checked> 消息
|
|
754
|
-
</label>
|
|
755
|
-
<label>
|
|
756
|
-
<input type="checkbox" id="filterDocuments" checked> 文档
|
|
757
|
-
</label>
|
|
758
|
-
<label>
|
|
759
|
-
<input type="checkbox" id="filterTasks" checked> 任务
|
|
760
|
-
</label>
|
|
761
|
-
</div>
|
|
762
|
-
<div class="search-results" id="searchResults"></div>
|
|
763
|
-
</div>
|
|
764
|
-
`;
|
|
765
|
-
|
|
766
|
-
const searchInput = document.getElementById('searchInput');
|
|
767
|
-
const searchBtn = document.getElementById('searchBtn');
|
|
768
|
-
const searchResults = document.getElementById('searchResults');
|
|
769
|
-
|
|
770
|
-
const performSearch = async () => {
|
|
771
|
-
const query = searchInput.value.trim();
|
|
772
|
-
if (!query) {
|
|
773
|
-
searchResults.innerHTML = '<div class="empty-state">请输入搜索关键词</div>';
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const filters = {
|
|
778
|
-
messages: document.getElementById('filterMessages').checked,
|
|
779
|
-
documents: document.getElementById('filterDocuments').checked,
|
|
780
|
-
tasks: document.getElementById('filterTasks').checked
|
|
781
|
-
};
|
|
782
|
-
|
|
783
|
-
searchResults.innerHTML = '<div class="loading">搜索中...</div>';
|
|
784
|
-
|
|
785
|
-
try {
|
|
786
|
-
const results = [];
|
|
787
|
-
|
|
788
|
-
// 搜索消息
|
|
789
|
-
if (filters.messages && currentGroup) {
|
|
790
|
-
try {
|
|
791
|
-
const messagesResult = await apiService.getGroupMessages(currentGroup._id);
|
|
792
|
-
if (messagesResult.messages) {
|
|
793
|
-
const matchedMessages = messagesResult.messages.filter(msg =>
|
|
794
|
-
msg.content.toLowerCase().includes(query.toLowerCase())
|
|
795
|
-
);
|
|
796
|
-
matchedMessages.forEach(msg => {
|
|
797
|
-
results.push({
|
|
798
|
-
type: 'message',
|
|
799
|
-
title: `消息 - ${msg.username}`,
|
|
800
|
-
content: msg.content,
|
|
801
|
-
time: msg.timestamp,
|
|
802
|
-
group: currentGroup.name
|
|
803
|
-
});
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
} catch (err) {
|
|
807
|
-
console.error('搜索消息失败:', err);
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
// 搜索文档
|
|
812
|
-
if (filters.documents) {
|
|
813
|
-
try {
|
|
814
|
-
if (currentGroup) {
|
|
815
|
-
const docsResult = await apiService.getDocuments(currentGroup._id);
|
|
816
|
-
if (docsResult.documents) {
|
|
817
|
-
const matchedDocs = docsResult.documents.filter(doc =>
|
|
818
|
-
doc.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
819
|
-
doc.content.toLowerCase().includes(query.toLowerCase())
|
|
820
|
-
);
|
|
821
|
-
matchedDocs.forEach(doc => {
|
|
822
|
-
results.push({
|
|
823
|
-
type: 'document',
|
|
824
|
-
title: doc.title,
|
|
825
|
-
content: doc.content.substring(0, 200),
|
|
826
|
-
time: doc.updatedAt,
|
|
827
|
-
id: doc._id,
|
|
828
|
-
group: currentGroup.name
|
|
829
|
-
});
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
} catch (err) {
|
|
834
|
-
console.error('搜索文档失败:', err);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// 搜索任务
|
|
839
|
-
if (filters.tasks) {
|
|
840
|
-
try {
|
|
841
|
-
const tasksResult = await apiService.getMyTasks();
|
|
842
|
-
if (tasksResult.tasks) {
|
|
843
|
-
const matchedTasks = tasksResult.tasks.filter(task =>
|
|
844
|
-
task.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
845
|
-
(task.description && task.description.toLowerCase().includes(query.toLowerCase()))
|
|
846
|
-
);
|
|
847
|
-
matchedTasks.forEach(task => {
|
|
848
|
-
results.push({
|
|
849
|
-
type: 'task',
|
|
850
|
-
title: task.title,
|
|
851
|
-
content: task.description || '',
|
|
852
|
-
time: task.updatedAt,
|
|
853
|
-
id: task._id,
|
|
854
|
-
status: task.status
|
|
855
|
-
});
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
} catch (err) {
|
|
859
|
-
console.error('搜索任务失败:', err);
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// 显示结果
|
|
864
|
-
if (results.length === 0) {
|
|
865
|
-
searchResults.innerHTML = '<div class="empty-state">未找到相关结果</div>';
|
|
866
|
-
} else {
|
|
867
|
-
searchResults.innerHTML = results.map(result => {
|
|
868
|
-
const typeIcon = {
|
|
869
|
-
message: '💬',
|
|
870
|
-
document: '📄',
|
|
871
|
-
task: '📋'
|
|
872
|
-
};
|
|
873
|
-
return `
|
|
874
|
-
<div class="search-result-item">
|
|
875
|
-
<div class="result-header">
|
|
876
|
-
<span class="result-type">${typeIcon[result.type]} ${result.type === 'message' ? '消息' : result.type === 'document' ? '文档' : '任务'}</span>
|
|
877
|
-
<span class="result-time">${new Date(result.time).toLocaleString()}</span>
|
|
878
|
-
</div>
|
|
879
|
-
<h4>${highlightText(result.title, query)}</h4>
|
|
880
|
-
<p>${highlightText(result.content, query)}</p>
|
|
881
|
-
${result.group ? `<span class="result-group">群组: ${result.group}</span>` : ''}
|
|
882
|
-
${result.status ? `<span class="result-status">状态: ${getStatusText(result.status)}</span>` : ''}
|
|
883
|
-
</div>
|
|
884
|
-
`;
|
|
885
|
-
}).join('');
|
|
886
|
-
}
|
|
887
|
-
} catch (error) {
|
|
888
|
-
searchResults.innerHTML = `<div class="empty-state">搜索失败: ${error.message}</div>`;
|
|
889
|
-
}
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
searchBtn.addEventListener('click', performSearch);
|
|
893
|
-
searchInput.addEventListener('keypress', (e) => {
|
|
894
|
-
if (e.key === 'Enter') performSearch();
|
|
895
|
-
});
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function highlightText(text, query) {
|
|
899
|
-
if (!query) return text;
|
|
900
|
-
const regex = new RegExp(`(${query})`, 'gi');
|
|
901
|
-
return text.replace(regex, '<mark>$1</mark>');
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
function getStatusText(status) {
|
|
905
|
-
const statusMap = {
|
|
906
|
-
'pending': '待处理',
|
|
907
|
-
'in_progress': '进行中',
|
|
908
|
-
'completed': '已完成',
|
|
909
|
-
'terminated': '已终止'
|
|
910
|
-
};
|
|
911
|
-
return statusMap[status] || status;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
// 知识库(用户版)
|
|
916
|
-
async function renderKnowledgeView(container) {
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function renderGroupsView(container) {
|
|
350
|
+
const result = await apiService.getGroups();
|
|
351
|
+
groups = result.groups;
|
|
352
|
+
|
|
353
|
+
container.innerHTML = `
|
|
354
|
+
<div class="view-header">
|
|
355
|
+
<h2>我的群组</h2>
|
|
356
|
+
</div>
|
|
357
|
+
<div class="groups-grid" id="groupsList"></div>
|
|
358
|
+
`;
|
|
359
|
+
|
|
360
|
+
const groupsList = document.getElementById('groupsList');
|
|
361
|
+
if (groups.length === 0) {
|
|
362
|
+
groupsList.innerHTML = '<div class="empty-state">您还没有加入任何群组<br>请前往"所有群组"查看并加入</div>';
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
groups.forEach(group => {
|
|
367
|
+
const groupCard = document.createElement('div');
|
|
368
|
+
groupCard.className = 'group-card';
|
|
369
|
+
groupCard.innerHTML = `
|
|
370
|
+
<h3>${group.name}</h3>
|
|
371
|
+
<p>${group.description || '暂无描述'}</p>
|
|
372
|
+
<div class="group-stats">
|
|
373
|
+
<span>👥 ${group.members.length} 成员</span>
|
|
374
|
+
<span>📄 ${group.documents.length} 文档</span>
|
|
375
|
+
<span>📋 ${group.tasks.length} 任务</span>
|
|
376
|
+
</div>
|
|
377
|
+
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
|
378
|
+
<button class="btn-select" data-id="${group._id}">进入群组</button>
|
|
379
|
+
<button class="btn-secondary" data-id="${group._id}" data-action="leave">退出群组</button>
|
|
380
|
+
</div>
|
|
381
|
+
`;
|
|
382
|
+
groupsList.appendChild(groupCard);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
document.querySelectorAll('.btn-select').forEach(btn => {
|
|
386
|
+
btn.addEventListener('click', () => {
|
|
387
|
+
currentGroup = groups.find(g => g._id === btn.dataset.id);
|
|
388
|
+
wsService.joinGroup(currentGroup._id);
|
|
389
|
+
alert(`已进入群组: ${currentGroup.name}`);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
document.querySelectorAll('[data-action="leave"]').forEach(btn => {
|
|
394
|
+
btn.addEventListener('click', async () => {
|
|
395
|
+
if (confirm('确定要退出该群组吗?')) {
|
|
396
|
+
try {
|
|
397
|
+
await apiService.leaveGroup(btn.dataset.id);
|
|
398
|
+
alert('已退出群组');
|
|
399
|
+
await renderGroupsView(container);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
alert('退出失败: ' + error.message);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function renderAllGroupsView(container) {
|
|
409
|
+
const allGroupsResult = await apiService.getAllGroups();
|
|
410
|
+
const myGroupsResult = await apiService.getGroups();
|
|
411
|
+
const myGroupIds = myGroupsResult.groups.map(g => g._id);
|
|
412
|
+
|
|
413
|
+
container.innerHTML = `
|
|
414
|
+
<div class="view-header">
|
|
415
|
+
<h2>所有群组</h2>
|
|
416
|
+
</div>
|
|
417
|
+
<div class="groups-grid" id="allGroupsList"></div>
|
|
418
|
+
`;
|
|
419
|
+
|
|
420
|
+
const allGroupsList = document.getElementById('allGroupsList');
|
|
421
|
+
allGroupsResult.groups.forEach(group => {
|
|
422
|
+
const isJoined = myGroupIds.includes(group._id);
|
|
423
|
+
const groupCard = document.createElement('div');
|
|
424
|
+
groupCard.className = 'group-card';
|
|
425
|
+
groupCard.innerHTML = `
|
|
426
|
+
<h3>${group.name}</h3>
|
|
427
|
+
<p>${group.description || '暂无描述'}</p>
|
|
428
|
+
<div class="group-stats">
|
|
429
|
+
<span>👥 ${group.members.length} 成员</span>
|
|
430
|
+
<span>📄 ${group.documents.length} 文档</span>
|
|
431
|
+
</div>
|
|
432
|
+
${isJoined ?
|
|
433
|
+
'<div style="color: var(--success); margin-top: 10px;">✓ 已加入</div>' :
|
|
434
|
+
`<button class="btn-primary" data-id="${group._id}" data-action="join">加入群组</button>`
|
|
435
|
+
}
|
|
436
|
+
`;
|
|
437
|
+
allGroupsList.appendChild(groupCard);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
document.querySelectorAll('[data-action="join"]').forEach(btn => {
|
|
441
|
+
btn.addEventListener('click', async () => {
|
|
442
|
+
try {
|
|
443
|
+
await apiService.joinGroup(btn.dataset.id);
|
|
444
|
+
alert('加入成功!');
|
|
445
|
+
await renderAllGroupsView(container);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
alert('加入失败: ' + error.message);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function renderTasksView(container) {
|
|
454
|
+
try {
|
|
455
|
+
const result = await apiService.getMyTasks();
|
|
456
|
+
|
|
457
|
+
// 获取用户所在群组的投票
|
|
458
|
+
let polls = [];
|
|
459
|
+
try {
|
|
460
|
+
const groupsResult = await apiService.getGroups();
|
|
461
|
+
const myGroups = groupsResult.groups;
|
|
462
|
+
|
|
463
|
+
// 获取所有群组的投票
|
|
464
|
+
for (const group of myGroups) {
|
|
465
|
+
try {
|
|
466
|
+
const pollsResult = await apiService.getGroupPolls(group._id);
|
|
467
|
+
if (pollsResult.polls && Array.isArray(pollsResult.polls)) {
|
|
468
|
+
polls = polls.concat(pollsResult.polls.map(poll => ({
|
|
469
|
+
...poll,
|
|
470
|
+
groupName: group.name
|
|
471
|
+
})));
|
|
472
|
+
}
|
|
473
|
+
} catch (err) {
|
|
474
|
+
console.error(`获取群组 ${group.name} 的投票失败:`, err);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error('获取投票失败:', err);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
container.innerHTML = `
|
|
482
|
+
<div class="view-header">
|
|
483
|
+
<h2>我的任务</h2>
|
|
484
|
+
</div>
|
|
485
|
+
<div class="tasks-list" id="tasksList"></div>
|
|
486
|
+
`;
|
|
487
|
+
|
|
488
|
+
const tasksList = document.getElementById('tasksList');
|
|
489
|
+
|
|
490
|
+
if (result.tasks.length === 0 && polls.length === 0) {
|
|
491
|
+
tasksList.innerHTML = '<div class="empty-state">暂无任务</div>';
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// 渲染普通任务
|
|
496
|
+
result.tasks.forEach(task => {
|
|
497
|
+
const taskCard = document.createElement('div');
|
|
498
|
+
taskCard.className = `task-card status-${task.status}`;
|
|
499
|
+
taskCard.innerHTML = `
|
|
500
|
+
<h3>${task.title}</h3>
|
|
501
|
+
<p>${task.description}</p>
|
|
502
|
+
<div class="task-meta">
|
|
503
|
+
<span class="status-badge">${getStatusText(task.status)}</span>
|
|
504
|
+
<span>群组: ${task.group.name}</span>
|
|
505
|
+
${task.deadline ? `<span>截止: ${new Date(task.deadline).toLocaleDateString()}</span>` : ''}
|
|
506
|
+
</div>
|
|
507
|
+
${task.relatedDocument ? `<a href="#" class="doc-link" data-id="${task.relatedDocument._id}">📄 查看相关文档</a>` : ''}
|
|
508
|
+
<div class="task-actions">
|
|
509
|
+
${task.status === 'pending' ? `<button class="btn-primary btn-sm" data-id="${task._id}" data-action="start">开始任务</button>` : ''}
|
|
510
|
+
${task.status === 'in_progress' ? `<button class="btn-success btn-sm" data-id="${task._id}" data-action="complete">完成任务</button>` : ''}
|
|
511
|
+
</div>
|
|
512
|
+
`;
|
|
513
|
+
tasksList.appendChild(taskCard);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// 渲染投票任务
|
|
517
|
+
polls.forEach(poll => {
|
|
518
|
+
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes.length, 0);
|
|
519
|
+
const hasVoted = poll.options.some(opt => opt.votes.includes(currentUserId));
|
|
520
|
+
const isEnded = poll.status === 'ended' || (poll.endTime && new Date(poll.endTime) < new Date());
|
|
521
|
+
|
|
522
|
+
const pollCard = document.createElement('div');
|
|
523
|
+
pollCard.className = 'task-card poll-task';
|
|
524
|
+
pollCard.style.cssText = 'background: linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%); border-left: 4px solid var(--primary);';
|
|
525
|
+
pollCard.innerHTML = `
|
|
526
|
+
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
|
|
527
|
+
<span style="font-size: 32px;">📊</span>
|
|
528
|
+
<h3 style="margin: 0;">${poll.title}</h3>
|
|
529
|
+
</div>
|
|
530
|
+
<p>${poll.description || '暂无描述'}</p>
|
|
531
|
+
<div class="task-meta">
|
|
532
|
+
<span class="status-badge" style="background: ${isEnded ? 'var(--danger)' : 'var(--success)'};">${isEnded ? '已结束' : '进行中'}</span>
|
|
533
|
+
<span>群组: ${poll.groupName}</span>
|
|
534
|
+
<span>👥 ${totalVotes} 人投票</span>
|
|
535
|
+
${hasVoted ? '<span style="color: var(--success);">✓ 已投票</span>' : '<span style="color: var(--warning);">⏳ 待投票</span>'}
|
|
536
|
+
</div>
|
|
537
|
+
<div class="task-actions">
|
|
538
|
+
<button class="btn-primary btn-sm" data-poll-id="${poll._id}" data-action="view-poll">查看详情</button>
|
|
539
|
+
</div>
|
|
540
|
+
`;
|
|
541
|
+
tasksList.appendChild(pollCard);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// 任务操作
|
|
545
|
+
document.querySelectorAll('[data-action="start"], [data-action="complete"]').forEach(btn => {
|
|
546
|
+
btn.addEventListener('click', async () => {
|
|
547
|
+
const taskId = btn.dataset.id;
|
|
548
|
+
const action = btn.dataset.action;
|
|
549
|
+
const status = action === 'start' ? 'in_progress' : 'completed';
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
await apiService.updateTaskStatus(taskId, status);
|
|
553
|
+
await renderTasksView(container);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
alert('操作失败: ' + error.message);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// 投票查看操作
|
|
561
|
+
document.querySelectorAll('[data-action="view-poll"]').forEach(btn => {
|
|
562
|
+
btn.addEventListener('click', () => {
|
|
563
|
+
const pollId = btn.dataset.pollId;
|
|
564
|
+
window.viewPollDetail(pollId);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {
|
|
568
|
+
console.error('获取任务失败:', error);
|
|
569
|
+
container.innerHTML = `
|
|
570
|
+
<div class="view-header">
|
|
571
|
+
<h2>我的任务</h2>
|
|
572
|
+
</div>
|
|
573
|
+
<div class="empty-state">加载任务失败: ${error.message}</div>
|
|
574
|
+
`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function renderDocumentsView(container) {
|
|
917
579
|
if (!currentGroup) {
|
|
918
580
|
container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
|
|
919
581
|
return;
|
|
920
582
|
}
|
|
921
583
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
584
|
+
const result = await apiService.getDocuments(currentGroup._id);
|
|
585
|
+
|
|
586
|
+
container.innerHTML = `
|
|
587
|
+
<div class="view-header">
|
|
588
|
+
<h2>共享文档 - ${currentGroup.name}</h2>
|
|
589
|
+
</div>
|
|
590
|
+
<div class="documents-list" id="docsList"></div>
|
|
591
|
+
`;
|
|
592
|
+
|
|
593
|
+
const docsList = document.getElementById('docsList');
|
|
594
|
+
|
|
595
|
+
if (result.documents.length === 0) {
|
|
596
|
+
docsList.innerHTML = '<div class="empty-state">暂无文档</div>';
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
result.documents.forEach(doc => {
|
|
601
|
+
const docCard = document.createElement('div');
|
|
602
|
+
docCard.className = 'document-card';
|
|
603
|
+
docCard.innerHTML = `
|
|
604
|
+
<h3>📄 ${doc.title}</h3>
|
|
605
|
+
<div class="doc-meta">
|
|
606
|
+
<span>创建者: ${doc.creator.username}</span>
|
|
607
|
+
<span>${doc.permission === 'readonly' ? '🔒 只读' : '✏️ 可编辑'}</span>
|
|
608
|
+
<span>更新: ${new Date(doc.updatedAt).toLocaleString()}</span>
|
|
609
|
+
</div>
|
|
610
|
+
<button class="btn-edit" data-id="${doc._id}">
|
|
611
|
+
${doc.permission === 'readonly' ? '查看' : '编辑'}
|
|
612
|
+
</button>
|
|
613
|
+
`;
|
|
614
|
+
docsList.appendChild(docCard);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
document.querySelectorAll('.btn-edit').forEach(btn => {
|
|
618
|
+
btn.addEventListener('click', () => {
|
|
619
|
+
renderDocumentEditor(container, btn.dataset.id);
|
|
926
620
|
});
|
|
927
|
-
|
|
928
|
-
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function renderDocumentEditor(container, documentId) {
|
|
625
|
+
const result = await apiService.getDocument(documentId);
|
|
626
|
+
const doc = result.document;
|
|
627
|
+
|
|
628
|
+
container.innerHTML = `
|
|
629
|
+
<div class="view-header">
|
|
630
|
+
<button class="btn-back" id="backBtn">← 返回</button>
|
|
631
|
+
<h2>${doc.title}</h2>
|
|
632
|
+
<span class="doc-status">${doc.permission === 'readonly' ? '🔒 只读模式' : '✏️ 编辑模式'}</span>
|
|
633
|
+
</div>
|
|
634
|
+
<div class="editor-container">
|
|
635
|
+
<div class="editor-toolbar">
|
|
636
|
+
<div class="online-users" id="onlineUsers">
|
|
637
|
+
<span class="user-badge">👤 ${user.username}</span>
|
|
638
|
+
</div>
|
|
639
|
+
${doc.permission === 'editable' ? '<button class="btn-primary" id="saveBtn">保存</button>' : ''}
|
|
640
|
+
</div>
|
|
641
|
+
<div id="editor" ${doc.permission === 'readonly' ? 'class="readonly"' : ''}></div>
|
|
642
|
+
<div class="editor-footer">
|
|
643
|
+
<span>最后编辑: ${new Date(doc.updatedAt).toLocaleString()}</span>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
`;
|
|
647
|
+
|
|
648
|
+
// 初始化 Quill 编辑器
|
|
649
|
+
const quill = new Quill('#editor', {
|
|
650
|
+
theme: 'snow',
|
|
651
|
+
modules: {
|
|
652
|
+
toolbar: doc.permission === 'readonly' ? false : [
|
|
653
|
+
[{ 'header': [1, 2, 3, false] }],
|
|
654
|
+
['bold', 'italic', 'underline', 'strike'],
|
|
655
|
+
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
|
656
|
+
[{ 'color': [] }, { 'background': [] }],
|
|
657
|
+
['link', 'image', 'code-block'],
|
|
658
|
+
['clean']
|
|
659
|
+
]
|
|
660
|
+
},
|
|
661
|
+
readOnly: doc.permission === 'readonly'
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// 设置初始内容
|
|
665
|
+
quill.root.innerHTML = doc.content || '';
|
|
666
|
+
|
|
667
|
+
// 实时同步
|
|
668
|
+
if (doc.permission === 'editable') {
|
|
669
|
+
let typingTimeout;
|
|
670
|
+
let saveTimeout;
|
|
671
|
+
|
|
672
|
+
quill.on('text-change', () => {
|
|
673
|
+
clearTimeout(typingTimeout);
|
|
674
|
+
clearTimeout(saveTimeout);
|
|
675
|
+
wsService.sendTyping(documentId, user.username, true);
|
|
676
|
+
|
|
677
|
+
typingTimeout = setTimeout(() => {
|
|
678
|
+
wsService.sendTyping(documentId, user.username, false);
|
|
679
|
+
}, 1000);
|
|
680
|
+
|
|
681
|
+
// 自动保存
|
|
682
|
+
saveTimeout = setTimeout(async () => {
|
|
683
|
+
const content = quill.root.innerHTML;
|
|
684
|
+
try {
|
|
685
|
+
await apiService.updateDocument(documentId, content);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
console.error('自动保存失败:', error);
|
|
688
|
+
}
|
|
689
|
+
}, 2000);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
document.getElementById('saveBtn').addEventListener('click', async () => {
|
|
693
|
+
try {
|
|
694
|
+
const content = quill.root.innerHTML;
|
|
695
|
+
await apiService.updateDocument(documentId, content);
|
|
696
|
+
alert('保存成功!');
|
|
697
|
+
} catch (error) {
|
|
698
|
+
alert('保存失败: ' + error.message);
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// 监听文档更新
|
|
704
|
+
wsService.on('document_update', (data) => {
|
|
705
|
+
if (data.documentId === documentId && data.userId !== user.id) {
|
|
706
|
+
const selection = quill.getSelection();
|
|
707
|
+
quill.root.innerHTML = data.content;
|
|
708
|
+
if (selection) {
|
|
709
|
+
quill.setSelection(selection);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
});
|
|
929
713
|
|
|
714
|
+
// 监听打字状态
|
|
715
|
+
wsService.on('typing', (data) => {
|
|
716
|
+
if (data.documentId === documentId && data.userId !== user.id) {
|
|
717
|
+
const onlineUsers = document.getElementById('onlineUsers');
|
|
718
|
+
if (data.isTyping) {
|
|
719
|
+
onlineUsers.innerHTML += `<span class="user-badge typing" data-user="${data.userId}">✏️ ${data.username}</span>`;
|
|
720
|
+
} else {
|
|
721
|
+
const badge = onlineUsers.querySelector(`[data-user="${data.userId}"]`);
|
|
722
|
+
if (badge) badge.remove();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
document.getElementById('backBtn').addEventListener('click', () => {
|
|
728
|
+
renderDocumentsView(container);
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function renderFilesView(container) {
|
|
733
|
+
if (!currentGroup) {
|
|
734
|
+
container.innerHTML = '<div class="empty-state">请先选择一个群组</div>';
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
const result = await apiService.getGroupFiles(currentGroup._id);
|
|
740
|
+
|
|
930
741
|
container.innerHTML = `
|
|
931
742
|
<div class="view-header">
|
|
932
|
-
<h2
|
|
933
|
-
<
|
|
743
|
+
<h2>文件共享 - ${currentGroup.name}</h2>
|
|
744
|
+
<button class="btn-primary" id="uploadFileBtn">📤 上传文件</button>
|
|
745
|
+
</div>
|
|
746
|
+
<div class="files-list" id="filesList"></div>
|
|
747
|
+
|
|
748
|
+
<!-- 文件上传模态框 -->
|
|
749
|
+
<div class="modal hidden" id="uploadFileModal">
|
|
750
|
+
<div class="modal-content">
|
|
751
|
+
<div class="modal-header">
|
|
752
|
+
<h3>上传文件</h3>
|
|
753
|
+
<button class="modal-close" id="closeUploadModal">×</button>
|
|
754
|
+
</div>
|
|
755
|
+
<form id="uploadFileForm">
|
|
756
|
+
<div class="form-group">
|
|
757
|
+
<label>选择文件</label>
|
|
758
|
+
<input type="file" id="fileInput" required>
|
|
759
|
+
<small>支持图片、PDF、Word、Excel等,最大10MB</small>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="form-group">
|
|
762
|
+
<label>描述(可选)</label>
|
|
763
|
+
<textarea id="fileDescription" rows="3" placeholder="文件描述..."></textarea>
|
|
764
|
+
</div>
|
|
765
|
+
<div class="form-actions">
|
|
766
|
+
<button type="button" class="btn-secondary" id="cancelUpload">取消</button>
|
|
767
|
+
<button type="submit" class="btn-primary">上传</button>
|
|
768
|
+
</div>
|
|
769
|
+
</form>
|
|
770
|
+
</div>
|
|
934
771
|
</div>
|
|
935
|
-
<div class="knowledge-list" id="knowledgeList"></div>
|
|
936
772
|
`;
|
|
937
773
|
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
774
|
+
const filesList = document.getElementById('filesList');
|
|
775
|
+
|
|
776
|
+
if (!result.files || result.files.length === 0) {
|
|
777
|
+
filesList.innerHTML = '<div class="empty-state">暂无文件</div>';
|
|
941
778
|
} else {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
779
|
+
result.files.forEach(file => {
|
|
780
|
+
const fileCard = document.createElement('div');
|
|
781
|
+
fileCard.className = 'file-card';
|
|
782
|
+
|
|
783
|
+
const fileIcon = getFileIcon(file.mimetype);
|
|
784
|
+
const fileSize = formatFileSize(file.size);
|
|
785
|
+
|
|
786
|
+
fileCard.innerHTML = `
|
|
787
|
+
<div class="file-icon">${fileIcon}</div>
|
|
788
|
+
<div class="file-info">
|
|
789
|
+
<h4>${file.originalName}</h4>
|
|
790
|
+
<div class="file-meta">
|
|
791
|
+
<span>上传者: ${file.uploader.username}</span>
|
|
792
|
+
<span>大小: ${fileSize}</span>
|
|
793
|
+
<span>时间: ${new Date(file.createdAt).toLocaleString()}</span>
|
|
950
794
|
</div>
|
|
951
|
-
${
|
|
952
|
-
<div class="tags">
|
|
953
|
-
${item.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
|
|
954
|
-
</div>
|
|
955
|
-
` : ''}
|
|
795
|
+
${file.description ? `<p class="file-description">${file.description}</p>` : ''}
|
|
956
796
|
</div>
|
|
957
|
-
|
|
958
|
-
|
|
797
|
+
<div class="file-actions">
|
|
798
|
+
<a href="${apiService.getFileDownloadUrl(file._id)}" class="btn-primary" download>下载</a>
|
|
799
|
+
${String(file.uploader._id) === String(currentUserId) ? `<button class="btn-danger" data-id="${file._id}" data-action="delete-file">删除</button>` : ''}
|
|
800
|
+
</div>
|
|
801
|
+
`;
|
|
802
|
+
filesList.appendChild(fileCard);
|
|
803
|
+
});
|
|
959
804
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
805
|
+
// 删除文件事件
|
|
806
|
+
document.querySelectorAll('[data-action="delete-file"]').forEach(btn => {
|
|
807
|
+
btn.addEventListener('click', async () => {
|
|
808
|
+
if (confirm('确定要删除这个文件吗?')) {
|
|
809
|
+
try {
|
|
810
|
+
await apiService.deleteFile(btn.dataset.id);
|
|
811
|
+
alert('文件删除成功!');
|
|
812
|
+
await renderFilesView(container);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
alert('删除失败: ' + error.message);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
});
|
|
971
818
|
});
|
|
972
819
|
}
|
|
820
|
+
|
|
821
|
+
// 文件上传功能
|
|
822
|
+
document.getElementById('uploadFileBtn').addEventListener('click', () => {
|
|
823
|
+
document.getElementById('uploadFileModal').classList.remove('hidden');
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
document.getElementById('closeUploadModal').addEventListener('click', () => {
|
|
827
|
+
document.getElementById('uploadFileModal').classList.add('hidden');
|
|
828
|
+
document.getElementById('uploadFileForm').reset();
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
document.getElementById('cancelUpload').addEventListener('click', () => {
|
|
832
|
+
document.getElementById('uploadFileModal').classList.add('hidden');
|
|
833
|
+
document.getElementById('uploadFileForm').reset();
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
document.getElementById('uploadFileForm').addEventListener('submit', async (e) => {
|
|
837
|
+
e.preventDefault();
|
|
838
|
+
const fileInput = document.getElementById('fileInput');
|
|
839
|
+
const description = document.getElementById('fileDescription').value;
|
|
840
|
+
|
|
841
|
+
if (!fileInput.files[0]) {
|
|
842
|
+
alert('请选择文件');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
try {
|
|
847
|
+
await apiService.uploadFile(currentGroup._id, fileInput.files[0], description);
|
|
848
|
+
alert('文件上传成功!');
|
|
849
|
+
document.getElementById('uploadFileModal').classList.add('hidden');
|
|
850
|
+
document.getElementById('uploadFileForm').reset();
|
|
851
|
+
await renderFilesView(container);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
alert('上传失败: ' + error.message);
|
|
854
|
+
}
|
|
855
|
+
});
|
|
973
856
|
} catch (error) {
|
|
974
|
-
|
|
857
|
+
console.error('获取文件列表失败:', error);
|
|
858
|
+
container.innerHTML = `
|
|
859
|
+
<div class="view-header">
|
|
860
|
+
<h2>文件共享</h2>
|
|
861
|
+
</div>
|
|
862
|
+
<div class="empty-state">加载文件失败: ${error.message}</div>
|
|
863
|
+
`;
|
|
975
864
|
}
|
|
976
865
|
}
|
|
977
866
|
|
|
978
|
-
|
|
979
|
-
|
|
867
|
+
function getFileIcon(mimetype) {
|
|
868
|
+
if (mimetype.startsWith('image/')) return '🖼️';
|
|
869
|
+
if (mimetype === 'application/pdf') return '📕';
|
|
870
|
+
if (mimetype.includes('word') || mimetype.includes('document')) return '📘';
|
|
871
|
+
if (mimetype.includes('excel') || mimetype.includes('spreadsheet')) return '📗';
|
|
872
|
+
if (mimetype.includes('zip') || mimetype.includes('compressed')) return '📦';
|
|
873
|
+
return '📄';
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function formatFileSize(bytes) {
|
|
877
|
+
if (bytes === 0) return '0 Bytes';
|
|
878
|
+
const k = 1024;
|
|
879
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
880
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
881
|
+
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async function renderChatView(container) {
|
|
885
|
+
if (!currentGroup) {
|
|
886
|
+
container.innerHTML = `
|
|
887
|
+
<div class="empty-state" style="text-align: center; padding: 60px 20px; background: var(--bg-secondary); border-radius: 16px; border: 2px dashed var(--border);">
|
|
888
|
+
<div style="font-size: 64px; margin-bottom: 20px;">💬</div>
|
|
889
|
+
<h3 style="font-size: 24px; margin-bottom: 12px; color: var(--text-primary);">群聊</h3>
|
|
890
|
+
<p style="color: var(--text-secondary); margin-bottom: 24px; font-size: 16px;">请先在"我的群组"中选择一个群组</p>
|
|
891
|
+
<button class="btn-primary" onclick="document.querySelector('[data-view=\\"groups\\"]').click()" style="padding: 12px 32px; font-size: 16px;">
|
|
892
|
+
前往我的群组
|
|
893
|
+
</button>
|
|
894
|
+
</div>
|
|
895
|
+
`;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
try {
|
|
900
|
+
const groupResult = await apiService.getGroup(currentGroup._id);
|
|
901
|
+
const group = groupResult.group;
|
|
902
|
+
const isMutedAll = Boolean(group.mutedAll);
|
|
903
|
+
const isMutedMe = (group.mutedUsers || []).map(String).includes(String(currentUserId));
|
|
904
|
+
const canSpeak = !isMutedAll && !isMutedMe;
|
|
905
|
+
|
|
980
906
|
container.innerHTML = `
|
|
981
|
-
|
|
982
|
-
|
|
907
|
+
<div class="view-header" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 12px; margin-bottom: 20px;">
|
|
908
|
+
<h2 style="margin: 0; display: flex; align-items: center; gap: 12px;">
|
|
909
|
+
<span style="font-size: 32px;">💬</span>
|
|
910
|
+
<span>群聊 - ${currentGroup.name}</span>
|
|
911
|
+
</h2>
|
|
912
|
+
${!canSpeak ? `
|
|
913
|
+
<div style="margin-top: 12px; padding: 12px; background: rgba(255,255,255,0.2); border-radius: 8px; font-size: 14px;">
|
|
914
|
+
⚠️ ${isMutedAll ? '全体禁言中,无法发言' : '你已被禁言'}
|
|
983
915
|
</div>
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
916
|
+
` : ''}
|
|
917
|
+
</div>
|
|
918
|
+
|
|
919
|
+
<!-- 标签页切换 -->
|
|
920
|
+
<div class="chat-tabs" style="display: flex; gap: 8px; margin-bottom: 16px; background: var(--bg-secondary); padding: 8px; border-radius: 12px;">
|
|
921
|
+
<button class="chat-tab active" data-tab="chat" style="flex: 1; padding: 12px 20px; background: var(--primary); color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s;">
|
|
922
|
+
💬 聊天
|
|
923
|
+
</button>
|
|
924
|
+
<button class="chat-tab" data-tab="whiteboard" style="flex: 1; padding: 12px 20px; background: transparent; color: var(--text-primary); border: none; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s;">
|
|
925
|
+
🎨 白板
|
|
926
|
+
</button>
|
|
927
|
+
<button class="chat-tab" data-tab="ai" style="flex: 1; padding: 12px 20px; background: transparent; color: var(--text-primary); border: none; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s;">
|
|
928
|
+
🤖 AI助手
|
|
929
|
+
</button>
|
|
930
|
+
</div>
|
|
931
|
+
|
|
932
|
+
<!-- 聊天内容 -->
|
|
933
|
+
<div class="tab-content active" data-content="chat">
|
|
934
|
+
<div class="chat-container" style="display: flex; flex-direction: column; height: calc(100vh - 350px); background: var(--bg-secondary); border-radius: 12px; overflow: hidden;">
|
|
935
|
+
<div class="messages" id="messages" style="flex: 1; overflow-y: auto; padding: 20px;"></div>
|
|
936
|
+
<div class="chat-input" style="display: flex; gap: 10px; padding: 16px; background: var(--bg-tertiary); border-top: 1px solid var(--border);">
|
|
937
|
+
<button class="btn-emoji" id="emojiBtn" ${canSpeak ? '' : 'disabled'} style="padding: 10px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; cursor: ${canSpeak ? 'pointer' : 'not-allowed'}; font-size: 20px;">😊</button>
|
|
938
|
+
<input type="text" id="messageInput" placeholder="${canSpeak ? '输入消息...' : (isMutedAll ? '全体禁言中,无法发言' : '你已被禁言')}" ${canSpeak ? '' : 'disabled'} style="flex: 1; padding: 10px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary);">
|
|
939
|
+
<button class="btn-primary" id="sendBtn" ${canSpeak ? '' : 'disabled'} style="padding: 10px 24px; border-radius: 8px; cursor: ${canSpeak ? 'pointer' : 'not-allowed'};">发送</button>
|
|
940
|
+
</div>
|
|
941
|
+
<emoji-picker id="emojiPicker" class="hidden" style="position: absolute; bottom: 80px; left: 20px; z-index: 1000;"></emoji-picker>
|
|
989
942
|
</div>
|
|
990
943
|
</div>
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
944
|
+
|
|
945
|
+
<!-- 白板内容 -->
|
|
946
|
+
<div class="tab-content" data-content="whiteboard" style="display: none;">
|
|
947
|
+
<div class="whiteboard-container" style="background: var(--bg-secondary); border-radius: 12px; padding: 16px; height: calc(100vh - 350px);">
|
|
948
|
+
<div class="whiteboard-toolbar" style="display: flex; gap: 10px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 8px;">
|
|
949
|
+
<button class="tool-btn active" data-tool="pen" style="padding: 8px 16px; background: var(--primary); color: white; border: none; border-radius: 6px; cursor: pointer;">✏️ 画笔</button>
|
|
950
|
+
<button class="tool-btn" data-tool="eraser" style="padding: 8px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">🧹 橡皮</button>
|
|
951
|
+
<input type="color" id="colorPicker" value="#667eea" style="width: 50px; height: 36px; border: none; border-radius: 6px; cursor: pointer;">
|
|
952
|
+
<input type="range" id="brushSize" min="1" max="20" value="3" style="width: 120px;">
|
|
953
|
+
<button class="btn-secondary" id="clearCanvas" style="padding: 8px 16px; border-radius: 6px;">🗑️ 清空</button>
|
|
954
|
+
<button class="btn-primary" id="sendWhiteboardBtn" style="padding: 8px 16px; border-radius: 6px; background: var(--success); border: none; color: white; cursor: pointer;">📤 发送到群聊</button>
|
|
955
|
+
</div>
|
|
956
|
+
<canvas id="whiteboard" style="width: 100%; height: calc(100% - 80px); background: white; border-radius: 8px; cursor: crosshair;"></canvas>
|
|
957
|
+
</div>
|
|
994
958
|
</div>
|
|
959
|
+
|
|
960
|
+
<!-- AI助手内容 -->
|
|
961
|
+
<div class="tab-content" data-content="ai" style="display: none;">
|
|
962
|
+
<div class="ai-container" style="display: flex; flex-direction: column; height: calc(100vh - 350px); background: var(--bg-secondary); border-radius: 12px; overflow: hidden;">
|
|
963
|
+
<div class="ai-chat" id="aiChat" style="flex: 1; overflow-y: auto; padding: 20px;">
|
|
964
|
+
<div class="ai-message ai" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px; border-radius: 12px; margin-bottom: 16px;">
|
|
965
|
+
<p style="margin: 0 0 8px 0; font-weight: 600;">🤖 AI助手</p>
|
|
966
|
+
<p style="margin: 0;">你好!我是AI助手,有什么可以帮助你的吗?</p>
|
|
967
|
+
<p style="margin: 8px 0 0 0; font-size: 14px; opacity: 0.9;">你可以问我关于文档、任务、群组的问题。</p>
|
|
968
|
+
</div>
|
|
969
|
+
</div>
|
|
970
|
+
<div class="ai-input" style="display: flex; gap: 10px; padding: 16px; background: var(--bg-tertiary); border-top: 1px solid var(--border);">
|
|
971
|
+
<textarea id="aiInput" placeholder="向AI助手提问..." rows="2" style="flex: 1; padding: 10px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); resize: none;"></textarea>
|
|
972
|
+
<button class="btn-primary" id="aiSendBtn" style="padding: 10px 24px; border-radius: 8px; align-self: flex-end;">发送</button>
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
995
975
|
</div>
|
|
996
976
|
`;
|
|
997
977
|
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1000
|
-
const
|
|
978
|
+
const messagesDiv = document.getElementById('messages');
|
|
979
|
+
const messageInput = document.getElementById('messageInput');
|
|
980
|
+
const sendBtn = document.getElementById('sendBtn');
|
|
981
|
+
|
|
982
|
+
// 加载历史消息
|
|
983
|
+
try {
|
|
984
|
+
const messagesResult = await apiService.getGroupMessages(currentGroup._id);
|
|
985
|
+
if (messagesResult.messages && Array.isArray(messagesResult.messages)) {
|
|
986
|
+
if (messagesResult.messages.length === 0) {
|
|
987
|
+
messagesDiv.innerHTML = `
|
|
988
|
+
<div style="text-align: center; padding: 40px; color: var(--text-tertiary);">
|
|
989
|
+
<div style="font-size: 48px; margin-bottom: 16px;">💬</div>
|
|
990
|
+
<p>还没有消息,开始聊天吧!</p>
|
|
991
|
+
</div>
|
|
992
|
+
`;
|
|
993
|
+
} else {
|
|
994
|
+
messagesResult.messages.forEach(msg => {
|
|
995
|
+
const messageEl = document.createElement('div');
|
|
996
|
+
const isOwn = String(msg.sender) === String(currentUserId) || msg.username === user.username;
|
|
997
|
+
|
|
998
|
+
// 格式化消息内容(处理白板、投票等)
|
|
999
|
+
const formattedContent = formatMessageContent(msg.content);
|
|
1000
|
+
const isSpecialMessage = msg.content.startsWith('[白板作品]') || msg.content.startsWith('[投票]');
|
|
1001
|
+
const isWhiteboard = msg.content.startsWith('[白板作品]');
|
|
1002
|
+
|
|
1003
|
+
messageEl.className = `message ${isOwn ? 'own' : ''}`;
|
|
1004
|
+
messageEl.style.cssText = `
|
|
1005
|
+
margin-bottom: 16px;
|
|
1006
|
+
display: flex;
|
|
1007
|
+
flex-direction: column;
|
|
1008
|
+
align-items: ${isOwn ? 'flex-end' : 'flex-start'};
|
|
1009
|
+
`;
|
|
1010
|
+
|
|
1011
|
+
messageEl.innerHTML = `
|
|
1012
|
+
<div class="message-header" style="display: flex; gap: 8px; margin-bottom: 4px; font-size: 12px; color: var(--text-tertiary);">
|
|
1013
|
+
<span class="message-user">${msg.username}</span>
|
|
1014
|
+
<span class="message-time">${new Date(msg.timestamp).toLocaleTimeString('zh-CN')}</span>
|
|
1015
|
+
</div>
|
|
1016
|
+
<div class="message-content" style="background: ${isWhiteboard ? 'transparent' : (isSpecialMessage ? 'transparent' : (isOwn ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'var(--bg-tertiary)'))}; color: ${isOwn && !isSpecialMessage ? 'white' : 'var(--text-primary)'}; padding: ${isSpecialMessage ? '0' : '12px 16px'}; border-radius: 16px; max-width: ${isSpecialMessage ? '90%' : '70%'}; word-wrap: break-word; box-shadow: ${isOwn && !isSpecialMessage ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 8px rgba(0,0,0,0.05)'};">${formattedContent}</div>
|
|
1017
|
+
`;
|
|
1018
|
+
messagesDiv.appendChild(messageEl);
|
|
1019
|
+
});
|
|
1020
|
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
console.error('加载历史消息失败:', err);
|
|
1025
|
+
messagesDiv.innerHTML = `
|
|
1026
|
+
<div style="text-align: center; padding: 40px; color: var(--danger);">
|
|
1027
|
+
<div style="font-size: 48px; margin-bottom: 16px;">⚠️</div>
|
|
1028
|
+
<p>加载历史消息失败</p>
|
|
1029
|
+
<p style="font-size: 14px; color: var(--text-tertiary);">${err.message}</p>
|
|
1030
|
+
</div>
|
|
1031
|
+
`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// 表情包功能
|
|
1035
|
+
const emojiBtn = document.getElementById('emojiBtn');
|
|
1036
|
+
const emojiPicker = document.getElementById('emojiPicker');
|
|
1037
|
+
|
|
1038
|
+
if (canSpeak) {
|
|
1039
|
+
emojiBtn.addEventListener('click', () => {
|
|
1040
|
+
emojiPicker.classList.toggle('hidden');
|
|
1041
|
+
});
|
|
1001
1042
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1043
|
+
emojiPicker.addEventListener('emoji-click', (event) => {
|
|
1044
|
+
messageInput.value += event.detail.unicode;
|
|
1045
|
+
messageInput.focus();
|
|
1046
|
+
emojiPicker.classList.add('hidden');
|
|
1047
|
+
});
|
|
1005
1048
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1049
|
+
// 点击外部关闭表情选择器
|
|
1050
|
+
document.addEventListener('click', (e) => {
|
|
1051
|
+
if (!emojiBtn.contains(e.target) && !emojiPicker.contains(e.target)) {
|
|
1052
|
+
emojiPicker.classList.add('hidden');
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1012
1056
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1057
|
+
// 消息通知系统
|
|
1058
|
+
function showNotification(title, body, icon = '💬') {
|
|
1059
|
+
if ('Notification' in window && Notification.permission === 'granted') {
|
|
1060
|
+
new Notification(title, {
|
|
1061
|
+
body: body,
|
|
1062
|
+
icon: '/icon.png',
|
|
1063
|
+
badge: '/icon.png',
|
|
1064
|
+
tag: 'chat-message'
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1019
1068
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1069
|
+
// 请求通知权限
|
|
1070
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
1071
|
+
Notification.requestPermission();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// 监听消息
|
|
1075
|
+
wsService.on('chat_message', (data) => {
|
|
1076
|
+
if (data.groupId === currentGroup._id) {
|
|
1077
|
+
const messageEl = document.createElement('div');
|
|
1078
|
+
const isOwn = String(data.userId) === String(currentUserId) || data.username === user.username;
|
|
1079
|
+
messageEl.className = `message ${isOwn ? 'own' : ''}`;
|
|
1080
|
+
messageEl.style.cssText = `
|
|
1081
|
+
margin-bottom: 16px;
|
|
1082
|
+
display: flex;
|
|
1083
|
+
flex-direction: column;
|
|
1084
|
+
align-items: ${isOwn ? 'flex-end' : 'flex-start'};
|
|
1085
|
+
`;
|
|
1086
|
+
|
|
1087
|
+
// 格式化消息内容(处理白板、投票等)
|
|
1088
|
+
const formattedContent = formatMessageContent(data.content);
|
|
1089
|
+
const isSpecialMessage = data.content.startsWith('[白板作品]') || data.content.startsWith('[投票]');
|
|
1090
|
+
const isWhiteboard = data.content.startsWith('[白板作品]');
|
|
1091
|
+
|
|
1092
|
+
messageEl.innerHTML = `
|
|
1093
|
+
<div class="message-header" style="display: flex; gap: 8px; margin-bottom: 4px; font-size: 12px; color: var(--text-tertiary);">
|
|
1094
|
+
<span class="message-user">${data.username}</span>
|
|
1095
|
+
<span class="message-time">${new Date(data.timestamp).toLocaleTimeString('zh-CN')}</span>
|
|
1096
|
+
</div>
|
|
1097
|
+
<div class="message-content" style="background: ${isWhiteboard ? 'transparent' : (isSpecialMessage ? 'transparent' : (isOwn ? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' : 'var(--bg-tertiary)'))}; color: ${isOwn && !isSpecialMessage ? 'white' : 'var(--text-primary)'}; padding: ${isSpecialMessage ? '0' : '12px 16px'}; border-radius: 16px; max-width: ${isSpecialMessage ? '90%' : '70%'}; word-wrap: break-word; box-shadow: ${isOwn && !isSpecialMessage ? '0 4px 12px rgba(102, 126, 234, 0.3)' : '0 2px 8px rgba(0,0,0,0.05)'};">${formattedContent}</div>
|
|
1098
|
+
`;
|
|
1099
|
+
messagesDiv.appendChild(messageEl);
|
|
1100
|
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
1101
|
+
|
|
1102
|
+
// 显示通知(如果不是自己发送的消息)
|
|
1103
|
+
if (!isOwn) {
|
|
1104
|
+
showNotification(`${data.username} 在 ${currentGroup.name}`, data.content.startsWith('[') ? '发送了特殊消息' : data.content);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// 服务端拦截提示(比如被禁言/未加入群组)
|
|
1110
|
+
wsService.on('chat_blocked', (data) => {
|
|
1111
|
+
if (data.groupId === currentGroup._id) {
|
|
1112
|
+
const notificationEl = document.createElement('div');
|
|
1113
|
+
notificationEl.style.cssText = `
|
|
1114
|
+
text-align: center;
|
|
1115
|
+
padding: 12px;
|
|
1116
|
+
margin: 16px auto;
|
|
1117
|
+
background: var(--danger);
|
|
1118
|
+
color: white;
|
|
1119
|
+
border-radius: 8px;
|
|
1120
|
+
max-width: 80%;
|
|
1121
|
+
`;
|
|
1122
|
+
notificationEl.textContent = data.message || '消息发送失败';
|
|
1123
|
+
messagesDiv.appendChild(notificationEl);
|
|
1124
|
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// 监听点名通知
|
|
1129
|
+
wsService.on('call_response', (data) => {
|
|
1130
|
+
if (data.groupId === currentGroup._id) {
|
|
1131
|
+
const notificationEl = document.createElement('div');
|
|
1132
|
+
notificationEl.style.cssText = `
|
|
1133
|
+
text-align: center;
|
|
1134
|
+
padding: 12px;
|
|
1135
|
+
margin: 16px auto;
|
|
1136
|
+
background: var(--success);
|
|
1137
|
+
color: white;
|
|
1138
|
+
border-radius: 8px;
|
|
1139
|
+
max-width: 80%;
|
|
1140
|
+
`;
|
|
1141
|
+
notificationEl.textContent = `${data.username} 已响应点名`;
|
|
1142
|
+
messagesDiv.appendChild(notificationEl);
|
|
1143
|
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// 发送消息
|
|
1148
|
+
const sendMessage = () => {
|
|
1149
|
+
if (!canSpeak) {
|
|
1150
|
+
alert(isMutedAll ? '全体禁言中,无法发言' : '你已被禁言');
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const content = messageInput.value.trim();
|
|
1155
|
+
if (content) {
|
|
1156
|
+
try {
|
|
1157
|
+
wsService.sendChatMessage(currentGroup._id, user.username, content);
|
|
1158
|
+
messageInput.value = '';
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
console.error('发送消息失败:', error);
|
|
1161
|
+
alert('发送失败: ' + error.message);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
if (canSpeak) {
|
|
1167
|
+
sendBtn.addEventListener('click', sendMessage);
|
|
1168
|
+
messageInput.addEventListener('keypress', (e) => {
|
|
1169
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1170
|
+
e.preventDefault();
|
|
1171
|
+
sendMessage();
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// 标签页切换功能
|
|
1177
|
+
const tabs = document.querySelectorAll('.chat-tab');
|
|
1178
|
+
const tabContents = document.querySelectorAll('.tab-content');
|
|
1179
|
+
|
|
1180
|
+
tabs.forEach(tab => {
|
|
1181
|
+
tab.addEventListener('click', () => {
|
|
1182
|
+
const targetTab = tab.dataset.tab;
|
|
1183
|
+
|
|
1184
|
+
// 更新标签样式
|
|
1185
|
+
tabs.forEach(t => {
|
|
1186
|
+
if (t.dataset.tab === targetTab) {
|
|
1187
|
+
t.style.background = 'var(--primary)';
|
|
1188
|
+
t.style.color = 'white';
|
|
1189
|
+
} else {
|
|
1190
|
+
t.style.background = 'transparent';
|
|
1191
|
+
t.style.color = 'var(--text-primary)';
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// 切换内容
|
|
1196
|
+
tabContents.forEach(content => {
|
|
1197
|
+
if (content.dataset.content === targetTab) {
|
|
1198
|
+
content.style.display = 'block';
|
|
1199
|
+
} else {
|
|
1200
|
+
content.style.display = 'none';
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// 初始化白板
|
|
1205
|
+
if (targetTab === 'whiteboard') {
|
|
1206
|
+
initWhiteboard();
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// 白板功能
|
|
1212
|
+
function initWhiteboard() {
|
|
1213
|
+
const canvas = document.getElementById('whiteboard');
|
|
1214
|
+
if (!canvas || canvas.dataset.initialized) return;
|
|
1215
|
+
|
|
1216
|
+
canvas.dataset.initialized = 'true';
|
|
1217
|
+
const ctx = canvas.getContext('2d');
|
|
1218
|
+
|
|
1219
|
+
// 设置画布大小
|
|
1220
|
+
canvas.width = canvas.offsetWidth;
|
|
1221
|
+
canvas.height = canvas.offsetHeight;
|
|
1222
|
+
|
|
1223
|
+
let isDrawing = false;
|
|
1224
|
+
let currentTool = 'pen';
|
|
1225
|
+
let currentColor = '#667eea';
|
|
1226
|
+
let brushSize = 3;
|
|
1227
|
+
|
|
1228
|
+
// 工具按钮
|
|
1229
|
+
document.querySelectorAll('.tool-btn').forEach(btn => {
|
|
1230
|
+
btn.addEventListener('click', () => {
|
|
1231
|
+
currentTool = btn.dataset.tool;
|
|
1232
|
+
document.querySelectorAll('.tool-btn').forEach(b => {
|
|
1233
|
+
b.style.background = 'var(--bg-secondary)';
|
|
1234
|
+
b.style.color = 'var(--text-primary)';
|
|
1235
|
+
b.style.border = '1px solid var(--border)';
|
|
1236
|
+
});
|
|
1237
|
+
btn.style.background = 'var(--primary)';
|
|
1238
|
+
btn.style.color = 'white';
|
|
1239
|
+
btn.style.border = 'none';
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
// 颜色选择器
|
|
1244
|
+
document.getElementById('colorPicker').addEventListener('change', (e) => {
|
|
1245
|
+
currentColor = e.target.value;
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// 画笔大小
|
|
1249
|
+
document.getElementById('brushSize').addEventListener('input', (e) => {
|
|
1250
|
+
brushSize = e.target.value;
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// 清空画布
|
|
1254
|
+
document.getElementById('clearCanvas').addEventListener('click', () => {
|
|
1255
|
+
if (confirm('确定要清空画布吗?')) {
|
|
1256
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1257
|
+
wsService.sendWhiteboardClear(currentGroup._id);
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
// 发送到群聊
|
|
1262
|
+
document.getElementById('sendWhiteboardBtn').addEventListener('click', async () => {
|
|
1263
|
+
try {
|
|
1264
|
+
// 获取Base64图片数据
|
|
1265
|
+
const dataURL = canvas.toDataURL('image/png');
|
|
1266
|
+
|
|
1267
|
+
// 检查画布是否为空
|
|
1268
|
+
const emptyCanvas = document.createElement('canvas');
|
|
1269
|
+
emptyCanvas.width = canvas.width;
|
|
1270
|
+
emptyCanvas.height = canvas.height;
|
|
1271
|
+
const emptyDataURL = emptyCanvas.toDataURL('image/png');
|
|
1272
|
+
|
|
1273
|
+
if (dataURL === emptyDataURL) {
|
|
1274
|
+
alert('画布是空的,请先绘制内容!');
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// 方案1: 直接发送Base64(适合小图片)
|
|
1279
|
+
// 如果图片大小不超过1MB,直接发送Base64
|
|
1280
|
+
const base64Size = dataURL.length * 0.75 / 1024 / 1024; // 转换为MB
|
|
1281
|
+
|
|
1282
|
+
if (base64Size < 1) {
|
|
1283
|
+
// 直接发送Base64
|
|
1284
|
+
wsService.sendChatMessage(currentGroup._id, user.username, `[白板作品]${dataURL}`);
|
|
1285
|
+
alert('白板作品已发送到群聊!');
|
|
1286
|
+
} else {
|
|
1287
|
+
// 方案2: 上传到服务器(适合大图片)
|
|
1288
|
+
const blob = await fetch(dataURL).then(r => r.blob());
|
|
1289
|
+
|
|
1290
|
+
// 创建 FormData 上传图片
|
|
1291
|
+
const formData = new FormData();
|
|
1292
|
+
formData.append('file', blob, `whiteboard-${Date.now()}.png`);
|
|
1293
|
+
formData.append('groupId', currentGroup._id);
|
|
1294
|
+
formData.append('description', '协作白板作品');
|
|
1295
|
+
|
|
1296
|
+
const token = localStorage.getItem('token');
|
|
1297
|
+
const response = await fetch('http://localhost:8765/api/files/upload', {
|
|
1298
|
+
method: 'POST',
|
|
1299
|
+
headers: {
|
|
1300
|
+
'Authorization': `Bearer ${token}`
|
|
1301
|
+
},
|
|
1302
|
+
body: formData
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
if (response.ok) {
|
|
1306
|
+
const result = await response.json();
|
|
1307
|
+
const fileId = result.file._id;
|
|
1308
|
+
const fileUrl = `http://localhost:8765/api/files/${fileId}/download?token=${token}`;
|
|
1309
|
+
|
|
1310
|
+
// 发送包含图片链接的消息到群聊
|
|
1311
|
+
wsService.sendChatMessage(currentGroup._id, user.username, `[白板作品]${fileUrl}`);
|
|
1312
|
+
alert('白板作品已发送到群聊!');
|
|
1313
|
+
} else {
|
|
1314
|
+
throw new Error('上传失败');
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
console.error('发送白板失败:', error);
|
|
1319
|
+
alert('发送失败,请重试!');
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
// 绘图事件
|
|
1324
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
1325
|
+
isDrawing = true;
|
|
1326
|
+
const rect = canvas.getBoundingClientRect();
|
|
1327
|
+
const x = e.clientX - rect.left;
|
|
1328
|
+
const y = e.clientY - rect.top;
|
|
1329
|
+
|
|
1330
|
+
ctx.beginPath();
|
|
1331
|
+
ctx.moveTo(x, y);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
1335
|
+
if (!isDrawing) return;
|
|
1336
|
+
|
|
1337
|
+
const rect = canvas.getBoundingClientRect();
|
|
1338
|
+
const x = e.clientX - rect.left;
|
|
1339
|
+
const y = e.clientY - rect.top;
|
|
1340
|
+
|
|
1341
|
+
ctx.lineWidth = brushSize;
|
|
1342
|
+
ctx.lineCap = 'round';
|
|
1343
|
+
|
|
1344
|
+
if (currentTool === 'pen') {
|
|
1345
|
+
ctx.strokeStyle = currentColor;
|
|
1346
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1347
|
+
} else if (currentTool === 'eraser') {
|
|
1348
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
ctx.lineTo(x, y);
|
|
1352
|
+
ctx.stroke();
|
|
1353
|
+
|
|
1354
|
+
// 发送绘图数据到其他用户
|
|
1355
|
+
wsService.sendWhiteboardDraw(currentGroup._id, {
|
|
1356
|
+
tool: currentTool,
|
|
1357
|
+
color: currentColor,
|
|
1358
|
+
size: brushSize,
|
|
1359
|
+
x: x,
|
|
1360
|
+
y: y
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
canvas.addEventListener('mouseup', () => {
|
|
1365
|
+
isDrawing = false;
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
canvas.addEventListener('mouseleave', () => {
|
|
1369
|
+
isDrawing = false;
|
|
1029
1370
|
});
|
|
1030
|
-
|
|
1371
|
+
|
|
1372
|
+
// 监听其他用户的绘图
|
|
1373
|
+
wsService.on('whiteboard_draw', (data) => {
|
|
1374
|
+
if (data.groupId === currentGroup._id) {
|
|
1375
|
+
ctx.lineWidth = data.size;
|
|
1376
|
+
ctx.lineCap = 'round';
|
|
1377
|
+
|
|
1378
|
+
if (data.tool === 'pen') {
|
|
1379
|
+
ctx.strokeStyle = data.color;
|
|
1380
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
1381
|
+
} else if (data.tool === 'eraser') {
|
|
1382
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
ctx.lineTo(data.x, data.y);
|
|
1386
|
+
ctx.stroke();
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// 监听清空画布
|
|
1391
|
+
wsService.on('whiteboard_clear', (data) => {
|
|
1392
|
+
if (data.groupId === currentGroup._id) {
|
|
1393
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// AI助手功能
|
|
1399
|
+
const aiChat = document.getElementById('aiChat');
|
|
1400
|
+
const aiInput = document.getElementById('aiInput');
|
|
1401
|
+
const aiSendBtn = document.getElementById('aiSendBtn');
|
|
1031
1402
|
|
|
1032
|
-
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1403
|
+
const sendAIMessage = async () => {
|
|
1404
|
+
const question = aiInput.value.trim();
|
|
1405
|
+
if (!question) return;
|
|
1406
|
+
|
|
1407
|
+
// 显示用户消息
|
|
1408
|
+
const userMsg = document.createElement('div');
|
|
1409
|
+
userMsg.style.cssText = `
|
|
1410
|
+
background: var(--primary);
|
|
1411
|
+
color: white;
|
|
1412
|
+
padding: 12px 16px;
|
|
1413
|
+
border-radius: 12px;
|
|
1414
|
+
margin-bottom: 16px;
|
|
1415
|
+
max-width: 80%;
|
|
1416
|
+
margin-left: auto;
|
|
1417
|
+
word-wrap: break-word;
|
|
1418
|
+
`;
|
|
1419
|
+
userMsg.textContent = question;
|
|
1420
|
+
aiChat.appendChild(userMsg);
|
|
1421
|
+
aiInput.value = '';
|
|
1422
|
+
|
|
1423
|
+
// 显示加载中
|
|
1424
|
+
const loadingMsg = document.createElement('div');
|
|
1425
|
+
loadingMsg.style.cssText = `
|
|
1426
|
+
background: var(--bg-tertiary);
|
|
1427
|
+
color: var(--text-secondary);
|
|
1428
|
+
padding: 12px 16px;
|
|
1429
|
+
border-radius: 12px;
|
|
1430
|
+
margin-bottom: 16px;
|
|
1431
|
+
max-width: 80%;
|
|
1432
|
+
font-style: italic;
|
|
1433
|
+
`;
|
|
1434
|
+
loadingMsg.textContent = '🤔 思考中...';
|
|
1435
|
+
aiChat.appendChild(loadingMsg);
|
|
1037
1436
|
aiChat.scrollTop = aiChat.scrollHeight;
|
|
1437
|
+
|
|
1438
|
+
try {
|
|
1439
|
+
const token = localStorage.getItem('token');
|
|
1440
|
+
const response = await fetch('http://localhost:8765/api/ai/ask', {
|
|
1441
|
+
method: 'POST',
|
|
1442
|
+
headers: {
|
|
1443
|
+
'Content-Type': 'application/json',
|
|
1444
|
+
'Authorization': `Bearer ${token}`
|
|
1445
|
+
},
|
|
1446
|
+
body: JSON.stringify({ question, groupId: currentGroup?._id })
|
|
1447
|
+
});
|
|
1448
|
+
const result = await response.json();
|
|
1449
|
+
|
|
1450
|
+
loadingMsg.remove();
|
|
1451
|
+
const aiMsg = document.createElement('div');
|
|
1452
|
+
aiMsg.style.cssText = `
|
|
1453
|
+
background: var(--bg-tertiary);
|
|
1454
|
+
color: var(--text-primary);
|
|
1455
|
+
padding: 12px 16px;
|
|
1456
|
+
border-radius: 12px;
|
|
1457
|
+
margin-bottom: 16px;
|
|
1458
|
+
max-width: 80%;
|
|
1459
|
+
line-height: 1.6;
|
|
1460
|
+
word-wrap: break-word;
|
|
1461
|
+
`;
|
|
1462
|
+
aiMsg.textContent = result.answer || '抱歉,我无法回答这个问题。';
|
|
1463
|
+
aiChat.appendChild(aiMsg);
|
|
1464
|
+
aiChat.scrollTop = aiChat.scrollHeight;
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
loadingMsg.remove();
|
|
1467
|
+
const errorMsg = document.createElement('div');
|
|
1468
|
+
errorMsg.style.cssText = `
|
|
1469
|
+
background: var(--danger);
|
|
1470
|
+
color: white;
|
|
1471
|
+
padding: 12px 16px;
|
|
1472
|
+
border-radius: 12px;
|
|
1473
|
+
margin-bottom: 16px;
|
|
1474
|
+
max-width: 80%;
|
|
1475
|
+
`;
|
|
1476
|
+
errorMsg.textContent = '抱歉,发生了错误: ' + error.message;
|
|
1477
|
+
aiChat.appendChild(errorMsg);
|
|
1478
|
+
aiChat.scrollTop = aiChat.scrollHeight;
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
aiSendBtn.addEventListener('click', sendAIMessage);
|
|
1483
|
+
aiInput.addEventListener('keypress', (e) => {
|
|
1484
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1485
|
+
e.preventDefault();
|
|
1486
|
+
sendAIMessage();
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
console.error('加载群聊失败:', error);
|
|
1492
|
+
container.innerHTML = `
|
|
1493
|
+
<div class="empty-state" style="text-align: center; padding: 60px 20px;">
|
|
1494
|
+
<div style="font-size: 64px; margin-bottom: 20px;">⚠️</div>
|
|
1495
|
+
<h3 style="font-size: 24px; margin-bottom: 12px; color: var(--danger);">加载失败</h3>
|
|
1496
|
+
<p style="color: var(--text-secondary); margin-bottom: 24px;">${error.message}</p>
|
|
1497
|
+
<button class="btn-primary" onclick="location.reload()">重新加载</button>
|
|
1498
|
+
</div>
|
|
1499
|
+
`;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
async function renderSearchView(container) {
|
|
1504
|
+
container.innerHTML = `
|
|
1505
|
+
<div class="view-header">
|
|
1506
|
+
<h2>🔍 搜索</h2>
|
|
1507
|
+
</div>
|
|
1508
|
+
<div class="search-container">
|
|
1509
|
+
<div class="search-box">
|
|
1510
|
+
<input type="text" id="searchInput" placeholder="搜索消息、文档、任务...">
|
|
1511
|
+
<button class="btn-primary" id="searchBtn">搜索</button>
|
|
1512
|
+
</div>
|
|
1513
|
+
<div class="search-filters">
|
|
1514
|
+
<label>
|
|
1515
|
+
<input type="checkbox" id="filterMessages" checked> 消息
|
|
1516
|
+
</label>
|
|
1517
|
+
<label>
|
|
1518
|
+
<input type="checkbox" id="filterDocuments" checked> 文档
|
|
1519
|
+
</label>
|
|
1520
|
+
<label>
|
|
1521
|
+
<input type="checkbox" id="filterTasks" checked> 任务
|
|
1522
|
+
</label>
|
|
1523
|
+
</div>
|
|
1524
|
+
<div class="search-results" id="searchResults"></div>
|
|
1525
|
+
</div>
|
|
1526
|
+
`;
|
|
1527
|
+
|
|
1528
|
+
const searchInput = document.getElementById('searchInput');
|
|
1529
|
+
const searchBtn = document.getElementById('searchBtn');
|
|
1530
|
+
const searchResults = document.getElementById('searchResults');
|
|
1531
|
+
|
|
1532
|
+
const performSearch = async () => {
|
|
1533
|
+
const query = searchInput.value.trim();
|
|
1534
|
+
if (!query) {
|
|
1535
|
+
searchResults.innerHTML = '<div class="empty-state">请输入搜索关键词</div>';
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const filters = {
|
|
1540
|
+
messages: document.getElementById('filterMessages').checked,
|
|
1541
|
+
documents: document.getElementById('filterDocuments').checked,
|
|
1542
|
+
tasks: document.getElementById('filterTasks').checked
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
searchResults.innerHTML = '<div class="loading">搜索中...</div>';
|
|
1546
|
+
|
|
1547
|
+
try {
|
|
1548
|
+
const results = [];
|
|
1549
|
+
|
|
1550
|
+
// 搜索消息
|
|
1551
|
+
if (filters.messages && currentGroup) {
|
|
1552
|
+
try {
|
|
1553
|
+
const messagesResult = await apiService.getGroupMessages(currentGroup._id);
|
|
1554
|
+
if (messagesResult.messages) {
|
|
1555
|
+
const matchedMessages = messagesResult.messages.filter(msg =>
|
|
1556
|
+
msg.content.toLowerCase().includes(query.toLowerCase())
|
|
1557
|
+
);
|
|
1558
|
+
matchedMessages.forEach(msg => {
|
|
1559
|
+
results.push({
|
|
1560
|
+
type: 'message',
|
|
1561
|
+
title: `消息 - ${msg.username}`,
|
|
1562
|
+
content: msg.content,
|
|
1563
|
+
time: msg.timestamp,
|
|
1564
|
+
group: currentGroup.name
|
|
1565
|
+
});
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
console.error('搜索消息失败:', err);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// 搜索文档
|
|
1574
|
+
if (filters.documents) {
|
|
1575
|
+
try {
|
|
1576
|
+
if (currentGroup) {
|
|
1577
|
+
const docsResult = await apiService.getDocuments(currentGroup._id);
|
|
1578
|
+
if (docsResult.documents) {
|
|
1579
|
+
const matchedDocs = docsResult.documents.filter(doc =>
|
|
1580
|
+
doc.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
1581
|
+
doc.content.toLowerCase().includes(query.toLowerCase())
|
|
1582
|
+
);
|
|
1583
|
+
matchedDocs.forEach(doc => {
|
|
1584
|
+
results.push({
|
|
1585
|
+
type: 'document',
|
|
1586
|
+
title: doc.title,
|
|
1587
|
+
content: doc.content.substring(0, 200),
|
|
1588
|
+
time: doc.updatedAt,
|
|
1589
|
+
id: doc._id,
|
|
1590
|
+
group: currentGroup.name
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
console.error('搜索文档失败:', err);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// 搜索任务
|
|
1601
|
+
if (filters.tasks) {
|
|
1602
|
+
try {
|
|
1603
|
+
const tasksResult = await apiService.getMyTasks();
|
|
1604
|
+
if (tasksResult.tasks) {
|
|
1605
|
+
const matchedTasks = tasksResult.tasks.filter(task =>
|
|
1606
|
+
task.title.toLowerCase().includes(query.toLowerCase()) ||
|
|
1607
|
+
(task.description && task.description.toLowerCase().includes(query.toLowerCase()))
|
|
1608
|
+
);
|
|
1609
|
+
matchedTasks.forEach(task => {
|
|
1610
|
+
results.push({
|
|
1611
|
+
type: 'task',
|
|
1612
|
+
title: task.title,
|
|
1613
|
+
content: task.description || '',
|
|
1614
|
+
time: task.updatedAt,
|
|
1615
|
+
id: task._id,
|
|
1616
|
+
status: task.status
|
|
1617
|
+
});
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
console.error('搜索任务失败:', err);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// 显示结果
|
|
1626
|
+
if (results.length === 0) {
|
|
1627
|
+
searchResults.innerHTML = '<div class="empty-state">未找到相关结果</div>';
|
|
1628
|
+
} else {
|
|
1629
|
+
searchResults.innerHTML = results.map(result => {
|
|
1630
|
+
const typeIcon = {
|
|
1631
|
+
message: '💬',
|
|
1632
|
+
document: '📄',
|
|
1633
|
+
task: '📋'
|
|
1634
|
+
};
|
|
1635
|
+
return `
|
|
1636
|
+
<div class="search-result-item">
|
|
1637
|
+
<div class="result-header">
|
|
1638
|
+
<span class="result-type">${typeIcon[result.type]} ${result.type === 'message' ? '消息' : result.type === 'document' ? '文档' : '任务'}</span>
|
|
1639
|
+
<span class="result-time">${new Date(result.time).toLocaleString()}</span>
|
|
1640
|
+
</div>
|
|
1641
|
+
<h4>${highlightText(result.title, query)}</h4>
|
|
1642
|
+
<p>${highlightText(result.content, query)}</p>
|
|
1643
|
+
${result.group ? `<span class="result-group">群组: ${result.group}</span>` : ''}
|
|
1644
|
+
${result.status ? `<span class="result-status">状态: ${getStatusText(result.status)}</span>` : ''}
|
|
1645
|
+
</div>
|
|
1646
|
+
`;
|
|
1647
|
+
}).join('');
|
|
1648
|
+
}
|
|
1038
1649
|
} catch (error) {
|
|
1039
|
-
|
|
1040
|
-
const errorMsg = document.createElement('div');
|
|
1041
|
-
errorMsg.className = 'ai-message ai error';
|
|
1042
|
-
errorMsg.textContent = '抱歉,发生了错误: ' + error.message;
|
|
1043
|
-
aiChat.appendChild(errorMsg);
|
|
1650
|
+
searchResults.innerHTML = `<div class="empty-state">搜索失败: ${error.message}</div>`;
|
|
1044
1651
|
}
|
|
1045
1652
|
};
|
|
1046
1653
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if (e.key === 'Enter'
|
|
1050
|
-
e.preventDefault();
|
|
1051
|
-
sendMessage();
|
|
1052
|
-
}
|
|
1654
|
+
searchBtn.addEventListener('click', performSearch);
|
|
1655
|
+
searchInput.addEventListener('keypress', (e) => {
|
|
1656
|
+
if (e.key === 'Enter') performSearch();
|
|
1053
1657
|
});
|
|
1054
1658
|
}
|
|
1055
1659
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1660
|
+
function highlightText(text, query) {
|
|
1661
|
+
if (!query) return text;
|
|
1662
|
+
const regex = new RegExp(`(${query})`, 'gi');
|
|
1663
|
+
return text.replace(regex, '<mark>$1</mark>');
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function getStatusText(status) {
|
|
1667
|
+
const statusMap = {
|
|
1668
|
+
'pending': '待处理',
|
|
1669
|
+
'in_progress': '进行中',
|
|
1670
|
+
'completed': '已完成',
|
|
1671
|
+
'terminated': '已终止'
|
|
1672
|
+
};
|
|
1673
|
+
return statusMap[status] || status;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
// 知识库(用户版 - 完整功能)
|
|
1678
|
+
async function renderKnowledgeView(container) {
|
|
1679
|
+
if (!currentGroup) {
|
|
1680
|
+
container.innerHTML = `
|
|
1681
|
+
<div class="empty-state" style="text-align: center; padding: 60px 20px; background: var(--bg-secondary); border-radius: 16px; border: 2px dashed var(--border);">
|
|
1682
|
+
<div style="font-size: 64px; margin-bottom: 20px;">📚</div>
|
|
1683
|
+
<h3 style="font-size: 24px; margin-bottom: 12px; color: var(--text-primary);">知识库</h3>
|
|
1684
|
+
<p style="color: var(--text-secondary); margin-bottom: 24px; font-size: 16px;">请先在"我的群组"中选择一个群组</p>
|
|
1685
|
+
<button class="btn-primary" onclick="document.querySelector('[data-view=\\"groups\\"]').click()" style="padding: 12px 32px; font-size: 16px;">
|
|
1686
|
+
前往我的群组
|
|
1687
|
+
</button>
|
|
1688
|
+
</div>
|
|
1689
|
+
`;
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
try {
|
|
1694
|
+
const token = localStorage.getItem('token');
|
|
1695
|
+
const response = await fetch(`http://localhost:8765/api/knowledge/group/${currentGroup._id}`, {
|
|
1696
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
if (!response.ok) {
|
|
1700
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const result = await response.json();
|
|
1704
|
+
|
|
1705
|
+
// 确保 knowledgeItems 是数组
|
|
1706
|
+
let knowledgeItems = [];
|
|
1707
|
+
if (Array.isArray(result)) {
|
|
1708
|
+
knowledgeItems = result;
|
|
1709
|
+
} else if (result.data && Array.isArray(result.data)) {
|
|
1710
|
+
knowledgeItems = result.data;
|
|
1711
|
+
} else if (result.data && result.data.knowledgeList && Array.isArray(result.data.knowledgeList)) {
|
|
1712
|
+
knowledgeItems = result.data.knowledgeList;
|
|
1713
|
+
} else if (result.items && Array.isArray(result.items)) {
|
|
1714
|
+
knowledgeItems = result.items;
|
|
1715
|
+
} else if (result.knowledge && Array.isArray(result.knowledge)) {
|
|
1716
|
+
knowledgeItems = result.knowledge;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
container.innerHTML = `
|
|
1720
|
+
<div class="view-header">
|
|
1721
|
+
<h2>📚 知识库 - ${currentGroup.name}</h2>
|
|
1722
|
+
<button class="btn-primary" id="createKnowledgeBtn">📝 创建知识条目</button>
|
|
1723
|
+
</div>
|
|
1724
|
+
<div class="knowledge-grid" id="knowledgeList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; padding: 20px;"></div>
|
|
1725
|
+
|
|
1726
|
+
<!-- 知识库模态框 -->
|
|
1727
|
+
<div id="knowledgeModal" class="modal hidden">
|
|
1728
|
+
<div class="modal-content">
|
|
1729
|
+
<div class="modal-header">
|
|
1730
|
+
<h3 id="modalTitle">创建知识条目</h3>
|
|
1731
|
+
<button class="modal-close" id="closeKnowledgeModal">×</button>
|
|
1732
|
+
</div>
|
|
1733
|
+
<form id="knowledgeForm">
|
|
1734
|
+
<div class="form-group">
|
|
1735
|
+
<label>📌 标题</label>
|
|
1736
|
+
<input type="text" name="title" required style="width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 8px;">
|
|
1737
|
+
</div>
|
|
1738
|
+
<div class="form-group">
|
|
1739
|
+
<label>📝 内容</label>
|
|
1740
|
+
<textarea name="content" rows="6" required style="width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 8px;"></textarea>
|
|
1741
|
+
</div>
|
|
1742
|
+
<div class="form-group">
|
|
1743
|
+
<label>🏷️ 标签(用逗号分隔)</label>
|
|
1744
|
+
<input type="text" name="tags" placeholder="例如: 学习,文档,教程" style="width: 100%; padding: 10px; border: 1px solid var(--border); border-radius: 8px;">
|
|
1745
|
+
</div>
|
|
1746
|
+
<div class="form-group" style="display: flex; align-items: center; gap: 10px; padding: 15px; background: var(--bg-tertiary); border-radius: 8px; margin-top: 15px;">
|
|
1747
|
+
<input type="checkbox" name="isShared" id="isSharedCheckbox" style="width: 20px; height: 20px; cursor: pointer;">
|
|
1748
|
+
<label for="isSharedCheckbox" style="margin: 0; cursor: pointer; display: flex; align-items: center; gap: 8px;">
|
|
1749
|
+
<span style="font-size: 18px;">🌐</span>
|
|
1750
|
+
<div>
|
|
1751
|
+
<div style="font-weight: 600; color: var(--text-primary);">共享到所有群组</div>
|
|
1752
|
+
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 2px;">开启后,此知识条目将对所有群组可见</div>
|
|
1753
|
+
</div>
|
|
1754
|
+
</label>
|
|
1755
|
+
</div>
|
|
1756
|
+
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
|
1757
|
+
<button type="submit" class="btn-primary" style="flex: 1;">保存</button>
|
|
1758
|
+
<button type="button" class="btn-secondary" id="cancelKnowledgeModal" style="flex: 1;">取消</button>
|
|
1759
|
+
</div>
|
|
1760
|
+
</form>
|
|
1761
|
+
</div>
|
|
1762
|
+
</div>
|
|
1763
|
+
`;
|
|
1764
|
+
|
|
1765
|
+
const knowledgeList = document.getElementById('knowledgeList');
|
|
1766
|
+
|
|
1767
|
+
if (knowledgeItems.length === 0) {
|
|
1768
|
+
knowledgeList.innerHTML = '<div class="empty-state" style="grid-column: 1/-1;">暂无知识条目</div>';
|
|
1769
|
+
} else {
|
|
1770
|
+
knowledgeItems.forEach(item => {
|
|
1771
|
+
const card = document.createElement('div');
|
|
1772
|
+
card.className = 'knowledge-card';
|
|
1773
|
+
card.style.cssText = 'background: var(--bg-secondary); padding: 20px; border-radius: 12px; border: 1px solid var(--border); transition: transform 0.2s, box-shadow 0.2s; position: relative;';
|
|
1774
|
+
card.innerHTML = `
|
|
1775
|
+
${item.isShared ? '<div style="position: absolute; top: 15px; right: 15px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; display: flex; align-items: center; gap: 4px;"><span>🌐</span><span>已共享</span></div>' : ''}
|
|
1776
|
+
<h3 style="margin: 0 0 10px 0; font-size: 18px; ${item.isShared ? 'padding-right: 80px;' : ''}">${item.title}</h3>
|
|
1777
|
+
<p style="color: var(--text-secondary); margin: 0 0 15px 0; line-height: 1.6;">${item.content.substring(0, 150)}${item.content.length > 150 ? '...' : ''}</p>
|
|
1778
|
+
<div class="knowledge-meta" style="font-size: 12px; color: var(--text-tertiary); margin-bottom: 10px;">
|
|
1779
|
+
<span>👤 ${item.author?.username || item.creator?.username || '未知'}</span>
|
|
1780
|
+
<span style="margin-left: 15px;">📅 ${new Date(item.createdAt).toLocaleDateString()}</span>
|
|
1781
|
+
</div>
|
|
1782
|
+
${item.tags && item.tags.length > 0 ? `
|
|
1783
|
+
<div class="tags" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 15px;">
|
|
1784
|
+
${item.tags.map(tag => `<span class="tag" style="background: var(--primary); color: white; padding: 4px 10px; border-radius: 12px; font-size: 12px;">${tag}</span>`).join('')}
|
|
1785
|
+
</div>
|
|
1786
|
+
` : ''}
|
|
1787
|
+
<div style="display: flex; gap: 10px;">
|
|
1788
|
+
<button class="btn-secondary btn-sm" data-id="${item._id}" data-action="edit" style="flex: 1;">✏️ 编辑</button>
|
|
1789
|
+
<button class="btn-danger btn-sm" data-id="${item._id}" data-action="delete" style="flex: 1;">🗑️ 删除</button>
|
|
1790
|
+
</div>
|
|
1791
|
+
`;
|
|
1792
|
+
card.onmouseenter = () => {
|
|
1793
|
+
card.style.transform = 'translateY(-4px)';
|
|
1794
|
+
card.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)';
|
|
1795
|
+
};
|
|
1796
|
+
card.onmouseleave = () => {
|
|
1797
|
+
card.style.transform = 'translateY(0)';
|
|
1798
|
+
card.style.boxShadow = 'none';
|
|
1799
|
+
};
|
|
1800
|
+
knowledgeList.appendChild(card);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
// 编辑按钮
|
|
1804
|
+
document.querySelectorAll('[data-action="edit"]').forEach(btn => {
|
|
1805
|
+
btn.addEventListener('click', async () => {
|
|
1806
|
+
const item = knowledgeItems.find(k => k._id === btn.dataset.id);
|
|
1807
|
+
document.getElementById('modalTitle').textContent = '编辑知识条目';
|
|
1808
|
+
document.querySelector('[name="title"]').value = item.title;
|
|
1809
|
+
document.querySelector('[name="content"]').value = item.content;
|
|
1810
|
+
document.querySelector('[name="tags"]').value = item.tags?.join(', ') || '';
|
|
1811
|
+
document.getElementById('isSharedCheckbox').checked = item.isShared || false;
|
|
1812
|
+
document.getElementById('knowledgeForm').dataset.editId = item._id;
|
|
1813
|
+
document.getElementById('knowledgeModal').classList.remove('hidden');
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
// 删除按钮
|
|
1818
|
+
document.querySelectorAll('[data-action="delete"]').forEach(btn => {
|
|
1819
|
+
btn.addEventListener('click', async () => {
|
|
1820
|
+
if (confirm('确定要删除这个知识条目吗?')) {
|
|
1821
|
+
try {
|
|
1822
|
+
await fetch(`http://localhost:8765/api/knowledge/${btn.dataset.id}`, {
|
|
1823
|
+
method: 'DELETE',
|
|
1824
|
+
headers: { 'Authorization': `Bearer ${token}` }
|
|
1825
|
+
});
|
|
1826
|
+
alert('删除成功!');
|
|
1827
|
+
await renderKnowledgeView(container);
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
alert('删除失败: ' + error.message);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// 创建按钮
|
|
1837
|
+
document.getElementById('createKnowledgeBtn').addEventListener('click', () => {
|
|
1838
|
+
document.getElementById('modalTitle').textContent = '创建知识条目';
|
|
1839
|
+
document.getElementById('knowledgeForm').reset();
|
|
1840
|
+
delete document.getElementById('knowledgeForm').dataset.editId;
|
|
1841
|
+
document.getElementById('knowledgeModal').classList.remove('hidden');
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// 关闭模态框
|
|
1845
|
+
document.getElementById('closeKnowledgeModal').addEventListener('click', () => {
|
|
1846
|
+
document.getElementById('knowledgeModal').classList.add('hidden');
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
document.getElementById('cancelKnowledgeModal').addEventListener('click', () => {
|
|
1850
|
+
document.getElementById('knowledgeModal').classList.add('hidden');
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
// 表单提交
|
|
1854
|
+
document.getElementById('knowledgeForm').addEventListener('submit', async (e) => {
|
|
1855
|
+
e.preventDefault();
|
|
1856
|
+
const formData = new FormData(e.target);
|
|
1857
|
+
const data = {
|
|
1858
|
+
title: formData.get('title'),
|
|
1859
|
+
content: formData.get('content'),
|
|
1860
|
+
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t),
|
|
1861
|
+
groupId: currentGroup._id,
|
|
1862
|
+
isShared: document.getElementById('isSharedCheckbox').checked
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
try {
|
|
1866
|
+
const editId = e.target.dataset.editId;
|
|
1867
|
+
const url = editId
|
|
1868
|
+
? `http://localhost:8765/api/knowledge/${editId}`
|
|
1869
|
+
: 'http://localhost:8765/api/knowledge';
|
|
1870
|
+
const method = editId ? 'PUT' : 'POST';
|
|
1871
|
+
|
|
1872
|
+
const response = await fetch(url, {
|
|
1873
|
+
method,
|
|
1874
|
+
headers: {
|
|
1875
|
+
'Content-Type': 'application/json',
|
|
1876
|
+
'Authorization': `Bearer ${token}`
|
|
1877
|
+
},
|
|
1878
|
+
body: JSON.stringify(data)
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
if (!response.ok) {
|
|
1882
|
+
throw new Error('操作失败');
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
alert(editId ? '更新成功!' : '创建成功!');
|
|
1886
|
+
document.getElementById('knowledgeModal').classList.add('hidden');
|
|
1887
|
+
await renderKnowledgeView(container);
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
alert('操作失败: ' + error.message);
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
console.error('加载知识库失败:', error);
|
|
1895
|
+
container.innerHTML = `
|
|
1896
|
+
<div class="view-header">
|
|
1897
|
+
<h2>📚 知识库 - ${currentGroup.name}</h2>
|
|
1898
|
+
</div>
|
|
1899
|
+
<div class="empty-state" style="text-align: center; padding: 40px 20px;">
|
|
1900
|
+
<div style="font-size: 48px; margin-bottom: 16px;">⚠️</div>
|
|
1901
|
+
<h3 style="margin-bottom: 8px; color: var(--danger);">加载失败</h3>
|
|
1902
|
+
<p style="color: var(--text-secondary); margin-bottom: 16px;">${error.message}</p>
|
|
1903
|
+
<button class="btn-primary" onclick="location.reload()">重新加载</button>
|
|
1904
|
+
</div>
|
|
1905
|
+
`;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
renderView('groups');
|
|
1911
|
+
}
|
|
1912
|
+
|