384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
|
|
// ocr.js
|
|||
|
|
lucide.createIcons();
|
|||
|
|
|
|||
|
|
async function checkAuth() {
|
|||
|
|
const r = await fetch('/api/auth/me');
|
|||
|
|
const d = await r.json();
|
|||
|
|
const profile = document.getElementById('userProfile');
|
|||
|
|
const entry = document.getElementById('loginEntryBtn');
|
|||
|
|
const submitBtn = document.getElementById('submitBtn');
|
|||
|
|
|
|||
|
|
if(d.logged_in) {
|
|||
|
|
if(profile) profile.classList.remove('hidden');
|
|||
|
|
if(entry) entry.classList.add('hidden');
|
|||
|
|
submitBtn.disabled = false;
|
|||
|
|
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|||
|
|
const phoneDisp = document.getElementById('userPhoneDisplay');
|
|||
|
|
if(phoneDisp) phoneDisp.innerText = d.phone;
|
|||
|
|
|
|||
|
|
// 处理积分显示
|
|||
|
|
const pointsBadge = document.getElementById('pointsBadge');
|
|||
|
|
const pointsDisplay = document.getElementById('pointsDisplay');
|
|||
|
|
if(pointsBadge && pointsDisplay) {
|
|||
|
|
pointsBadge.classList.remove('hidden');
|
|||
|
|
pointsDisplay.innerText = d.points;
|
|||
|
|
}
|
|||
|
|
const headerPoints = document.getElementById('headerPoints');
|
|||
|
|
if(headerPoints) headerPoints.innerText = d.points;
|
|||
|
|
} else {
|
|||
|
|
if(profile) profile.classList.add('hidden');
|
|||
|
|
if(entry) entry.classList.remove('hidden');
|
|||
|
|
submitBtn.disabled = true;
|
|||
|
|
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|||
|
|
}
|
|||
|
|
lucide.createIcons();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let uploadedFiles = [];
|
|||
|
|
const FIXED_MODEL = 'gemini-3-flash-preview';
|
|||
|
|
|
|||
|
|
function updateCostPreview() {
|
|||
|
|
const costPreview = document.getElementById('costPreview');
|
|||
|
|
const isPremium = document.getElementById('isPremium')?.checked || false;
|
|||
|
|
|
|||
|
|
// 默认消耗 1 积分,优质模式 X2
|
|||
|
|
let cost = 1;
|
|||
|
|
if(isPremium) cost *= 2;
|
|||
|
|
costPreview.innerText = `本次解析将消耗 ${cost} 积分`;
|
|||
|
|
costPreview.classList.remove('hidden');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderImagePreviews() {
|
|||
|
|
const prev = document.getElementById('imagePreview');
|
|||
|
|
if(!prev) return;
|
|||
|
|
prev.innerHTML = '';
|
|||
|
|
|
|||
|
|
uploadedFiles.forEach((file, index) => {
|
|||
|
|
const d = document.createElement('div');
|
|||
|
|
d.className = 'relative w-24 h-24 rounded-2xl overflow-hidden flex-shrink-0 border-2 border-white shadow-md group';
|
|||
|
|
d.innerHTML = `
|
|||
|
|
<div class="w-full h-full bg-slate-100 animate-pulse flex items-center justify-center">
|
|||
|
|
<i data-lucide="image" class="w-5 h-5 text-slate-300"></i>
|
|||
|
|
</div>
|
|||
|
|
<div class="absolute bottom-1 left-1 bg-black/60 text-white text-[8px] px-1.5 py-0.5 rounded-md font-black backdrop-blur-sm z-10">图${index + 1}</div>
|
|||
|
|
<button onclick="removeUploadedFile(${index})" class="absolute top-1 right-1 bg-rose-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20">
|
|||
|
|
<i data-lucide="x" class="w-3 h-3"></i>
|
|||
|
|
</button>
|
|||
|
|
`;
|
|||
|
|
prev.appendChild(d);
|
|||
|
|
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = (ev) => {
|
|||
|
|
d.innerHTML = `
|
|||
|
|
<img src="${ev.target.result}" class="w-full h-full object-cover">
|
|||
|
|
<div class="absolute bottom-1 left-1 bg-black/60 text-white text-[8px] px-1.5 py-0.5 rounded-md font-black backdrop-blur-sm z-10">图${index + 1}</div>
|
|||
|
|
<button onclick="removeUploadedFile(${index})" class="absolute top-1 right-1 bg-rose-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-20">
|
|||
|
|
<i data-lucide="x" class="w-3 h-3"></i>
|
|||
|
|
</button>
|
|||
|
|
`;
|
|||
|
|
lucide.createIcons();
|
|||
|
|
};
|
|||
|
|
reader.readAsDataURL(file);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeUploadedFile(index) {
|
|||
|
|
uploadedFiles.splice(index, 1);
|
|||
|
|
renderImagePreviews();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleNewFiles(files) {
|
|||
|
|
const newFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
|
|||
|
|
if (newFiles.length === 0) return;
|
|||
|
|
|
|||
|
|
if (uploadedFiles.length + newFiles.length > 3) {
|
|||
|
|
showToast('最多只能上传 3 张照片', 'warning');
|
|||
|
|
const remaining = 3 - uploadedFiles.length;
|
|||
|
|
if (remaining > 0) {
|
|||
|
|
uploadedFiles = uploadedFiles.concat(newFiles.slice(0, remaining));
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
uploadedFiles = uploadedFiles.concat(newFiles);
|
|||
|
|
}
|
|||
|
|
renderImagePreviews();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('fileInput').onchange = (e) => {
|
|||
|
|
handleNewFiles(e.target.files);
|
|||
|
|
e.target.value = '';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 粘贴上传逻辑
|
|||
|
|
document.addEventListener('paste', (e) => {
|
|||
|
|
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
|||
|
|
const files = [];
|
|||
|
|
for (let item of items) {
|
|||
|
|
if (item.type.indexOf('image') !== -1) {
|
|||
|
|
files.push(item.getAsFile());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (files.length > 0) {
|
|||
|
|
handleNewFiles(files);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// === 高度定制的复制逻辑 ===
|
|||
|
|
function generateCopyString(data) {
|
|||
|
|
const r = data.right;
|
|||
|
|
const l = data.left;
|
|||
|
|
const tPd = data.total_pd;
|
|||
|
|
|
|||
|
|
const blocks = [];
|
|||
|
|
|
|||
|
|
const fmtDeg = (val) => {
|
|||
|
|
if (!val || val === "未标明" || val.trim() === "") return null;
|
|||
|
|
const num = parseFloat(val);
|
|||
|
|
if (isNaN(num)) return null;
|
|||
|
|
const deg = Math.round(num * 100);
|
|||
|
|
if (deg === 0) return null;
|
|||
|
|
if (deg < 0) return Math.abs(deg).toString();
|
|||
|
|
if (deg > 0) return "+" + deg.toString();
|
|||
|
|
return null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const fmtCyl = (val) => fmtDeg(val);
|
|||
|
|
const fmtRaw = (val) => (!val || val === "未标明" || val.trim() === "") ? null : val.trim();
|
|||
|
|
const fmtPD = (val) => {
|
|||
|
|
if (!val || val === "未标明" || val.trim() === "") return null;
|
|||
|
|
const num = parseFloat(val);
|
|||
|
|
if (isNaN(num)) return val.trim();
|
|||
|
|
return num.toString();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const rS = fmtDeg(r.s), lS = fmtDeg(l.s);
|
|||
|
|
const rC = fmtCyl(r.c), lC = fmtCyl(l.c);
|
|||
|
|
const rA = rC ? fmtRaw(r.a) : null, lA = lC ? fmtRaw(l.a) : null;
|
|||
|
|
const rAdd = fmtDeg(r.add), lAdd = fmtDeg(l.add);
|
|||
|
|
const rPH = fmtRaw(r.ph), lPH = fmtRaw(l.ph);
|
|||
|
|
const rPd = fmtPD(r.pd), lPd = fmtPD(l.pd), totalPd = fmtPD(tPd);
|
|||
|
|
|
|||
|
|
if (!rS && !rC && !lS && !lC) return "平光";
|
|||
|
|
|
|||
|
|
const isDoubleSame = (rS === lS) && (rC === lC) && (rA === lA);
|
|||
|
|
|
|||
|
|
if (isDoubleSame) {
|
|||
|
|
let block = `双眼${rS || ""}`;
|
|||
|
|
if (rC) block += `散光${rC}`;
|
|||
|
|
if (rA) block += `轴位${rA}`;
|
|||
|
|
blocks.push(block);
|
|||
|
|
} else {
|
|||
|
|
if (rS || rC) {
|
|||
|
|
let block = `右眼${rS || ""}`;
|
|||
|
|
if (rC) block += `散光${rC}`;
|
|||
|
|
if (rA) block += `轴位${rA}`;
|
|||
|
|
blocks.push(block);
|
|||
|
|
}
|
|||
|
|
if (lS || lC) {
|
|||
|
|
let block = `左眼${lS || ""}`;
|
|||
|
|
if (lC) block += `散光${lC}`;
|
|||
|
|
if (lA) block += `轴位${lA}`;
|
|||
|
|
blocks.push(block);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (rAdd && lAdd && rAdd === lAdd) blocks.push(`ADD${rAdd}`);
|
|||
|
|
else {
|
|||
|
|
if (rAdd) blocks.push(`ADD${rAdd}`);
|
|||
|
|
if (lAdd) blocks.push(`ADD${lAdd}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (rPH) blocks.push(`瞳高右眼${rPH}`);
|
|||
|
|
if (lPH) blocks.push(`瞳高左眼${lPH}`);
|
|||
|
|
|
|||
|
|
if (totalPd) blocks.push(`瞳距${totalPd}`);
|
|||
|
|
else {
|
|||
|
|
if (rPd) blocks.push(`右眼瞳距${rPd}`);
|
|||
|
|
if (lPd) blocks.push(`左眼瞳距${lPd}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return blocks.join(' ');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function copyToClipboard(text) {
|
|||
|
|
try {
|
|||
|
|
await navigator.clipboard.writeText(text);
|
|||
|
|
return true;
|
|||
|
|
} catch (err) {
|
|||
|
|
console.warn("现代复制API失败,尝试传统方法:", err);
|
|||
|
|
try {
|
|||
|
|
const textarea = document.createElement('textarea');
|
|||
|
|
textarea.value = text;
|
|||
|
|
textarea.style.position = 'fixed';
|
|||
|
|
textarea.style.left = '-9999px';
|
|||
|
|
textarea.style.top = '0';
|
|||
|
|
textarea.setAttribute('readonly', '');
|
|||
|
|
document.body.appendChild(textarea);
|
|||
|
|
textarea.focus();
|
|||
|
|
textarea.select();
|
|||
|
|
const successful = document.execCommand('copy');
|
|||
|
|
document.body.removeChild(textarea);
|
|||
|
|
return successful;
|
|||
|
|
} catch (e) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dropZone = document.getElementById('dropZone');
|
|||
|
|
if (dropZone) {
|
|||
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(name => {
|
|||
|
|
dropZone.addEventListener(name, (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
e.stopPropagation();
|
|||
|
|
}, false);
|
|||
|
|
});
|
|||
|
|
dropZone.addEventListener('drop', (e) => {
|
|||
|
|
handleNewFiles(e.dataTransfer.files);
|
|||
|
|
}, false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('isPremium').onchange = () => updateCostPreview();
|
|||
|
|
|
|||
|
|
document.getElementById('submitBtn').onclick = async () => {
|
|||
|
|
const btn = document.getElementById('submitBtn');
|
|||
|
|
|
|||
|
|
const authCheck = await fetch('/api/auth/me');
|
|||
|
|
const authData = await authCheck.json();
|
|||
|
|
if(!authData.logged_in) return showToast('请先登录', 'warning');
|
|||
|
|
|
|||
|
|
if(uploadedFiles.length === 0) return showToast('请上传验光单照片', 'warning');
|
|||
|
|
|
|||
|
|
const isPremium = document.getElementById('isPremium')?.checked || false;
|
|||
|
|
const cost = isPremium ? 2 : 1;
|
|||
|
|
if(authData.points < cost) return showToast('积分不足', 'warning');
|
|||
|
|
|
|||
|
|
btn.disabled = true;
|
|||
|
|
const btnText = btn.querySelector('span');
|
|||
|
|
btnText.innerText = "AI 正在深度解读中...";
|
|||
|
|
|
|||
|
|
document.getElementById('statusInfo').classList.remove('hidden');
|
|||
|
|
document.getElementById('placeholder').classList.add('hidden');
|
|||
|
|
document.getElementById('finalWrapper').classList.remove('hidden');
|
|||
|
|
const textResult = document.getElementById('textResult');
|
|||
|
|
textResult.innerHTML = '<div class="flex flex-col items-center justify-center p-20 gap-4"><i data-lucide="loader-2" class="w-10 h-10 animate-spin text-indigo-500"></i><span class="text-xs font-bold text-slate-400 animate-pulse">正在提取光学数据...</span></div>';
|
|||
|
|
lucide.createIcons();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
let image_urls = [];
|
|||
|
|
const uploadData = new FormData();
|
|||
|
|
for(let f of uploadedFiles) uploadData.append('images', f);
|
|||
|
|
const upR = await fetch('/api/upload', { method: 'POST', body: uploadData });
|
|||
|
|
const upRes = await upR.json();
|
|||
|
|
if(upRes.error) throw new Error(upRes.error);
|
|||
|
|
image_urls = upRes.urls;
|
|||
|
|
|
|||
|
|
const messages = [
|
|||
|
|
{ role: "system", content: AppPrompts.systemMessage },
|
|||
|
|
{
|
|||
|
|
role: "user",
|
|||
|
|
content: [
|
|||
|
|
{ type: "text", text: "请解读这张验光单。" }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
image_urls.forEach(url => {
|
|||
|
|
messages[1].content.push({ type: "image_url", image_url: { url: url } });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const r = await fetch('/api/generate', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
mode: 'trial',
|
|||
|
|
is_premium: isPremium,
|
|||
|
|
prompt: "解读验光单",
|
|||
|
|
model: FIXED_MODEL,
|
|||
|
|
messages: messages,
|
|||
|
|
image_urls: image_urls
|
|||
|
|
})
|
|||
|
|
});
|
|||
|
|
const res = await r.json();
|
|||
|
|
if(res.error) throw new Error(res.error);
|
|||
|
|
|
|||
|
|
const content = res.data[0].content;
|
|||
|
|
|
|||
|
|
// 分离正文和 JSON 数据
|
|||
|
|
let displayMarkdown = content;
|
|||
|
|
let jsonData = null;
|
|||
|
|
|
|||
|
|
const jsonMatch = content.match(/```json\n?([\s\S]*?)\n?```/);
|
|||
|
|
if (jsonMatch && jsonMatch[1]) {
|
|||
|
|
try {
|
|||
|
|
jsonData = JSON.parse(jsonMatch[1].trim());
|
|||
|
|
// 实时解析 Markdown (过滤 JSON 块)
|
|||
|
|
displayMarkdown = content.replace(/```json[\s\S]*?```/g, '').trim();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error("JSON 解析失败", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 渲染 Markdown
|
|||
|
|
marked.setOptions({
|
|||
|
|
gfm: true,
|
|||
|
|
breaks: true,
|
|||
|
|
tables: true,
|
|||
|
|
headerIds: true,
|
|||
|
|
mangle: false
|
|||
|
|
});
|
|||
|
|
textResult.innerHTML = marked.parse(displayMarkdown);
|
|||
|
|
|
|||
|
|
// 处理复制按钮逻辑
|
|||
|
|
const actionBtn = document.getElementById('resultActions');
|
|||
|
|
const copyBtn = document.getElementById('copyJsonBtn');
|
|||
|
|
if (jsonData && actionBtn && copyBtn) {
|
|||
|
|
actionBtn.classList.remove('hidden');
|
|||
|
|
copyBtn.onclick = async () => {
|
|||
|
|
const copyString = generateCopyString(jsonData);
|
|||
|
|
const success = await copyToClipboard(copyString);
|
|||
|
|
if (success) {
|
|||
|
|
showToast('解析数据已成功复制', 'success');
|
|||
|
|
} else {
|
|||
|
|
showToast('复制失败,请手动选择', 'error');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
checkAuth();
|
|||
|
|
} catch (e) {
|
|||
|
|
showToast('解读失败: ' + e.message, 'error');
|
|||
|
|
textResult.innerHTML = `<div class="p-10 text-rose-500 font-bold text-center">解读失败: ${e.message}</div>`;
|
|||
|
|
} finally {
|
|||
|
|
btn.disabled = false;
|
|||
|
|
btnText.innerText = "开始解读验光单";
|
|||
|
|
document.getElementById('statusInfo').classList.add('hidden');
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
async function init() {
|
|||
|
|
checkAuth();
|
|||
|
|
updateCostPreview();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showToast(message, type = 'info') {
|
|||
|
|
const container = document.getElementById('toastContainer');
|
|||
|
|
if(!container) {
|
|||
|
|
// 如果在 iframe 中,尝试调用父页面的 toast
|
|||
|
|
if(window.parent && window.parent.showToast) {
|
|||
|
|
window.parent.showToast(message, type);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
alert(message);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const toast = document.createElement('div');
|
|||
|
|
toast.className = `toast ${type}`;
|
|||
|
|
const colors = { success: 'text-emerald-500', error: 'text-rose-500', warning: 'text-amber-500', info: 'text-indigo-500' };
|
|||
|
|
const icons = { success: 'check-circle', error: 'x-circle', warning: 'alert-triangle', info: 'info' };
|
|||
|
|
toast.innerHTML = `<i data-lucide="${icons[type]}" class="w-5 h-5 ${colors[type]} flex-shrink-0"></i><span class="text-sm font-medium text-slate-700">${message}</span>`;
|
|||
|
|
container.appendChild(toast);
|
|||
|
|
lucide.createIcons();
|
|||
|
|
setTimeout(() => { toast.style.animation = 'slideOutRight 0.3s ease-in'; setTimeout(() => toast.remove(), 300); }, 3000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
init();
|