ai_v/static/js/ocr.js
24024 825f4fb4a9 feat(ocr): 新增验光单助手功能页面
- 在主应用路由中添加 /ocr 页面路由渲染 ocr.html
- 菜单中新增“验光单助手”入口,图标为 scan-eye,便于访问
- 在生成文本接口中支持聊天模型,处理 messages 内图片链接为 Base64
- 兼容 messages 为空场景,重构 payload 结构支持图片 Base64 传输
- 解析验光单请求不保存生成记录,避免污染历史数据
- 获取历史记录时过滤掉“解读验光单”的操作记录
- AI 接口配置新增 CHAT_API 地址,支持聊天模型调用

style(frontend): 优化首页图片展示与交互样式

- 缩小加载动画高度,调整提示文字为“AI 构思中...”
- 图片展示容器增加阴影和悬停放大效果,提升视觉体验
- 结果区域改为flex布局,支持滚动区域和固定底部操作栏
- 按钮圆角加大,阴影色调调整,增强交互反馈
- 引入 Tailwind typography 插件,提升排版一致性
- 静态资源由 CDN 改为本地引用避免外部依赖

docs(ui): 补充首页联系方式提示,优化用户导航

- 在用户个人信息区域新增客服 QQ 联系方式悬浮提示
- 调整首页初始占位状态布局,提升视觉层次感
- 细化按钮标签与图标增强可用性提示
2026-01-14 00:00:23 +08:00

384 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();