collabdocchat 1.2.13 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +219 -218
- package/index.html +2 -0
- package/install-and-start.bat +5 -0
- package/install-and-start.sh +5 -0
- package/package.json +9 -2
- package/scripts/generate-docs.js +448 -0
- package/scripts/pre-publish-check.js +213 -0
- package/scripts/start-app.js +15 -15
- package/server/index.js +38 -6
- package/server/middleware/cache.js +115 -0
- package/server/middleware/errorHandler.js +209 -0
- package/server/models/Document.js +66 -59
- package/server/models/File.js +49 -43
- package/server/models/Group.js +6 -0
- package/server/models/KnowledgeBase.js +254 -0
- package/server/models/Message.js +43 -0
- package/server/models/Task.js +87 -55
- package/server/models/User.js +67 -60
- package/server/models/Workflow.js +249 -0
- package/server/routes/ai.js +327 -0
- package/server/routes/audit.js +245 -210
- package/server/routes/backup.js +108 -0
- package/server/routes/chunked-upload.js +343 -0
- package/server/routes/export.js +440 -0
- package/server/routes/files.js +294 -218
- package/server/routes/groups.js +182 -0
- package/server/routes/knowledge.js +509 -0
- package/server/routes/tasks.js +257 -110
- package/server/routes/workflows.js +380 -0
- package/server/utils/backup.js +439 -0
- package/server/utils/cache.js +223 -0
- package/server/utils/workflow-engine.js +479 -0
- package/server/websocket/enhanced.js +509 -0
- package/server/websocket/index.js +233 -1
- package/src/components/knowledge-modal.js +485 -0
- package/src/components/optimized-poll-detail.js +724 -0
- package/src/main.js +5 -0
- package/src/pages/admin-dashboard.js +2248 -44
- package/src/pages/optimized-backup-view.js +616 -0
- package/src/pages/optimized-knowledge-view.js +803 -0
- package/src/pages/optimized-task-detail.js +843 -0
- package/src/pages/optimized-workflow-view.js +806 -0
- package/src/pages/simplified-workflows.js +651 -0
- package/src/pages/user-dashboard.js +677 -58
- package/src/services/api.js +64 -0
- package/src/services/auth.js +1 -1
- package/src/services/websocket.js +124 -16
- package/src/styles/collaboration-modern.js +708 -0
- package/src/styles/enhancements.css +392 -0
- package/src/styles/main.css +620 -1420
- package/src/styles/responsive.css +1000 -0
- package/src/styles/sidebar-fix.css +60 -0
- package/src/utils/ai-assistant.js +1398 -0
- package/src/utils/chat-enhancements.js +509 -0
- package/src/utils/collaboration-enhancer.js +1151 -0
- package/src/utils/feature-integrator.js +1724 -0
- package/src/utils/onboarding-guide.js +734 -0
- package/src/utils/performance.js +394 -0
- package/src/utils/permission-manager.js +890 -0
- package/src/utils/responsive-handler.js +491 -0
- package/src/utils/theme-manager.js +811 -0
- package/src/utils/ui-enhancements-loader.js +329 -0
- package/USAGE.md +0 -298
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协作增强功能
|
|
3
|
+
* 提供更多协作工具和功能
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class CollaborationEnhancer {
|
|
7
|
+
constructor(apiService, wsService) {
|
|
8
|
+
this.apiService = apiService;
|
|
9
|
+
this.wsService = wsService;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 协作白板
|
|
14
|
+
*/
|
|
15
|
+
createWhiteboard(container, groupId, username, isAdmin = false) {
|
|
16
|
+
// 管理员控制按钮
|
|
17
|
+
const adminControls = isAdmin ? `
|
|
18
|
+
<button class="btn-warning" id="toggleCollaboration">🔒 禁止协作</button>
|
|
19
|
+
<button class="btn-success" id="sendToChat">📤 发送到群聊</button>
|
|
20
|
+
` : '';
|
|
21
|
+
|
|
22
|
+
container.innerHTML = `
|
|
23
|
+
<div class="whiteboard-container">
|
|
24
|
+
<div class="whiteboard-toolbar">
|
|
25
|
+
<button class="tool-btn active" data-tool="pen">✏️ 画笔</button>
|
|
26
|
+
<button class="tool-btn" data-tool="eraser">🧹 橡皮</button>
|
|
27
|
+
<button class="tool-btn" data-tool="text">📝 文字</button>
|
|
28
|
+
<button class="tool-btn" data-tool="shape">⬜ 形状</button>
|
|
29
|
+
<input type="color" id="colorPicker" value="#6366f1" title="选择颜色">
|
|
30
|
+
<input type="range" id="brushSize" min="1" max="20" value="3" title="画笔粗细">
|
|
31
|
+
<button class="btn-secondary" id="clearBoard">清空</button>
|
|
32
|
+
<button class="btn-primary" id="saveBoard">保存</button>
|
|
33
|
+
${adminControls}
|
|
34
|
+
</div>
|
|
35
|
+
<canvas id="whiteboard" width="800" height="600"></canvas>
|
|
36
|
+
<div class="whiteboard-footer">
|
|
37
|
+
<div class="whiteboard-users" id="whiteboardUsers">
|
|
38
|
+
<span class="user-badge">👤 在线用户: <span id="userCount">1</span></span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="whiteboard-status" id="whiteboardStatus">
|
|
41
|
+
<span class="status-badge status-active">✓ 协作模式</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const canvas = container.querySelector('#whiteboard');
|
|
48
|
+
const ctx = canvas.getContext('2d');
|
|
49
|
+
let isDrawing = false;
|
|
50
|
+
let currentTool = 'pen';
|
|
51
|
+
let currentColor = '#6366f1';
|
|
52
|
+
let brushSize = 3;
|
|
53
|
+
let collaborationEnabled = true; // 协作是否启用
|
|
54
|
+
let onlineUsers = new Set([username]); // 在线用户集合
|
|
55
|
+
|
|
56
|
+
// 调整画布大小
|
|
57
|
+
const resizeCanvas = () => {
|
|
58
|
+
const rect = canvas.parentElement.getBoundingClientRect();
|
|
59
|
+
const availableWidth = rect.width - 40;
|
|
60
|
+
const availableHeight = Math.min(500, window.innerHeight - 400);
|
|
61
|
+
|
|
62
|
+
canvas.width = Math.max(600, availableWidth);
|
|
63
|
+
canvas.height = Math.max(400, availableHeight);
|
|
64
|
+
};
|
|
65
|
+
resizeCanvas();
|
|
66
|
+
window.addEventListener('resize', resizeCanvas);
|
|
67
|
+
|
|
68
|
+
// 更新在线用户显示
|
|
69
|
+
const updateOnlineUsers = () => {
|
|
70
|
+
const userCountEl = container.querySelector('#userCount');
|
|
71
|
+
if (userCountEl) {
|
|
72
|
+
userCountEl.textContent = onlineUsers.size;
|
|
73
|
+
}
|
|
74
|
+
const usersEl = container.querySelector('#whiteboardUsers');
|
|
75
|
+
const userList = Array.from(onlineUsers).map(u => `<span class="user-badge">${u}</span>`).join('');
|
|
76
|
+
usersEl.innerHTML = `👤 在线用户 (${onlineUsers.size}): ${userList}`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 更新协作状态显示
|
|
80
|
+
const updateCollaborationStatus = () => {
|
|
81
|
+
const statusEl = container.querySelector('#whiteboardStatus');
|
|
82
|
+
if (statusEl) {
|
|
83
|
+
if (collaborationEnabled) {
|
|
84
|
+
statusEl.innerHTML = '<span class="status-badge status-active">✓ 协作模式</span>';
|
|
85
|
+
} else {
|
|
86
|
+
statusEl.innerHTML = '<span class="status-badge status-locked">🔒 仅管理员可编辑</span>';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// 工具切换
|
|
92
|
+
container.querySelectorAll('.tool-btn').forEach(btn => {
|
|
93
|
+
btn.addEventListener('click', () => {
|
|
94
|
+
container.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
|
|
95
|
+
btn.classList.add('active');
|
|
96
|
+
currentTool = btn.dataset.tool;
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 颜色选择
|
|
101
|
+
container.querySelector('#colorPicker').addEventListener('change', (e) => {
|
|
102
|
+
currentColor = e.target.value;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 画笔大小
|
|
106
|
+
container.querySelector('#brushSize').addEventListener('input', (e) => {
|
|
107
|
+
brushSize = e.target.value;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 绘画逻辑
|
|
111
|
+
let lastX = 0;
|
|
112
|
+
let lastY = 0;
|
|
113
|
+
|
|
114
|
+
// 检查是否可以绘画
|
|
115
|
+
const canDraw = () => {
|
|
116
|
+
if (isAdmin) return true; // 管理员始终可以绘画
|
|
117
|
+
if (!collaborationEnabled) {
|
|
118
|
+
return false; // 协作被禁用,普通用户不能绘画
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
124
|
+
if (!canDraw()) {
|
|
125
|
+
alert('管理员已禁止协作绘画');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
isDrawing = true;
|
|
129
|
+
const rect = canvas.getBoundingClientRect();
|
|
130
|
+
lastX = e.clientX - rect.left;
|
|
131
|
+
lastY = e.clientY - rect.top;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
135
|
+
if (!isDrawing) return;
|
|
136
|
+
if (!canDraw()) {
|
|
137
|
+
isDrawing = false;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rect = canvas.getBoundingClientRect();
|
|
142
|
+
const x = e.clientX - rect.left;
|
|
143
|
+
const y = e.clientY - rect.top;
|
|
144
|
+
|
|
145
|
+
// 绘制到本地画布
|
|
146
|
+
this.drawLine(ctx, lastX, lastY, x, y, currentTool, currentColor, brushSize);
|
|
147
|
+
|
|
148
|
+
// 广播绘画数据到其他用户
|
|
149
|
+
this.wsService.send({
|
|
150
|
+
type: 'whiteboard_draw',
|
|
151
|
+
groupId: groupId,
|
|
152
|
+
username: username,
|
|
153
|
+
data: {
|
|
154
|
+
tool: currentTool,
|
|
155
|
+
color: currentColor,
|
|
156
|
+
size: brushSize,
|
|
157
|
+
from: { x: lastX, y: lastY },
|
|
158
|
+
to: { x, y }
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
lastX = x;
|
|
163
|
+
lastY = y;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
canvas.addEventListener('mouseup', () => {
|
|
167
|
+
isDrawing = false;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
canvas.addEventListener('mouseleave', () => {
|
|
171
|
+
isDrawing = false;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 清空画布
|
|
175
|
+
container.querySelector('#clearBoard').addEventListener('click', () => {
|
|
176
|
+
if (!isAdmin && !collaborationEnabled) {
|
|
177
|
+
alert('管理员已禁止协作,无法清空画布');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (confirm('确定要清空画布吗?')) {
|
|
181
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
182
|
+
this.wsService.send({
|
|
183
|
+
type: 'whiteboard_clear',
|
|
184
|
+
groupId: groupId,
|
|
185
|
+
username: username
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 保存画布
|
|
191
|
+
container.querySelector('#saveBoard').addEventListener('click', () => {
|
|
192
|
+
const dataURL = canvas.toDataURL('image/png');
|
|
193
|
+
const link = document.createElement('a');
|
|
194
|
+
link.download = `whiteboard_${Date.now()}.png`;
|
|
195
|
+
link.href = dataURL;
|
|
196
|
+
link.click();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// 管理员功能:切换协作模式
|
|
200
|
+
if (isAdmin) {
|
|
201
|
+
const toggleBtn = container.querySelector('#toggleCollaboration');
|
|
202
|
+
toggleBtn.addEventListener('click', () => {
|
|
203
|
+
collaborationEnabled = !collaborationEnabled;
|
|
204
|
+
|
|
205
|
+
// 更新按钮文本
|
|
206
|
+
if (collaborationEnabled) {
|
|
207
|
+
toggleBtn.innerHTML = '🔒 禁止协作';
|
|
208
|
+
toggleBtn.className = 'btn-warning';
|
|
209
|
+
} else {
|
|
210
|
+
toggleBtn.innerHTML = '🔓 允许协作';
|
|
211
|
+
toggleBtn.className = 'btn-success';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 更新状态显示
|
|
215
|
+
updateCollaborationStatus();
|
|
216
|
+
|
|
217
|
+
// 广播协作状态变更
|
|
218
|
+
this.wsService.send({
|
|
219
|
+
type: 'whiteboard_collaboration_toggle',
|
|
220
|
+
groupId: groupId,
|
|
221
|
+
enabled: collaborationEnabled,
|
|
222
|
+
username: username
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
alert(collaborationEnabled ? '已允许所有用户协作绘画' : '已禁止普通用户绘画,仅管理员可编辑');
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 发送到群聊(仅管理员)
|
|
230
|
+
if (isAdmin) {
|
|
231
|
+
container.querySelector('#sendToChat').addEventListener('click', async () => {
|
|
232
|
+
try {
|
|
233
|
+
const sendBtn = container.querySelector('#sendToChat');
|
|
234
|
+
sendBtn.disabled = true;
|
|
235
|
+
sendBtn.textContent = '发送中...';
|
|
236
|
+
|
|
237
|
+
// 将画布转换为 Blob
|
|
238
|
+
canvas.toBlob(async (blob) => {
|
|
239
|
+
if (!blob) {
|
|
240
|
+
alert('生成图片失败');
|
|
241
|
+
sendBtn.disabled = false;
|
|
242
|
+
sendBtn.textContent = '📤 发送到群聊';
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
// 创建 File 对象
|
|
248
|
+
const file = new File([blob], `whiteboard_${Date.now()}.png`, { type: 'image/png' });
|
|
249
|
+
|
|
250
|
+
// 上传文件
|
|
251
|
+
const uploadResult = await this.apiService.uploadFile(groupId, file, '白板分享');
|
|
252
|
+
|
|
253
|
+
// 获取文件查看URL(用于在线预览)
|
|
254
|
+
const fileViewUrl = this.apiService.getFileViewUrl(uploadResult.file._id);
|
|
255
|
+
|
|
256
|
+
// 发送包含图片的消息到群聊
|
|
257
|
+
const message = `📋 ${username} 分享了白板内容\n<img src="${fileViewUrl}" alt="白板" class="chat-image" data-file-id="${uploadResult.file._id}">`;
|
|
258
|
+
this.wsService.sendChatMessage(groupId, username, message);
|
|
259
|
+
|
|
260
|
+
alert('白板已发送到群聊!');
|
|
261
|
+
sendBtn.disabled = false;
|
|
262
|
+
sendBtn.textContent = '📤 发送到群聊';
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.error('发送白板失败:', error);
|
|
265
|
+
alert('发送失败: ' + error.message);
|
|
266
|
+
sendBtn.disabled = false;
|
|
267
|
+
sendBtn.textContent = '📤 发送到群聊';
|
|
268
|
+
}
|
|
269
|
+
}, 'image/png');
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error('发送白板失败:', error);
|
|
272
|
+
alert('发送失败: ' + error.message);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 监听其他用户的绘画
|
|
278
|
+
this.wsService.on('whiteboard_draw', (data) => {
|
|
279
|
+
if (data.groupId === groupId) {
|
|
280
|
+
// 绘制其他用户的笔画
|
|
281
|
+
this.drawLine(ctx, data.data.from.x, data.data.from.y, data.data.to.x, data.data.to.y,
|
|
282
|
+
data.data.tool, data.data.color, data.data.size);
|
|
283
|
+
|
|
284
|
+
// 显示绘画者信息(短暂提示)
|
|
285
|
+
if (data.username && data.username !== username) {
|
|
286
|
+
this.showDrawingNotification(container, data.username);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// 监听清空画布
|
|
292
|
+
this.wsService.on('whiteboard_clear', (data) => {
|
|
293
|
+
if (data.groupId === groupId) {
|
|
294
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
295
|
+
if (data.username && data.username !== username) {
|
|
296
|
+
alert(`${data.username} 清空了画布`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// 监听协作模式切换
|
|
302
|
+
this.wsService.on('whiteboard_collaboration_toggle', (data) => {
|
|
303
|
+
if (data.groupId === groupId) {
|
|
304
|
+
collaborationEnabled = data.enabled;
|
|
305
|
+
updateCollaborationStatus();
|
|
306
|
+
|
|
307
|
+
if (!isAdmin) {
|
|
308
|
+
alert(data.enabled ?
|
|
309
|
+
`管理员已允许协作绘画` :
|
|
310
|
+
`管理员已禁止协作,您暂时无法绘画`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// 监听用户加入/离开
|
|
317
|
+
this.wsService.on('whiteboard_user_join', (data) => {
|
|
318
|
+
if (data.groupId === groupId) {
|
|
319
|
+
onlineUsers.add(data.username);
|
|
320
|
+
updateOnlineUsers();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
this.wsService.on('whiteboard_user_leave', (data) => {
|
|
325
|
+
if (data.groupId === groupId) {
|
|
326
|
+
onlineUsers.delete(data.username);
|
|
327
|
+
updateOnlineUsers();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// 通知其他用户自己加入了白板
|
|
332
|
+
this.wsService.send({
|
|
333
|
+
type: 'whiteboard_user_join',
|
|
334
|
+
groupId: groupId,
|
|
335
|
+
username: username
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// 初始化显示
|
|
339
|
+
updateOnlineUsers();
|
|
340
|
+
updateCollaborationStatus();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 绘制线条的辅助方法
|
|
345
|
+
*/
|
|
346
|
+
drawLine(ctx, fromX, fromY, toX, toY, tool, color, size) {
|
|
347
|
+
ctx.beginPath();
|
|
348
|
+
ctx.moveTo(fromX, fromY);
|
|
349
|
+
ctx.lineTo(toX, toY);
|
|
350
|
+
ctx.strokeStyle = tool === 'eraser' ? '#ffffff' : color;
|
|
351
|
+
ctx.lineWidth = size;
|
|
352
|
+
ctx.lineCap = 'round';
|
|
353
|
+
ctx.lineJoin = 'round';
|
|
354
|
+
ctx.stroke();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 显示绘画通知
|
|
359
|
+
*/
|
|
360
|
+
showDrawingNotification(container, username) {
|
|
361
|
+
// 移除旧的通知
|
|
362
|
+
const oldNotification = container.querySelector('.drawing-notification');
|
|
363
|
+
if (oldNotification) {
|
|
364
|
+
oldNotification.remove();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// 创建新通知
|
|
368
|
+
const notification = document.createElement('div');
|
|
369
|
+
notification.className = 'drawing-notification';
|
|
370
|
+
notification.textContent = `${username} 正在绘画...`;
|
|
371
|
+
notification.style.cssText = `
|
|
372
|
+
position: absolute;
|
|
373
|
+
top: 60px;
|
|
374
|
+
right: 20px;
|
|
375
|
+
background: var(--primary);
|
|
376
|
+
color: white;
|
|
377
|
+
padding: 8px 16px;
|
|
378
|
+
border-radius: 8px;
|
|
379
|
+
font-size: 14px;
|
|
380
|
+
z-index: 1000;
|
|
381
|
+
animation: slideIn 0.3s ease;
|
|
382
|
+
`;
|
|
383
|
+
container.appendChild(notification);
|
|
384
|
+
|
|
385
|
+
// 3秒后自动移除
|
|
386
|
+
setTimeout(() => {
|
|
387
|
+
notification.style.animation = 'slideOut 0.3s ease';
|
|
388
|
+
setTimeout(() => notification.remove(), 300);
|
|
389
|
+
}, 3000);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 投票功能(管理员创建为任务)
|
|
394
|
+
*/
|
|
395
|
+
createPoll(container, groupId) {
|
|
396
|
+
container.innerHTML = `
|
|
397
|
+
<div class="poll-creator">
|
|
398
|
+
<h3>创建投票任务</h3>
|
|
399
|
+
<p class="info-text">投票将作为任务分配给所有群组成员,成员可以在"我的任务"中查看并投票</p>
|
|
400
|
+
<div class="form-group">
|
|
401
|
+
<label>投票标题</label>
|
|
402
|
+
<input type="text" id="pollTitle" placeholder="请输入投票标题">
|
|
403
|
+
</div>
|
|
404
|
+
<div class="form-group">
|
|
405
|
+
<label>投票说明</label>
|
|
406
|
+
<textarea id="pollDescription" placeholder="请输入投票说明(可选)" rows="3"></textarea>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="form-group">
|
|
409
|
+
<label>选项</label>
|
|
410
|
+
<div id="pollOptions">
|
|
411
|
+
<input type="text" class="poll-option" placeholder="选项 1">
|
|
412
|
+
<input type="text" class="poll-option" placeholder="选项 2">
|
|
413
|
+
</div>
|
|
414
|
+
<button class="btn-secondary" id="addOption">+ 添加选项</button>
|
|
415
|
+
</div>
|
|
416
|
+
<div class="form-group">
|
|
417
|
+
<label>
|
|
418
|
+
<input type="checkbox" id="allowMultiple">
|
|
419
|
+
允许多选
|
|
420
|
+
</label>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="form-group">
|
|
423
|
+
<label>截止时间</label>
|
|
424
|
+
<input type="datetime-local" id="pollDeadline">
|
|
425
|
+
</div>
|
|
426
|
+
<button class="btn-primary" id="createPoll">创建投票任务</button>
|
|
427
|
+
</div>
|
|
428
|
+
`;
|
|
429
|
+
|
|
430
|
+
// 添加选项
|
|
431
|
+
container.querySelector('#addOption').addEventListener('click', () => {
|
|
432
|
+
const optionsDiv = container.querySelector('#pollOptions');
|
|
433
|
+
const optionCount = optionsDiv.querySelectorAll('.poll-option').length + 1;
|
|
434
|
+
if (optionCount > 10) {
|
|
435
|
+
alert('最多只能添加10个选项');
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const input = document.createElement('input');
|
|
439
|
+
input.type = 'text';
|
|
440
|
+
input.className = 'poll-option';
|
|
441
|
+
input.placeholder = `选项 ${optionCount}`;
|
|
442
|
+
optionsDiv.appendChild(input);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// 创建投票任务
|
|
446
|
+
container.querySelector('#createPoll').addEventListener('click', async () => {
|
|
447
|
+
const title = container.querySelector('#pollTitle').value;
|
|
448
|
+
const description = container.querySelector('#pollDescription').value;
|
|
449
|
+
const options = Array.from(container.querySelectorAll('.poll-option'))
|
|
450
|
+
.map(input => input.value)
|
|
451
|
+
.filter(value => value.trim());
|
|
452
|
+
const allowMultiple = container.querySelector('#allowMultiple').checked;
|
|
453
|
+
const deadline = container.querySelector('#pollDeadline').value;
|
|
454
|
+
|
|
455
|
+
if (!title || options.length < 2) {
|
|
456
|
+
alert('请填写标题和至少两个选项');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
// 获取群组信息
|
|
462
|
+
const groupResult = await this.apiService.getGroup(groupId);
|
|
463
|
+
const memberIds = groupResult.group.members.map(m => m._id);
|
|
464
|
+
|
|
465
|
+
// 创建投票数据
|
|
466
|
+
const pollData = {
|
|
467
|
+
options: options.map(opt => ({ text: opt, votes: [] })),
|
|
468
|
+
allowMultiple: allowMultiple,
|
|
469
|
+
totalVotes: 0
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// 创建为任务
|
|
473
|
+
await this.apiService.createTask({
|
|
474
|
+
title: `📊 ${title}`,
|
|
475
|
+
description: description || '请在下方选项中投票',
|
|
476
|
+
groupId: groupId,
|
|
477
|
+
assignedTo: memberIds,
|
|
478
|
+
deadline: deadline || null,
|
|
479
|
+
type: 'poll', // 标记为投票类型
|
|
480
|
+
pollData: pollData // 投票数据
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
alert('投票任务创建成功!所有成员可以在"我的任务"中查看并投票');
|
|
484
|
+
|
|
485
|
+
// 显示成功信息
|
|
486
|
+
container.innerHTML = `
|
|
487
|
+
<div class="success-message">
|
|
488
|
+
<h3>✅ 投票任务创建成功!</h3>
|
|
489
|
+
<p>投票标题:${title}</p>
|
|
490
|
+
<p>选项数量:${options.length}</p>
|
|
491
|
+
<p>分配给:${memberIds.length} 名成员</p>
|
|
492
|
+
<p>所有成员可以在"我的任务"中查看并投票</p>
|
|
493
|
+
<button class="btn-primary" id="closeSuccessMsg">关闭</button>
|
|
494
|
+
</div>
|
|
495
|
+
`;
|
|
496
|
+
|
|
497
|
+
// 添加关闭按钮事件
|
|
498
|
+
container.querySelector('#closeSuccessMsg').addEventListener('click', () => {
|
|
499
|
+
// 关闭协作工具模态框
|
|
500
|
+
const modal = document.querySelector('.modal');
|
|
501
|
+
if (modal) {
|
|
502
|
+
modal.remove();
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
} catch (error) {
|
|
506
|
+
alert('创建投票任务失败: ' + error.message);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 显示投票
|
|
513
|
+
*/
|
|
514
|
+
showPoll(container, poll, groupId) {
|
|
515
|
+
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes.length, 0);
|
|
516
|
+
|
|
517
|
+
container.innerHTML = `
|
|
518
|
+
<div class="poll-display">
|
|
519
|
+
<h3>${poll.title}</h3>
|
|
520
|
+
<div class="poll-options">
|
|
521
|
+
${poll.options.map((option, index) => {
|
|
522
|
+
const percentage = totalVotes > 0 ? (option.votes.length / totalVotes * 100).toFixed(1) : 0;
|
|
523
|
+
return `
|
|
524
|
+
<div class="poll-option-item">
|
|
525
|
+
<label>
|
|
526
|
+
<input type="${poll.allowMultiple ? 'checkbox' : 'radio'}"
|
|
527
|
+
name="poll"
|
|
528
|
+
value="${index}">
|
|
529
|
+
<span>${option.text}</span>
|
|
530
|
+
</label>
|
|
531
|
+
<div class="poll-progress">
|
|
532
|
+
<div class="poll-bar" style="width: ${percentage}%"></div>
|
|
533
|
+
<span class="poll-percentage">${percentage}% (${option.votes.length})</span>
|
|
534
|
+
</div>
|
|
535
|
+
</div>
|
|
536
|
+
`;
|
|
537
|
+
}).join('')}
|
|
538
|
+
</div>
|
|
539
|
+
<button class="btn-primary" id="submitVote">提交投票</button>
|
|
540
|
+
${poll.deadline ? `<p class="poll-deadline">截止时间: ${new Date(poll.deadline).toLocaleString()}</p>` : ''}
|
|
541
|
+
</div>
|
|
542
|
+
`;
|
|
543
|
+
|
|
544
|
+
// 提交投票
|
|
545
|
+
container.querySelector('#submitVote').addEventListener('click', () => {
|
|
546
|
+
const selected = Array.from(container.querySelectorAll('input[name="poll"]:checked'))
|
|
547
|
+
.map(input => parseInt(input.value));
|
|
548
|
+
|
|
549
|
+
if (selected.length === 0) {
|
|
550
|
+
alert('请选择至少一个选项');
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
this.wsService.send({
|
|
555
|
+
type: 'poll_vote',
|
|
556
|
+
groupId: groupId,
|
|
557
|
+
pollId: poll.id,
|
|
558
|
+
votes: selected
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
alert('投票成功!');
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 屏幕共享(模拟)
|
|
567
|
+
*/
|
|
568
|
+
async startScreenShare(container) {
|
|
569
|
+
try {
|
|
570
|
+
// 实际应用中应使用 navigator.mediaDevices.getDisplayMedia()
|
|
571
|
+
container.innerHTML = `
|
|
572
|
+
<div class="screen-share">
|
|
573
|
+
<h3>屏幕共享</h3>
|
|
574
|
+
<p>屏幕共享功能需要浏览器支持 Screen Capture API</p>
|
|
575
|
+
<div class="screen-preview">
|
|
576
|
+
<p>📺 屏幕共享预览区域</p>
|
|
577
|
+
</div>
|
|
578
|
+
<button class="btn-danger" id="stopShare">停止共享</button>
|
|
579
|
+
</div>
|
|
580
|
+
`;
|
|
581
|
+
|
|
582
|
+
container.querySelector('#stopShare').addEventListener('click', () => {
|
|
583
|
+
container.innerHTML = '<p>屏幕共享已停止</p>';
|
|
584
|
+
});
|
|
585
|
+
} catch (error) {
|
|
586
|
+
alert('屏幕共享失败: ' + error.message);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* 代码协作编辑
|
|
592
|
+
*/
|
|
593
|
+
createCodeEditor(container) {
|
|
594
|
+
container.innerHTML = `
|
|
595
|
+
<div class="code-editor">
|
|
596
|
+
<div class="editor-toolbar">
|
|
597
|
+
<select id="languageSelect">
|
|
598
|
+
<option value="javascript">JavaScript</option>
|
|
599
|
+
<option value="python">Python</option>
|
|
600
|
+
<option value="java">Java</option>
|
|
601
|
+
<option value="cpp">C++</option>
|
|
602
|
+
<option value="html">HTML</option>
|
|
603
|
+
<option value="css">CSS</option>
|
|
604
|
+
</select>
|
|
605
|
+
<button class="btn-secondary" id="formatCode">格式化</button>
|
|
606
|
+
<button class="btn-primary" id="runCode">运行</button>
|
|
607
|
+
</div>
|
|
608
|
+
<textarea id="codeEditor" placeholder="在这里编写代码..."></textarea>
|
|
609
|
+
<div class="code-output" id="codeOutput">
|
|
610
|
+
<h4>输出</h4>
|
|
611
|
+
<pre id="outputContent"></pre>
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
`;
|
|
615
|
+
|
|
616
|
+
const editor = container.querySelector('#codeEditor');
|
|
617
|
+
const output = container.querySelector('#outputContent');
|
|
618
|
+
|
|
619
|
+
// 格式化代码
|
|
620
|
+
container.querySelector('#formatCode').addEventListener('click', () => {
|
|
621
|
+
// 简单的格式化(实际应使用专业的格式化库)
|
|
622
|
+
const code = editor.value;
|
|
623
|
+
const formatted = code.split('\n').map(line => line.trim()).join('\n');
|
|
624
|
+
editor.value = formatted;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// 运行代码(模拟)
|
|
628
|
+
container.querySelector('#runCode').addEventListener('click', () => {
|
|
629
|
+
const code = editor.value;
|
|
630
|
+
const language = container.querySelector('#languageSelect').value;
|
|
631
|
+
|
|
632
|
+
output.textContent = `正在运行 ${language} 代码...\n\n`;
|
|
633
|
+
|
|
634
|
+
setTimeout(() => {
|
|
635
|
+
output.textContent += `代码执行完成!\n`;
|
|
636
|
+
output.textContent += `(实际应用中需要后端支持代码执行)`;
|
|
637
|
+
}, 1000);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// 实时同步代码
|
|
641
|
+
let syncTimeout;
|
|
642
|
+
editor.addEventListener('input', () => {
|
|
643
|
+
clearTimeout(syncTimeout);
|
|
644
|
+
syncTimeout = setTimeout(() => {
|
|
645
|
+
this.wsService.send({
|
|
646
|
+
type: 'code_sync',
|
|
647
|
+
code: editor.value,
|
|
648
|
+
language: container.querySelector('#languageSelect').value
|
|
649
|
+
});
|
|
650
|
+
}, 500);
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* 思维导图 - 增强版
|
|
656
|
+
*/
|
|
657
|
+
createMindMap(container) {
|
|
658
|
+
container.innerHTML = `
|
|
659
|
+
<div class="mindmap-container">
|
|
660
|
+
<div class="mindmap-toolbar">
|
|
661
|
+
<button class="btn-secondary" id="addChildNode">➕ 添加子节点</button>
|
|
662
|
+
<button class="btn-secondary" id="addSiblingNode">➕ 添加同级节点</button>
|
|
663
|
+
<button class="btn-danger" id="deleteNode">🗑️ 删除节点</button>
|
|
664
|
+
<button class="btn-secondary" id="toggleLines">🔗 显示/隐藏连线</button>
|
|
665
|
+
<button class="btn-secondary" id="autoLayout">📐 自动布局</button>
|
|
666
|
+
<button class="btn-primary" id="saveMindMap">💾 保存</button>
|
|
667
|
+
<button class="btn-success" id="exportImage">📸 导出图片</button>
|
|
668
|
+
</div>
|
|
669
|
+
<div class="mindmap-canvas" id="mindmapCanvas">
|
|
670
|
+
<svg class="mindmap-lines" id="mindmapLines"></svg>
|
|
671
|
+
<div class="mindmap-node root" data-id="root" data-level="0" style="left: 400px; top: 50px;">
|
|
672
|
+
<div class="node-actions">
|
|
673
|
+
<button class="node-action-btn add" title="添加子节点">+</button>
|
|
674
|
+
</div>
|
|
675
|
+
<div class="node-content" contenteditable="true">中心主题</div>
|
|
676
|
+
</div>
|
|
677
|
+
<div class="mindmap-legend">
|
|
678
|
+
<div class="mindmap-legend-item">
|
|
679
|
+
<div class="legend-color" style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);"></div>
|
|
680
|
+
<span>根节点</span>
|
|
681
|
+
</div>
|
|
682
|
+
<div class="mindmap-legend-item">
|
|
683
|
+
<div class="legend-color" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);"></div>
|
|
684
|
+
<span>一级节点</span>
|
|
685
|
+
</div>
|
|
686
|
+
<div class="mindmap-legend-item">
|
|
687
|
+
<div class="legend-color" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);"></div>
|
|
688
|
+
<span>二级节点</span>
|
|
689
|
+
</div>
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
`;
|
|
694
|
+
|
|
695
|
+
let nodeCount = 0;
|
|
696
|
+
let selectedNode = null;
|
|
697
|
+
let showLines = true;
|
|
698
|
+
const nodes = new Map();
|
|
699
|
+
const connections = new Map(); // 存储父子关系
|
|
700
|
+
|
|
701
|
+
// 初始化根节点
|
|
702
|
+
const rootNode = container.querySelector('.mindmap-node.root');
|
|
703
|
+
nodes.set('root', { element: rootNode, parent: null, children: [] });
|
|
704
|
+
this.makeDraggable(rootNode, () => this.updateLines(container, connections, showLines));
|
|
705
|
+
|
|
706
|
+
// 添加子节点
|
|
707
|
+
const addChildNode = (parentNode) => {
|
|
708
|
+
const canvas = container.querySelector('#mindmapCanvas');
|
|
709
|
+
const parentId = parentNode.dataset.id;
|
|
710
|
+
const parentLevel = parseInt(parentNode.dataset.level || 0);
|
|
711
|
+
const childLevel = parentLevel + 1;
|
|
712
|
+
const nodeId = `node_${++nodeCount}`;
|
|
713
|
+
|
|
714
|
+
// 确定节点样式类
|
|
715
|
+
let nodeClass = 'mindmap-node';
|
|
716
|
+
if (childLevel === 1) nodeClass += ' child';
|
|
717
|
+
else if (childLevel >= 2) nodeClass += ' grandchild';
|
|
718
|
+
|
|
719
|
+
// 计算子节点位置(在父节点右下方)
|
|
720
|
+
const parentRect = parentNode.getBoundingClientRect();
|
|
721
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
722
|
+
const left = (parentRect.left - canvasRect.left) + 200;
|
|
723
|
+
const top = (parentRect.top - canvasRect.top) + (nodes.get(parentId).children.length * 80);
|
|
724
|
+
|
|
725
|
+
const node = document.createElement('div');
|
|
726
|
+
node.className = nodeClass;
|
|
727
|
+
node.dataset.id = nodeId;
|
|
728
|
+
node.dataset.level = childLevel;
|
|
729
|
+
node.dataset.parent = parentId;
|
|
730
|
+
node.style.left = left + 'px';
|
|
731
|
+
node.style.top = top + 'px';
|
|
732
|
+
node.innerHTML = `
|
|
733
|
+
<div class="node-actions">
|
|
734
|
+
<button class="node-action-btn add" title="添加子节点">+</button>
|
|
735
|
+
<button class="node-action-btn delete" title="删除节点">×</button>
|
|
736
|
+
</div>
|
|
737
|
+
<div class="node-content" contenteditable="true">新节点</div>
|
|
738
|
+
`;
|
|
739
|
+
|
|
740
|
+
canvas.appendChild(node);
|
|
741
|
+
|
|
742
|
+
// 记录节点关系
|
|
743
|
+
nodes.set(nodeId, { element: node, parent: parentId, children: [] });
|
|
744
|
+
nodes.get(parentId).children.push(nodeId);
|
|
745
|
+
connections.set(nodeId, parentId);
|
|
746
|
+
|
|
747
|
+
// 使节点可拖动
|
|
748
|
+
this.makeDraggable(node, () => this.updateLines(container, connections, showLines));
|
|
749
|
+
|
|
750
|
+
// 绑定节点操作按钮
|
|
751
|
+
this.bindNodeActions(node, container, nodes, connections, showLines);
|
|
752
|
+
|
|
753
|
+
// 更新连线
|
|
754
|
+
this.updateLines(container, connections, showLines);
|
|
755
|
+
|
|
756
|
+
// 自动聚焦到新节点
|
|
757
|
+
node.querySelector('.node-content').focus();
|
|
758
|
+
node.querySelector('.node-content').select();
|
|
759
|
+
|
|
760
|
+
return node;
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// 绑定根节点操作按钮
|
|
764
|
+
this.bindNodeActions(rootNode, container, nodes, connections, showLines);
|
|
765
|
+
|
|
766
|
+
// 添加子节点按钮
|
|
767
|
+
container.querySelector('#addChildNode').addEventListener('click', () => {
|
|
768
|
+
if (selectedNode) {
|
|
769
|
+
addChildNode(selectedNode);
|
|
770
|
+
} else {
|
|
771
|
+
alert('请先选择一个节点');
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// 添加同级节点按钮
|
|
776
|
+
container.querySelector('#addSiblingNode').addEventListener('click', () => {
|
|
777
|
+
if (selectedNode && selectedNode.dataset.parent) {
|
|
778
|
+
const parentNode = container.querySelector(`[data-id="${selectedNode.dataset.parent}"]`);
|
|
779
|
+
if (parentNode) {
|
|
780
|
+
addChildNode(parentNode);
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
alert('根节点没有同级节点');
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// 删除节点按钮
|
|
788
|
+
container.querySelector('#deleteNode').addEventListener('click', () => {
|
|
789
|
+
if (selectedNode && !selectedNode.classList.contains('root')) {
|
|
790
|
+
this.deleteNodeAndChildren(selectedNode, nodes, connections);
|
|
791
|
+
this.updateLines(container, connections, showLines);
|
|
792
|
+
selectedNode = null;
|
|
793
|
+
} else {
|
|
794
|
+
alert('请选择要删除的节点(根节点不能删除)');
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
// 切换连线显示
|
|
799
|
+
container.querySelector('#toggleLines').addEventListener('click', () => {
|
|
800
|
+
showLines = !showLines;
|
|
801
|
+
this.updateLines(container, connections, showLines);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// 自动布局
|
|
805
|
+
container.querySelector('#autoLayout').addEventListener('click', () => {
|
|
806
|
+
this.autoLayoutNodes(container, nodes);
|
|
807
|
+
this.updateLines(container, connections, showLines);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// 节点选择
|
|
811
|
+
container.addEventListener('click', (e) => {
|
|
812
|
+
const node = e.target.closest('.mindmap-node');
|
|
813
|
+
if (node) {
|
|
814
|
+
container.querySelectorAll('.mindmap-node').forEach(n => n.classList.remove('selected'));
|
|
815
|
+
node.classList.add('selected');
|
|
816
|
+
selectedNode = node;
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// 保存思维导图
|
|
821
|
+
container.querySelector('#saveMindMap').addEventListener('click', () => {
|
|
822
|
+
const data = {
|
|
823
|
+
nodes: Array.from(nodes.entries()).map(([id, info]) => ({
|
|
824
|
+
id: id,
|
|
825
|
+
content: info.element.querySelector('.node-content').textContent,
|
|
826
|
+
level: info.element.dataset.level,
|
|
827
|
+
parent: info.parent,
|
|
828
|
+
position: {
|
|
829
|
+
left: info.element.style.left,
|
|
830
|
+
top: info.element.style.top
|
|
831
|
+
}
|
|
832
|
+
})),
|
|
833
|
+
connections: Array.from(connections.entries())
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
console.log('思维导图数据:', data);
|
|
837
|
+
|
|
838
|
+
// 保存到本地存储
|
|
839
|
+
localStorage.setItem('mindmap_data', JSON.stringify(data));
|
|
840
|
+
alert('思维导图已保存到本地!');
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// 导出为图片
|
|
844
|
+
container.querySelector('#exportImage').addEventListener('click', async () => {
|
|
845
|
+
try {
|
|
846
|
+
const canvas = container.querySelector('#mindmapCanvas');
|
|
847
|
+
|
|
848
|
+
// 使用 html2canvas 库(如果可用)
|
|
849
|
+
if (typeof html2canvas !== 'undefined') {
|
|
850
|
+
const canvasElement = await html2canvas(canvas);
|
|
851
|
+
const link = document.createElement('a');
|
|
852
|
+
link.download = `mindmap_${Date.now()}.png`;
|
|
853
|
+
link.href = canvasElement.toDataURL();
|
|
854
|
+
link.click();
|
|
855
|
+
} else {
|
|
856
|
+
alert('导出图片功能需要 html2canvas 库支持。\n您可以使用浏览器的截图功能来保存思维导图。');
|
|
857
|
+
}
|
|
858
|
+
} catch (error) {
|
|
859
|
+
console.error('导出失败:', error);
|
|
860
|
+
alert('导出失败,请使用浏览器截图功能');
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// 初始化连线
|
|
865
|
+
this.updateLines(container, connections, showLines);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* 绑定节点操作按钮
|
|
870
|
+
*/
|
|
871
|
+
bindNodeActions(node, container, nodes, connections, showLines) {
|
|
872
|
+
const addBtn = node.querySelector('.node-action-btn.add');
|
|
873
|
+
const deleteBtn = node.querySelector('.node-action-btn.delete');
|
|
874
|
+
|
|
875
|
+
if (addBtn) {
|
|
876
|
+
addBtn.addEventListener('click', (e) => {
|
|
877
|
+
e.stopPropagation();
|
|
878
|
+
const canvas = container.querySelector('#mindmapCanvas');
|
|
879
|
+
const parentId = node.dataset.id;
|
|
880
|
+
const parentLevel = parseInt(node.dataset.level || 0);
|
|
881
|
+
const childLevel = parentLevel + 1;
|
|
882
|
+
const nodeId = `node_${Date.now()}`;
|
|
883
|
+
|
|
884
|
+
let nodeClass = 'mindmap-node';
|
|
885
|
+
if (childLevel === 1) nodeClass += ' child';
|
|
886
|
+
else if (childLevel >= 2) nodeClass += ' grandchild';
|
|
887
|
+
|
|
888
|
+
const parentRect = node.getBoundingClientRect();
|
|
889
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
890
|
+
const left = (parentRect.left - canvasRect.left) + 200;
|
|
891
|
+
const top = (parentRect.top - canvasRect.top) + (nodes.get(parentId).children.length * 80);
|
|
892
|
+
|
|
893
|
+
const newNode = document.createElement('div');
|
|
894
|
+
newNode.className = nodeClass;
|
|
895
|
+
newNode.dataset.id = nodeId;
|
|
896
|
+
newNode.dataset.level = childLevel;
|
|
897
|
+
newNode.dataset.parent = parentId;
|
|
898
|
+
newNode.style.left = left + 'px';
|
|
899
|
+
newNode.style.top = top + 'px';
|
|
900
|
+
newNode.innerHTML = `
|
|
901
|
+
<div class="node-actions">
|
|
902
|
+
<button class="node-action-btn add" title="添加子节点">+</button>
|
|
903
|
+
<button class="node-action-btn delete" title="删除节点">×</button>
|
|
904
|
+
</div>
|
|
905
|
+
<div class="node-content" contenteditable="true">新节点</div>
|
|
906
|
+
`;
|
|
907
|
+
|
|
908
|
+
canvas.appendChild(newNode);
|
|
909
|
+
|
|
910
|
+
nodes.set(nodeId, { element: newNode, parent: parentId, children: [] });
|
|
911
|
+
nodes.get(parentId).children.push(nodeId);
|
|
912
|
+
connections.set(nodeId, parentId);
|
|
913
|
+
|
|
914
|
+
this.makeDraggable(newNode, () => this.updateLines(container, connections, showLines));
|
|
915
|
+
this.bindNodeActions(newNode, container, nodes, connections, showLines);
|
|
916
|
+
this.updateLines(container, connections, showLines);
|
|
917
|
+
|
|
918
|
+
newNode.querySelector('.node-content').focus();
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (deleteBtn) {
|
|
923
|
+
deleteBtn.addEventListener('click', (e) => {
|
|
924
|
+
e.stopPropagation();
|
|
925
|
+
if (confirm('确定要删除此节点及其所有子节点吗?')) {
|
|
926
|
+
this.deleteNodeAndChildren(node, nodes, connections);
|
|
927
|
+
this.updateLines(container, connections, showLines);
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* 删除节点及其所有子节点
|
|
935
|
+
*/
|
|
936
|
+
deleteNodeAndChildren(node, nodes, connections) {
|
|
937
|
+
const nodeId = node.dataset.id;
|
|
938
|
+
const nodeInfo = nodes.get(nodeId);
|
|
939
|
+
|
|
940
|
+
if (!nodeInfo) return;
|
|
941
|
+
|
|
942
|
+
// 递归删除所有子节点
|
|
943
|
+
const deleteRecursive = (id) => {
|
|
944
|
+
const info = nodes.get(id);
|
|
945
|
+
if (!info) return;
|
|
946
|
+
|
|
947
|
+
// 删除所有子节点
|
|
948
|
+
info.children.forEach(childId => deleteRecursive(childId));
|
|
949
|
+
|
|
950
|
+
// 从父节点的子节点列表中移除
|
|
951
|
+
if (info.parent) {
|
|
952
|
+
const parentInfo = nodes.get(info.parent);
|
|
953
|
+
if (parentInfo) {
|
|
954
|
+
parentInfo.children = parentInfo.children.filter(cid => cid !== id);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// 删除连接
|
|
959
|
+
connections.delete(id);
|
|
960
|
+
|
|
961
|
+
// 删除DOM元素
|
|
962
|
+
if (info.element && info.element.parentNode) {
|
|
963
|
+
info.element.remove();
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// 从nodes中删除
|
|
967
|
+
nodes.delete(id);
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
deleteRecursive(nodeId);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* 更新连线 - 性能优化版
|
|
975
|
+
*/
|
|
976
|
+
updateLines(container, connections, showLines) {
|
|
977
|
+
const svg = container.querySelector('#mindmapLines');
|
|
978
|
+
if (!svg) return;
|
|
979
|
+
|
|
980
|
+
if (!showLines) {
|
|
981
|
+
svg.innerHTML = '';
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// 使用 requestAnimationFrame 批量更新
|
|
986
|
+
requestAnimationFrame(() => {
|
|
987
|
+
const canvas = container.querySelector('#mindmapCanvas');
|
|
988
|
+
const canvasRect = canvas.getBoundingClientRect();
|
|
989
|
+
|
|
990
|
+
// 设置SVG尺寸
|
|
991
|
+
svg.setAttribute('width', canvas.scrollWidth);
|
|
992
|
+
svg.setAttribute('height', canvas.scrollHeight);
|
|
993
|
+
svg.style.position = 'absolute';
|
|
994
|
+
svg.style.top = '0';
|
|
995
|
+
svg.style.left = '0';
|
|
996
|
+
svg.style.pointerEvents = 'none';
|
|
997
|
+
|
|
998
|
+
// 使用 DocumentFragment 批量添加元素
|
|
999
|
+
const fragment = document.createDocumentFragment();
|
|
1000
|
+
const tempSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
1001
|
+
|
|
1002
|
+
connections.forEach((parentId, childId) => {
|
|
1003
|
+
const childNode = container.querySelector(`[data-id="${childId}"]`);
|
|
1004
|
+
const parentNode = container.querySelector(`[data-id="${parentId}"]`);
|
|
1005
|
+
|
|
1006
|
+
if (!childNode || !parentNode) return;
|
|
1007
|
+
|
|
1008
|
+
const childRect = childNode.getBoundingClientRect();
|
|
1009
|
+
const parentRect = parentNode.getBoundingClientRect();
|
|
1010
|
+
|
|
1011
|
+
const x1 = parentRect.left - canvasRect.left + parentRect.width;
|
|
1012
|
+
const y1 = parentRect.top - canvasRect.top + parentRect.height / 2;
|
|
1013
|
+
const x2 = childRect.left - canvasRect.left;
|
|
1014
|
+
const y2 = childRect.top - canvasRect.top + childRect.height / 2;
|
|
1015
|
+
|
|
1016
|
+
// 创建贝塞尔曲线
|
|
1017
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
1018
|
+
const midX = (x1 + x2) / 2;
|
|
1019
|
+
const d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
|
1020
|
+
line.setAttribute('d', d);
|
|
1021
|
+
line.setAttribute('stroke', '#6366f1');
|
|
1022
|
+
line.setAttribute('stroke-width', '2');
|
|
1023
|
+
line.setAttribute('fill', 'none');
|
|
1024
|
+
line.setAttribute('stroke-linecap', 'round');
|
|
1025
|
+
|
|
1026
|
+
tempSvg.appendChild(line);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
// 一次性替换所有内容
|
|
1030
|
+
svg.innerHTML = tempSvg.innerHTML;
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* 自动布局节点
|
|
1036
|
+
*/
|
|
1037
|
+
autoLayoutNodes(container, nodes) {
|
|
1038
|
+
const rootNode = nodes.get('root');
|
|
1039
|
+
if (!rootNode) return;
|
|
1040
|
+
|
|
1041
|
+
// 设置根节点位置
|
|
1042
|
+
rootNode.element.style.left = '50px';
|
|
1043
|
+
rootNode.element.style.top = '50px';
|
|
1044
|
+
|
|
1045
|
+
// 递归布局子节点
|
|
1046
|
+
const layoutChildren = (parentId, startX, startY, verticalSpacing = 100) => {
|
|
1047
|
+
const parentInfo = nodes.get(parentId);
|
|
1048
|
+
if (!parentInfo || parentInfo.children.length === 0) return;
|
|
1049
|
+
|
|
1050
|
+
let currentY = startY;
|
|
1051
|
+
|
|
1052
|
+
parentInfo.children.forEach((childId, index) => {
|
|
1053
|
+
const childInfo = nodes.get(childId);
|
|
1054
|
+
if (!childInfo) return;
|
|
1055
|
+
|
|
1056
|
+
childInfo.element.style.left = startX + 'px';
|
|
1057
|
+
childInfo.element.style.top = currentY + 'px';
|
|
1058
|
+
|
|
1059
|
+
// 递归布局子节点的子节点
|
|
1060
|
+
layoutChildren(childId, startX + 250, currentY, verticalSpacing);
|
|
1061
|
+
|
|
1062
|
+
currentY += verticalSpacing;
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
layoutChildren('root', 300, 50);
|
|
1067
|
+
|
|
1068
|
+
alert('自动布局完成!');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* 使元素可拖动 - 性能优化版
|
|
1073
|
+
*/
|
|
1074
|
+
makeDraggable(element, onDragEnd) {
|
|
1075
|
+
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
|
|
1076
|
+
let isDragging = false;
|
|
1077
|
+
let animationFrameId = null;
|
|
1078
|
+
|
|
1079
|
+
element.onmousedown = dragMouseDown;
|
|
1080
|
+
|
|
1081
|
+
function dragMouseDown(e) {
|
|
1082
|
+
// 如果点击的是可编辑内容或按钮,不触发拖动
|
|
1083
|
+
if (e.target.contentEditable === 'true' || e.target.tagName === 'BUTTON') return;
|
|
1084
|
+
|
|
1085
|
+
e.preventDefault();
|
|
1086
|
+
e.stopPropagation();
|
|
1087
|
+
pos3 = e.clientX;
|
|
1088
|
+
pos4 = e.clientY;
|
|
1089
|
+
isDragging = true;
|
|
1090
|
+
|
|
1091
|
+
document.onmouseup = closeDragElement;
|
|
1092
|
+
document.onmousemove = elementDrag;
|
|
1093
|
+
|
|
1094
|
+
// 添加拖动样式
|
|
1095
|
+
element.style.cursor = 'grabbing';
|
|
1096
|
+
element.style.zIndex = '1000';
|
|
1097
|
+
element.style.transition = 'none'; // 禁用过渡动画以提高性能
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function elementDrag(e) {
|
|
1101
|
+
if (!isDragging) return;
|
|
1102
|
+
|
|
1103
|
+
e.preventDefault();
|
|
1104
|
+
|
|
1105
|
+
// 使用 requestAnimationFrame 优化性能
|
|
1106
|
+
if (animationFrameId) {
|
|
1107
|
+
cancelAnimationFrame(animationFrameId);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
animationFrameId = requestAnimationFrame(() => {
|
|
1111
|
+
pos1 = pos3 - e.clientX;
|
|
1112
|
+
pos2 = pos4 - e.clientY;
|
|
1113
|
+
pos3 = e.clientX;
|
|
1114
|
+
pos4 = e.clientY;
|
|
1115
|
+
|
|
1116
|
+
// 直接使用 transform 而不是 top/left,性能更好
|
|
1117
|
+
const newTop = element.offsetTop - pos2;
|
|
1118
|
+
const newLeft = element.offsetLeft - pos1;
|
|
1119
|
+
|
|
1120
|
+
element.style.top = newTop + 'px';
|
|
1121
|
+
element.style.left = newLeft + 'px';
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function closeDragElement() {
|
|
1126
|
+
isDragging = false;
|
|
1127
|
+
document.onmouseup = null;
|
|
1128
|
+
document.onmousemove = null;
|
|
1129
|
+
|
|
1130
|
+
// 取消未完成的动画帧
|
|
1131
|
+
if (animationFrameId) {
|
|
1132
|
+
cancelAnimationFrame(animationFrameId);
|
|
1133
|
+
animationFrameId = null;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 恢复样式
|
|
1137
|
+
element.style.cursor = 'move';
|
|
1138
|
+
element.style.zIndex = '';
|
|
1139
|
+
element.style.transition = ''; // 恢复过渡动画
|
|
1140
|
+
|
|
1141
|
+
// 拖动结束后才更新连线(而不是拖动过程中)
|
|
1142
|
+
if (typeof onDragEnd === 'function') {
|
|
1143
|
+
// 使用 setTimeout 确保 DOM 更新完成
|
|
1144
|
+
setTimeout(() => {
|
|
1145
|
+
onDragEnd();
|
|
1146
|
+
}, 0);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|