import React, { useEffect, useRef, useState } from 'react'; import { useTheme } from '../../contexts'; import { Content } from '../../components/layout/Content'; import { TPattern } from '../../components/common/TPattern'; import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs'; import type { Doc } from '../../types'; type PipelineStatus = 'idle' | 'running' | 'completed' | 'error'; const PIPELINE_STEPS = [ { name: 'LOAD' }, { name: 'PARSE' }, { name: 'CHUNK' }, { name: 'EMBED' }, { name: 'STORE' }, ]; const REGULATION_TYPES = ['', '国家标准', '行业标准', '地方标准', '企业标准', '法律法规', '监管规定']; const STEP_DURATION_MS = 700; const INITIAL_SEARCH_QUERY = '新能源汽车电池安全要求'; function wait(ms: number) { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } export const DocsPage: React.FC = () => { const { theme, isDark } = useTheme(); const fileInputRef = useRef(null); const pipelineRunIdRef = useRef(0); const [activeStep, setActiveStep] = useState(-1); const [completedSteps, setCompletedSteps] = useState([]); const [pipelineStatus, setPipelineStatus] = useState('idle'); const [docs, setDocs] = useState([]); const [uploading, setUploading] = useState(false); const [uploadFileName, setUploadFileName] = useState(''); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(INITIAL_SEARCH_QUERY); const [searchResults, setSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [searchError, setSearchError] = useState(''); // Upload metadata const [regulationType, setRegulationType] = useState(''); const [version, setVersion] = useState(''); // Batch queue: files waiting to be uploaded after the current one finishes const batchQueueRef = useRef([]); async function loadDocuments() { setLoading(true); try { const response = await getDocumentList(); const apiDocs: Doc[] = response.docs.map((doc) => ({ id: parseInt(String(doc.id).replace('doc-', ''), 10) || Math.floor(Math.random() * 10000), name: doc.name, chunks: doc.chunks, size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document', status: doc.status === 'indexed' ? 'indexed' : doc.status === 'failed' ? 'failed' : 'parsing', docId: doc.id, downloadUrl: doc.download_url, updatedAt: doc.updated_at, summary: doc.summary, regulationType: doc.regulation_type, version: doc.version, })); setDocs(apiDocs); } catch (error) { console.error('Failed to load documents:', error); setDocs([]); } finally { setLoading(false); } } async function runSearch(query: string) { if (!query.trim()) return; setSearchLoading(true); setSearchError(''); try { const response = await searchRegulations(query.trim(), 8); setSearchResults(response.results); } catch (error) { console.error('Failed to search regulations:', error); setSearchError(error instanceof Error ? error.message : '检索失败'); setSearchResults([]); } finally { setSearchLoading(false); } } useEffect(() => { const timerId = window.setTimeout(() => { void loadDocuments(); }, 0); return () => window.clearTimeout(timerId); }, []); useEffect(() => { const timerId = window.setTimeout(() => { void runSearch(INITIAL_SEARCH_QUERY); }, 0); return () => window.clearTimeout(timerId); }, []); useEffect(() => { return () => { pipelineRunIdRef.current += 1; }; }, []); useEffect(() => { const parsingDocs = docs.filter( (doc) => doc.status === 'parsing' && doc.docId && !doc.docId.startsWith('pending-') ); if (parsingDocs.length === 0) return; const timerId = window.setInterval(() => { parsingDocs.forEach((doc) => { void getDocumentStatus(doc.docId!).then((res) => { if (res.status === 'indexed' || res.status === 'failed') { setDocs((prev) => prev.map((d) => d.docId === doc.docId ? { ...d, status: res.status === 'indexed' ? 'indexed' : 'failed', chunks: res.num_chunks ?? d.chunks, summary: res.summary ?? d.summary, regulationType: res.regulation_type ?? d.regulationType, version: res.version ?? d.version, } : d ) ); } }).catch(() => {}); }); }, 5000); return () => window.clearInterval(timerId); }, [docs]); const runPipelineFlow = async (runId: number, uploadPromise: Promise>>) => { const guard = (fn: () => void) => { if (pipelineRunIdRef.current !== runId) return false; fn(); return true; }; for (let i = 0; i < PIPELINE_STEPS.length - 1; i++) { if (!guard(() => setActiveStep(i))) return; await wait(STEP_DURATION_MS); if (!guard(() => setCompletedSteps((p) => p.includes(i) ? p : [...p, i]))) return; } if (!guard(() => setActiveStep(PIPELINE_STEPS.length - 1))) return; await uploadPromise; if (!guard(() => setCompletedSteps((p) => { const last = PIPELINE_STEPS.length - 1; return p.includes(last) ? p : [...p, last]; }))) return; await wait(240); if (pipelineRunIdRef.current !== runId) return; setActiveStep(-1); setPipelineStatus('completed'); }; const uploadSingleFile = async (file: File, runId: number) => { setUploading(true); setUploadFileName(file.name); setActiveStep(-1); setCompletedSteps([]); setPipelineStatus('running'); const fileSizeMB = (file.size / (1024 * 1024)).toFixed(1); const tempDocId = `pending-${Date.now()}`; const newDoc: Doc = { id: Date.now(), name: file.name, chunks: 0, size: `${fileSizeMB}MB`, status: 'parsing', docId: tempDocId, regulationType: regulationType || undefined, version: version || undefined, }; setDocs((prev) => [newDoc, ...prev]); const uploadPromise = uploadDocument(file, { regulationType: regulationType || undefined, version: version || undefined, }); void runPipelineFlow(runId, uploadPromise); try { const uploadRes = await uploadPromise; if (pipelineRunIdRef.current !== runId) return; setDocs((prev) => prev.map((doc) => doc.id === newDoc.id ? { ...doc, status: 'indexed', docId: uploadRes.doc_id, chunks: uploadRes.num_chunks || doc.chunks, summary: uploadRes.summary } : doc ) ); void loadDocuments(); } catch (error) { console.error('Upload failed:', error); if (pipelineRunIdRef.current !== runId) return; setDocs((prev) => prev.filter((doc) => doc.id !== newDoc.id)); setPipelineStatus('error'); setActiveStep(-1); setCompletedSteps([]); } finally { setUploading(false); setUploadFileName(''); if (fileInputRef.current) fileInputRef.current.value = ''; // Process next file in batch queue const next = batchQueueRef.current.shift(); if (next) { const nextRunId = pipelineRunIdRef.current + 1; pipelineRunIdRef.current = nextRunId; void uploadSingleFile(next, nextRunId); } } }; const handleFileSelect = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files ?? []); if (files.length === 0 || uploading) return; const [first, ...rest] = files; batchQueueRef.current = rest; const runId = pipelineRunIdRef.current + 1; pipelineRunIdRef.current = runId; await uploadSingleFile(first, runId); }; const handleDelete = async (docId: string) => { try { await deleteDocument(docId); setDocs((prev) => prev.filter((doc) => doc.docId !== docId)); } catch (error) { console.error('Delete failed:', error); } }; const handleRetry = async (docId: string) => { setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'parsing' } : doc)); try { const result = await retryDocument(docId); setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'indexed', chunks: result.num_chunks || doc.chunks } : doc) ); } catch (error) { console.error('Retry failed:', error); setDocs((prev) => prev.map((doc) => doc.docId === docId ? { ...doc, status: 'failed' } : doc)); } }; const triggerFileUpload = () => { if (uploading) return; fileInputRef.current?.click(); }; const handleDragOver = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); }; const handleDrop = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); const files = Array.from(event.dataTransfer.files); if (files.length === 0 || uploading) return; const [first, ...rest] = files; batchQueueRef.current = rest; const runId = pipelineRunIdRef.current + 1; pipelineRunIdRef.current = runId; void uploadSingleFile(first, runId); }; const getStepStyle = (index: number) => { if (activeStep === index) return { background: theme.bgCard, border: `2px solid ${theme.accent}`, boxShadow: `0 0 12px ${theme.accent}40` }; if (completedSteps.includes(index)) return { background: theme.bgCard, border: `1px solid ${theme.green}` }; return { background: theme.bgCard, border: `1px solid ${theme.border}` }; }; const getCheckStyle = (index: number) => { if (activeStep === index) return { background: theme.gradientAccent, color: '#fff', animation: 'pulse 0.6s infinite' }; if (completedSteps.includes(index)) return { background: theme.green, color: '#fff' }; return { background: theme.bgHover, color: theme.text3 }; }; const getPipelineHint = () => { if (pipelineStatus === 'running') { const queueLen = batchQueueRef.current.length; const suffix = queueLen > 0 ? ` (+${queueLen} 待上传)` : ''; return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`; } if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE'; if (pipelineStatus === 'error') return 'PIPELINE FAILED'; return 'WAITING FOR UPLOAD'; }; const inputStyle: React.CSSProperties = { padding: '8px 12px', fontSize: 13, background: theme.bgCard, border: `1px solid ${theme.border}`, borderRadius: 8, color: theme.text, outline: 'none', }; return (

UPLOAD

{/* Metadata row */}
setVersion(e.target.value)} placeholder="版本号(可选,如 2024)" style={{ ...inputStyle, flex: 1 }} />
{uploading ? (
) : ( )}
{uploading ? '正在上传并启动处理链路...' : '拖拽文件或点击上传(支持多选)'}
{uploading ? uploadFileName : 'PDF · DOCX · DOC · MAX 100MB · 支持批量'}

PROCESSING PIPELINE

{getPipelineHint()}
{PIPELINE_STEPS.map((step, index) => { const isCompleted = completedSteps.includes(index); const isActive = activeStep === index; const arrowActive = activeStep > index || isCompleted; return (
{isActive ? step.name : isCompleted ? '✓' : step.name}
{step.name}
{isCompleted ? 'DONE' : isActive ? 'RUNNING' : 'PENDING'}
{index < PIPELINE_STEPS.length - 1 && (
)}
); })}

文档管理清单 ({loading ? '...' : docs.length})

{docs.map((doc) => (
{doc.name}
{doc.updatedAt ? new Date(doc.updatedAt).toLocaleString() : doc.size} {doc.docId ? ` · ${doc.docId}` : ''}
{/* Tags row */} {(doc.regulationType || doc.version) && (
{doc.regulationType && ( {doc.regulationType} )} {doc.version && ( v{doc.version} )}
)} {doc.summary && (
{doc.summary}
)}
{doc.status === 'failed' && doc.docId && !doc.docId.startsWith('pending-') && ( )} {doc.downloadUrl && doc.status === 'indexed' && ( 下载 )}
{doc.status === 'parsing' ? '处理中...' : doc.status === 'failed' ? '处理失败' : `${doc.chunks} chunks`}
{doc.docId && !doc.docId.startsWith('pending-') && ( )}
))}

文档管理内法规检索

setSearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void runSearch(searchQuery); }} placeholder="输入法规关键词、条款或制度主题" style={{ flex: 1, padding: 12, fontSize: 14, background: theme.bgCard, border: `1px solid ${theme.border}`, borderRadius: 8, color: theme.text, outline: 'none' }} />
{searchError &&
{searchError}
}
{searchResults.map((item) => (
{item.file}
{(item.score * 100).toFixed(1)}%
{item.clause}{item.tags.length > 0 ? ` · ${item.tags.join(' · ')}` : ''}
{item.content}
))} {!searchLoading && searchResults.length === 0 && (
暂无检索结果
)}
); };