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);
|
|||
|
|
}
|
|||
|
|
});
|