diff --git a/src/app.tsx b/src/app.tsx index 38ebc366b8b4fa5a659c0344c54b2e25a7532583..0f24c8355c67bd537cd521b6a25752926b1886e8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,7 +2,6 @@ import { Toaster as Sonner } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/toaster'; import i18n from '@/locales/config'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { App, ConfigProvider, ConfigProviderProps, theme } from 'antd'; import pt_BR from 'antd/lib/locale/pt_BR'; import deDE from 'antd/locale/de_DE'; @@ -22,6 +21,7 @@ import { ThemeProvider, useTheme } from './components/theme-provider'; import { SidebarProvider } from './components/ui/sidebar'; import { TooltipProvider } from './components/ui/tooltip'; import storage from './utils/authorization-util'; +import { GenerateProgressProvider } from './contexts/generate-progress-context'; dayjs.extend(customParseFormat); dayjs.extend(advancedFormat); @@ -102,7 +102,9 @@ const RootProvider = ({ children }: React.PropsWithChildren) => { - {children} + + {children} + diff --git a/src/components/global-progress-indicator/index.less b/src/components/global-progress-indicator/index.less new file mode 100644 index 0000000000000000000000000000000000000000..776fccafa8b63e889dbb3a897bb1d565f0cbbc59 --- /dev/null +++ b/src/components/global-progress-indicator/index.less @@ -0,0 +1,24 @@ +.progressIndicator { + position: fixed; + bottom: 20px; + left: 20px; + z-index: 9999; + min-width: 200px; + padding: 12px 16px; + background: #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, .15); + border: 1px solid #e8e8e8; + border-radius: 8px; + + .content { + display: flex; + justify-content: flex-start; + align-items: center; + } + + .text { + color: #333; + font-size: 14px; + white-space: nowrap; + } +} diff --git a/src/components/global-progress-indicator/index.tsx b/src/components/global-progress-indicator/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..203f3c7ce0d2f89b0dbf1d31646d247cd25d5d7d --- /dev/null +++ b/src/components/global-progress-indicator/index.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useState } from 'react'; +import { Progress, Button, Modal, Card, List, message } from 'antd'; +import { useGenerateProgressContext } from '@/contexts/generate-progress-context'; +import { useGenerateProgress } from '@/hooks/knowledge-hooks'; +import { useOtherDocGenerateAiQuestion, useGenerateAiQuestion, useSaveAiQuestions } from '@/hooks/knowledge-hooks'; +import styles from './index.less'; + +const GlobalProgressIndicator: React.FC = () => { + const { progressState, updateProgress, setError, clearProgress, startProgress } = useGenerateProgressContext(); + const { progressData } = useGenerateProgress(progressState?.historyId || null); + const { otherDocGenerateAiQuestion } = useOtherDocGenerateAiQuestion(); + const { generateAiQuestion } = useGenerateAiQuestion(); + const { saveAiQuestions, loading: saveLoading } = useSaveAiQuestions(); + const [expandedMap, setExpandedMap] = useState>({}); + const [showModal, setShowModal] = useState(false); + + // 同步轮询数据到全局状态 + useEffect(() => { + if (progressState?.historyId && progressData) { + if (progressData.progress === -1) { + setError(true); + } else { + updateProgress(progressData.progress, progressData.aiQuestions); + } + } + }, [progressData, progressState?.historyId, updateProgress, setError]); + + // 当进度完成或失败时,停止轮询(由 hook 内部处理) + useEffect(() => { + if (progressState && (progressState.progress === 1 || progressState.error)) { + // 进度完成或失败,不需要额外操作,hook 会自动停止轮询 + } + }, [progressState]); + + if (!progressState) { + return null; + } + + const handleViewClick = () => { + setShowModal(true); + }; + + const handleModalClose = () => { + setShowModal(false); + // 如果任务已完成或失败,关闭左下角提示 + if (progressState && (progressState.progress === 1 || progressState.error)) { + clearProgress(); + } + }; + + const handleRetry = async () => { + if (!progressState.lastQuestionCount) { + message.warning('未获取到问题数量'); + return; + } + try { + if (progressState.lastGenerateType === 'selected') { + const result = await otherDocGenerateAiQuestion({ + doc_ids: progressState.selectedDocIds, + question_count: progressState.lastQuestionCount, + }); + if (result?.history_id) { + startProgress(result.history_id, 'selected', progressState.lastQuestionCount, progressState.selectedDocIds, progressState.knowledgeBaseId || null); + message.info('开始生成问题,请稍候...'); + } + } else if (progressState.lastGenerateType === 'all') { + const result = await generateAiQuestion(progressState.lastQuestionCount); + if (result?.history_id) { + startProgress(result.history_id, 'all', progressState.lastQuestionCount, [], progressState.knowledgeBaseId || null); + message.info('开始生成问题,请稍候...'); + } + } + } catch (e) { + message.error('生成失败,请重试'); + } + }; + + const handleSave = async () => { + if (!progressState.historyId || !progressState.aiQuestions) { + message.warning('没有可保存的问题'); + return; + } + try { + await saveAiQuestions({ + aiQuestions: progressState.aiQuestions, + historyId: progressState.historyId, + kbIdOverride: progressState.knowledgeBaseId || undefined + }); + setShowModal(false); + clearProgress(); + } catch (error) { + message.error('保存失败,请重试'); + } + }; + + const isCompleted = progressState.progress === 1; + const isError = progressState.error || progressState.progress === -1; + const isInProgress = !isCompleted && !isError; + + return ( + <> +
+
+ {isInProgress && ( +
+ + 正在从知识库自动生成测试问题中 +
+ )} + {isCompleted && ( + <> + AI写文档测试问题已完成 + + + )} + {isError && ( + <> + AI写文档测试问题失败 + + + )} +
+
+ + +
+ {isError ? ( +
+
+ 网络不好,请稍后再试 +
+
+ +
+
+ ) : isCompleted ? ( + <> + {progressState.aiQuestions && progressState.aiQuestions.length > 0 && ( +
+
生成的问题预览:
+
+ {progressState.aiQuestions.map((item: any, index: number) => { + const key = `${item.category || '未分类'}-${index}`; + const expanded = !!expandedMap[key]; + const shownQuestions = expanded ? item.questions : (item.questions || []).slice(0, 3); + return ( + + {item.category || '未分类'} + + ({item.doc_count}个文件,占{Math.round((item.question_ratio || 0) * 100)}%,共生成{item.question_count}个问题) + +
+ } + headStyle={{ padding: '8px 12px' }} + bodyStyle={{ padding: '8px 12px' }} + > +
+ ( + + {q.question_text} + + )} + /> + {(item.questions?.length || 0) > 3 && ( +
+ +
+ )} + + ); + })} +
+
+ )} +
+ + +
+ + ) : ( +
+ +
+ 进度: {(progressState.progress || 0) * 100}% +
+
+ )} +
+
+ + ); +}; + +export default GlobalProgressIndicator; + diff --git a/src/contexts/generate-progress-context.tsx b/src/contexts/generate-progress-context.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f441447a46c6b160e3617e6d240051ed37de4b3f --- /dev/null +++ b/src/contexts/generate-progress-context.tsx @@ -0,0 +1,128 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; + +export interface GenerateProgressState { + historyId: string | null; + progress: number; + aiQuestions: any[]; + lastGenerateType: 'all' | 'selected' | null; + lastQuestionCount: number | null; + selectedDocIds: string[]; + knowledgeBaseId?: string | null; + error: boolean; +} + +interface GenerateProgressContextType { + progressState: GenerateProgressState | null; + setProgressState: (state: GenerateProgressState | null) => void; + startProgress: (historyId: string, lastGenerateType: 'all' | 'selected', lastQuestionCount: number, selectedDocIds?: string[], knowledgeBaseId?: string | null) => void; + updateProgress: (progress: number, aiQuestions?: any[]) => void; + setError: (error: boolean) => void; + clearProgress: () => void; +} + +const GenerateProgressContext = createContext(undefined); + +export const GenerateProgressProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [progressState, setProgressState] = useState(null); + const STORAGE_KEY = 'aiGenerateProgressState'; + + // 初始化时从 localStorage 恢复进度状态 + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed: GenerateProgressState = JSON.parse(saved); + // 基本字段校验 + if (parsed && typeof parsed === 'object' && ('historyId' in parsed)) { + setProgressState(parsed); + } + } + } catch (e) { + // ignore corrupted storage + localStorage.removeItem(STORAGE_KEY); + } + }, []); + + // 状态变化时持久化到 localStorage(null 则删除) + useEffect(() => { + if (progressState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(progressState)); + } catch { + // ignore quota exceeded + } + } else { + localStorage.removeItem(STORAGE_KEY); + } + }, [progressState]); + + const startProgress = useCallback(( + historyId: string, + lastGenerateType: 'all' | 'selected', + lastQuestionCount: number, + selectedDocIds: string[] = [], + knowledgeBaseId: string | null = null + ) => { + setProgressState({ + historyId, + progress: 0, + aiQuestions: [], + lastGenerateType, + lastQuestionCount, + selectedDocIds, + knowledgeBaseId, + error: false, + }); + }, []); + + const updateProgress = useCallback((progress: number, aiQuestions?: any[]) => { + setProgressState((prev) => { + if (!prev) return null; + return { + ...prev, + progress, + aiQuestions: aiQuestions || prev.aiQuestions, + error: false, + }; + }); + }, []); + + const setError = useCallback((error: boolean) => { + setProgressState((prev) => { + if (!prev) return null; + return { + ...prev, + error, + progress: error ? -1 : prev.progress, + }; + }); + }, []); + + const clearProgress = useCallback(() => { + setProgressState(null); + }, []); + + return ( + + {children} + + ); +}; + +export const useGenerateProgressContext = () => { + const context = useContext(GenerateProgressContext); + if (context === undefined) { + throw new Error('useGenerateProgressContext must be used within a GenerateProgressProvider'); + } + return context; +}; + diff --git a/src/hooks/knowledge-hooks.ts b/src/hooks/knowledge-hooks.ts index 6b495e17d195d2c15b94857bbd5801625ff1e2c9..a4cb2cd1219c1b257425bb59e7d655434c62db1b 100644 --- a/src/hooks/knowledge-hooks.ts +++ b/src/hooks/knowledge-hooks.ts @@ -385,7 +385,7 @@ export const useTestChunkAllRetrieval = (): ResponsePostType & { knowledge_ids: values.kb_id ? values.kb_id : [knowledgeBaseId], query: questions, keyword: false, - document_ids: values.doc_ids, + filter_document_ids: values.doc_ids, highlight: false, //joinRule:'or', rerank_id: values.rerank_id, @@ -1010,11 +1010,17 @@ export const useGenerateProgress = (historyId: string | null) => { }, queryFn: async () => { if (!historyId) return { progress: 0, aiQuestions: [], id: '' }; - const response = await getGenerateProgress(historyId); - if (response?.data?.code === 0) { - return response.data.data; + try { + const response = await getGenerateProgress(historyId); + if (response?.data?.code === 0) { + return response.data.data; + } + // 如果接口返回错误,返回 progress: -1 表示失败 + return { progress: -1, aiQuestions: [], id: historyId }; + } catch (error) { + // 网络错误或其他异常,返回 progress: -1 + return { progress: -1, aiQuestions: [], id: historyId }; } - return { progress: 0, aiQuestions: [], id: '' }; }, }); @@ -1067,12 +1073,17 @@ export const useSaveAiQuestions = () => { const queryClient = useQueryClient(); const { mutateAsync, isPending: loading } = useMutation({ - mutationFn: async (params: { aiQuestions: any[]; historyId: string }) => { - if (!knowledgeBaseId) throw new Error('知识库ID不能为空'); + mutationFn: async (params: { + aiQuestions: any[]; + historyId: string; + kbIdOverride?: string; + }) => { + const kbId = params.kbIdOverride || knowledgeBaseId; + if (!kbId) throw new Error('知识库ID不能为空'); const response = await saveAiQuestions({ ai_generate_questions: params.aiQuestions, - kb_id: knowledgeBaseId, + kb_id: kbId, history_id: params.historyId, }); diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 45c9933e269f217cb595fd8e5d2d0edd8d292213..837de2be5a5536465d2861dd9420f82d58210390 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -4,6 +4,7 @@ import { Outlet, useLocation } from 'umi'; import '../locales/config'; import Header from './components/header'; import { useEffect } from 'react'; +import GlobalProgressIndicator from '@/components/global-progress-indicator'; import styles from './index.less'; @@ -21,7 +22,7 @@ const DEFAULT_TITLE = '赛迪知源'; const App: React.FC = () => { const { - token: { colorBgContainer, borderRadiusLG }, + token: { borderRadiusLG }, } = theme.useToken(); const location = useLocation(); @@ -44,7 +45,7 @@ const App: React.FC = () => { { + ); }; diff --git a/src/pages/add-knowledge/components/knowledge-testing/depth-evaluation/test-questions/ai-generate-modal.tsx b/src/pages/add-knowledge/components/knowledge-testing/depth-evaluation/test-questions/ai-generate-modal.tsx index aa056b59a6056b13c2b44b56220134015ac9fc48..3ff9a8270be3546d72d4549fdb11b77c2550dcd9 100644 --- a/src/pages/add-knowledge/components/knowledge-testing/depth-evaluation/test-questions/ai-generate-modal.tsx +++ b/src/pages/add-knowledge/components/knowledge-testing/depth-evaluation/test-questions/ai-generate-modal.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; import { Modal, Form, message, InputNumber, Table, Button, Space, Tooltip, Progress, List, Card } from 'antd'; import { InfoCircleOutlined, CloseOutlined } from '@ant-design/icons'; -import { useGenerateAiQuestion, useFetchFileUpdates, useFetchAiQuestionCount, useFetchCheckFirstGenerate, useFetchAiQuestionCountByDocIds, useOtherDocGenerateAiQuestion, useGenerateProgress, useSaveAiQuestions } from '@/hooks/knowledge-hooks'; +import { useGenerateAiQuestion, useFetchFileUpdates, useFetchAiQuestionCount, useFetchCheckFirstGenerate, useFetchAiQuestionCountByDocIds, useOtherDocGenerateAiQuestion, useGenerateProgress, useSaveAiQuestions, useKnowledgeBaseId } from '@/hooks/knowledge-hooks'; +import { useGenerateProgressContext } from '@/contexts/generate-progress-context'; interface AIGenerateModalProps { visible: boolean; @@ -17,6 +18,8 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on const { firstGenerateStatus, loading: firstGenerateLoading, refetch: refetchFirstGenerate } = useFetchCheckFirstGenerate(); const { fetchCount } = useFetchAiQuestionCountByDocIds(); const { otherDocGenerateAiQuestion, loading: otherGenLoading } = useOtherDocGenerateAiQuestion(); + const { startProgress, progressState, clearProgress } = useGenerateProgressContext(); + const kbId = useKnowledgeBaseId(); // 左侧面板选中:'new' | 'edited' const [activeSource, setActiveSource] = useState<'new' | 'edited'>('new'); @@ -45,16 +48,17 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on const [lastGenerateType, setLastGenerateType] = useState<'all' | 'selected' | null>(null); const [lastQuestionCount, setLastQuestionCount] = useState(null); const [expandedMap, setExpandedMap] = useState>({}); - + const [questionNumber, setQuestionNumber] = useState<{ value: number; validateStatus?: 'error' | 'success' | undefined; errorMsg?: string | null; }>({ value: questionCount?.recommendCount ?? 1 }); const manualEditedRef = useRef(false); - - // 轮询进度 - const { progressData } = useGenerateProgress(historyId); + + // 轮询进度 - 使用全局状态中的 historyId 或本地 historyId + const activeHistoryId = progressState?.historyId || historyId; + const { progressData } = useGenerateProgress(activeHistoryId); // 保存AI问题 const { saveAiQuestions, loading: saveLoading } = useSaveAiQuestions(); @@ -63,13 +67,16 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on refetch(); refetchCount(); refetchFirstGenerate(); - } else { - // 弹窗关闭时重置内部状态,确保下次打开先显示文件列表 - setDynamicLimit(undefined); - setSelectedDocIds([]); - setActiveSource('new'); + // 如果全局状态中有正在进行的任务,同步到本地状态 + if (progressState?.historyId) { + setHistoryId(progressState.historyId); + setShowProgress(true); + setLastGenerateType(progressState.lastGenerateType); + setLastQuestionCount(progressState.lastQuestionCount); + } } - }, [visible, refetch, refetchCount, refetchFirstGenerate]); + // 注意:弹窗关闭时的逻辑在 handleCancel 中处理,这里不做处理 + }, [visible, refetch, refetchCount, refetchFirstGenerate, progressState]); const handleOk = async () => { const values = await form.validateFields(); @@ -79,7 +86,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on message.warning(`请输入大于1的数量`); return; } - + try { const result = await generateAiQuestion(qty); if (result?.history_id) { @@ -87,6 +94,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on setShowProgress(true); setLastGenerateType('all'); setLastQuestionCount(qty); + // 不立即启动全局进度跟踪,等弹窗关闭后再启动 message.info('开始生成问题,请稍候...'); } } catch (error) { @@ -103,7 +111,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on message.warning(`请输入大于1的数量`); return; } - + try { const result = await otherDocGenerateAiQuestion({ doc_ids: selectedDocIds, question_count: qty }); if (result?.history_id) { @@ -111,6 +119,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on setShowProgress(true); setLastGenerateType('selected'); setLastQuestionCount(qty); + // 不立即启动全局进度跟踪,等弹窗关闭后再启动 message.info('开始生成问题,请稍候...'); } } catch (error) { @@ -144,11 +153,11 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on return { value, validateStatus: 'success' as const, errorMsg: null }; }; setQuestionNumber(validateQuestionCount(rec, res.limitCount || 0)); - // try { - // const res = await fetchCount(ids); - // form.setFieldsValue({ questionCount: res.recommendCount || 0 }); - // setDynamicLimit(res.limitCount || 0); - } + // try { + // const res = await fetchCount(ids); + // form.setFieldsValue({ questionCount: res.recommendCount || 0 }); + // setDynamicLimit(res.limitCount || 0); + } finally { setFetchingCount(false); } @@ -160,8 +169,35 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on setDynamicLimit(undefined); setSelectedDocIds([]); setActiveSource('new'); - setHistoryId(null); - setShowProgress(false); + + // 检查是否有正在进行的任务(本地状态) + const hasActiveTask = historyId && showProgress; + // 获取当前进度值,优先使用全局状态,其次使用轮询数据 + const currentProgressValue = progressState?.progress ?? progressData?.progress ?? 0; + // 判断任务是否正在进行中(有任务且进度不是1(完成)也不是-1(失败)) + const isTaskInProgress = hasActiveTask && currentProgressValue !== 1 && currentProgressValue !== -1; + + if (isTaskInProgress && !progressState?.historyId) { + // 如果正在生成中且全局状态还没有启动,关闭弹窗后启动全局进度跟踪(显示左下角提示) + if (lastGenerateType && lastQuestionCount !== null && historyId) { + startProgress( + historyId, + lastGenerateType, + lastQuestionCount, + lastGenerateType === 'selected' ? selectedDocIds : [], + kbId || null + ); + } + setShowProgress(false); + } else { + // 如果任务已完成、失败、没有任务,或者全局状态已经启动,清除本地状态 + setHistoryId(null); + setShowProgress(false); + // 如果全局状态存在且任务已完成或失败,清除全局状态 + if (progressState && (progressState.progress === 1 || progressState.error)) { + clearProgress(); + } + } onCancel(); }; @@ -198,13 +234,14 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on form.setFieldsValue({ questionCount: next.value }); }; - // 监听进度完成 + // 监听进度完成 - 使用全局状态或本地进度数据 + const currentProgress = progressState?.progress ?? progressData.progress; useEffect(() => { - if (progressData.progress === 1 && showProgress) { + if (currentProgress === 1 && showProgress) { message.success('问题生成完成!'); // 不自动关闭弹窗,保持显示问题列表 } - }, [progressData.progress, showProgress]); + }, [currentProgress, showProgress]); return ( @@ -228,7 +265,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on {showProgress ? (
- {progressData.progress === -1 ? ( + {(currentProgress === -1 || progressState?.error) ? ( <>
网络不好,请稍后再试 @@ -247,6 +284,8 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on if (result?.history_id) { setHistoryId(result.history_id); setShowProgress(true); + setLastQuestionCount(Number(qty)); + // 不立即启动全局进度跟踪,等弹窗关闭后再启动 message.info('开始生成问题,请稍候...'); } } catch (e) { @@ -258,6 +297,8 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on if (result?.history_id) { setHistoryId(result.history_id); setShowProgress(true); + setLastQuestionCount(Number(qty)); + // 不立即启动全局进度跟踪,等弹窗关闭后再启动 message.info('开始生成问题,请稍候...'); } } catch (e) { @@ -269,15 +310,15 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on }}>重新生成
- ) : progressData.progress !== 1 ? ( + ) : currentProgress !== 1 ? ( <> {/*
AI正在生成问题...
*/} - = ({ visible, onCancel, on size={120} />
- 进度: {(progressData.progress || 0) * 100}% + 进度: {(currentProgress || 0) * 100}%
) : ( @@ -294,12 +335,12 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on
)}
- - {progressData.progress === 1 && progressData.aiQuestions?.length > 0 && ( + + {currentProgress === 1 && (progressState?.aiQuestions || progressData.aiQuestions)?.length > 0 && (
生成的问题预览:
- {progressData.aiQuestions.map((item: any, index: number) => { + {(progressState?.aiQuestions || progressData.aiQuestions || []).map((item: any, index: number) => { const key = `${item.category || '未分类'}-${index}`; const expanded = !!expandedMap[key]; const shownQuestions = expanded ? item.questions : (item.questions || []).slice(0, 3); @@ -309,7 +350,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on size="small" style={{ marginBottom: 12 }} title={ -
+
{item.category || '未分类'} ({item.doc_count}个文件,占{Math.round((item.question_ratio || 0) * 100)}%,共生成{item.question_count}个问题) @@ -344,24 +385,30 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on )}
- {progressData.progress === 1 && ( - )} @@ -516,7 +563,7 @@ const AIGenerateModal: React.FC = ({ visible, onCancel, on value={questionNumber.value} style={{ width: '100%' }} placeholder="请输入要生成的问题数量" - onChange={onNumberChange} + onChange={onNumberChange} />
)} +
+ 源文件:{x.title} +
);