- 在主应用路由中添加 /ocr 页面路由渲染 ocr.html - 菜单中新增“验光单助手”入口,图标为 scan-eye,便于访问 - 在生成文本接口中支持聊天模型,处理 messages 内图片链接为 Base64 - 兼容 messages 为空场景,重构 payload 结构支持图片 Base64 传输 - 解析验光单请求不保存生成记录,避免污染历史数据 - 获取历史记录时过滤掉“解读验光单”的操作记录 - AI 接口配置新增 CHAT_API 地址,支持聊天模型调用 style(frontend): 优化首页图片展示与交互样式 - 缩小加载动画高度,调整提示文字为“AI 构思中...” - 图片展示容器增加阴影和悬停放大效果,提升视觉体验 - 结果区域改为flex布局,支持滚动区域和固定底部操作栏 - 按钮圆角加大,阴影色调调整,增强交互反馈 - 引入 Tailwind typography 插件,提升排版一致性 - 静态资源由 CDN 改为本地引用避免外部依赖 docs(ui): 补充首页联系方式提示,优化用户导航 - 在用户个人信息区域新增客服 QQ 联系方式悬浮提示 - 调整首页初始占位状态布局,提升视觉层次感 - 细化按钮标签与图标增强可用性提示
415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
// main.js
|
||
|
||
// 状态管理
|
||
let imageList = [];
|
||
let finalMarkdown = "";
|
||
let extractedData = null;
|
||
let statusInterval = null; // 用于动态文字切换
|
||
|
||
// DOM 元素引用
|
||
const els = {
|
||
dropArea: document.getElementById('drop-area'),
|
||
fileInput: document.getElementById('file-input'),
|
||
placeholder: document.getElementById('upload-placeholder'),
|
||
previewGrid: document.getElementById('preview-grid'),
|
||
controlBar: document.getElementById('control-bar'),
|
||
imgCount: document.getElementById('img-count'),
|
||
clearBtn: document.getElementById('clear-btn'),
|
||
analyzeBtn: document.getElementById('analyze-btn'),
|
||
btnText: document.getElementById('btn-text'),
|
||
btnSpinner: document.getElementById('btn-spinner'),
|
||
|
||
// 结果区域视图
|
||
placeholderView: document.getElementById('placeholder-view'), // 初始欢迎页
|
||
loadingView: document.getElementById('loading-view'), // 加载动画页
|
||
loadingStatus: document.getElementById('loading-status-text'), // 动态提示文字
|
||
resultContent: document.getElementById('result-content'), // 结果显示页
|
||
resultScroll: document.getElementById('result-scroll'), // 滚动容器
|
||
copyBtn: document.getElementById('copy-btn')
|
||
};
|
||
|
||
// === 事件监听 ===
|
||
|
||
els.dropArea.addEventListener('click', (e) => {
|
||
if (e.target !== els.clearBtn && imageList.length < 2) els.fileInput.click();
|
||
});
|
||
|
||
els.fileInput.addEventListener('change', e => handleFiles(e.target.files));
|
||
|
||
document.addEventListener('paste', e => {
|
||
const items = e.clipboardData.items;
|
||
const files = [];
|
||
for (let item of items) {
|
||
if (item.type.indexOf('image') !== -1) files.push(item.getAsFile());
|
||
}
|
||
if (files.length > 0) handleFiles(files);
|
||
});
|
||
|
||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(name => {
|
||
els.dropArea.addEventListener(name, e => { e.preventDefault(); e.stopPropagation(); });
|
||
});
|
||
els.dropArea.addEventListener('drop', e => handleFiles(e.dataTransfer.files));
|
||
|
||
els.clearBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
resetImages();
|
||
});
|
||
|
||
// === 复制按钮逻辑 ===
|
||
els.copyBtn.addEventListener('click', async () => {
|
||
let textToCopy = "";
|
||
|
||
if (!extractedData) {
|
||
textToCopy = els.resultContent.innerText;
|
||
showCopyFeedback("⚠️ 未识别到数据,已复制全文");
|
||
} else {
|
||
textToCopy = generateCopyString(extractedData);
|
||
}
|
||
|
||
const success = await copyToClipboard(textToCopy);
|
||
|
||
if (success) {
|
||
showCopyFeedback(extractedData ? "✅ 格式化数据已复制" : "✅ 已复制全文");
|
||
} else {
|
||
prompt("浏览器禁止自动复制,请手动复制以下内容:", textToCopy);
|
||
}
|
||
});
|
||
|
||
// === 兼容性极强的复制函数 ===
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
function showCopyFeedback(msg) {
|
||
const originalText = "📄 复制结果";
|
||
els.copyBtn.innerText = msg;
|
||
setTimeout(() => {
|
||
els.copyBtn.innerText = originalText;
|
||
}, 2000);
|
||
}
|
||
|
||
// === 高度定制的复制逻辑 ===
|
||
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 handleFiles(files) {
|
||
if (imageList.length >= 2) {
|
||
alert("最多只能上传 2 张图片,请先清空后再试。");
|
||
return;
|
||
}
|
||
const remainingSlots = 2 - imageList.length;
|
||
const filesProcess = Array.from(files).slice(0, remainingSlots);
|
||
|
||
for (let file of filesProcess) {
|
||
if (!file.type.startsWith('image/')) continue;
|
||
try {
|
||
const base64 = await readFileAsBase64(file);
|
||
const compressed = await compressImage(base64, 1024, 0.8);
|
||
imageList.push(compressed);
|
||
} catch (err) {
|
||
console.error("图片处理失败", err);
|
||
}
|
||
}
|
||
updatePreviewUI();
|
||
}
|
||
|
||
function resetImages() {
|
||
imageList = [];
|
||
updatePreviewUI();
|
||
els.fileInput.value = '';
|
||
}
|
||
|
||
function updatePreviewUI() {
|
||
const count = imageList.length;
|
||
if (count === 0) {
|
||
els.placeholder.classList.remove('hidden');
|
||
els.previewGrid.classList.add('hidden');
|
||
els.controlBar.classList.add('hidden');
|
||
} else {
|
||
els.placeholder.classList.add('hidden');
|
||
els.previewGrid.classList.remove('hidden');
|
||
els.controlBar.classList.remove('hidden');
|
||
}
|
||
els.previewGrid.innerHTML = '';
|
||
els.previewGrid.className = `w-full h-full grid gap-2 ${count > 1 ? 'grid-cols-2' : 'grid-cols-1'}`;
|
||
imageList.forEach((imgSrc) => {
|
||
const div = document.createElement('div');
|
||
div.className = "relative rounded-lg overflow-hidden border border-slate-200 bg-slate-100 flex items-center justify-center h-full max-h-[400px]";
|
||
div.innerHTML = `<img src="${imgSrc}" class="max-w-full max-h-full object-contain">`;
|
||
els.previewGrid.appendChild(div);
|
||
});
|
||
els.imgCount.innerText = `${count}/2`;
|
||
}
|
||
|
||
function readFileAsBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => resolve(e.target.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function compressImage(base64Str, maxDim, quality) {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.src = base64Str;
|
||
img.onload = () => {
|
||
let w = img.width, h = img.height;
|
||
if (w > maxDim || h > maxDim) {
|
||
if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; }
|
||
else { w = Math.round(w * maxDim / h); h = maxDim; }
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(img, 0, 0, w, h);
|
||
resolve(canvas.toDataURL('image/jpeg', quality));
|
||
};
|
||
});
|
||
}
|
||
|
||
// === 核心:设置加载状态 UI ===
|
||
function setLoading(isLoading) {
|
||
els.analyzeBtn.disabled = isLoading;
|
||
|
||
// 清除之前的定时器
|
||
if (statusInterval) {
|
||
clearInterval(statusInterval);
|
||
statusInterval = null;
|
||
}
|
||
|
||
if (isLoading) {
|
||
els.btnText.innerText = "正在分析...";
|
||
els.btnSpinner.classList.remove('hidden');
|
||
|
||
if (els.placeholderView) els.placeholderView.classList.add('hidden');
|
||
if (els.resultContent) els.resultContent.classList.add('hidden');
|
||
if (els.loadingView) els.loadingView.classList.remove('hidden');
|
||
|
||
const statuses = [
|
||
"正在上传并扫描验光单...",
|
||
"AI 正在识别视觉参数...",
|
||
"正在校对球镜与柱镜数值...",
|
||
"正在进行深度光学分析...",
|
||
"正在生成专业解读报告...",
|
||
"正在整理结果排版..."
|
||
];
|
||
|
||
let i = 0;
|
||
if (els.loadingStatus) {
|
||
els.loadingStatus.innerText = statuses[0];
|
||
|
||
statusInterval = setInterval(() => {
|
||
if (i < statuses.length - 1) {
|
||
i++;
|
||
els.loadingStatus.innerText = statuses[i];
|
||
} else {
|
||
// 播完了还没返回?进入“深度思考”省略号动画模式
|
||
clearInterval(statusInterval);
|
||
let dots = 0;
|
||
const baseText = statuses[statuses.length - 1];
|
||
statusInterval = setInterval(() => {
|
||
dots = (dots + 1) % 4;
|
||
els.loadingStatus.innerText = baseText + ".".repeat(dots);
|
||
}, 500);
|
||
}
|
||
}, 3000); // 3秒切换一次,更真实
|
||
}
|
||
} else {
|
||
els.btnText.innerText = "开始智能解读";
|
||
els.btnSpinner.classList.add('hidden');
|
||
|
||
if (els.loadingView) els.loadingView.classList.add('hidden');
|
||
if (els.resultContent) els.resultContent.classList.remove('hidden');
|
||
}
|
||
}
|
||
|
||
// === API 调用逻辑 ===
|
||
els.analyzeBtn.addEventListener('click', async () => {
|
||
if (imageList.length === 0) return alert('请至少上传 1 张验光单');
|
||
|
||
const apiKey = AppConfig.getDecryptedKey();
|
||
if (!apiKey) return alert("配置错误:无法读取 API Key");
|
||
|
||
setLoading(true);
|
||
finalMarkdown = "";
|
||
extractedData = null;
|
||
els.resultContent.innerHTML = "";
|
||
|
||
try {
|
||
const userContent = AppPrompts.generateUserPayload(imageList);
|
||
|
||
const response = await fetch(AppConfig.API_URL, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${apiKey}`
|
||
},
|
||
body: JSON.stringify({
|
||
model: AppConfig.MODEL,
|
||
stream: true,
|
||
messages: [
|
||
{ role: "system", content: AppPrompts.systemMessage },
|
||
{ role: "user", content: userContent }
|
||
]
|
||
})
|
||
});
|
||
|
||
if (!response.ok) throw new Error(`API 请求失败: ${response.status}`);
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
|
||
let hasStartedStreaming = false;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
const lines = chunk.split('\n');
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
||
try {
|
||
const json = JSON.parse(line.substring(6));
|
||
const content = json.choices[0].delta?.content || "";
|
||
|
||
if (content && !hasStartedStreaming) {
|
||
hasStartedStreaming = true;
|
||
// 数据回来的一瞬间,立即停止加载动画和文字
|
||
if (els.loadingView) els.loadingView.classList.add('hidden');
|
||
els.resultContent.classList.remove('hidden');
|
||
if (statusInterval) clearInterval(statusInterval);
|
||
}
|
||
|
||
finalMarkdown += content;
|
||
|
||
// 实时解析 Markdown (过滤 JSON 块)
|
||
const displayMarkdown = finalMarkdown.replace(/```json[\s\S]*```/, '');
|
||
els.resultContent.innerHTML = marked.parse(displayMarkdown);
|
||
|
||
// 自动滚动到底部
|
||
if (els.resultScroll) {
|
||
els.resultScroll.scrollTop = els.resultScroll.scrollHeight;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 结束后提取 JSON 用于复制功能
|
||
const jsonMatch = finalMarkdown.match(/```json\n([\s\S]*?)\n```/);
|
||
if (jsonMatch && jsonMatch[1]) {
|
||
try {
|
||
extractedData = JSON.parse(jsonMatch[1]);
|
||
} catch (e) {}
|
||
}
|
||
|
||
els.copyBtn.disabled = false;
|
||
els.copyBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||
|
||
} catch (err) {
|
||
els.resultContent.innerHTML = `<div class="p-4 bg-red-50 text-red-500 rounded-lg"><strong>出错了:</strong> ${err.message}</div>`;
|
||
if (els.loadingView) els.loadingView.classList.add('hidden');
|
||
els.resultContent.classList.remove('hidden');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}); |