| <!DOCTYPE html> |
| <html lang="zh"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>题目识别与解答系统(带公式识别与渲染)</title> |
| <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet"> |
| <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script> |
| <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> |
| <style> |
| :root { |
| --primary: #4F46E5; |
| --primary-hover: #4338CA; |
| --secondary: #E0E7FF; |
| --text: #1F2937; |
| --background: #F9FAFB; |
| } |
| |
| body { |
| font-family: system-ui, -apple-system, sans-serif; |
| background-color: var(--background); |
| color: var(--text); |
| } |
| |
| .card { |
| background: white; |
| border-radius: 1rem; |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); |
| transition: transform 0.2s; |
| } |
| |
| .btn { |
| background-color: var(--primary); |
| color: white; |
| padding: 0.75rem 1.5rem; |
| border-radius: 0.5rem; |
| transition: all 0.2s; |
| font-weight: 500; |
| } |
| |
| .btn:hover { |
| background-color: var(--primary-hover); |
| transform: translateY(-1px); |
| } |
| |
| .btn:active { |
| transform: translateY(0); |
| } |
| |
| .btn-secondary { |
| background-color: var(--secondary); |
| color: var(--primary); |
| } |
| |
| .btn-secondary:hover { |
| background-color: #D1D5DB; |
| } |
| |
| .loading { |
| position: fixed; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| background: rgba(255, 255, 255, 0.9); |
| display: none; |
| justify-content: center; |
| align-items: center; |
| z-index: 1000; |
| } |
| |
| .spinner { |
| width: 50px; |
| height: 50px; |
| border: 5px solid var(--secondary); |
| border-top: 5px solid var(--primary); |
| border-radius: 50%; |
| animation: spin 1s linear infinite; |
| } |
| |
| @keyframes spin { |
| 0% { transform: rotate(0deg); } |
| 100% { transform: rotate(360deg); } |
| } |
| |
| .preview-container { |
| aspect-ratio: 16/9; |
| overflow: hidden; |
| border-radius: 0.5rem; |
| background-color: var(--background); |
| border: 2px dashed #E5E7EB; |
| } |
| |
| .preview-image { |
| width: 100%; |
| height: 100%; |
| object-fit: contain; |
| } |
| |
| .solution-box { |
| border: 1px solid #E5E7EB; |
| border-radius: 0.5rem; |
| padding: 1rem; |
| margin-bottom: 1rem; |
| background-color: #FFFFFF; |
| } |
| |
| .solution-box h3 { |
| color: var(--primary); |
| margin-bottom: 0.5rem; |
| } |
| |
| .copy-btn { |
| position: absolute; |
| right: 1rem; |
| top: 1rem; |
| padding: 0.5rem 1rem; |
| font-size: 0.875rem; |
| opacity: 0; |
| transition: opacity 0.2s; |
| } |
| |
| .solution-box:hover .copy-btn { |
| opacity: 1; |
| } |
| .prose { |
| font-size: 1rem; |
| line-height: 1.75; |
| color: var(--text); |
| } |
| |
| .prose p { |
| margin-bottom: 1.25em; |
| } |
| |
| .prose .math { |
| overflow-x: auto; |
| margin: 1em 0; |
| } |
| |
| #solution-content, |
| #analysis-content { |
| opacity: 0; |
| transition: opacity 0.3s ease; |
| } |
| |
| .solution-box { |
| position: relative; |
| margin-bottom: 1.5rem; |
| padding: 1.5rem; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="loading"> |
| <div class="spinner"></div> |
| </div> |
|
|
| <div class="container mx-auto px-4 py-8 max-w-6xl"> |
| <h1 class="text-4xl font-bold text-center mb-8 text-gray-900">题目识别与解答系统(带公式识别与渲染)</h1> |
|
|
| |
| <div class="card p-6 mb-8"> |
| <h2 class="text-2xl font-semibold mb-4">图片上传</h2> |
| <div class="space-y-4"> |
| <input type="file" |
| id="image-input" |
| accept="image/*" |
| class="hidden" |
| onchange="handleImageUpload(event)"> |
| <label for="image-input" |
| class="btn inline-block cursor-pointer"> |
| 选择图片 |
| </label> |
| <div class="preview-container"> |
| <img id="preview-image" |
| class="preview-image" |
| src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" |
| alt="预览"> |
| </div> |
| <button onclick="processImage()" class="btn w-full"> |
| 开始识别 |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div id="result-section" class="card p-6 mb-8" style="display: none;"> |
| <h2 class="text-2xl font-semibold mb-4">识别结果</h2> |
| <div class="grid md:grid-cols-2 gap-6"> |
| <div> |
| <h3 class="text-lg font-medium mb-2">源代码</h3> |
| <textarea id="source-text" |
| class="w-full h-64 p-4 border rounded-lg resize-y font-mono text-sm" |
| readonly></textarea> |
| <div class="flex gap-2 mt-2"> |
| <button onclick="copyContent('text')" class="btn">复制文本</button> |
| <button onclick="copyContent('formulas')" class="btn">复制公式</button> |
| <button onclick="copyContent('all')" class="btn">复制全部</button> |
| </div> |
| </div> |
| <div> |
| <h3 class="text-lg font-medium mb-2">预览</h3> |
| <div id="preview-text" |
| class="w-full h-64 p-4 border rounded-lg overflow-y-auto bg-white"></div> |
| </div> |
| </div> |
| <div class="mt-6"> |
| <button onclick="getSolution()" class="btn w-full"> |
| 获取解答 |
| </button> |
| </div> |
| </div> |
|
|
| |
| |
| <style> |
| .math-solution-text { |
| font-size: 1rem; |
| line-height: 1.75; |
| color: #1F2937; |
| background-color: white; |
| padding: 1rem 1.5rem; |
| border-radius: 0.5rem; |
| margin-top: 0.5rem; |
| white-space: pre-wrap; |
| word-wrap: break-word; |
| border: 1px solid #E5E7EB; |
| min-height: 2rem; |
| } |
| |
| .math-solution-text .math { |
| display: inline-block; |
| margin: 0.5em 0; |
| overflow-x: auto; |
| max-width: 100%; |
| } |
| |
| .math-solution-text p { |
| margin-bottom: 1em; |
| } |
| </style> |
| |
| |
| <div id="solution-section" class="card p-6" style="display: none;"> |
| |
| <div class="solution-box relative mb-4"> |
| <h3 class="text-xl font-medium">答案</h3> |
| <button onclick="copySolutionContent('answer')" |
| class="copy-btn btn-secondary"> |
| 复制答案 |
| </button> |
| <div id="answer-content" class="math-solution-text"></div> |
| </div> |
| |
| |
| <div class="solution-box relative"> |
| <h3 class="text-xl font-medium">解析</h3> |
| <button onclick="copySolutionContent('analysis')" |
| class="copy-btn btn-secondary"> |
| 复制解析 |
| </button> |
| <div id="analysis-content" class="math-solution-text"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| let currentResult = null; |
| let originalLatexContent = ''; |
| |
| function toggleLoading(show) { |
| document.querySelector('.loading').style.display = show ? 'flex' : 'none'; |
| } |
| |
| function showToast(message, type = 'info') { |
| alert(message); |
| } |
| |
| function handleImageUpload(event) { |
| const file = event.target.files[0]; |
| if (!file) return; |
| |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| document.getElementById('preview-image').src = e.target.result; |
| }; |
| reader.readAsDataURL(file); |
| } |
| |
| async function processImage() { |
| const fileInput = document.getElementById('image-input'); |
| const file = fileInput.files[0]; |
| |
| if (!file) { |
| showToast('请先选择图片', 'error'); |
| return; |
| } |
| |
| toggleLoading(true); |
| |
| try { |
| const formData = new FormData(); |
| formData.append('file', file); |
| |
| const response = await fetch('/process', { |
| method: 'POST', |
| body: formData |
| }); |
| |
| if (!response.ok) { |
| throw new Error('处理失败'); |
| } |
| |
| const result = await response.json(); |
| |
| if (result.error) { |
| throw new Error(result.error); |
| } |
| |
| currentResult = result.result; |
| |
| |
| document.getElementById('preview-image').src = |
| `data:image/png;base64,${result.original_image}`; |
| |
| |
| document.getElementById('source-text').value = |
| JSON.stringify(currentResult, null, 2); |
| |
| |
| originalLatexContent = currentResult.text; |
| currentResult.formulas.forEach((formula, index) => { |
| originalLatexContent = originalLatexContent.replace( |
| `[formula_${index + 1}]`, |
| `$${formula}$` |
| ); |
| }); |
| |
| |
| const previewDiv = document.getElementById('preview-text'); |
| previewDiv.innerHTML = originalLatexContent; |
| MathJax.typesetPromise([previewDiv]).catch(console.error); |
| |
| document.getElementById('result-section').style.display = 'block'; |
| showToast('识别成功'); |
| |
| } catch (error) { |
| showToast(error.message, 'error'); |
| console.error('处理错误:', error); |
| } finally { |
| toggleLoading(false); |
| } |
| } |
| |
| async function copyContent(type) { |
| if (!currentResult) return; |
| |
| try { |
| let content = ''; |
| switch (type) { |
| case 'text': |
| content = currentResult.text; |
| break; |
| case 'formulas': |
| content = currentResult.formulas.join('\n'); |
| break; |
| case 'all': |
| content = originalLatexContent; |
| break; |
| } |
| |
| await navigator.clipboard.writeText(content); |
| showToast('复制成功'); |
| } catch (err) { |
| showToast('复制失败', 'error'); |
| console.error('复制错误:', err); |
| } |
| } |
| |
| async function copySolutionContent(type) { |
| const element = document.getElementById(`${type}-content`); |
| if (!element) return; |
| |
| try { |
| const content = element.getAttribute('data-original') || element.textContent; |
| await navigator.clipboard.writeText(content); |
| showToast('复制成功'); |
| } catch (err) { |
| showToast('复制失败', 'error'); |
| console.error('复制错误:', err); |
| } |
| } |
| |
| async function getSolution() { |
| if (!currentResult) { |
| showToast('没有可解答的内容', 'error'); |
| return; |
| } |
| |
| toggleLoading(true); |
| |
| const solutionSection = document.getElementById('solution-section'); |
| const answerContent = document.getElementById('answer-content'); |
| const analysisContent = document.getElementById('analysis-content'); |
| |
| solutionSection.style.display = 'block'; |
| answerContent.innerHTML = ''; |
| analysisContent.innerHTML = ''; |
| |
| try { |
| const response = await fetch('/solve', { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json' |
| }, |
| body: JSON.stringify({ |
| text: currentResult.text, |
| formulas: currentResult.formulas |
| }) |
| }); |
| |
| if (!response.ok) throw new Error('获取解答失败'); |
| |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let answer = ''; |
| let analysis = ''; |
| |
| while (true) { |
| const {value, done} = await reader.read(); |
| if (done) break; |
| |
| const chunk = decoder.decode(value); |
| const lines = chunk.split('\n'); |
| for (const line of lines) { |
| if (!line.trim() || !line.startsWith('data: ')) continue; |
| |
| const data = JSON.parse(line.slice(5)); |
| |
| if (data.content) { |
| if (data.type === 'answer') { |
| |
| answer += data.content.replace(/\$\$(.*?)\$\$/g, '\\[$1\\]'); |
| answerContent.innerHTML = answer; |
| answerContent.setAttribute('data-original', answer); |
| await MathJax.typesetPromise([answerContent]); |
| } else if (data.type === 'analysis') { |
| |
| analysis += data.content; |
| analysisContent.innerHTML = analysis; |
| analysisContent.setAttribute('data-original', analysis); |
| await MathJax.typesetPromise([analysisContent]); |
| analysisContent.style.opacity = '1'; |
| } |
| } |
| } |
| } |
| } catch (error) { |
| showToast(error.message, 'error'); |
| answerContent.innerHTML = `<p class="text-red-500">获取解答失败: ${error.message}</p>`; |
| } finally { |
| toggleLoading(false); |
| } |
| } |
| async function copySolutionContent(type) { |
| const element = document.getElementById(`${type}-content`); |
| if (!element) return; |
| |
| try { |
| const content = element.getAttribute('data-original'); |
| if (content) { |
| await navigator.clipboard.writeText(content); |
| showToast('复制成功'); |
| |
| |
| const button = element.previousElementSibling; |
| const originalText = button.textContent; |
| button.textContent = '复制成功'; |
| button.classList.add('bg-green-100'); |
| |
| setTimeout(() => { |
| button.textContent = originalText; |
| button.classList.remove('bg-green-100'); |
| }, 1000); |
| } |
| } catch (err) { |
| showToast('复制失败', 'error'); |
| console.error('复制错误:', err); |
| } |
| } |
| |
| window.MathJax = { |
| tex: { |
| inlineMath: [['$', '$'], ['\\(', '\\)']], |
| displayMath: [['$$', '$$'], ['\\[', '\\]']], |
| processEscapes: true |
| }, |
| options: { |
| ignoreHtmlClass: 'tex2jax_ignore', |
| processHtmlClass: 'tex2jax_process' |
| }, |
| startup: { |
| pageReady() { |
| return MathJax.startup.defaultPageReady(); |
| } |
| } |
| }; |
| |
| |
| function addCopyAnimation(button) { |
| const originalText = button.textContent; |
| button.textContent = '已复制'; |
| button.classList.add('bg-green-500'); |
| |
| setTimeout(() => { |
| button.textContent = originalText; |
| button.classList.remove('bg-green-500'); |
| }, 1000); |
| } |
| |
| |
| function updateSolutionDisplay(content, type) { |
| const container = document.getElementById(`${type}-content`); |
| if (!container) return; |
| |
| |
| const originalContent = content; |
| container.setAttribute('data-original', originalContent); |
| |
| |
| container.innerHTML = content; |
| |
| |
| MathJax.typesetPromise([container]).then(() => { |
| |
| container.style.opacity = '0'; |
| container.style.transform = 'translateY(20px)'; |
| container.style.transition = 'all 0.5s ease'; |
| |
| requestAnimationFrame(() => { |
| container.style.opacity = '1'; |
| container.style.transform = 'translateY(0)'; |
| }); |
| }).catch(console.error); |
| } |
| |
| |
| function handleError(error, container) { |
| const errorMessage = document.createElement('div'); |
| errorMessage.className = 'bg-red-50 border-l-4 border-red-500 p-4 my-4'; |
| errorMessage.innerHTML = ` |
| <div class="flex items-center"> |
| <div class="flex-shrink-0"> |
| <svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor"> |
| <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/> |
| </svg> |
| </div> |
| <div class="ml-3"> |
| <p class="text-sm text-red-700"> |
| ${error.message || '发生错误,请稍后重试'} |
| </p> |
| </div> |
| </div> |
| `; |
| container.appendChild(errorMessage); |
| } |
| |
| |
| window.addEventListener('error', (event) => { |
| console.error('全局错误:', event.error); |
| showToast('操作出错,请刷新页面重试', 'error'); |
| }); |
| |
| |
| document.getElementById('image-input').addEventListener('change', (event) => { |
| const file = event.target.files[0]; |
| if (!file) return; |
| |
| |
| if (!file.type.startsWith('image/')) { |
| showToast('请选择图片文件', 'error'); |
| event.target.value = ''; |
| return; |
| } |
| |
| |
| if (file.size > 10 * 1024 * 1024) { |
| showToast('图片大小不能超过10MB', 'error'); |
| event.target.value = ''; |
| return; |
| } |
| |
| handleImageUpload(event); |
| }); |
| </script> |
| </body> |
| </html> |