Add files via upload

This commit is contained in:
公明
2025-12-25 02:57:53 +08:00
committed by GitHub
parent 5f12a246aa
commit 0a189c0afe
7 changed files with 705 additions and 85 deletions
+1
View File
@@ -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)
+29
View File
@@ -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")
+20 -3
View File
@@ -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
}
+61 -1
View File
@@ -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": "更新成功"})
}
+89 -31
View File
@@ -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 {
+503 -48
View File
@@ -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 = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 12l6 6M15 12l-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>移出本组</span>
`;
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 = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>${group.name}</span>
`;
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 = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>+ 新增分组</span>
`;
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 = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">加载中...</div>';
// 确保使用正确的 groupId
const url = `/api/groups/${groupId}/conversations`;
const response = await apiFetch(url);
if (!response.ok) {
console.error(`Failed to load conversations for group ${groupId}:`, response.statusText);
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">加载失败,请重试</div>';
return;
}
let groupConvs = await response.json();
// 处理 null 或 undefined 的情况,将其视为空数组
if (!groupConvs) {
groupConvs = [];
}
// 验证返回的数据类型
if (!Array.isArray(groupConvs)) {
console.error(`Invalid response for group ${groupId}:`, groupConvs);
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">数据格式错误</div>';
return;
}
// 更新分组映射缓存(只更新当前分组的对话)
// 先清理该分组之前的映射(如果有对话被移出)
Object.keys(conversationGroupMappingCache).forEach(convId => {
if (conversationGroupMappingCache[convId] === groupId) {
// 如果这个对话不在新的列表中,说明已被移出
if (!groupConvs.find(c => c.id === convId)) {
delete conversationGroupMappingCache[convId];
}
}
});
// 更新当前分组的对话映射
groupConvs.forEach(conv => {
conversationGroupMappingCache[conv.id] = groupId;
});
// 再次清空列表(清除"加载中"提示)
list.innerHTML = '';
if (!Array.isArray(groupConvs) || groupConvs.length === 0) {
if (groupConvs.length === 0) {
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">该分组暂无对话</div>';
return;
}
@@ -4465,19 +4848,55 @@ async function loadGroupConversations(groupId) {
// 加载每个对话的详细信息以获取消息
for (const conv of groupConvs) {
try {
// 验证对话ID存在
if (!conv.id) {
console.warn('Conversation missing id:', conv);
continue;
}
const convResponse = await apiFetch(`/api/conversations/${conv.id}`);
if (!convResponse.ok) {
console.error(`Failed to load conversation ${conv.id}:`, convResponse.statusText);
continue;
}
const fullConv = await convResponse.json();
const item = document.createElement('div');
item.className = 'group-conversation-item';
item.onclick = () => {
exitGroupDetail();
loadConversation(conv.id);
};
item.dataset.conversationId = conv.id;
// 只有在分组详情页面且对话ID匹配时才显示active状态
// 如果不在分组详情页面,不应该显示active状态
if (currentGroupId && conv.id === currentConversationId) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
// 创建内容包装器
const contentWrapper = document.createElement('div');
contentWrapper.className = 'group-conversation-content-wrapper';
const titleWrapper = document.createElement('div');
titleWrapper.style.display = 'flex';
titleWrapper.style.alignItems = 'center';
titleWrapper.style.gap = '4px';
const title = document.createElement('div');
title.className = 'group-conversation-title';
title.textContent = fullConv.title || conv.title || '未命名对话';
titleWrapper.appendChild(title);
// 如果对话在分组中置顶,显示置顶图标
if (conv.groupPinned) {
const pinIcon = document.createElement('span');
pinIcon.className = 'conversation-item-pinned';
pinIcon.innerHTML = '📌';
pinIcon.title = '在分组中已置顶';
titleWrapper.appendChild(pinIcon);
}
contentWrapper.appendChild(titleWrapper);
const timeWrapper = document.createElement('div');
timeWrapper.className = 'group-conversation-time';
@@ -4490,8 +4909,7 @@ async function loadGroupConversations(groupId) {
minute: '2-digit'
});
item.appendChild(title);
item.appendChild(timeWrapper);
contentWrapper.appendChild(timeWrapper);
// 如果有第一条消息,显示内容预览
if (fullConv.messages && fullConv.messages.length > 0) {
@@ -4504,10 +4922,32 @@ async function loadGroupConversations(groupId) {
preview += '...';
}
content.textContent = preview;
item.appendChild(content);
contentWrapper.appendChild(content);
}
}
item.appendChild(contentWrapper);
// 添加三个点菜单按钮
const menuBtn = document.createElement('button');
menuBtn.className = 'conversation-item-menu';
menuBtn.innerHTML = '⋯';
menuBtn.onclick = (e) => {
e.stopPropagation();
contextMenuConversationId = conv.id;
showConversationContextMenu(e);
};
item.appendChild(menuBtn);
item.onclick = () => {
// 切换到对话界面,但保持分组详情状态
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';
loadConversation(conv.id);
};
list.appendChild(item);
} catch (err) {
console.error(`加载对话 ${conv.id} 失败:`, err);
@@ -4593,8 +5033,16 @@ async function deleteGroup() {
}
});
exitGroupDetail();
loadGroups();
// 如果"移动到分组"子菜单是打开的,刷新它
const submenu = document.getElementById('move-to-group-submenu');
if (submenu && submenu.style.display !== 'none') {
// 子菜单是打开的,重新加载分组列表并刷新子菜单
await loadGroups();
await showMoveToGroupSubmenu();
} else {
exitGroupDetail();
loadGroups();
}
} catch (error) {
console.error('删除分组失败:', error);
alert('删除失败: ' + (error.message || '未知错误'));
@@ -4726,12 +5174,19 @@ async function deleteGroupFromContext() {
}
});
// 如果当前在分组详情页,退出详情页
if (currentGroupId === groupId) {
exitGroupDetail();
// 如果"移动到分组"子菜单是打开的,刷新它
const submenu = document.getElementById('move-to-group-submenu');
if (submenu && submenu.style.display !== 'none') {
// 子菜单是打开的,重新加载分组列表并刷新子菜单
await loadGroups();
await showMoveToGroupSubmenu();
} else {
// 如果当前在分组详情页,退出详情页
if (currentGroupId === groupId) {
exitGroupDetail();
}
loadGroups();
}
loadGroups();
} catch (error) {
console.error('删除分组失败:', error);
alert('删除失败: ' + (error.message || '未知错误'));
+2 -2
View File
@@ -897,7 +897,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 17v5M5 17h14l-1-7H6l-1 7zM9 10V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>置顶此对话</span>
<span id="pin-conversation-menu-text">置顶此对话</span>
</div>
<div class="context-menu-item" onclick="showBatchManageModal()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -942,7 +942,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 17v5M5 17h14l-1-7H6l-1 7zM9 10V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>置顶此分组</span>
<span id="pin-group-menu-text">置顶此分组</span>
</div>
<div class="context-menu-item context-menu-item-danger" onclick="deleteGroupFromContext()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">