ai_v/main.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

415 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.

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