- 在主应用路由中添加 /ocr 页面路由渲染 ocr.html - 菜单中新增“验光单助手”入口,图标为 scan-eye,便于访问 - 在生成文本接口中支持聊天模型,处理 messages 内图片链接为 Base64 - 兼容 messages 为空场景,重构 payload 结构支持图片 Base64 传输 - 解析验光单请求不保存生成记录,避免污染历史数据 - 获取历史记录时过滤掉“解读验光单”的操作记录 - AI 接口配置新增 CHAT_API 地址,支持聊天模型调用 style(frontend): 优化首页图片展示与交互样式 - 缩小加载动画高度,调整提示文字为“AI 构思中...” - 图片展示容器增加阴影和悬停放大效果,提升视觉体验 - 结果区域改为flex布局,支持滚动区域和固定底部操作栏 - 按钮圆角加大,阴影色调调整,增强交互反馈 - 引入 Tailwind typography 插件,提升排版一致性 - 静态资源由 CDN 改为本地引用避免外部依赖 docs(ui): 补充首页联系方式提示,优化用户导航 - 在用户个人信息区域新增客服 QQ 联系方式悬浮提示 - 调整首页初始占位状态布局,提升视觉层次感 - 细化按钮标签与图标增强可用性提示
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();
|