Spaces:
Running
Running
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { useNavigate, Link, useLocation } from 'react-router-dom'; | |
| import toast from 'react-hot-toast'; | |
| import { | |
| ArrowLeft, ArrowRight, FileText, Upload, Play, Download, | |
| CheckCircle, AlertCircle, Loader2, Settings, Eye, Edit3, | |
| Save, Trash2, RefreshCw, FileUp, X, ChevronDown, ChevronUp, | |
| Hash, Type, List, BookOpen, Quote, Table, Image, Code | |
| } from 'lucide-react'; | |
| import { wordFormatterAPI } from '../api'; | |
| // Paragraph type configuration with icons and colors | |
| const PARAGRAPH_TYPES = { | |
| title: { label: '标题', icon: Type, color: 'bg-blue-100 text-blue-700 border-blue-300' }, | |
| heading1: { label: '一级标题', icon: Hash, color: 'bg-blue-100 text-blue-700 border-blue-300' }, | |
| heading2: { label: '二级标题', icon: Hash, color: 'bg-cyan-100 text-cyan-700 border-cyan-300' }, | |
| heading3: { label: '三级标题', icon: Hash, color: 'bg-teal-100 text-teal-700 border-teal-300' }, | |
| abstract: { label: '摘要', icon: BookOpen, color: 'bg-amber-100 text-amber-700 border-amber-300' }, | |
| keywords: { label: '关键词', icon: List, color: 'bg-orange-100 text-orange-700 border-orange-300' }, | |
| body: { label: '正文', icon: FileText, color: 'bg-gray-100 text-gray-700 border-gray-300' }, | |
| quote: { label: '引用', icon: Quote, color: 'bg-blue-100 text-blue-700 border-blue-300' }, | |
| list_item: { label: '列表项', icon: List, color: 'bg-green-100 text-green-700 border-green-300' }, | |
| table: { label: '表格', icon: Table, color: 'bg-pink-100 text-pink-700 border-pink-300' }, | |
| figure: { label: '图片', icon: Image, color: 'bg-rose-100 text-rose-700 border-rose-300' }, | |
| code: { label: '代码', icon: Code, color: 'bg-slate-100 text-slate-700 border-slate-300' }, | |
| reference: { label: '参考文献', icon: BookOpen, color: 'bg-teal-100 text-teal-700 border-teal-300' }, | |
| }; | |
| const ArticlePreprocessorPage = () => { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const fileInputRef = useRef(null); | |
| const eventSourceRef = useRef(null); | |
| // Input mode and content | |
| const [inputMode, setInputMode] = useState('file'); // 'file' or 'text' | |
| const [text, setText] = useState(''); | |
| const [file, setFile] = useState(null); | |
| const [dragActive, setDragActive] = useState(false); | |
| // Configuration | |
| const [showConfig, setShowConfig] = useState(false); | |
| const [chunkParagraphs, setChunkParagraphs] = useState(40); | |
| const [chunkChars, setChunkChars] = useState(8000); | |
| // Job state | |
| const [currentJobId, setCurrentJobId] = useState(null); | |
| const [jobStatus, setJobStatus] = useState(null); // 'pending', 'running', 'completed', 'failed' | |
| const [progress, setProgress] = useState(null); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| // Result state | |
| const [paragraphs, setParagraphs] = useState([]); | |
| const [markedText, setMarkedText] = useState(''); | |
| const [integrityStatus, setIntegrityStatus] = useState(null); | |
| const [editingIndex, setEditingIndex] = useState(null); | |
| // View mode | |
| const [viewMode, setViewMode] = useState('list'); // 'list' or 'raw' | |
| const [usage, setUsage] = useState(null); | |
| // Check if coming from spec generator with a spec | |
| const selectedSpec = location.state?.specJson || null; | |
| const specName = location.state?.specName || null; | |
| useEffect(() => { | |
| loadUsage(); | |
| return () => { | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| } | |
| }; | |
| }, []); | |
| const loadUsage = async () => { | |
| try { | |
| const response = await wordFormatterAPI.getUsage(); | |
| setUsage(response.data); | |
| } catch (error) { | |
| console.error('Load usage failed:', error); | |
| } | |
| }; | |
| // File handling | |
| const handleFileChange = (e) => { | |
| const selectedFile = e.target.files?.[0]; | |
| if (selectedFile) { | |
| validateAndSetFile(selectedFile); | |
| } | |
| }; | |
| const validateAndSetFile = (selectedFile) => { | |
| const allowedTypes = [ | |
| 'text/plain', | |
| 'text/markdown', | |
| 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', | |
| ]; | |
| const allowedExtensions = ['.txt', '.md', '.docx']; | |
| const ext = selectedFile.name.substring(selectedFile.name.lastIndexOf('.')).toLowerCase(); | |
| if (!allowedTypes.includes(selectedFile.type) && !allowedExtensions.includes(ext)) { | |
| toast.error('仅支持 .txt, .md, .docx 文件'); | |
| return; | |
| } | |
| if (selectedFile.size > 10 * 1024 * 1024) { | |
| toast.error('文件大小不能超过 10MB'); | |
| return; | |
| } | |
| setFile(selectedFile); | |
| toast.success(`已选择文件: ${selectedFile.name}`); | |
| }; | |
| const handleDrag = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (e.type === 'dragenter' || e.type === 'dragover') { | |
| setDragActive(true); | |
| } else if (e.type === 'dragleave') { | |
| setDragActive(false); | |
| } | |
| }; | |
| const handleDrop = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setDragActive(false); | |
| if (e.dataTransfer.files?.[0]) { | |
| validateAndSetFile(e.dataTransfer.files[0]); | |
| } | |
| }; | |
| // Start preprocessing | |
| const handleStartPreprocess = async () => { | |
| if (inputMode === 'file' && !file) { | |
| toast.error('请选择文件'); | |
| return; | |
| } | |
| if (inputMode === 'text' && !text.trim()) { | |
| toast.error('请输入文本内容'); | |
| return; | |
| } | |
| try { | |
| setIsSubmitting(true); | |
| setJobStatus('pending'); | |
| setParagraphs([]); | |
| setMarkedText(''); | |
| setIntegrityStatus(null); | |
| let response; | |
| if (inputMode === 'file') { | |
| response = await wordFormatterAPI.preprocessFile(file, { | |
| chunkParagraphs, | |
| chunkChars, | |
| }); | |
| } else { | |
| response = await wordFormatterAPI.preprocessText(text, { | |
| chunkParagraphs, | |
| chunkChars, | |
| }); | |
| } | |
| const jobId = response.data.job_id; | |
| setCurrentJobId(jobId); | |
| startSSE(jobId); | |
| toast.success('预处理任务已开始'); | |
| } catch (error) { | |
| console.error('Start preprocess failed:', error); | |
| toast.error(error.response?.data?.detail || '启动预处理失败'); | |
| setJobStatus(null); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| // SSE connection | |
| const startSSE = (jobId) => { | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| } | |
| const url = wordFormatterAPI.getPreprocessStreamUrl(jobId); | |
| const es = new EventSource(url); | |
| eventSourceRef.current = es; | |
| es.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| handleSSEData(data); | |
| } catch (e) { | |
| console.error('SSE parse error:', e); | |
| } | |
| }; | |
| es.addEventListener('progress', (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| setJobStatus('running'); | |
| setProgress(data); | |
| } catch (e) { | |
| console.error('SSE progress error:', e); | |
| } | |
| }); | |
| es.addEventListener('completed', (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| setJobStatus('completed'); | |
| fetchResult(jobId); | |
| toast.success('文章预处理完成!'); | |
| loadUsage(); | |
| } catch (e) { | |
| console.error('SSE completed error:', e); | |
| } | |
| es.close(); | |
| }); | |
| es.addEventListener('error', (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| setJobStatus('failed'); | |
| toast.error(`预处理失败: ${data.message}`); | |
| } catch (e) { | |
| console.error('SSE error event:', e); | |
| } | |
| es.close(); | |
| }); | |
| es.onerror = () => { | |
| if (es.readyState === EventSource.CLOSED) { | |
| return; | |
| } | |
| console.log('SSE connection error, will retry fetching result...'); | |
| es.close(); | |
| // 延迟后尝试获取结果,如果任务仍在运行会自动忽略 | |
| setTimeout(() => fetchResult(jobId), 2000); | |
| }; | |
| }; | |
| const handleSSEData = (data) => { | |
| if (data.status) { | |
| setJobStatus(data.status); | |
| } | |
| if (data.progress) { | |
| setProgress(data); | |
| } | |
| }; | |
| // Fetch preprocessing result | |
| const fetchResult = async (jobId) => { | |
| try { | |
| const response = await wordFormatterAPI.getPreprocessResult(jobId); | |
| if (response.data.success) { | |
| // 后端直接返回 response.data,无需 .result | |
| // 字段映射:后端 text/paragraph_type -> 前端 content/type | |
| const paragraphsData = (response.data.paragraphs || []).map((p) => ({ | |
| index: p.index, | |
| content: p.text, | |
| type: p.paragraph_type || 'body', | |
| })); | |
| setParagraphs(paragraphsData); | |
| setMarkedText(response.data.marked_text || ''); | |
| setIntegrityStatus({ | |
| verified: response.data.integrity_check_passed, | |
| originalHash: response.data.original_hash, | |
| processedHash: response.data.processed_hash, | |
| }); | |
| setJobStatus('completed'); | |
| } else { | |
| // 任务失败 | |
| setJobStatus('failed'); | |
| toast.error(response.data.error || '预处理失败'); | |
| } | |
| } catch (error) { | |
| console.error('Fetch result failed:', error); | |
| const status = error.response?.status; | |
| if (status === 404) { | |
| toast.error('任务不存在或已过期'); | |
| setJobStatus(null); | |
| } else if (status === 400) { | |
| // 任务尚未完成,保持当前状态 | |
| console.log('任务尚未完成,稍后重试'); | |
| } else { | |
| // 其他错误 | |
| console.error('获取结果失败:', error.response?.data?.detail || error.message); | |
| } | |
| } | |
| }; | |
| // Edit paragraph type | |
| const handleTypeChange = (index, newType) => { | |
| const updated = [...paragraphs]; | |
| updated[index] = { ...updated[index], type: newType }; | |
| setParagraphs(updated); | |
| setEditingIndex(null); | |
| // Regenerate marked text | |
| regenerateMarkedText(updated); | |
| }; | |
| const regenerateMarkedText = (updatedParagraphs) => { | |
| const lines = updatedParagraphs.map((p) => { | |
| return `<!-- wf:type=${p.type} -->\n${p.content}`; | |
| }); | |
| setMarkedText(lines.join('\n\n')); | |
| }; | |
| // Export marked text | |
| const handleExportMarkdown = () => { | |
| if (!markedText) { | |
| toast.error('没有可导出的内容'); | |
| return; | |
| } | |
| const blob = new Blob([markedText], { type: 'text/markdown;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = file?.name?.replace(/\.[^.]+$/, '_marked.md') || 'article_marked.md'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| toast.success('已导出 Markdown 文件'); | |
| }; | |
| // Navigate to format page | |
| const handleGoToFormat = () => { | |
| if (!markedText) { | |
| toast.error('请先完成预处理'); | |
| return; | |
| } | |
| navigate('/word-formatter', { | |
| state: { | |
| preprocessedText: markedText, | |
| specJson: selectedSpec, | |
| specName: specName, | |
| }, | |
| }); | |
| }; | |
| // Reset form | |
| const handleReset = () => { | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| } | |
| setFile(null); | |
| setText(''); | |
| setCurrentJobId(null); | |
| setJobStatus(null); | |
| setProgress(null); | |
| setParagraphs([]); | |
| setMarkedText(''); | |
| setIntegrityStatus(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| // Delete current job | |
| const handleDeleteJob = async () => { | |
| if (!currentJobId) return; | |
| try { | |
| await wordFormatterAPI.deletePreprocessJob(currentJobId); | |
| handleReset(); | |
| toast.success('任务已删除'); | |
| } catch (error) { | |
| console.error('Delete job failed:', error); | |
| toast.error('删除任务失败'); | |
| } | |
| }; | |
| // Render paragraph type badge | |
| const renderTypeBadge = (type, index) => { | |
| const config = PARAGRAPH_TYPES[type] || PARAGRAPH_TYPES.body; | |
| const IconComponent = config.icon; | |
| const isEditing = editingIndex === index; | |
| if (isEditing) { | |
| return ( | |
| <div className="absolute top-0 left-0 z-10 bg-white border rounded-lg shadow-lg p-2 min-w-48"> | |
| <div className="text-xs text-gray-500 mb-1">选择段落类型</div> | |
| <div className="grid grid-cols-2 gap-1"> | |
| {Object.entries(PARAGRAPH_TYPES).map(([key, cfg]) => { | |
| const Icon = cfg.icon; | |
| return ( | |
| <button | |
| key={key} | |
| onClick={() => handleTypeChange(index, key)} | |
| className={`flex items-center gap-1 px-2 py-1 text-xs rounded ${cfg.color} hover:opacity-80 transition-opacity`} | |
| > | |
| <Icon className="w-3 h-3" /> | |
| {cfg.label} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <button | |
| onClick={() => setEditingIndex(null)} | |
| className="mt-2 text-xs text-gray-500 hover:text-gray-700" | |
| > | |
| 取消 | |
| </button> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <button | |
| onClick={() => setEditingIndex(index)} | |
| className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded border ${config.color} hover:opacity-80 transition-opacity`} | |
| title="点击修改类型" | |
| > | |
| <IconComponent className="w-3 h-3" /> | |
| {config.label} | |
| <Edit3 className="w-2.5 h-2.5 ml-1 opacity-50" /> | |
| </button> | |
| ); | |
| }; | |
| // Render progress bar | |
| const renderProgress = () => { | |
| // 后端发送: { phase, progress (0-1), message, detail } | |
| // detail 格式: "分块 x/y" 或 null | |
| // 没有进度数据时显示加载中状态 | |
| if (!progress) { | |
| return ( | |
| <div className="bg-white rounded-lg border p-4 mb-4"> | |
| <div className="flex items-center gap-2"> | |
| <Loader2 className="w-5 h-5 animate-spin text-blue-600" /> | |
| <span className="text-sm font-medium text-gray-700">正在初始化预处理任务...</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const percentage = Math.round((progress.progress || 0) * 100); | |
| // 解析 detail 获取分块信息 | |
| let chunkInfo = ''; | |
| if (progress.detail) { | |
| chunkInfo = progress.detail; | |
| } | |
| // 根据后端 phase 值显示消息 | |
| const phaseMessages = { | |
| splitting: '正在分割文章...', | |
| marking: `正在识别段落类型${chunkInfo ? ` (${chunkInfo})` : ''}`, | |
| validating: '正在验证完整性...', | |
| completed: '处理完成', | |
| error: '处理出错', | |
| }; | |
| const displayMessage = progress.message || phaseMessages[progress.phase] || '处理中...'; | |
| return ( | |
| <div className="bg-white rounded-lg border p-4 mb-4"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <span className="text-sm font-medium text-gray-700"> | |
| {displayMessage} | |
| </span> | |
| <span className="text-sm text-gray-500">{percentage}%</span> | |
| </div> | |
| <div className="w-full bg-gray-200 rounded-full h-2"> | |
| <div | |
| className="bg-blue-600 h-2 rounded-full transition-all duration-300" | |
| style={{ width: `${percentage}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gray-50"> | |
| {/* Header */} | |
| <header className="bg-white border-b sticky top-0 z-10"> | |
| <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between"> | |
| <div className="flex items-center gap-4"> | |
| <Link | |
| to="/spec-generator" | |
| className="flex items-center gap-1 text-gray-600 hover:text-gray-900" | |
| > | |
| <ArrowLeft className="w-4 h-4" /> | |
| <span className="text-sm">返回规范生成</span> | |
| </Link> | |
| <div className="h-6 w-px bg-gray-300" /> | |
| <h1 className="text-lg font-semibold text-gray-900">文章预处理</h1> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| {usage && ( | |
| <div className="text-sm text-gray-600"> | |
| 使用量: {usage.used}/{usage.limit} | |
| </div> | |
| )} | |
| {selectedSpec && ( | |
| <div className="flex items-center gap-1 px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm"> | |
| <CheckCircle className="w-4 h-4" /> | |
| 已选规范: {specName || '自定义'} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </header> | |
| <main className="max-w-7xl mx-auto px-4 py-6"> | |
| {/* Workflow indicator */} | |
| <div className="mb-6 flex items-center justify-center gap-2 text-sm text-gray-500"> | |
| <span className="px-3 py-1 bg-gray-100 rounded-full">1. 生成规范</span> | |
| <ArrowRight className="w-4 h-4" /> | |
| <span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full font-medium"> | |
| 2. 文章预处理 | |
| </span> | |
| <ArrowRight className="w-4 h-4" /> | |
| <span className="px-3 py-1 bg-gray-100 rounded-full">3. 生成 Word</span> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| {/* Left Panel - Input */} | |
| <div className="space-y-4"> | |
| {/* Input Mode Toggle */} | |
| <div className="bg-white rounded-lg border p-4"> | |
| <div className="flex items-center gap-4 mb-4"> | |
| <button | |
| onClick={() => setInputMode('file')} | |
| className={`flex-1 py-2 px-4 rounded-lg border transition-colors ${ | |
| inputMode === 'file' | |
| ? 'bg-blue-50 border-blue-300 text-blue-700' | |
| : 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100' | |
| }`} | |
| > | |
| <Upload className="w-4 h-4 inline mr-2" /> | |
| 上传文件 | |
| </button> | |
| <button | |
| onClick={() => setInputMode('text')} | |
| className={`flex-1 py-2 px-4 rounded-lg border transition-colors ${ | |
| inputMode === 'text' | |
| ? 'bg-blue-50 border-blue-300 text-blue-700' | |
| : 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100' | |
| }`} | |
| > | |
| <FileText className="w-4 h-4 inline mr-2" /> | |
| 粘贴文本 | |
| </button> | |
| </div> | |
| {inputMode === 'file' ? ( | |
| <div | |
| onDragEnter={handleDrag} | |
| onDragOver={handleDrag} | |
| onDragLeave={handleDrag} | |
| onDrop={handleDrop} | |
| className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${ | |
| dragActive | |
| ? 'border-blue-400 bg-blue-50' | |
| : 'border-gray-300 hover:border-gray-400' | |
| }`} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".txt,.md,.docx" | |
| onChange={handleFileChange} | |
| className="hidden" | |
| /> | |
| {file ? ( | |
| <div className="flex items-center justify-center gap-3"> | |
| <FileUp className="w-8 h-8 text-blue-500" /> | |
| <div className="text-left"> | |
| <div className="font-medium text-gray-900">{file.name}</div> | |
| <div className="text-sm text-gray-500"> | |
| {(file.size / 1024).toFixed(1)} KB | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => { | |
| setFile(null); | |
| if (fileInputRef.current) fileInputRef.current.value = ''; | |
| }} | |
| className="p-1 text-gray-400 hover:text-red-500" | |
| > | |
| <X className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| ) : ( | |
| <> | |
| <Upload className="w-12 h-12 mx-auto text-gray-400 mb-3" /> | |
| <p className="text-gray-600 mb-2">拖拽文件到这里,或点击选择</p> | |
| <button | |
| onClick={() => fileInputRef.current?.click()} | |
| className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" | |
| > | |
| 选择文件 | |
| </button> | |
| <p className="text-sm text-gray-400 mt-2">支持 .txt, .md, .docx (最大 10MB)</p> | |
| </> | |
| )} | |
| </div> | |
| ) : ( | |
| <textarea | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| placeholder="在此粘贴您的文章内容..." | |
| className="w-full h-64 p-3 border rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" | |
| /> | |
| )} | |
| </div> | |
| {/* Configuration */} | |
| <div className="bg-white rounded-lg border"> | |
| <button | |
| onClick={() => setShowConfig(!showConfig)} | |
| className="w-full px-4 py-3 flex items-center justify-between text-gray-700 hover:bg-gray-50" | |
| > | |
| <span className="flex items-center gap-2"> | |
| <Settings className="w-4 h-4" /> | |
| 高级配置 | |
| </span> | |
| {showConfig ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} | |
| </button> | |
| {showConfig && ( | |
| <div className="px-4 pb-4 space-y-3 border-t"> | |
| <div className="pt-3"> | |
| <label className="block text-sm font-medium text-gray-700 mb-1"> | |
| 每块最大段落数 | |
| </label> | |
| <input | |
| type="number" | |
| value={chunkParagraphs} | |
| onChange={(e) => setChunkParagraphs(parseInt(e.target.value) || 40)} | |
| min={10} | |
| max={100} | |
| className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" | |
| /> | |
| <p className="text-xs text-gray-500 mt-1">建议 30-50,过大可能导致 AI 识别不准</p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-1"> | |
| 每块最大字符数 | |
| </label> | |
| <input | |
| type="number" | |
| value={chunkChars} | |
| onChange={(e) => setChunkChars(parseInt(e.target.value) || 8000)} | |
| min={2000} | |
| max={20000} | |
| className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500" | |
| /> | |
| <p className="text-xs text-gray-500 mt-1">建议 6000-10000,防止 AI 上下文溢出</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Action Buttons */} | |
| <div className="flex gap-3"> | |
| <button | |
| onClick={handleStartPreprocess} | |
| disabled={isSubmitting || jobStatus === 'running'} | |
| className="flex-1 flex items-center justify-center gap-2 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| {isSubmitting || jobStatus === 'running' ? ( | |
| <> | |
| <Loader2 className="w-5 h-5 animate-spin" /> | |
| 处理中... | |
| </> | |
| ) : ( | |
| <> | |
| <Play className="w-5 h-5" /> | |
| 开始预处理 | |
| </> | |
| )} | |
| </button> | |
| <button | |
| onClick={handleReset} | |
| disabled={jobStatus === 'running'} | |
| className="px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50" | |
| > | |
| <RefreshCw className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Right Panel - Result */} | |
| <div className="space-y-4"> | |
| {/* Progress */} | |
| {(jobStatus === 'running' || jobStatus === 'pending') && renderProgress()} | |
| {/* Result Header */} | |
| {jobStatus === 'completed' && paragraphs.length > 0 && ( | |
| <div className="bg-white rounded-lg border p-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h3 className="font-medium text-gray-900">预处理结果</h3> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => setViewMode('list')} | |
| className={`p-2 rounded ${ | |
| viewMode === 'list' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-100' | |
| }`} | |
| title="列表视图" | |
| > | |
| <List className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={() => setViewMode('raw')} | |
| className={`p-2 rounded ${ | |
| viewMode === 'raw' ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-100' | |
| }`} | |
| title="原始文本" | |
| > | |
| <Eye className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Integrity Status */} | |
| {integrityStatus && ( | |
| <div | |
| className={`flex items-center gap-2 p-2 rounded text-sm mb-4 ${ | |
| integrityStatus.verified | |
| ? 'bg-green-50 text-green-700' | |
| : 'bg-yellow-50 text-yellow-700' | |
| }`} | |
| > | |
| {integrityStatus.verified ? ( | |
| <> | |
| <CheckCircle className="w-4 h-4" /> | |
| 内容完整性验证通过 - 原文未被修改 | |
| </> | |
| ) : ( | |
| <> | |
| <AlertCircle className="w-4 h-4" /> | |
| 警告:内容可能已被修改,请仔细检查 | |
| </> | |
| )} | |
| </div> | |
| )} | |
| {/* Statistics */} | |
| <div className="grid grid-cols-3 gap-4 text-center text-sm mb-4"> | |
| <div className="bg-gray-50 rounded p-2"> | |
| <div className="text-2xl font-semibold text-blue-600">{paragraphs.length}</div> | |
| <div className="text-gray-500">总段落</div> | |
| </div> | |
| <div className="bg-gray-50 rounded p-2"> | |
| <div className="text-2xl font-semibold text-blue-600"> | |
| {paragraphs.filter((p) => p.type.startsWith('heading')).length} | |
| </div> | |
| <div className="text-gray-500">标题</div> | |
| </div> | |
| <div className="bg-gray-50 rounded p-2"> | |
| <div className="text-2xl font-semibold text-green-600"> | |
| {paragraphs.filter((p) => p.type === 'body').length} | |
| </div> | |
| <div className="text-gray-500">正文</div> | |
| </div> | |
| </div> | |
| {/* Content View */} | |
| <div className="max-h-96 overflow-y-auto border rounded-lg"> | |
| {viewMode === 'list' ? ( | |
| <div className="divide-y"> | |
| {paragraphs.map((para, index) => ( | |
| <div key={index} className="p-3 hover:bg-gray-50 relative"> | |
| <div className="flex items-start gap-3"> | |
| <span className="text-xs text-gray-400 mt-1 w-6">{index + 1}</span> | |
| <div className="flex-1"> | |
| <div className="mb-1">{renderTypeBadge(para.type, index)}</div> | |
| <p className="text-sm text-gray-700 line-clamp-2">{para.content}</p> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <pre className="p-4 text-sm text-gray-700 whitespace-pre-wrap font-mono"> | |
| {markedText} | |
| </pre> | |
| )} | |
| </div> | |
| {/* Action Buttons */} | |
| <div className="flex gap-3 mt-4"> | |
| <button | |
| onClick={handleExportMarkdown} | |
| className="flex-1 flex items-center justify-center gap-2 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50" | |
| > | |
| <Download className="w-4 h-4" /> | |
| 导出 Markdown | |
| </button> | |
| <button | |
| onClick={handleGoToFormat} | |
| className="flex-1 flex items-center justify-center gap-2 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" | |
| > | |
| 下一步: 生成 Word | |
| <ArrowRight className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Empty state */} | |
| {!jobStatus && ( | |
| <div className="bg-white rounded-lg border p-8 text-center"> | |
| <FileText className="w-16 h-16 mx-auto text-gray-300 mb-4" /> | |
| <h3 className="text-lg font-medium text-gray-900 mb-2">等待预处理</h3> | |
| <p className="text-gray-500 text-sm"> | |
| 上传文件或粘贴文本后,点击"开始预处理"按钮 | |
| <br /> | |
| AI 将自动识别并标记段落类型 | |
| </p> | |
| </div> | |
| )} | |
| {/* Failed state */} | |
| {jobStatus === 'failed' && ( | |
| <div className="bg-white rounded-lg border p-8 text-center"> | |
| <AlertCircle className="w-16 h-16 mx-auto text-red-400 mb-4" /> | |
| <h3 className="text-lg font-medium text-gray-900 mb-2">预处理失败</h3> | |
| <p className="text-gray-500 text-sm mb-4">请检查文件格式或网络连接后重试</p> | |
| <button | |
| onClick={handleReset} | |
| className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" | |
| > | |
| 重新开始 | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| ); | |
| }; | |
| export default ArticlePreprocessorPage; | |