diff --git a/internal/app/app.go b/internal/app/app.go index 7dbe860a..2f827b88 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -374,6 +374,7 @@ func setupRoutes( protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations) protected.POST("/groups/conversations", groupHandler.AddConversationToGroup) protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup) + protected.PUT("/groups/:id/conversations/:conversationId/pinned", groupHandler.UpdateConversationPinnedInGroup) // 监控 protected.GET("/monitor", monitorHandler.Monitor) diff --git a/internal/database/database.go b/internal/database/database.go index 0ab14813..8ecdc81d 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -242,6 +242,11 @@ func (db *DB) initTables() error { // 不返回错误,允许继续运行 } + if err := db.migrateConversationGroupMappingsTable(); err != nil { + db.logger.Warn("迁移conversation_group_mappings表失败", zap.Error(err)) + // 不返回错误,允许继续运行 + } + if _, err := db.Exec(createIndexes); err != nil { return fmt.Errorf("创建索引失败: %w", err) } @@ -334,6 +339,30 @@ func (db *DB) migrateConversationGroupsTable() error { return nil } +// migrateConversationGroupMappingsTable 迁移conversation_group_mappings表,添加新字段 +func (db *DB) migrateConversationGroupMappingsTable() error { + // 检查pinned字段是否存在 + var count int + err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversation_group_mappings') WHERE name='pinned'").Scan(&count) + if err != nil { + // 如果查询失败,尝试添加字段 + if _, addErr := db.Exec("ALTER TABLE conversation_group_mappings ADD COLUMN pinned INTEGER DEFAULT 0"); addErr != nil { + // 如果字段已存在,忽略错误 + errMsg := strings.ToLower(addErr.Error()) + if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") { + db.logger.Warn("添加pinned字段失败", zap.Error(addErr)) + } + } + } else if count == 0 { + // 字段不存在,添加它 + if _, err := db.Exec("ALTER TABLE conversation_group_mappings ADD COLUMN pinned INTEGER DEFAULT 0"); err != nil { + db.logger.Warn("添加pinned字段失败", zap.Error(err)) + } + } + + return nil +} + // NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表) func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) { sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1") diff --git a/internal/database/group.go b/internal/database/group.go index 7a56ff56..2e9eac49 100644 --- a/internal/database/group.go +++ b/internal/database/group.go @@ -222,11 +222,11 @@ func (db *DB) RemoveConversationFromGroup(conversationID, groupID string) error // GetConversationsByGroup 获取分组中的所有对话 func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) { rows, err := db.Query( - `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at + `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, COALESCE(cgm.pinned, 0) as group_pinned FROM conversations c INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id WHERE cgm.group_id = ? - ORDER BY c.updated_at DESC`, + ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC`, groupID, ) if err != nil { @@ -239,8 +239,9 @@ func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) { var conv Conversation var createdAt, updatedAt string var pinned int + var groupPinned int - if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt); err != nil { + if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); err != nil { return nil, fmt.Errorf("扫描对话失败: %w", err) } @@ -318,3 +319,19 @@ func (db *DB) UpdateGroupPinned(id string, pinned bool) error { } return nil } + +// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态 +func (db *DB) UpdateConversationPinnedInGroup(conversationID, groupID string, pinned bool) error { + pinnedValue := 0 + if pinned { + pinnedValue = 1 + } + _, err := db.Exec( + "UPDATE conversation_group_mappings SET pinned = ? WHERE conversation_id = ? AND group_id = ?", + pinnedValue, conversationID, groupID, + ) + if err != nil { + return fmt.Errorf("更新分组对话置顶状态失败: %w", err) + } + return nil +} diff --git a/internal/handler/group.go b/internal/handler/group.go index 54a4b219..9ecf5a95 100644 --- a/internal/handler/group.go +++ b/internal/handler/group.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "time" "cyberstrike-ai/internal/database" @@ -175,6 +176,16 @@ func (h *GroupHandler) RemoveConversationFromGroup(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "移除成功"}) } +// GroupConversation 分组对话响应结构 +type GroupConversation struct { + ID string `json:"id"` + Title string `json:"title"` + Pinned bool `json:"pinned"` + GroupPinned bool `json:"groupPinned"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + // GetGroupConversations 获取分组中的所有对话 func (h *GroupHandler) GetGroupConversations(c *gin.Context) { groupID := c.Param("id") @@ -186,7 +197,31 @@ func (h *GroupHandler) GetGroupConversations(c *gin.Context) { return } - c.JSON(http.StatusOK, conversations) + // 获取每个对话在分组中的置顶状态 + groupConvs := make([]GroupConversation, 0, len(conversations)) + for _, conv := range conversations { + // 查询分组内置顶状态 + var groupPinned int + err := h.db.QueryRow( + "SELECT COALESCE(pinned, 0) FROM conversation_group_mappings WHERE conversation_id = ? AND group_id = ?", + conv.ID, groupID, + ).Scan(&groupPinned) + if err != nil { + h.logger.Warn("查询分组内置顶状态失败", zap.String("conversationId", conv.ID), zap.Error(err)) + groupPinned = 0 + } + + groupConvs = append(groupConvs, GroupConversation{ + ID: conv.ID, + Title: conv.Title, + Pinned: conv.Pinned, + GroupPinned: groupPinned != 0, + CreatedAt: conv.CreatedAt, + UpdatedAt: conv.UpdatedAt, + }) + } + + c.JSON(http.StatusOK, groupConvs) } // UpdateConversationPinnedRequest 更新对话置顶状态请求 @@ -236,3 +271,28 @@ func (h *GroupHandler) UpdateGroupPinned(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) } + +// UpdateConversationPinnedInGroupRequest 更新分组对话置顶状态请求 +type UpdateConversationPinnedInGroupRequest struct { + Pinned bool `json:"pinned"` +} + +// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态 +func (h *GroupHandler) UpdateConversationPinnedInGroup(c *gin.Context) { + groupID := c.Param("id") + conversationID := c.Param("conversationId") + + var req UpdateConversationPinnedInGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.db.UpdateConversationPinnedInGroup(conversationID, groupID, req.Pinned); err != nil { + h.logger.Error("更新分组对话置顶状态失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "更新成功"}) +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 291984cc..1c959c6d 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -5130,42 +5130,45 @@ header { } .conversation-item-menu { - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; border: none; background: transparent; - color: var(--text-muted); + color: var(--text-secondary); cursor: pointer; - border-radius: 4px; + border-radius: 6px; display: flex; align-items: center; justify-content: center; - opacity: 0; + opacity: 0.6; transition: all 0.2s ease; flex-shrink: 0; + font-size: 18px; + font-weight: 600; + line-height: 1; } -.conversation-item:hover .conversation-item-menu { +.conversation-item:hover .conversation-item-menu, +.group-conversation-item:hover .conversation-item-menu { opacity: 1; } .conversation-item-menu:hover { background: var(--bg-tertiary); color: var(--text-primary); + opacity: 1; } /* 分组详情页面 */ .group-detail-page { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--bg-primary); - z-index: 10; display: flex; flex-direction: column; + flex: 1; + min-width: 0; + background: var(--bg-primary); + overflow: hidden; + height: 100%; } .group-detail-header { @@ -5244,55 +5247,92 @@ header { flex: 1; overflow-y: auto; padding: 24px; + background: #f5f7fa; } .group-conversations-list { display: flex; flex-direction: column; - gap: 16px; + gap: 8px; + max-width: 100%; } .group-conversation-item { - padding: 16px; + padding: 12px 16px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; transition: all 0.2s ease; + position: relative; + display: flex; + align-items: flex-start; + gap: 12px; } .group-conversation-item:hover { + background: var(--bg-tertiary); border-color: var(--accent-color); - box-shadow: 0 2px 8px rgba(0, 102, 255, 0.1); +} + +.group-conversation-item.active { + background: rgba(0, 102, 255, 0.08); + border-color: var(--accent-color); +} + +.group-conversation-item:hover .conversation-item-menu { + opacity: 1; +} + +.group-conversation-content-wrapper { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.group-conversation-item .conversation-item-menu { + position: absolute; + top: 8px; + right: 8px; + opacity: 0.6; + flex-shrink: 0; } .group-conversation-title { - font-size: 1rem; - font-weight: 600; + font-size: 0.9375rem; + font-weight: 500; color: var(--text-primary); - margin-bottom: 8px; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; } .group-conversation-time { - font-size: 0.875rem; + font-size: 0.75rem; color: var(--text-muted); - display: flex; - align-items: center; - justify-content: space-between; + line-height: 1.4; } .group-conversation-content { - margin-top: 12px; - padding: 12px; + margin-top: 4px; + padding: 8px 12px; background: var(--bg-secondary); border-radius: 6px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.8125rem; - color: var(--text-primary); - max-height: 200px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-all; + color: var(--text-secondary); + line-height: 1.5; + max-height: 60px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-word; } /* 批量管理模态框 */ @@ -5559,7 +5599,18 @@ header { border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); min-width: 160px; + max-width: 240px; + max-height: 400px; + overflow-y: auto; padding: 4px; + z-index: 10001; +} + +.context-submenu[style*="right: 100%"] { + left: auto; + right: 100%; + margin-left: 0; + margin-right: 4px; } .context-submenu-item { @@ -5572,6 +5623,13 @@ header { font-size: 0.875rem; border-radius: 6px; transition: all 0.2s ease; + white-space: nowrap; +} + +.context-submenu-item svg { + width: 16px; + height: 16px; + flex-shrink: 0; } .context-submenu-item:hover { diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 09ffcb1a..8dcfa5b3 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1162,13 +1162,24 @@ function copyDetailBlock(elementId, triggerBtn = null) { // 开始新对话 function startNewConversation() { + // 如果当前在分组详情页面,先退出分组详情 + if (currentGroupId) { + const groupDetailPage = document.getElementById('group-detail-page'); + const chatContainer = document.querySelector('.chat-container'); + if (groupDetailPage) groupDetailPage.style.display = 'none'; + if (chatContainer) chatContainer.style.display = 'flex'; + currentGroupId = null; + // 刷新对话列表 + loadConversationsWithGroups(); + } + currentConversationId = null; document.getElementById('chat-messages').innerHTML = ''; addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'); addAttackChainButton(null); updateActiveConversation(); // 刷新对话列表,确保显示最新的历史对话 - loadConversations(); + loadConversationsWithGroups(); // 清除防抖定时器,防止恢复草稿时触发保存 if (draftSaveTimer) { clearTimeout(draftSaveTimer); @@ -1428,6 +1439,32 @@ async function loadConversation(conversationId) { return; } + // 如果当前在分组详情页面,切换到对话界面 + // 退出分组详情模式,显示所有最近对话,提供更好的用户体验 + if (currentGroupId) { + const sidebar = document.querySelector('.conversation-sidebar'); + const groupDetailPage = document.getElementById('group-detail-page'); + const chatContainer = document.querySelector('.chat-container'); + + // 确保侧边栏始终可见 + if (sidebar) sidebar.style.display = 'flex'; + // 隐藏分组详情页,显示对话界面 + if (groupDetailPage) groupDetailPage.style.display = 'none'; + if (chatContainer) chatContainer.style.display = 'flex'; + + // 退出分组详情模式,这样最近对话列表会显示所有对话 + // 用户可以在侧边栏看到所有对话,方便切换 + const previousGroupId = currentGroupId; + currentGroupId = null; + + // 刷新最近对话列表,显示所有对话(包括分组中的) + loadConversationsWithGroups(); + } + + // 无论是否在分组详情页面,都刷新分组列表,确保高亮状态正确 + // 这样可以清除之前分组的高亮状态,确保UI状态一致 + await loadGroups(); + // 更新当前对话ID currentConversationId = conversationId; updateActiveConversation(); @@ -3869,11 +3906,56 @@ function createConversationListItemWithMenu(conversation, isPinned) { } // 显示对话上下文菜单 -function showConversationContextMenu(event) { +async function showConversationContextMenu(event) { const menu = document.getElementById('conversation-context-menu'); if (!menu) return; - // 先显示菜单以获取尺寸 + const convId = contextMenuConversationId; + // 先获取对话的置顶状态并更新菜单文本(在显示菜单之前) + if (convId) { + try { + let isPinned = false; + if (currentGroupId) { + // 如果在分组详情页面,获取分组内置顶状态 + const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); + if (response.ok) { + const groupConvs = await response.json(); + const conv = groupConvs.find(c => c.id === convId); + if (conv) { + isPinned = conv.groupPinned || false; + } + } + } else { + // 不在分组详情页面,获取全局置顶状态 + const response = await apiFetch(`/api/conversations/${convId}`); + if (response.ok) { + const conv = await response.json(); + isPinned = conv.pinned || false; + } + } + + // 更新菜单文本 + const pinMenuText = document.getElementById('pin-conversation-menu-text'); + if (pinMenuText) { + pinMenuText.textContent = isPinned ? '取消置顶' : '置顶此对话'; + } + } catch (error) { + console.error('获取对话置顶状态失败:', error); + // 如果获取失败,使用默认文本 + const pinMenuText = document.getElementById('pin-conversation-menu-text'); + if (pinMenuText) { + pinMenuText.textContent = '置顶此对话'; + } + } + } else { + // 如果没有对话ID,使用默认文本 + const pinMenuText = document.getElementById('pin-conversation-menu-text'); + if (pinMenuText) { + pinMenuText.textContent = '置顶此对话'; + } + } + + // 在状态获取完成后再显示菜单 menu.style.display = 'block'; menu.style.visibility = 'visible'; menu.style.opacity = '1'; @@ -3886,17 +3968,26 @@ function showConversationContextMenu(event) { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; + // 获取子菜单的宽度(如果存在) + const submenu = document.getElementById('move-to-group-submenu'); + const submenuWidth = submenu ? 180 : 0; // 子菜单宽度 + 间距 + let left = event.clientX; let top = event.clientY; // 如果菜单会超出右边界,调整到左侧 - if (left + menuRect.width > viewportWidth) { + // 考虑子菜单的宽度 + if (left + menuRect.width + submenuWidth > viewportWidth) { left = event.clientX - menuRect.width; + // 如果调整后仍然超出,则放在按钮左侧 + if (left < 0) { + left = Math.max(8, event.clientX - menuRect.width - submenuWidth); + } } // 如果菜单会超出下边界,调整到上方 if (top + menuRect.height > viewportHeight) { - top = event.clientY - menuRect.height; + top = Math.max(8, event.clientY - menuRect.height); } // 确保不超出左边界 @@ -3911,6 +4002,19 @@ function showConversationContextMenu(event) { menu.style.left = left + 'px'; menu.style.top = top + 'px'; + + // 如果菜单在右侧,子菜单应该在左侧显示 + if (submenu && left < event.clientX) { + submenu.style.left = 'auto'; + submenu.style.right = '100%'; + submenu.style.marginLeft = '0'; + submenu.style.marginRight = '4px'; + } else if (submenu) { + submenu.style.left = '100%'; + submenu.style.right = 'auto'; + submenu.style.marginLeft = '4px'; + submenu.style.marginRight = '0'; + } // 点击外部关闭菜单 const closeMenu = (e) => { @@ -3925,13 +4029,44 @@ function showConversationContextMenu(event) { } // 显示分组上下文菜单 -function showGroupContextMenu(event, groupId) { +async function showGroupContextMenu(event, groupId) { const menu = document.getElementById('group-context-menu'); if (!menu) return; contextMenuGroupId = groupId; - // 先显示菜单以获取尺寸 + // 先获取分组的置顶状态并更新菜单文本(在显示菜单之前) + try { + // 先从缓存中查找 + let group = groupsCache.find(g => g.id === groupId); + let isPinned = false; + + if (group) { + isPinned = group.pinned || false; + } else { + // 如果缓存中没有,从API获取 + const response = await apiFetch(`/api/groups/${groupId}`); + if (response.ok) { + group = await response.json(); + isPinned = group.pinned || false; + } + } + + // 更新菜单文本 + const pinMenuText = document.getElementById('pin-group-menu-text'); + if (pinMenuText) { + pinMenuText.textContent = isPinned ? '取消置顶' : '置顶此分组'; + } + } catch (error) { + console.error('获取分组置顶状态失败:', error); + // 如果获取失败,使用默认文本 + const pinMenuText = document.getElementById('pin-group-menu-text'); + if (pinMenuText) { + pinMenuText.textContent = '置顶此分组'; + } + } + + // 在状态获取完成后再显示菜单 menu.style.display = 'block'; menu.style.visibility = 'visible'; menu.style.opacity = '1'; @@ -4041,21 +4176,45 @@ async function pinConversation() { if (!convId) return; try { - // 获取当前对话的置顶状态 - const response = await apiFetch(`/api/conversations/${convId}`); - const conv = await response.json(); - const newPinned = !conv.pinned; + // 如果当前在分组详情页面,使用分组内置顶 + if (currentGroupId) { + // 获取当前对话在分组中的置顶状态 + const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`); + const groupConvs = await response.json(); + const conv = groupConvs.find(c => c.id === convId); + + // 如果找不到对话,说明可能有问题,使用默认值 + const currentPinned = conv && conv.groupPinned !== undefined ? conv.groupPinned : false; + const newPinned = !currentPinned; - // 更新置顶状态 - await apiFetch(`/api/conversations/${convId}/pinned`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ pinned: newPinned }), - }); + // 更新分组内置顶状态 + await apiFetch(`/api/groups/${currentGroupId}/conversations/${convId}/pinned`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pinned: newPinned }), + }); - loadConversationsWithGroups(); + // 重新加载分组对话 + loadGroupConversations(currentGroupId); + } else { + // 不在分组详情页面,使用全局置顶 + const response = await apiFetch(`/api/conversations/${convId}`); + const conv = await response.json(); + const newPinned = !conv.pinned; + + // 更新全局置顶状态 + await apiFetch(`/api/conversations/${convId}/pinned`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ pinned: newPinned }), + }); + + loadConversationsWithGroups(); + } } catch (error) { console.error('置顶对话失败:', error); alert('置顶失败: ' + (error.message || '未知错误')); @@ -4071,34 +4230,143 @@ async function showMoveToGroupSubmenu() { submenu.innerHTML = ''; - // 确保分组列表已加载 - if (groupsCache.length === 0) { - await loadGroups(); + // 确保分组列表已加载 - 强制重新加载以确保数据是最新的 + try { + // 如果缓存为空,强制加载 + if (!Array.isArray(groupsCache) || groupsCache.length === 0) { + await loadGroups(); + } else { + // 即使缓存不为空,也尝试刷新一次,确保数据是最新的 + // 但使用静默方式,不显示错误 + try { + const response = await apiFetch('/api/groups'); + if (response.ok) { + const freshGroups = await response.json(); + if (Array.isArray(freshGroups)) { + groupsCache = freshGroups; + } + } + } catch (err) { + // 如果刷新失败,使用缓存的数据 + console.warn('刷新分组列表失败,使用缓存数据:', err); + } + } + + // 再次验证缓存 + if (!Array.isArray(groupsCache)) { + console.warn('groupsCache 不是有效数组,重置为空数组'); + groupsCache = []; + // 如果仍然无效,尝试重新加载 + if (groupsCache.length === 0) { + await loadGroups(); + } + } + } catch (error) { + console.error('加载分组列表失败:', error); + // 即使加载失败,也继续显示菜单,使用现有缓存 } - // 如果有分组,显示所有分组 + // 如果当前在分组详情页面,显示"移出本组"选项 + if (currentGroupId && contextMenuConversationId) { + // 检查对话是否在当前分组中 + const convInGroup = conversationGroupMappingCache[contextMenuConversationId] === currentGroupId; + if (convInGroup) { + const removeItem = document.createElement('div'); + removeItem.className = 'context-submenu-item'; + removeItem.innerHTML = ` + + 移出本组 + `; + removeItem.onclick = () => { + removeConversationFromGroup(contextMenuConversationId, currentGroupId); + }; + submenu.appendChild(removeItem); + + // 添加分隔线 + const divider = document.createElement('div'); + divider.className = 'context-menu-divider'; + submenu.appendChild(divider); + } + } + + // 验证 groupsCache 是否为有效数组 + if (!Array.isArray(groupsCache)) { + console.warn('groupsCache 不是有效数组,重置为空数组'); + groupsCache = []; + } + + // 如果有分组,显示所有分组(排除当前分组) if (groupsCache.length > 0) { groupsCache.forEach(group => { + // 验证分组对象是否有效 + if (!group || !group.id || !group.name) { + console.warn('无效的分组对象:', group); + return; + } + + // 如果当前在分组详情页面,不显示当前分组 + if (currentGroupId && group.id === currentGroupId) { + return; + } + const item = document.createElement('div'); item.className = 'context-submenu-item'; - item.textContent = group.name; + item.innerHTML = ` + + ${group.name} + `; item.onclick = () => { moveConversationToGroup(contextMenuConversationId, group.id); }; submenu.appendChild(item); }); + } else { + // 如果仍然没有分组,记录日志以便调试 + console.warn('showMoveToGroupSubmenu: groupsCache 为空,无法显示分组列表'); } // 始终显示"创建分组"选项 const addItem = document.createElement('div'); addItem.className = 'context-submenu-item add-group-item'; - addItem.textContent = '+ 创建分组'; + addItem.innerHTML = ` + + + 新增分组 + `; addItem.onclick = () => { showCreateGroupModal(true); }; submenu.appendChild(addItem); submenu.style.display = 'block'; + + // 计算子菜单位置,防止溢出 + setTimeout(() => { + const submenuRect = submenu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // 如果子菜单超出右边界,调整到左侧 + if (submenuRect.right > viewportWidth) { + submenu.style.left = 'auto'; + submenu.style.right = '100%'; + submenu.style.marginLeft = '0'; + submenu.style.marginRight = '4px'; + } + + // 如果子菜单超出下边界,调整位置 + if (submenuRect.bottom > viewportHeight) { + const overflow = submenuRect.bottom - viewportHeight; + const currentTop = parseInt(submenu.style.top) || 0; + submenu.style.top = (currentTop - overflow - 8) + 'px'; + } + }, 0); } // 移动对话到分组 @@ -4116,8 +4384,18 @@ async function moveConversationToGroup(convId, groupId) { }); // 更新缓存 + const oldGroupId = conversationGroupMappingCache[convId]; conversationGroupMappingCache[convId] = groupId; - loadConversationsWithGroups(); + + // 如果当前在分组详情页面,重新加载分组对话 + if (currentGroupId) { + // 如果从当前分组移出,或者移动到当前分组,都需要重新加载 + if (currentGroupId === oldGroupId || currentGroupId === groupId) { + loadGroupConversations(currentGroupId); + } + } else { + loadConversationsWithGroups(); + } } catch (error) { console.error('移动对话到分组失败:', error); alert('移动失败: ' + (error.message || '未知错误')); @@ -4126,6 +4404,38 @@ async function moveConversationToGroup(convId, groupId) { closeContextMenu(); } +// 从分组中移除对话 +async function removeConversationFromGroup(convId, groupId) { + try { + await apiFetch(`/api/groups/${groupId}/conversations/${convId}`, { + method: 'DELETE', + }); + + // 更新缓存 - 立即删除,确保后续加载时能正确识别 + delete conversationGroupMappingCache[convId]; + + // 如果当前在分组详情页面,重新加载分组对话 + if (currentGroupId === groupId) { + await loadGroupConversations(groupId); + } + + // 重新加载分组映射,确保缓存是最新的 + await loadConversationGroupMapping(); + + // 刷新最近对话列表,让移出的对话立即显示 + // 使用临时变量保存 currentGroupId,然后临时设置为 null,确保显示所有不在分组的对话 + const savedGroupId = currentGroupId; + currentGroupId = null; + await loadConversationsWithGroups(); + currentGroupId = savedGroupId; + } catch (error) { + console.error('从分组中移除对话失败:', error); + alert('移除失败: ' + (error.message || '未知错误')); + } + + closeContextMenu(); +} + // 加载对话分组映射 async function loadConversationGroupMapping() { try { @@ -4389,6 +4699,11 @@ async function createGroup(event) { } const newGroup = await response.json(); + + // 检查"移动到分组"子菜单是否打开 + const submenu = document.getElementById('move-to-group-submenu'); + const isSubmenuOpen = submenu && submenu.style.display !== 'none'; + await loadGroups(); const modal = document.getElementById('create-group-modal'); @@ -4399,6 +4714,11 @@ async function createGroup(event) { if (shouldMove && contextMenuConversationId) { moveConversationToGroup(contextMenuConversationId, newGroup.id); } + + // 如果子菜单是打开的,刷新它,让新创建的分组立即显示 + if (isSubmenuOpen) { + await showMoveToGroupSubmenu(); + } } catch (error) { console.error('创建分组失败:', error); alert('创建失败: ' + (error.message || '未知错误')); @@ -4418,15 +4738,22 @@ async function enterGroupDetail(groupId) { return; } - // 隐藏侧边栏,显示分组详情页 + // 显示分组详情页,隐藏对话界面,但保持侧边栏可见 const sidebar = document.querySelector('.conversation-sidebar'); const groupDetailPage = document.getElementById('group-detail-page'); + const chatContainer = document.querySelector('.chat-container'); const titleEl = document.getElementById('group-detail-title'); - if (sidebar) sidebar.style.display = 'none'; + // 保持侧边栏可见 + if (sidebar) sidebar.style.display = 'flex'; + // 隐藏对话界面,显示分组详情页 + if (chatContainer) chatContainer.style.display = 'none'; if (groupDetailPage) groupDetailPage.style.display = 'flex'; if (titleEl) titleEl.textContent = group.name; + // 刷新分组列表,确保当前分组高亮显示 + await loadGroups(); + loadGroupConversations(groupId); } catch (error) { console.error('加载分组失败:', error); @@ -4439,9 +4766,13 @@ function exitGroupDetail() { currentGroupId = null; const sidebar = document.querySelector('.conversation-sidebar'); const groupDetailPage = document.getElementById('group-detail-page'); + const chatContainer = document.querySelector('.chat-container'); + // 保持侧边栏可见 if (sidebar) sidebar.style.display = 'flex'; + // 隐藏分组详情页,显示对话界面 if (groupDetailPage) groupDetailPage.style.display = 'none'; + if (chatContainer) chatContainer.style.display = 'flex'; loadConversationsWithGroups(); } @@ -4449,15 +4780,67 @@ function exitGroupDetail() { // 加载分组中的对话 async function loadGroupConversations(groupId) { try { - const response = await apiFetch(`/api/groups/${groupId}/conversations`); - const groupConvs = await response.json(); - + if (!groupId) { + console.error('loadGroupConversations: groupId is null or undefined'); + return; + } + + // 确保分组映射已加载 + if (Object.keys(conversationGroupMappingCache).length === 0) { + await loadConversationGroupMapping(); + } + + // 先清空列表,避免显示旧数据 const list = document.getElementById('group-conversations-list'); - if (!list) return; + if (!list) { + console.error('group-conversations-list element not found'); + return; + } + list.innerHTML = '