|
<!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/[email protected]/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> |