diff --git a/frontend/src/api/knowledge-base/index.ts b/frontend/src/api/knowledge-base/index.ts index 15aa4c28f..6cc33b293 100644 --- a/frontend/src/api/knowledge-base/index.ts +++ b/frontend/src/api/knowledge-base/index.ts @@ -232,6 +232,16 @@ export function getFAQImportProgress(taskId: string) { return get(`/api/v1/faq/import/progress/${taskId}`); } +export function getFAQImportResult(knowledgeBaseId: string) { + return get(`/api/v1/knowledge-bases/${knowledgeBaseId}/faq/import/last-result`); +} + +export function updateFAQImportResultDisplayStatus(knowledgeBaseId: string, displayStatus: 'open' | 'close') { + return put(`/api/v1/knowledge-bases/${knowledgeBaseId}/faq/import/last-result/display`, { + display_status: displayStatus + }); +} + export function searchKnowledge(keyword: string, offset = 0, limit = 20, fileTypes?: string[]) { let url = `/api/v1/knowledge/search?keyword=${encodeURIComponent(keyword)}&offset=${offset}&limit=${limit}`; if (fileTypes && fileTypes.length > 0) { diff --git a/frontend/src/views/knowledge/components/FAQEntryManager.vue b/frontend/src/views/knowledge/components/FAQEntryManager.vue index b2ae71752..b2dd79e94 100644 --- a/frontend/src/views/knowledge/components/FAQEntryManager.vue +++ b/frontend/src/views/knowledge/components/FAQEntryManager.vue @@ -54,6 +54,57 @@ + +
+
+
+ + 最近导入结果 + {{ formatImportTime(importResult.imported_at) }} +
+
+
+ 导入数据 + {{ importResult.total_entries }}条 +
+
+ 成功 + {{ importResult.success_count }}条 +
+
+ 失败 + {{ importResult.failed_count }}条 + + 下载原因 + +
+
+ 跳过 + {{ importResult.skipped_count }}条 +
+
+ +
+
+
@@ -106,6 +157,57 @@
+ +
+
+
+ + 最近导入结果 + {{ formatImportTime(importResult.imported_at) }} +
+
+
+ 导入数据 + {{ importResult.total_entries }}条 +
+
+ 成功 + {{ importResult.success_count }}条 +
+
+ 失败 + {{ importResult.failed_count }}条 + + 下载原因 + +
+
+ 跳过 + {{ importResult.skipped_count }}条 +
+
+ +
+
+
+ + +
+
+
+ + 最近导入结果 + {{ formatImportTime(importResult.imported_at) }} +
+
+
+ 导入数据 + {{ importResult.total_entries }}条 +
+
+ 成功 + {{ importResult.success_count }}条 +
+
+ 失败 + {{ importResult.failed_count }}条 + + 下载原因 + +
+
+ 跳过 + {{ importResult.skipped_count }}条 +
+
+ +
+
+ + +
+
+
+ + 最近导入结果 + {{ formatImportTime(importResult.imported_at) }} +
+
+
+ 导入数据 + {{ importResult.total_entries }}条 +
+
+ 成功 + {{ importResult.success_count }}条 +
+
+ 失败 + {{ importResult.failed_count }}条 + + 下载原因 + +
+
+ 跳过 + {{ importResult.skipped_count }}条 +
+
+ +
+
@@ -1112,6 +1316,8 @@ import { getKnowledgeBaseById, listKnowledgeBases, getFAQImportProgress, + getFAQImportResult, + updateFAQImportResultDisplayStatus, } from '@/api/knowledge-base' import * as XLSX from 'xlsx' import Papa from 'papaparse' @@ -1305,6 +1511,20 @@ const importState = reactive({ pollingInterval: null as ReturnType | null, }) +// FAQ导入结果状态(持久化的) +const importResult = ref<{ + total_entries: number + success_count: number + failed_count: number + skipped_count: number + import_mode: string + imported_at: string + task_id: string + processing_time: number + failed_entries_url?: string + display_status: string +} | null>(null) + // Search test state const searchDrawerVisible = ref(false) const searching = ref(false) @@ -2238,6 +2458,7 @@ const startPolling = (taskId: string) => { overallFAQTotal.value = 0 // Reset to trigger re-fetch await loadEntries() await loadTags() + await loadImportResult() // 加载最新的导入结果统计 // 任务完成后,3秒后自动关闭进度条 setTimeout(() => { if (importState.taskStatus?.status === 'success') { @@ -2367,6 +2588,46 @@ const restoreImportTask = async () => { } } +// 加载持久化的导入结果统计 +const loadImportResult = async () => { + if (!props.kbId) return + try { + const res: any = await getFAQImportResult(props.kbId) + if (res?.data) { + importResult.value = res.data + } else { + importResult.value = null + } + } catch (error) { + console.error('Failed to load FAQ import result:', error) + importResult.value = null + } +} + +// 关闭导入结果统计卡片 +const closeImportResult = async () => { + if (!props.kbId) return + try { + await updateFAQImportResultDisplayStatus(props.kbId, 'close') + if (importResult.value) { + importResult.value.display_status = 'close' + } + } catch (error) { + console.error('Failed to close import result:', error) + } +} + +// 格式化导入时间 +const formatImportTime = (timeStr?: string) => { + if (!timeStr) return '' + try { + const date = new Date(timeStr) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + } catch (e) { + return timeStr + } +} + const handleImport = async () => { if (!importState.file || !importState.preview.length) { MessagePlugin.warning(t('knowledgeEditor.faqImport.selectFile')) @@ -2827,6 +3088,7 @@ onMounted(async () => { // 如果已有kbId,恢复导入任务状态 if (props.kbId) { await restoreImportTask() + await loadImportResult() // 加载导入结果 } // 主动触发一次选中数量事件,确保左侧菜单能接收到初始状态 nextTick(() => { diff --git a/internal/application/service/knowledge.go b/internal/application/service/knowledge.go index 5d6530e42..c262b4c9b 100644 --- a/internal/application/service/knowledge.go +++ b/internal/application/service/knowledge.go @@ -2897,6 +2897,57 @@ func csvEscape(s string) string { return s } +// saveFAQImportResultToDatabase 保存FAQ导入结果统计到数据库 +func (s *knowledgeService) saveFAQImportResultToDatabase(ctx context.Context, + payload *types.FAQImportPayload, progress *types.FAQImportProgress, originalTotalEntries int) error { + + // 获取FAQ知识库实例 + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + knowledge, err := s.repo.GetKnowledgeByID(ctx, tenantID, payload.KnowledgeID) + if err != nil { + return fmt.Errorf("failed to get FAQ knowledge: %w", err) + } + + // 计算跳过的条目数(总数 - 成功 - 失败) + skippedCount := originalTotalEntries - progress.SuccessCount - progress.FailedCount + if skippedCount < 0 { + skippedCount = 0 + } + + // 创建导入结果统计 + importResult := &types.FAQImportResult{ + TotalEntries: originalTotalEntries, + SuccessCount: progress.SuccessCount, + FailedCount: progress.FailedCount, + SkippedCount: skippedCount, + ImportMode: payload.Mode, + ImportedAt: time.Now(), + TaskID: payload.TaskID, + ProcessingTime: time.Now().Unix() - progress.CreatedAt, // 处理耗时(秒) + DisplayStatus: "open", // 新导入的结果默认显示 + } + + // 如果有失败条目且提供了下载URL,设置失败URL + if progress.FailedCount > 0 && progress.FailedEntriesURL != "" { + importResult.FailedEntriesURL = progress.FailedEntriesURL + } + + // 设置导入结果到Knowledge的metadata中 + if err := knowledge.SetLastFAQImportResult(importResult); err != nil { + return fmt.Errorf("failed to set FAQ import result: %w", err) + } + + // 更新数据库 + if err := s.repo.UpdateKnowledge(ctx, knowledge); err != nil { + return fmt.Errorf("failed to update knowledge with import result: %w", err) + } + + logger.Infof(ctx, "Saved FAQ import result to database: knowledge_id=%s, task_id=%s, total=%d, success=%d, failed=%d, skipped=%d", + payload.KnowledgeID, payload.TaskID, originalTotalEntries, progress.SuccessCount, progress.FailedCount, skippedCount) + + return nil +} + // buildFAQFailedEntry 构建 FAQFailedEntry func buildFAQFailedEntry(idx int, reason string, entry *types.FAQEntryPayload) types.FAQFailedEntry { answerAll := false @@ -5967,6 +6018,13 @@ func (s *knowledgeService) finalizeFAQValidation(ctx context.Context, payload *t } } + // 如果不是 dry run 模式,保存导入结果统计到数据库 + if !payload.DryRun { + if err := s.saveFAQImportResultToDatabase(ctx, payload, progress, originalTotalEntries); err != nil { + logger.Warnf(ctx, "Failed to save FAQ import result to database: %v", err) + } + } + // 使用 updateFAQImportProgressStatus 来确保正确清理 running key // 但是需要先保存其他字段,因为 updateFAQImportProgressStatus 不会保存所有字段 if err := s.saveFAQImportProgress(ctx, progress); err != nil { @@ -6040,6 +6098,94 @@ func (s *knowledgeService) GetFAQImportProgress(ctx context.Context, taskID stri return &progress, nil } +// GetLastFAQImportResult retrieves the latest FAQ import result for a knowledge base +func (s *knowledgeService) GetLastFAQImportResult(ctx context.Context, kbID string) (*types.FAQImportResult, error) { + // 获取当前租户ID + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + + // 查找FAQ类型的knowledge + knowledgeList, err := s.repo.ListKnowledgeByKnowledgeBaseID(ctx, tenantID, kbID) + if err != nil { + return nil, fmt.Errorf("failed to list knowledge: %w", err) + } + + // 查找FAQ类型的knowledge + var faqKnowledge *types.Knowledge + for _, k := range knowledgeList { + if k.Type == types.KnowledgeTypeFAQ { + faqKnowledge = k + break + } + } + + if faqKnowledge == nil { + return nil, werrors.NewNotFoundError("FAQ knowledge not found in this knowledge base") + } + + // 解析导入结果 + result, err := faqKnowledge.GetLastFAQImportResult() + if err != nil { + return nil, fmt.Errorf("failed to parse FAQ import result: %w", err) + } + + return result, nil +} + +// UpdateLastFAQImportResultDisplayStatus updates the display status of FAQ import result +func (s *knowledgeService) UpdateLastFAQImportResultDisplayStatus(ctx context.Context, kbID string, displayStatus string) error { + // 验证displayStatus参数 + if displayStatus != "open" && displayStatus != "close" { + return werrors.NewBadRequestError("invalid display status, must be 'open' or 'close'") + } + + // 获取当前租户ID + tenantID := ctx.Value(types.TenantIDContextKey).(uint64) + + // 查找FAQ类型的knowledge + knowledgeList, err := s.repo.ListKnowledgeByKnowledgeBaseID(ctx, tenantID, kbID) + if err != nil { + return fmt.Errorf("failed to list knowledge: %w", err) + } + + // 查找FAQ类型的knowledge + var faqKnowledge *types.Knowledge + for _, k := range knowledgeList { + if k.Type == types.KnowledgeTypeFAQ { + faqKnowledge = k + break + } + } + + if faqKnowledge == nil { + return werrors.NewNotFoundError("FAQ knowledge not found in this knowledge base") + } + + // 解析当前的导入结果 + result, err := faqKnowledge.GetLastFAQImportResult() + if err != nil { + return fmt.Errorf("failed to parse FAQ import result: %w", err) + } + + if result == nil { + return werrors.NewNotFoundError("no FAQ import result found") + } + + // 更新显示状态 + result.DisplayStatus = displayStatus + + // 保存更新后的结果 + if err := faqKnowledge.SetLastFAQImportResult(result); err != nil { + return fmt.Errorf("failed to set FAQ import result: %w", err) + } + + // 更新数据库 + if err := s.repo.UpdateKnowledge(ctx, faqKnowledge); err != nil { + return fmt.Errorf("failed to update knowledge: %w", err) + } + + return nil +} + // ProcessKBClone handles Asynq knowledge base clone tasks func (s *knowledgeService) ProcessKBClone(ctx context.Context, t *asynq.Task) error { var payload types.KBClonePayload diff --git a/internal/handler/faq.go b/internal/handler/faq.go index c8b1f491b..b283e862e 100644 --- a/internal/handler/faq.go +++ b/internal/handler/faq.go @@ -422,3 +422,82 @@ func (h *FAQHandler) GetImportProgress(c *gin.Context) { "data": progress, }) } + +// GetLastImportResult godoc +// @Summary 获取FAQ最后一次导入结果统计 +// @Description 获取FAQ知识库的最后一次导入结果统计信息 +// @Tags FAQ管理 +// @Accept json +// @Produce json +// @Param id path string true "知识库ID" +// @Success 200 {object} map[string]interface{} "导入结果统计" +// @Failure 404 {object} errors.AppError "知识库不存在或无导入记录" +// @Security Bearer +// @Security ApiKeyAuth +// @Router /knowledge-bases/{id}/faq/import/last-result [get] +func (h *FAQHandler) GetLastImportResult(c *gin.Context) { + ctx := c.Request.Context() + kbID := secutils.SanitizeForLog(c.Param("id")) + + result, err := h.knowledgeService.GetLastFAQImportResult(ctx, kbID) + if err != nil { + logger.ErrorWithFields(ctx, err, nil) + c.Error(err) + return + } + + if result == nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": nil, + "message": "暂无导入记录", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": result, + }) +} + +// updateLastFAQImportResultDisplayStatusRequest is the request payload for UpdateLastImportResultDisplayStatus +type updateLastFAQImportResultDisplayStatusRequest struct { + DisplayStatus string `json:"display_status" binding:"required,oneof=open close"` +} + +// UpdateLastImportResultDisplayStatus godoc +// @Summary 更新FAQ最后一次导入结果显示状态 +// @Description 更新FAQ知识库导入结果统计卡片的显示或隐藏状态 +// @Tags FAQ管理 +// @Accept json +// @Produce json +// @Param id path string true "知识库ID" +// @Param request body updateLastFAQImportResultDisplayStatusRequest true "状态更新请求" +// @Success 200 {object} map[string]interface{} "更新成功" +// @Failure 400 {object} errors.AppError "请求参数错误" +// @Failure 404 {object} errors.AppError "知识库不存在或无导入记录" +// @Security Bearer +// @Security ApiKeyAuth +// @Router /knowledge-bases/{id}/faq/import/last-result/display [put] +func (h *FAQHandler) UpdateLastImportResultDisplayStatus(c *gin.Context) { + ctx := c.Request.Context() + kbID := secutils.SanitizeForLog(c.Param("id")) + + var req updateLastFAQImportResultDisplayStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.Error(ctx, "Failed to bind display status update payload", err) + c.Error(errors.NewBadRequestError("请求参数不合法").WithDetails(err.Error())) + return + } + + if err := h.knowledgeService.UpdateLastFAQImportResultDisplayStatus(ctx, kbID, req.DisplayStatus); err != nil { + logger.ErrorWithFields(ctx, err, nil) + c.Error(err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + }) +} diff --git a/internal/router/router.go b/internal/router/router.go index d3863aa04..f2775ec9a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -194,6 +194,9 @@ func RegisterFAQRoutes(r *gin.RouterGroup, handler *handler.FAQHandler) { faq.PUT("/entries/tags", handler.UpdateEntryTagBatch) faq.DELETE("/entries", handler.DeleteEntries) faq.POST("/search", handler.SearchFAQ) + // FAQ import result API + faq.GET("/import/last-result", handler.GetLastImportResult) + faq.PUT("/import/last-result/display", handler.UpdateLastImportResultDisplayStatus) } // FAQ import progress route (outside of knowledge-base scope) faqImport := r.Group("/faq/import") diff --git a/internal/types/faq.go b/internal/types/faq.go index bdb0b9593..d34b2e840 100644 --- a/internal/types/faq.go +++ b/internal/types/faq.go @@ -320,6 +320,30 @@ type FAQImportMetadata struct { ImportProcessed int `json:"import_processed"` } +// FAQImportResult 存储FAQ导入完成后的统计结果 +// 这个信息是持久化的,不跟随进度状态,直到下次导入时被替换 +type FAQImportResult struct { + // 导入统计信息 + TotalEntries int `json:"total_entries"` // 总条目数 + SuccessCount int `json:"success_count"` // 成功导入的条目数 + FailedCount int `json:"failed_count"` // 失败的条目数 + SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等) + + // 导入模式和时间信息 + ImportMode string `json:"import_mode"` // 导入模式:append 或 replace + ImportedAt time.Time `json:"imported_at"` // 导入完成时间 + TaskID string `json:"task_id"` // 导入任务ID + + // 失败详情URL(失败条目较多时提供下载链接) + FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL + + // 显示控制 + DisplayStatus string `json:"display_status"` // 显示状态:open 或 close + + // 额外统计信息 + ProcessingTime int64 `json:"processing_time"` // 处理耗时(毫秒) +} + // ToJSON converts the metadata to JSON type. func (m *FAQImportMetadata) ToJSON() (JSON, error) { if m == nil { @@ -332,6 +356,18 @@ func (m *FAQImportMetadata) ToJSON() (JSON, error) { return JSON(bytes), nil } +// ToJSON converts the import result to JSON type. +func (r *FAQImportResult) ToJSON() (JSON, error) { + if r == nil { + return nil, nil + } + bytes, err := json.Marshal(r) + if err != nil { + return nil, err + } + return JSON(bytes), nil +} + // ParseFAQImportMetadata parses FAQ import metadata from Knowledge. func ParseFAQImportMetadata(k *Knowledge) (*FAQImportMetadata, error) { if k == nil || len(k.Metadata) == 0 { diff --git a/internal/types/interfaces/knowledge.go b/internal/types/interfaces/knowledge.go index f8b7ddd74..47fc293d6 100644 --- a/internal/types/interfaces/knowledge.go +++ b/internal/types/interfaces/knowledge.go @@ -126,6 +126,10 @@ type KnowledgeService interface { SaveKBCloneProgress(ctx context.Context, progress *types.KBCloneProgress) error // GetFAQImportProgress retrieves the progress of an FAQ import task GetFAQImportProgress(ctx context.Context, taskID string) (*types.FAQImportProgress, error) + // GetLastFAQImportResult retrieves the latest FAQ import result for a knowledge base + GetLastFAQImportResult(ctx context.Context, kbID string) (*types.FAQImportResult, error) + // UpdateLastFAQImportResultDisplayStatus updates the display status of FAQ import result + UpdateLastFAQImportResultDisplayStatus(ctx context.Context, kbID string, displayStatus string) error // SearchKnowledge searches knowledge items by keyword across the tenant. // fileTypes: optional list of file extensions to filter by (e.g., ["csv", "xlsx"]) SearchKnowledge(ctx context.Context, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, error) diff --git a/internal/types/knowledge.go b/internal/types/knowledge.go index ba38d0ed2..30e867734 100644 --- a/internal/types/knowledge.go +++ b/internal/types/knowledge.go @@ -93,6 +93,8 @@ type Knowledge struct { StorageSize int64 `json:"storage_size"` // Metadata of the knowledge Metadata JSON `json:"metadata" gorm:"type:json"` + // Last FAQ import result (for FAQ type knowledge only) + LastFAQImportResult JSON `json:"last_faq_import_result" gorm:"type:json"` // Creation time of the knowledge CreatedAt time.Time `json:"created_at"` // Last updated time of the knowledge @@ -214,6 +216,32 @@ func (k *Knowledge) SetManualMetadata(meta *ManualKnowledgeMetadata) error { return nil } +// SetLastFAQImportResult sets FAQ import result to the dedicated field. +func (k *Knowledge) SetLastFAQImportResult(result *FAQImportResult) error { + if result == nil { + k.LastFAQImportResult = nil + return nil + } + jsonValue, err := result.ToJSON() + if err != nil { + return err + } + k.LastFAQImportResult = jsonValue + return nil +} + +// GetLastFAQImportResult parses and returns FAQ import result from the dedicated field. +func (k *Knowledge) GetLastFAQImportResult() (*FAQImportResult, error) { + if len(k.LastFAQImportResult) == 0 { + return nil, nil + } + var result FAQImportResult + if err := json.Unmarshal(k.LastFAQImportResult, &result); err != nil { + return nil, err + } + return &result, nil +} + // IsManual returns true if the knowledge item is manual Markdown knowledge. func (k *Knowledge) IsManual() bool { return k != nil && k.Type == KnowledgeTypeManual diff --git a/migrations/versioned/000009_add_last_faq_import_result.down.sql b/migrations/versioned/000009_add_last_faq_import_result.down.sql new file mode 100644 index 000000000..db8ee2e95 --- /dev/null +++ b/migrations/versioned/000009_add_last_faq_import_result.down.sql @@ -0,0 +1,3 @@ +-- Remove last_faq_import_result column from knowledge table + +ALTER TABLE knowledge DROP COLUMN last_faq_import_result; \ No newline at end of file diff --git a/migrations/versioned/000009_add_last_faq_import_result.up.sql b/migrations/versioned/000009_add_last_faq_import_result.up.sql new file mode 100644 index 000000000..c8fd2d79b --- /dev/null +++ b/migrations/versioned/000009_add_last_faq_import_result.up.sql @@ -0,0 +1,4 @@ +-- Add last_faq_import_result column to knowledge table +-- This field stores the latest FAQ import result for FAQ type knowledge + +ALTER TABLE knowledge ADD COLUMN last_faq_import_result JSON DEFAULT NULL COMMENT 'Last FAQ import result (for FAQ type knowledge only)'; \ No newline at end of file