mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-30 21:20:12 +02:00
Add files via upload
This commit is contained in:
7 files changed
+704
-84
No files matched your search
@@ -374,6 +374,7 @@ func setupRoutes(
|
|||||||
protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations)
|
protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations)
|
||||||
protected.POST("/groups/conversations", groupHandler.AddConversationToGroup)
|
protected.POST("/groups/conversations", groupHandler.AddConversationToGroup)
|
||||||
protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup)
|
protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup)
|
||||||
|
protected.PUT("/groups/:id/conversations/:conversationId/pinned", groupHandler.UpdateConversationPinnedInGroup)
|
||||||
|
|
||||||
// 监控
|
// 监控
|
||||||
protected.GET("/monitor", monitorHandler.Monitor)
|
protected.GET("/monitor", monitorHandler.Monitor)
|
||||||
|
|||||||
@@ -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 {
|
if _, err := db.Exec(createIndexes); err != nil {
|
||||||
return fmt.Errorf("创建索引失败: %w", err)
|
return fmt.Errorf("创建索引失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -334,6 +339,30 @@ func (db *DB) migrateConversationGroupsTable() error {
|
|||||||
return nil
|
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 创建知识库数据库连接(只包含知识库相关的表)
|
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||||||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1")
|
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1")
|
||||||
|
|||||||
@@ -222,11 +222,11 @@ func (db *DB) RemoveConversationFromGroup(conversationID, groupID string) error
|
|||||||
// GetConversationsByGroup 获取分组中的所有对话
|
// GetConversationsByGroup 获取分组中的所有对话
|
||||||
func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) {
|
func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) {
|
||||||
rows, err := db.Query(
|
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
|
FROM conversations c
|
||||||
INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id
|
INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id
|
||||||
WHERE cgm.group_id = ?
|
WHERE cgm.group_id = ?
|
||||||
ORDER BY c.updated_at DESC`,
|
ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC`,
|
||||||
groupID,
|
groupID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,8 +239,9 @@ func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) {
|
|||||||
var conv Conversation
|
var conv Conversation
|
||||||
var createdAt, updatedAt string
|
var createdAt, updatedAt string
|
||||||
var pinned int
|
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)
|
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,3 +319,19 @@ func (db *DB) UpdateGroupPinned(id string, pinned bool) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
@@ -175,6 +176,16 @@ func (h *GroupHandler) RemoveConversationFromGroup(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "移除成功"})
|
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 获取分组中的所有对话
|
// GetGroupConversations 获取分组中的所有对话
|
||||||
func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
|
func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
|
||||||
groupID := c.Param("id")
|
groupID := c.Param("id")
|
||||||
@@ -186,7 +197,31 @@ func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
|
|||||||
return
|
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 更新对话置顶状态请求
|
// UpdateConversationPinnedRequest 更新对话置顶状态请求
|
||||||
@@ -236,3 +271,28 @@ func (h *GroupHandler) UpdateGroupPinned(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "更新成功"})
|
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
@@ -5130,42 +5130,45 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item-menu {
|
.conversation-item-menu {
|
||||||
width: 24px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0;
|
opacity: 0.6;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
flex-shrink: 0;
|
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;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-item-menu:hover {
|
.conversation-item-menu:hover {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分组详情页面 */
|
/* 分组详情页面 */
|
||||||
.group-detail-page {
|
.group-detail-page {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-detail-header {
|
.group-detail-header {
|
||||||
@@ -5244,55 +5247,92 @@ header {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-conversations-list {
|
.group-conversations-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 8px;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-conversation-item {
|
.group-conversation-item {
|
||||||
padding: 16px;
|
padding: 12px 16px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-conversation-item:hover {
|
.group-conversation-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
border-color: var(--accent-color);
|
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 {
|
.group-conversation-title {
|
||||||
font-size: 1rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--text-primary);
|
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 {
|
.group-conversation-time {
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
display: flex;
|
line-height: 1.4;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-conversation-content {
|
.group-conversation-content {
|
||||||
margin-top: 12px;
|
margin-top: 4px;
|
||||||
padding: 12px;
|
padding: 8px 12px;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
max-height: 200px;
|
line-height: 1.5;
|
||||||
overflow-y: auto;
|
max-height: 60px;
|
||||||
white-space: pre-wrap;
|
overflow: hidden;
|
||||||
word-break: break-all;
|
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;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu[style*="right: 100%"] {
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-submenu-item {
|
.context-submenu-item {
|
||||||
@@ -5572,6 +5623,13 @@ header {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-submenu-item svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-submenu-item:hover {
|
.context-submenu-item:hover {
|
||||||
|
|||||||
+502
-47
@@ -1162,13 +1162,24 @@ function copyDetailBlock(elementId, triggerBtn = null) {
|
|||||||
|
|
||||||
// 开始新对话
|
// 开始新对话
|
||||||
function startNewConversation() {
|
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;
|
currentConversationId = null;
|
||||||
document.getElementById('chat-messages').innerHTML = '';
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。');
|
addMessage('assistant', '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。');
|
||||||
addAttackChainButton(null);
|
addAttackChainButton(null);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
// 刷新对话列表,确保显示最新的历史对话
|
// 刷新对话列表,确保显示最新的历史对话
|
||||||
loadConversations();
|
loadConversationsWithGroups();
|
||||||
// 清除防抖定时器,防止恢复草稿时触发保存
|
// 清除防抖定时器,防止恢复草稿时触发保存
|
||||||
if (draftSaveTimer) {
|
if (draftSaveTimer) {
|
||||||
clearTimeout(draftSaveTimer);
|
clearTimeout(draftSaveTimer);
|
||||||
@@ -1428,6 +1439,32 @@ async function loadConversation(conversationId) {
|
|||||||
return;
|
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
|
// 更新当前对话ID
|
||||||
currentConversationId = conversationId;
|
currentConversationId = conversationId;
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
@@ -3869,11 +3906,56 @@ function createConversationListItemWithMenu(conversation, isPinned) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 显示对话上下文菜单
|
// 显示对话上下文菜单
|
||||||
function showConversationContextMenu(event) {
|
async function showConversationContextMenu(event) {
|
||||||
const menu = document.getElementById('conversation-context-menu');
|
const menu = document.getElementById('conversation-context-menu');
|
||||||
if (!menu) return;
|
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.display = 'block';
|
||||||
menu.style.visibility = 'visible';
|
menu.style.visibility = 'visible';
|
||||||
menu.style.opacity = '1';
|
menu.style.opacity = '1';
|
||||||
@@ -3886,17 +3968,26 @@ function showConversationContextMenu(event) {
|
|||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// 获取子菜单的宽度(如果存在)
|
||||||
|
const submenu = document.getElementById('move-to-group-submenu');
|
||||||
|
const submenuWidth = submenu ? 180 : 0; // 子菜单宽度 + 间距
|
||||||
|
|
||||||
let left = event.clientX;
|
let left = event.clientX;
|
||||||
let top = event.clientY;
|
let top = event.clientY;
|
||||||
|
|
||||||
// 如果菜单会超出右边界,调整到左侧
|
// 如果菜单会超出右边界,调整到左侧
|
||||||
if (left + menuRect.width > viewportWidth) {
|
// 考虑子菜单的宽度
|
||||||
|
if (left + menuRect.width + submenuWidth > viewportWidth) {
|
||||||
left = event.clientX - menuRect.width;
|
left = event.clientX - menuRect.width;
|
||||||
|
// 如果调整后仍然超出,则放在按钮左侧
|
||||||
|
if (left < 0) {
|
||||||
|
left = Math.max(8, event.clientX - menuRect.width - submenuWidth);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果菜单会超出下边界,调整到上方
|
// 如果菜单会超出下边界,调整到上方
|
||||||
if (top + menuRect.height > viewportHeight) {
|
if (top + menuRect.height > viewportHeight) {
|
||||||
top = event.clientY - menuRect.height;
|
top = Math.max(8, event.clientY - menuRect.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保不超出左边界
|
// 确保不超出左边界
|
||||||
@@ -3912,6 +4003,19 @@ function showConversationContextMenu(event) {
|
|||||||
menu.style.left = left + 'px';
|
menu.style.left = left + 'px';
|
||||||
menu.style.top = top + '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) => {
|
const closeMenu = (e) => {
|
||||||
if (!menu.contains(e.target)) {
|
if (!menu.contains(e.target)) {
|
||||||
@@ -3925,13 +4029,44 @@ function showConversationContextMenu(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 显示分组上下文菜单
|
// 显示分组上下文菜单
|
||||||
function showGroupContextMenu(event, groupId) {
|
async function showGroupContextMenu(event, groupId) {
|
||||||
const menu = document.getElementById('group-context-menu');
|
const menu = document.getElementById('group-context-menu');
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
|
|
||||||
contextMenuGroupId = groupId;
|
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.display = 'block';
|
||||||
menu.style.visibility = 'visible';
|
menu.style.visibility = 'visible';
|
||||||
menu.style.opacity = '1';
|
menu.style.opacity = '1';
|
||||||
@@ -4041,21 +4176,45 @@ async function pinConversation() {
|
|||||||
if (!convId) return;
|
if (!convId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前对话的置顶状态
|
// 如果当前在分组详情页面,使用分组内置顶
|
||||||
const response = await apiFetch(`/api/conversations/${convId}`);
|
if (currentGroupId) {
|
||||||
const conv = await response.json();
|
// 获取当前对话在分组中的置顶状态
|
||||||
const newPinned = !conv.pinned;
|
const response = await apiFetch(`/api/groups/${currentGroupId}/conversations`);
|
||||||
|
const groupConvs = await response.json();
|
||||||
|
const conv = groupConvs.find(c => c.id === convId);
|
||||||
|
|
||||||
// 更新置顶状态
|
// 如果找不到对话,说明可能有问题,使用默认值
|
||||||
await apiFetch(`/api/conversations/${convId}/pinned`, {
|
const currentPinned = conv && conv.groupPinned !== undefined ? conv.groupPinned : false;
|
||||||
method: 'PUT',
|
const newPinned = !currentPinned;
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ pinned: newPinned }),
|
|
||||||
});
|
|
||||||
|
|
||||||
loadConversationsWithGroups();
|
// 更新分组内置顶状态
|
||||||
|
await apiFetch(`/api/groups/${currentGroupId}/conversations/${convId}/pinned`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ pinned: newPinned }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新加载分组对话
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('置顶对话失败:', error);
|
console.error('置顶对话失败:', error);
|
||||||
alert('置顶失败: ' + (error.message || '未知错误'));
|
alert('置顶失败: ' + (error.message || '未知错误'));
|
||||||
@@ -4071,34 +4230,143 @@ async function showMoveToGroupSubmenu() {
|
|||||||
|
|
||||||
submenu.innerHTML = '';
|
submenu.innerHTML = '';
|
||||||
|
|
||||||
// 确保分组列表已加载
|
// 确保分组列表已加载 - 强制重新加载以确保数据是最新的
|
||||||
if (groupsCache.length === 0) {
|
try {
|
||||||
await loadGroups();
|
// 如果缓存为空,强制加载
|
||||||
|
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) {
|
if (groupsCache.length > 0) {
|
||||||
groupsCache.forEach(group => {
|
groupsCache.forEach(group => {
|
||||||
|
// 验证分组对象是否有效
|
||||||
|
if (!group || !group.id || !group.name) {
|
||||||
|
console.warn('无效的分组对象:', group);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前在分组详情页面,不显示当前分组
|
||||||
|
if (currentGroupId && group.id === currentGroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'context-submenu-item';
|
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 = () => {
|
item.onclick = () => {
|
||||||
moveConversationToGroup(contextMenuConversationId, group.id);
|
moveConversationToGroup(contextMenuConversationId, group.id);
|
||||||
};
|
};
|
||||||
submenu.appendChild(item);
|
submenu.appendChild(item);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// 如果仍然没有分组,记录日志以便调试
|
||||||
|
console.warn('showMoveToGroupSubmenu: groupsCache 为空,无法显示分组列表');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 始终显示"创建分组"选项
|
// 始终显示"创建分组"选项
|
||||||
const addItem = document.createElement('div');
|
const addItem = document.createElement('div');
|
||||||
addItem.className = 'context-submenu-item add-group-item';
|
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 = () => {
|
addItem.onclick = () => {
|
||||||
showCreateGroupModal(true);
|
showCreateGroupModal(true);
|
||||||
};
|
};
|
||||||
submenu.appendChild(addItem);
|
submenu.appendChild(addItem);
|
||||||
|
|
||||||
submenu.style.display = 'block';
|
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;
|
conversationGroupMappingCache[convId] = groupId;
|
||||||
loadConversationsWithGroups();
|
|
||||||
|
// 如果当前在分组详情页面,重新加载分组对话
|
||||||
|
if (currentGroupId) {
|
||||||
|
// 如果从当前分组移出,或者移动到当前分组,都需要重新加载
|
||||||
|
if (currentGroupId === oldGroupId || currentGroupId === groupId) {
|
||||||
|
loadGroupConversations(currentGroupId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadConversationsWithGroups();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('移动对话到分组失败:', error);
|
console.error('移动对话到分组失败:', error);
|
||||||
alert('移动失败: ' + (error.message || '未知错误'));
|
alert('移动失败: ' + (error.message || '未知错误'));
|
||||||
@@ -4126,6 +4404,38 @@ async function moveConversationToGroup(convId, groupId) {
|
|||||||
closeContextMenu();
|
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() {
|
async function loadConversationGroupMapping() {
|
||||||
try {
|
try {
|
||||||
@@ -4389,6 +4699,11 @@ async function createGroup(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newGroup = await response.json();
|
const newGroup = await response.json();
|
||||||
|
|
||||||
|
// 检查"移动到分组"子菜单是否打开
|
||||||
|
const submenu = document.getElementById('move-to-group-submenu');
|
||||||
|
const isSubmenuOpen = submenu && submenu.style.display !== 'none';
|
||||||
|
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
|
|
||||||
const modal = document.getElementById('create-group-modal');
|
const modal = document.getElementById('create-group-modal');
|
||||||
@@ -4399,6 +4714,11 @@ async function createGroup(event) {
|
|||||||
if (shouldMove && contextMenuConversationId) {
|
if (shouldMove && contextMenuConversationId) {
|
||||||
moveConversationToGroup(contextMenuConversationId, newGroup.id);
|
moveConversationToGroup(contextMenuConversationId, newGroup.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果子菜单是打开的,刷新它,让新创建的分组立即显示
|
||||||
|
if (isSubmenuOpen) {
|
||||||
|
await showMoveToGroupSubmenu();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建分组失败:', error);
|
console.error('创建分组失败:', error);
|
||||||
alert('创建失败: ' + (error.message || '未知错误'));
|
alert('创建失败: ' + (error.message || '未知错误'));
|
||||||
@@ -4418,15 +4738,22 @@ async function enterGroupDetail(groupId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏侧边栏,显示分组详情页
|
// 显示分组详情页,隐藏对话界面,但保持侧边栏可见
|
||||||
const sidebar = document.querySelector('.conversation-sidebar');
|
const sidebar = document.querySelector('.conversation-sidebar');
|
||||||
const groupDetailPage = document.getElementById('group-detail-page');
|
const groupDetailPage = document.getElementById('group-detail-page');
|
||||||
|
const chatContainer = document.querySelector('.chat-container');
|
||||||
const titleEl = document.getElementById('group-detail-title');
|
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 (groupDetailPage) groupDetailPage.style.display = 'flex';
|
||||||
if (titleEl) titleEl.textContent = group.name;
|
if (titleEl) titleEl.textContent = group.name;
|
||||||
|
|
||||||
|
// 刷新分组列表,确保当前分组高亮显示
|
||||||
|
await loadGroups();
|
||||||
|
|
||||||
loadGroupConversations(groupId);
|
loadGroupConversations(groupId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载分组失败:', error);
|
console.error('加载分组失败:', error);
|
||||||
@@ -4439,9 +4766,13 @@ function exitGroupDetail() {
|
|||||||
currentGroupId = null;
|
currentGroupId = null;
|
||||||
const sidebar = document.querySelector('.conversation-sidebar');
|
const sidebar = document.querySelector('.conversation-sidebar');
|
||||||
const groupDetailPage = document.getElementById('group-detail-page');
|
const groupDetailPage = document.getElementById('group-detail-page');
|
||||||
|
const chatContainer = document.querySelector('.chat-container');
|
||||||
|
|
||||||
|
// 保持侧边栏可见
|
||||||
if (sidebar) sidebar.style.display = 'flex';
|
if (sidebar) sidebar.style.display = 'flex';
|
||||||
|
// 隐藏分组详情页,显示对话界面
|
||||||
if (groupDetailPage) groupDetailPage.style.display = 'none';
|
if (groupDetailPage) groupDetailPage.style.display = 'none';
|
||||||
|
if (chatContainer) chatContainer.style.display = 'flex';
|
||||||
|
|
||||||
loadConversationsWithGroups();
|
loadConversationsWithGroups();
|
||||||
}
|
}
|
||||||
@@ -4449,15 +4780,67 @@ function exitGroupDetail() {
|
|||||||
// 加载分组中的对话
|
// 加载分组中的对话
|
||||||
async function loadGroupConversations(groupId) {
|
async function loadGroupConversations(groupId) {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/api/groups/${groupId}/conversations`);
|
if (!groupId) {
|
||||||
const groupConvs = await response.json();
|
console.error('loadGroupConversations: groupId is null or undefined');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保分组映射已加载
|
||||||
|
if (Object.keys(conversationGroupMappingCache).length === 0) {
|
||||||
|
await loadConversationGroupMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先清空列表,避免显示旧数据
|
||||||
const list = document.getElementById('group-conversations-list');
|
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 = '';
|
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>';
|
list.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-muted);">该分组暂无对话</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4465,19 +4848,55 @@ async function loadGroupConversations(groupId) {
|
|||||||
// 加载每个对话的详细信息以获取消息
|
// 加载每个对话的详细信息以获取消息
|
||||||
for (const conv of groupConvs) {
|
for (const conv of groupConvs) {
|
||||||
try {
|
try {
|
||||||
|
// 验证对话ID存在
|
||||||
|
if (!conv.id) {
|
||||||
|
console.warn('Conversation missing id:', conv);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const convResponse = await apiFetch(`/api/conversations/${conv.id}`);
|
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 fullConv = await convResponse.json();
|
||||||
|
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'group-conversation-item';
|
item.className = 'group-conversation-item';
|
||||||
item.onclick = () => {
|
item.dataset.conversationId = conv.id;
|
||||||
exitGroupDetail();
|
// 只有在分组详情页面且对话ID匹配时才显示active状态
|
||||||
loadConversation(conv.id);
|
// 如果不在分组详情页面,不应该显示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');
|
const title = document.createElement('div');
|
||||||
title.className = 'group-conversation-title';
|
title.className = 'group-conversation-title';
|
||||||
title.textContent = fullConv.title || conv.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');
|
const timeWrapper = document.createElement('div');
|
||||||
timeWrapper.className = 'group-conversation-time';
|
timeWrapper.className = 'group-conversation-time';
|
||||||
@@ -4490,8 +4909,7 @@ async function loadGroupConversations(groupId) {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
|
|
||||||
item.appendChild(title);
|
contentWrapper.appendChild(timeWrapper);
|
||||||
item.appendChild(timeWrapper);
|
|
||||||
|
|
||||||
// 如果有第一条消息,显示内容预览
|
// 如果有第一条消息,显示内容预览
|
||||||
if (fullConv.messages && fullConv.messages.length > 0) {
|
if (fullConv.messages && fullConv.messages.length > 0) {
|
||||||
@@ -4504,10 +4922,32 @@ async function loadGroupConversations(groupId) {
|
|||||||
preview += '...';
|
preview += '...';
|
||||||
}
|
}
|
||||||
content.textContent = 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);
|
list.appendChild(item);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`加载对话 ${conv.id} 失败:`, 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) {
|
} catch (error) {
|
||||||
console.error('删除分组失败:', error);
|
console.error('删除分组失败:', error);
|
||||||
alert('删除失败: ' + (error.message || '未知错误'));
|
alert('删除失败: ' + (error.message || '未知错误'));
|
||||||
@@ -4726,12 +5174,19 @@ async function deleteGroupFromContext() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果当前在分组详情页,退出详情页
|
// 如果"移动到分组"子菜单是打开的,刷新它
|
||||||
if (currentGroupId === groupId) {
|
const submenu = document.getElementById('move-to-group-submenu');
|
||||||
exitGroupDetail();
|
if (submenu && submenu.style.display !== 'none') {
|
||||||
|
// 子菜单是打开的,重新加载分组列表并刷新子菜单
|
||||||
|
await loadGroups();
|
||||||
|
await showMoveToGroupSubmenu();
|
||||||
|
} else {
|
||||||
|
// 如果当前在分组详情页,退出详情页
|
||||||
|
if (currentGroupId === groupId) {
|
||||||
|
exitGroupDetail();
|
||||||
|
}
|
||||||
|
loadGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadGroups();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除分组失败:', error);
|
console.error('删除分组失败:', error);
|
||||||
alert('删除失败: ' + (error.message || '未知错误'));
|
alert('删除失败: ' + (error.message || '未知错误'));
|
||||||
|
|||||||
@@ -897,7 +897,7 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span>置顶此对话</span>
|
<span id="pin-conversation-menu-text">置顶此对话</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" onclick="showBatchManageModal()">
|
<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">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span>置顶此分组</span>
|
<span id="pin-group-menu-text">置顶此分组</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item context-menu-item-danger" onclick="deleteGroupFromContext()">
|
<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">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
Reference in new issue
Block a user