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 联系方式悬浮提示 - 调整首页初始占位状态布局,提升视觉层次感 - 细化按钮标签与图标增强可用性提示
This commit is contained in:
parent
d4b28a731a
commit
825f4fb4a9
Binary file not shown.
4
app.py
4
app.py
@ -27,6 +27,10 @@ def create_app():
|
|||||||
def index():
|
def index():
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/ocr')
|
||||||
|
def ocr():
|
||||||
|
return render_template('ocr.html')
|
||||||
|
|
||||||
# 自动创建数据库表
|
# 自动创建数据库表
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
print("🔧 正在检查并创建数据库表...")
|
print("🔧 正在检查并创建数据库表...")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -5,6 +5,7 @@ import requests
|
|||||||
import io
|
import io
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import base64
|
||||||
from flask import Blueprint, request, jsonify, session, current_app
|
from flask import Blueprint, request, jsonify, session, current_app
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from config import Config
|
from config import Config
|
||||||
@ -177,6 +178,8 @@ def generate():
|
|||||||
|
|
||||||
# 获取模型及对应的消耗积分
|
# 获取模型及对应的消耗积分
|
||||||
model_value = data.get('model')
|
model_value = data.get('model')
|
||||||
|
is_chat_model = "gemini" in model_value.lower() or "gpt" in model_value.lower()
|
||||||
|
|
||||||
model_dict = SystemDict.query.filter_by(dict_type='ai_model', value=model_value).first()
|
model_dict = SystemDict.query.filter_by(dict_type='ai_model', value=model_value).first()
|
||||||
cost = model_dict.cost if model_dict else 1
|
cost = model_dict.cost if model_dict else 1
|
||||||
|
|
||||||
@ -212,6 +215,63 @@ def generate():
|
|||||||
if model == "nano-banana-2" and size:
|
if model == "nano-banana-2" and size:
|
||||||
payload["image_size"] = size
|
payload["image_size"] = size
|
||||||
|
|
||||||
|
# 如果是聊天模型,重新构建 payload
|
||||||
|
if is_chat_model:
|
||||||
|
messages = data.get('messages')
|
||||||
|
|
||||||
|
# 核心修复:将图片 URL 转换为 Base64,解决第三方接口禁止 9000 端口访问的问题
|
||||||
|
def url_to_base64(url):
|
||||||
|
if not url or not url.startswith('http'):
|
||||||
|
return url
|
||||||
|
if ':9000' not in url:
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
# 尝试通过 S3 客户端直接读取(更安全,避开网络回环问题)
|
||||||
|
filename = url.split('/')[-1]
|
||||||
|
from urllib.parse import unquote
|
||||||
|
filename = unquote(filename)
|
||||||
|
|
||||||
|
resp = s3_client.get_object(Bucket=Config.MINIO["bucket"], Key=filename)
|
||||||
|
content = resp['Body'].read()
|
||||||
|
mime_type = resp.get('ContentType', 'image/jpeg')
|
||||||
|
|
||||||
|
encoded_string = base64.b64encode(content).decode('utf-8')
|
||||||
|
return f"data:{mime_type};base64,{encoded_string}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Base64 转换失败: {e}")
|
||||||
|
return url
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
# 兼容性处理:如果没有 messages,构建一个简单的
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": data.get('system_prompt', "You are a helpful assistant.")},
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": prompt}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
for img_url in input_img_urls:
|
||||||
|
messages[1]["content"].append({"type": "image_url", "image_url": {"url": url_to_base64(img_url)}})
|
||||||
|
else:
|
||||||
|
# 递归处理传入的 messages
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get('content')
|
||||||
|
if isinstance(content, list):
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, dict) and item.get('type') == 'image_url':
|
||||||
|
img_info = item.get('image_url')
|
||||||
|
if img_info and 'url' in img_info:
|
||||||
|
img_info['url'] = url_to_base64(img_info['url'])
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
target_api = Config.CHAT_API
|
||||||
|
if not mode == 'key':
|
||||||
|
# 使用生图默认的试用/优质 Key
|
||||||
|
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
resp = requests.post(target_api, json=payload, headers=headers, timeout=300)
|
resp = requests.post(target_api, json=payload, headers=headers, timeout=300)
|
||||||
|
|
||||||
@ -224,6 +284,30 @@ def generate():
|
|||||||
return jsonify({"error": resp.text}), resp.status_code
|
return jsonify({"error": resp.text}), resp.status_code
|
||||||
|
|
||||||
api_result = resp.json()
|
api_result = resp.json()
|
||||||
|
|
||||||
|
if is_chat_model:
|
||||||
|
# 聊天模型返回的是文本
|
||||||
|
content = api_result['choices'][0]['message']['content']
|
||||||
|
|
||||||
|
# 核心修复:如果是验光单解读(OCR),不存入生图历史记录,避免污染生图历史
|
||||||
|
if prompt != "解读验光单":
|
||||||
|
new_record = GenerationRecord(
|
||||||
|
user_id=session.get('user_id'),
|
||||||
|
prompt=prompt,
|
||||||
|
model=model,
|
||||||
|
image_urls=json.dumps([{"type": "text", "content": content}])
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
db.session.commit()
|
||||||
|
system_logger.info(f"用户生成文本成功", phone=user.phone, model=model, record_id=new_record.id)
|
||||||
|
else:
|
||||||
|
system_logger.info(f"用户解析验光单成功", phone=user.phone, model=model)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"data": [{"content": content, "type": "text"}],
|
||||||
|
"message": "解析成功!"
|
||||||
|
})
|
||||||
|
|
||||||
raw_urls = [item['url'] for item in api_result.get('data', [])]
|
raw_urls = [item['url'] for item in api_result.get('data', [])]
|
||||||
|
|
||||||
# 立即写入数据库(先存原始 URL)
|
# 立即写入数据库(先存原始 URL)
|
||||||
@ -324,7 +408,8 @@ def get_history():
|
|||||||
|
|
||||||
pagination = GenerationRecord.query.filter(
|
pagination = GenerationRecord.query.filter(
|
||||||
GenerationRecord.user_id == user_id,
|
GenerationRecord.user_id == user_id,
|
||||||
GenerationRecord.created_at >= ninety_days_ago
|
GenerationRecord.created_at >= ninety_days_ago,
|
||||||
|
GenerationRecord.prompt != "解读验光单" # 过滤掉验光单助手的操作记录
|
||||||
).order_by(GenerationRecord.created_at.desc())\
|
).order_by(GenerationRecord.created_at.desc())\
|
||||||
.paginate(page=page, per_page=per_page, error_out=False)
|
.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|
||||||
|
|||||||
@ -173,6 +173,7 @@ def get_menu():
|
|||||||
# 菜单定义库:名称, 图标, 链接, 所需权限
|
# 菜单定义库:名称, 图标, 链接, 所需权限
|
||||||
all_menus = [
|
all_menus = [
|
||||||
{"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None},
|
{"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None},
|
||||||
|
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
|
||||||
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
|
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
|
||||||
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
|
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
|
||||||
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
|
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
|
||||||
|
|||||||
26
config.js
Normal file
26
config.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// config.js
|
||||||
|
const AppConfig = {
|
||||||
|
API_URL: 'https://ai.comfly.chat/v1/chat/completions',
|
||||||
|
//MODEL: 'gemini-3-pro-preview', // 你要求的模型
|
||||||
|
//MODEL: 'gemini-2.5-pro',
|
||||||
|
MODEL: 'gemini-3-flash-preview',
|
||||||
|
// 这里填入第一步生成的“加密Key”
|
||||||
|
ENCRYPTED_KEY: "MARAXis7BiwzBDwiLQIjLHNRWwwbWQAKDSFyAiQEYx8iIy8QUHNRAQkoJhMdCRRRWAwJ",
|
||||||
|
|
||||||
|
// 解密函数(必须与加密算法对应)
|
||||||
|
getDecryptedKey: function() {
|
||||||
|
const salt = "ComflyChatSecret2025"; // 必须与加密时的 Salt 一致
|
||||||
|
try {
|
||||||
|
const raw = atob(this.ENCRYPTED_KEY);
|
||||||
|
let result = "";
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const charCode = raw.charCodeAt(i) ^ salt.charCodeAt(i % salt.length);
|
||||||
|
result += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Key 解密失败");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -27,6 +27,7 @@ class Config:
|
|||||||
|
|
||||||
# AI API 配置
|
# AI API 配置
|
||||||
AI_API = "https://ai.t8star.cn/v1/images/generations"
|
AI_API = "https://ai.t8star.cn/v1/images/generations"
|
||||||
|
CHAT_API = "https://ai.comfly.chat/v1/chat/completions"
|
||||||
|
|
||||||
# 试用模式配置
|
# 试用模式配置
|
||||||
TRIAL_API = "https://ai.comfly.chat/v1/images/generations"
|
TRIAL_API = "https://ai.comfly.chat/v1/images/generations"
|
||||||
|
|||||||
@ -82,3 +82,49 @@
|
|||||||
[2026-01-12 23:09:28] INFO - 用户登录尝试
|
[2026-01-12 23:09:28] INFO - 用户登录尝试
|
||||||
[2026-01-12 23:09:28] INFO - 用户登录成功
|
[2026-01-12 23:09:28] INFO - 用户登录成功
|
||||||
[2026-01-12 23:18:01] INFO - 用户生成图片成功
|
[2026-01-12 23:18:01] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 22:27:07] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 22:44:18] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 22:44:21] WARNING - API 报错,积分已退还
|
||||||
|
[2026-01-13 22:46:45] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 22:47:04] INFO - 用户生成文本成功
|
||||||
|
[2026-01-13 22:50:18] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 22:50:36] INFO - 用户生成文本成功
|
||||||
|
[2026-01-13 22:57:55] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 22:58:07] INFO - 用户生成文本成功
|
||||||
|
[2026-01-13 23:02:42] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 23:02:55] INFO - 用户生成文本成功
|
||||||
|
[2026-01-13 23:08:26] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 23:08:43] INFO - 用户生成文本成功
|
||||||
|
[2026-01-13 23:09:40] INFO - 用户登录尝试
|
||||||
|
[2026-01-13 23:09:40] INFO - 用户登录成功
|
||||||
|
[2026-01-13 23:11:23] INFO - 积分预扣除 (普通试用)
|
||||||
|
[2026-01-13 23:11:41] INFO - 用户生成文本成功
|
||||||
|
[2026-01-13 23:13:29] INFO - 用户登录尝试
|
||||||
|
[2026-01-13 23:13:30] INFO - 用户登录成功
|
||||||
|
[2026-01-13 23:14:33] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:17:57] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:18:03] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:18:15] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:20:10] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:20:11] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:20:11] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:20:14] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:21:19] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:21:21] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:21:21] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:21:22] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:24:14] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:24:15] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:24:16] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:24:17] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:30:04] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:30:04] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:30:06] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:48:04] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:48:05] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:48:07] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:52:15] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:52:15] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:52:22] INFO - 用户生成图片成功
|
||||||
|
[2026-01-13 23:59:01] INFO - 用户登录尝试
|
||||||
|
[2026-01-13 23:59:02] INFO - 用户登录成功
|
||||||
|
|||||||
415
main.js
Normal file
415
main.js
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
103
prompts.js
Normal file
103
prompts.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// prompts.js
|
||||||
|
|
||||||
|
const AppPrompts = {
|
||||||
|
systemMessage: `你是一个专业的眼科验光师助手,服务对象是眼镜店客服人员。
|
||||||
|
|
||||||
|
1. **基本原则**:
|
||||||
|
- 使用简体中文。
|
||||||
|
- 核心任务:精准提取配镜数据(S, C, A, PD)。
|
||||||
|
- **全能模式**:你需要同时具备处理“电脑验光单”、“手写处方”、“光学十字图”的能力。
|
||||||
|
|
||||||
|
2. **【⚠️ 核心逻辑一:视觉符号与笔迹语义】(完整保留,严禁简化)**
|
||||||
|
请仔细观察图像中的手写标记,它们决定了数据的取舍:
|
||||||
|
* **标记 A:下划线/圈选 (Row Selection)** —— **针对电脑验光单**
|
||||||
|
* **现象**:在打印数据列表(非 AVE 行)的**某一行数字下方**画了横线,或圈出了某一行。
|
||||||
|
* **含义**:验光师认为该行数据最准,**强制放弃**底部的 AVE(平均值)。
|
||||||
|
* **操作**:直接提取**被标记行**的 CYL (柱镜) 和 AX (轴位)。即使 AVE 行有数据,也以被标记的行为准。
|
||||||
|
* **标记 B:删除线 (Strikethrough/Cancellation)**
|
||||||
|
* **现象**:线条直接**贯穿/覆盖**了数字(常见于散光/轴位)。
|
||||||
|
* **操作**:该项数据归零(如散光变 0.00)。
|
||||||
|
* **标记 C:手写数值 (Handwritten Override)**
|
||||||
|
* **现象**:旁边写了新的数字(如 -0.75)。
|
||||||
|
* **操作**:手写数值优先级最高,直接覆盖打印数值。
|
||||||
|
|
||||||
|
3. **【⚠️ 核心逻辑二:光学十字换算】(完整保留,严禁简化)**
|
||||||
|
若图像中出现手画的“十字交叉图”,请严格执行:
|
||||||
|
* **定球镜 (S)**:取十字线上两个数值中,代数较大(更偏正/更不负)的数值作为 S。
|
||||||
|
* **定轴位 (A)**:
|
||||||
|
- 找到标有角度数值(如 90°, 180°)的那根线。
|
||||||
|
- 若 S 在该线上,A = 该角度。
|
||||||
|
- 若 S 不在该线上,A = 该角度 ± 90°。
|
||||||
|
* **定柱镜 (C)**:C = (十字线上另一个数值) - S。
|
||||||
|
|
||||||
|
4. **【数据提取优先级序列】**
|
||||||
|
请按以下顺序确定最终数值:
|
||||||
|
1. **手写修正的具体数值** (最高)
|
||||||
|
2. **被下划线/圈选的特定打印行** (次高)
|
||||||
|
3. **打印的 AVE (平均值)** (普通)
|
||||||
|
4. **光学十字推算值** (仅在有十字图时生效)
|
||||||
|
|
||||||
|
5. **【⚠️ 智能诊断与客服话术生成】(功能升级)**
|
||||||
|
在精准提取数据后,请检查是否命中以下规则。
|
||||||
|
**执行要求**:若命中,请在输出结果的第三部分,直接生成一段**客服可以直接复制发送给客户的回复话术**。
|
||||||
|
*(若同时命中多条,请将话术自然融合,不要机械分点)*
|
||||||
|
|
||||||
|
* **场景 A:非标准步长安抚(数值不能被 0.25 整除,如 -0.12, -0.37)**
|
||||||
|
* **触发条件**:提取的 S 或 C 结尾不是 .00, .25, .50, .75。
|
||||||
|
* **话术策略**:安抚客户不用担心“奇怪的数字”,解释这是电脑原始数据,承诺会按标准(0.25档)调整。
|
||||||
|
* **话术示例**(仅供参考):
|
||||||
|
"亲,单子上显示的 -0.37 是电脑验光的原始参考值哈。我们配镜时会按照国际标准度数(比如 0.25 或 0.50),您看您这边有没有验光师的手写单子呢,这样配出来的眼镜佩戴舒适哦~"
|
||||||
|
|
||||||
|
* **场景 B:低度数/防蓝光推荐(双眼度数 < 0.25)**
|
||||||
|
* **触发条件**:双眼 S 和 C 绝对值均 < 0.25(含 0.00, 0.12, 0.25, 0.37)。
|
||||||
|
* **话术策略**:恭喜客户视力底子好 -> 建议不配度数 -> 强烈推荐 0 度防蓝光(保护视力)。
|
||||||
|
* **话术示例**(仅供参考):
|
||||||
|
"亲,您的视力底子非常好!数据看都只是极其微小的生理波动(不到 25 度),这通常是不需要配度数的。
|
||||||
|
如果您平时看手机电脑多,特别推荐您配一副【0度防蓝光眼镜】,既能阻隔辐射保护这么好的视力,平时戴着也很好看呢!"
|
||||||
|
|
||||||
|
* **场景 C:PD (瞳距) 缺失**
|
||||||
|
* **触发条件**:手写区无 PD 且全图未扫描到 PD。
|
||||||
|
* **话术策略**:温柔追问数据。
|
||||||
|
* **话术示例**:"亲,单子上没看到瞳距(PD)数据哦,您之前有测过吗?或者手边有旧眼镜我们可以帮您测一下~"
|
||||||
|
|
||||||
|
6. **【输出结构】**
|
||||||
|
请严格按照以下顺序输出:
|
||||||
|
- **第一部分:分析摘要**。简述数据来源(如:“依据下划线提取了第3行数据...”)。
|
||||||
|
- **第二部分:Markdown 表格**。包含:眼别, 球镜(S), 柱镜(C), 轴位(A), ADD, 瞳高(PH), 瞳距(PD)。
|
||||||
|
- **第三部分:建议客服回复的话术**。
|
||||||
|
* **关键**:请在此处输出 Point 5 中生成的**针对性话术**。
|
||||||
|
* **格式**:不要写“规则A触发”,直接写:“建议您这样回复客户:xxxxxxxx”。
|
||||||
|
* **融合要求**:如果同时有【非标准步长】和【低度数】,请生成一段包含这两点意思的完整话术。
|
||||||
|
- **第四部分:JSON 数据**(必须在最后)。
|
||||||
|
|
||||||
|
7. **【JSON 输出格式】**
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"right": { "s": "", "c": "", "a": "", "add": "", "ph": "", "pd": "" },
|
||||||
|
"left": { "s": "", "c": "", "a": "", "add": "", "ph": "", "pd": "" },
|
||||||
|
"total_pd": ""
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
`,
|
||||||
|
|
||||||
|
generateUserPayload: function(imageArray) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `请解读这张验光单。
|
||||||
|
**执行步骤**:
|
||||||
|
1. **精准提取**:严格执行【核心逻辑一】(视觉符号)和【核心逻辑二】(光学十字),确保数据绝对准确。
|
||||||
|
2. **话术生成(重要)**:
|
||||||
|
- 如果遇到 -0.12/-0.37 这种数,请生成一段话术,让客服告诉客户“这是电脑原始值,镜片的国际标准是0.25一档,可以询问客户有没有手写验光单”。
|
||||||
|
- 如果遇到低度数(< 0.25),请生成一段话术,让客服推荐“0度防蓝光眼镜”。
|
||||||
|
- **最终输出**:请在第三部分直接给出一代**客服可以复制发给客户的文字**,语气要亲切、自然。
|
||||||
|
3. **PD 检查**:寻找打印的瞳距值。
|
||||||
|
请输出最终结果。`
|
||||||
|
},
|
||||||
|
...imageArray.map(imgBase64 => ({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: imgBase64, detail: "high" }
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
2
static/js/Sortable.min.js
vendored
Normal file
2
static/js/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
26
static/js/config.js
Normal file
26
static/js/config.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// config.js
|
||||||
|
const AppConfig = {
|
||||||
|
API_URL: 'https://ai.comfly.chat/v1/chat/completions',
|
||||||
|
//MODEL: 'gemini-3-pro-preview', // 你要求的模型
|
||||||
|
//MODEL: 'gemini-2.5-pro',
|
||||||
|
MODEL: 'gemini-3-flash-preview',
|
||||||
|
// 这里填入第一步生成的“加密Key”
|
||||||
|
ENCRYPTED_KEY: "MARAXis7BiwzBDwiLQIjLHNRWwwbWQAKDSFyAiQEYx8iIy8QUHNRAQkoJhMdCRRRWAwJ",
|
||||||
|
|
||||||
|
// 解密函数(必须与加密算法对应)
|
||||||
|
getDecryptedKey: function() {
|
||||||
|
const salt = "ComflyChatSecret2025"; // 必须与加密时的 Salt 一致
|
||||||
|
try {
|
||||||
|
const raw = atob(this.ENCRYPTED_KEY);
|
||||||
|
let result = "";
|
||||||
|
for (let i = 0; i < raw.length; i++) {
|
||||||
|
const charCode = raw.charCodeAt(i) ^ salt.charCodeAt(i % salt.length);
|
||||||
|
result += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Key 解密失败");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
12
static/js/lucide.min.js
vendored
Normal file
12
static/js/lucide.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -466,8 +466,8 @@ document.getElementById('submitBtn').onclick = async () => {
|
|||||||
|
|
||||||
const startTask = async (index) => {
|
const startTask = async (index) => {
|
||||||
const slot = document.createElement('div');
|
const slot = document.createElement('div');
|
||||||
slot.className = 'image-frame relative bg-white/50 animate-pulse min-h-[300px] flex items-center justify-center rounded-[2.5rem] border border-slate-100 shadow-sm';
|
slot.className = 'image-frame relative bg-white/50 animate-pulse min-h-[200px] flex items-center justify-center rounded-[2.5rem] border border-slate-100 shadow-sm';
|
||||||
slot.innerHTML = `<div class="text-slate-400 text-xs font-bold italic">正在创作第 ${index + 1} 张...</div>`;
|
slot.innerHTML = `<div class="text-slate-400 text-[10px] font-bold italic">AI 构思中...</div>`;
|
||||||
grid.appendChild(slot);
|
grid.appendChild(slot);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -493,10 +493,12 @@ document.getElementById('submitBtn').onclick = async () => {
|
|||||||
const imgUrl = res.data[0].url;
|
const imgUrl = res.data[0].url;
|
||||||
currentGeneratedUrls.push(imgUrl);
|
currentGeneratedUrls.push(imgUrl);
|
||||||
|
|
||||||
slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700';
|
slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700 flex flex-col items-center justify-center overflow-hidden bg-white shadow-2xl transition-all hover:shadow-indigo-100/50';
|
||||||
slot.innerHTML = `
|
slot.innerHTML = `
|
||||||
<img src="${imgUrl}" class="w-full h-auto rounded-[2.5rem] object-contain shadow-xl">
|
<div class="w-full h-full flex items-center justify-center bg-slate-50/20 p-2">
|
||||||
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/20 rounded-[2.5rem]">
|
<img src="${imgUrl}" class="max-w-full max-h-[60vh] md:max-h-[70vh] object-contain rounded-2xl shadow-sm transition-transform duration-500 group-hover:scale-[1.01]">
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-slate-900/40 backdrop-blur-[2px] rounded-[2.5rem]">
|
||||||
<button onclick="downloadImage('${imgUrl}')" class="bg-white/90 p-4 rounded-full text-indigo-600 shadow-xl hover:scale-110 transition-transform">
|
<button onclick="downloadImage('${imgUrl}')" class="bg-white/90 p-4 rounded-full text-indigo-600 shadow-xl hover:scale-110 transition-transform">
|
||||||
<i data-lucide="download-cloud" class="w-6 h-6"></i>
|
<i data-lucide="download-cloud" class="w-6 h-6"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
383
static/js/ocr.js
Normal file
383
static/js/ocr.js
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
// 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();
|
||||||
103
static/js/prompts.js
Normal file
103
static/js/prompts.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
// prompts.js
|
||||||
|
|
||||||
|
const AppPrompts = {
|
||||||
|
systemMessage: `你是一个专业的眼科验光师助手,服务对象是眼镜店客服人员。
|
||||||
|
|
||||||
|
1. **基本原则**:
|
||||||
|
- 使用简体中文。
|
||||||
|
- 核心任务:精准提取配镜数据(S, C, A, PD)。
|
||||||
|
- **全能模式**:你需要同时具备处理“电脑验光单”、“手写处方”、“光学十字图”的能力。
|
||||||
|
|
||||||
|
2. **【⚠️ 核心逻辑一:视觉符号与笔迹语义】(完整保留,严禁简化)**
|
||||||
|
请仔细观察图像中的手写标记,它们决定了数据的取舍:
|
||||||
|
* **标记 A:下划线/圈选 (Row Selection)** —— **针对电脑验光单**
|
||||||
|
* **现象**:在打印数据列表(非 AVE 行)的**某一行数字下方**画了横线,或圈出了某一行。
|
||||||
|
* **含义**:验光师认为该行数据最准,**强制放弃**底部的 AVE(平均值)。
|
||||||
|
* **操作**:直接提取**被标记行**的 CYL (柱镜) 和 AX (轴位)。即使 AVE 行有数据,也以被标记的行为准。
|
||||||
|
* **标记 B:删除线 (Strikethrough/Cancellation)**
|
||||||
|
* **现象**:线条直接**贯穿/覆盖**了数字(常见于散光/轴位)。
|
||||||
|
* **操作**:该项数据归零(如散光变 0.00)。
|
||||||
|
* **标记 C:手写数值 (Handwritten Override)**
|
||||||
|
* **现象**:旁边写了新的数字(如 -0.75)。
|
||||||
|
* **操作**:手写数值优先级最高,直接覆盖打印数值。
|
||||||
|
|
||||||
|
3. **【⚠️ 核心逻辑二:光学十字换算】(完整保留,严禁简化)**
|
||||||
|
若图像中出现手画的“十字交叉图”,请严格执行:
|
||||||
|
* **定球镜 (S)**:取十字线上两个数值中,代数较大(更偏正/更不负)的数值作为 S。
|
||||||
|
* **定轴位 (A)**:
|
||||||
|
- 找到标有角度数值(如 90°, 180°)的那根线。
|
||||||
|
- 若 S 在该线上,A = 该角度。
|
||||||
|
- 若 S 不在该线上,A = 该角度 ± 90°。
|
||||||
|
* **定柱镜 (C)**:C = (十字线上另一个数值) - S。
|
||||||
|
|
||||||
|
4. **【数据提取优先级序列】**
|
||||||
|
请按以下顺序确定最终数值:
|
||||||
|
1. **手写修正的具体数值** (最高)
|
||||||
|
2. **被下划线/圈选的特定打印行** (次高)
|
||||||
|
3. **打印的 AVE (平均值)** (普通)
|
||||||
|
4. **光学十字推算值** (仅在有十字图时生效)
|
||||||
|
|
||||||
|
5. **【⚠️ 智能诊断与客服话术生成】(功能升级)**
|
||||||
|
在精准提取数据后,请检查是否命中以下规则。
|
||||||
|
**执行要求**:若命中,请在输出结果的第三部分,直接生成一段**客服可以直接复制发送给客户的回复话术**。
|
||||||
|
*(若同时命中多条,请将话术自然融合,不要机械分点)*
|
||||||
|
|
||||||
|
* **场景 A:非标准步长安抚(数值不能被 0.25 整除,如 -0.12, -0.37)**
|
||||||
|
* **触发条件**:提取的 S 或 C 结尾不是 .00, .25, .50, .75。
|
||||||
|
* **话术策略**:安抚客户不用担心“奇怪的数字”,解释这是电脑原始数据,承诺会按标准(0.25档)调整。
|
||||||
|
* **话术示例**(仅供参考):
|
||||||
|
"亲,单子上显示的 -0.37 是电脑验光的原始参考值哈。我们配镜时会按照国际标准度数(比如 0.25 或 0.50),您看您这边有没有验光师的手写单子呢,这样配出来的眼镜佩戴舒适哦~"
|
||||||
|
|
||||||
|
* **场景 B:低度数/防蓝光推荐(双眼度数 < 0.25)**
|
||||||
|
* **触发条件**:双眼 S 和 C 绝对值均 < 0.25(含 0.00, 0.12, 0.25, 0.37)。
|
||||||
|
* **话术策略**:恭喜客户视力底子好 -> 建议不配度数 -> 强烈推荐 0 度防蓝光(保护视力)。
|
||||||
|
* **话术示例**(仅供参考):
|
||||||
|
"亲,您的视力底子非常好!数据看都只是极其微小的生理波动(不到 25 度),这通常是不需要配度数的。
|
||||||
|
如果您平时看手机电脑多,特别推荐您配一副【0度防蓝光眼镜】,既能阻隔辐射保护这么好的视力,平时戴着也很好看呢!"
|
||||||
|
|
||||||
|
* **场景 C:PD (瞳距) 缺失**
|
||||||
|
* **触发条件**:手写区无 PD 且全图未扫描到 PD。
|
||||||
|
* **话术策略**:温柔追问数据。
|
||||||
|
* **话术示例**:"亲,单子上没看到瞳距(PD)数据哦,您之前有测过吗?或者手边有旧眼镜我们可以帮您测一下~"
|
||||||
|
|
||||||
|
6. **【输出结构】**
|
||||||
|
请严格按照以下顺序输出:
|
||||||
|
- **第一部分:分析摘要**。简述数据来源(如:“依据下划线提取了第3行数据...”)。
|
||||||
|
- **第二部分:Markdown 表格**。包含:眼别, 球镜(S), 柱镜(C), 轴位(A), ADD, 瞳高(PH), 瞳距(PD)。
|
||||||
|
- **第三部分:建议客服回复的话术**。
|
||||||
|
* **关键**:请在此处输出 Point 5 中生成的**针对性话术**。
|
||||||
|
* **格式**:不要写“规则A触发”,直接写:“建议您这样回复客户:xxxxxxxx”。
|
||||||
|
* **融合要求**:如果同时有【非标准步长】和【低度数】,请生成一段包含这两点意思的完整话术。
|
||||||
|
- **第四部分:JSON 数据**(必须在最后)。
|
||||||
|
|
||||||
|
7. **【JSON 输出格式】**
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"right": { "s": "", "c": "", "a": "", "add": "", "ph": "", "pd": "" },
|
||||||
|
"left": { "s": "", "c": "", "a": "", "add": "", "ph": "", "pd": "" },
|
||||||
|
"total_pd": ""
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
`,
|
||||||
|
|
||||||
|
generateUserPayload: function(imageArray) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `请解读这张验光单。
|
||||||
|
**执行步骤**:
|
||||||
|
1. **精准提取**:严格执行【核心逻辑一】(视觉符号)和【核心逻辑二】(光学十字),确保数据绝对准确。
|
||||||
|
2. **话术生成(重要)**:
|
||||||
|
- 如果遇到 -0.12/-0.37 这种数,请生成一段话术,让客服告诉客户“这是电脑原始值,镜片的国际标准是0.25一档,可以询问客户有没有手写验光单”。
|
||||||
|
- 如果遇到低度数(< 0.25),请生成一段话术,让客服推荐“0度防蓝光眼镜”。
|
||||||
|
- **最终输出**:请在第三部分直接给出一代**客服可以复制发给客户的文字**,语气要亲切、自然。
|
||||||
|
3. **PD 检查**:寻找打印的瞳距值。
|
||||||
|
请输出最终结果。`
|
||||||
|
},
|
||||||
|
...imageArray.map(imgBase64 => ({
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: imgBase64, detail: "high" }
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -4,10 +4,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}AI 视界{% endblock %}</title>
|
<title>{% block title %}AI 视界{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
<script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@ -25,6 +25,17 @@
|
|||||||
<div id="dynamicMenuList" class="flex-1 w-full px-2 space-y-4"></div>
|
<div id="dynamicMenuList" class="flex-1 w-full px-2 space-y-4"></div>
|
||||||
|
|
||||||
<div id="globalUserProfile" class="flex flex-col items-center gap-4 mb-4">
|
<div id="globalUserProfile" class="flex flex-col items-center gap-4 mb-4">
|
||||||
|
<!-- 联系方式 -->
|
||||||
|
<div class="relative group flex justify-center mb-2">
|
||||||
|
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700 hover:text-indigo-400 transition-colors cursor-help">
|
||||||
|
<i data-lucide="message-circle" class="w-5 h-5"></i>
|
||||||
|
</div>
|
||||||
|
<div class="absolute left-full ml-4 px-3 py-2 bg-slate-800 text-white text-[10px] font-black rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl border border-slate-700">
|
||||||
|
联系客服 QQ: 240241002
|
||||||
|
<div class="absolute right-full top-1/2 -translate-y-1/2 border-8 border-transparent border-r-slate-800"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700">
|
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700">
|
||||||
<i data-lucide="user" class="w-5 h-5"></i>
|
<i data-lucide="user" class="w-5 h-5"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -143,31 +143,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center p-8 relative">
|
<div class="flex-1 flex flex-col p-8 relative overflow-hidden min-h-0">
|
||||||
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
|
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
|
||||||
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
|
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
|
||||||
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
|
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
|
||||||
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
|
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="resultCanvas" class="w-full h-full flex items-center justify-center">
|
<div id="resultCanvas" class="flex-1 w-full flex flex-col relative overflow-hidden">
|
||||||
<div id="placeholder" class="text-center max-w-lg">
|
<!-- 初始占位状态 -->
|
||||||
|
<div id="placeholder" class="flex-1 flex flex-col items-center justify-center text-center max-w-lg mx-auto">
|
||||||
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
|
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
|
||||||
<i data-lucide="glasses" class="w-20 h-20 text-indigo-500"></i>
|
<i data-lucide="glasses" class="w-20 h-20 text-indigo-500"></i>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-4xl font-black text-slate-900 mb-8">视界进化 · 艺术呈现</h2>
|
<h2 class="text-4xl font-black text-slate-900 mb-8">视界进化 · 艺术呈现</h2>
|
||||||
<p class="text-slate-500 text-lg font-medium">在左侧完成设定,开启 AI 试戴体验</p>
|
<p class="text-slate-500 text-lg font-medium">在左侧完成设定,开启 AI 试戴体验</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="finalWrapper" class="hidden w-full h-full flex flex-col items-center justify-center py-6">
|
|
||||||
<div id="imageGrid" class="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-4xl p-4"></div>
|
<div id="finalWrapper" class="hidden flex-1 w-full flex flex-col overflow-hidden">
|
||||||
<div class="mt-14 flex items-center gap-8">
|
<!-- 滚动区域 -->
|
||||||
|
<div class="flex-1 w-full overflow-y-auto custom-scrollbar px-8 py-10">
|
||||||
|
<div id="imageGrid" class="grid grid-cols-1 md:grid-cols-2 gap-10 w-full max-w-6xl mx-auto items-start"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定底部操作栏 -->
|
||||||
|
<div id="resultActions" class="w-full flex-shrink-0 bg-slate-50/80 backdrop-blur-sm border-t border-slate-200/60 py-6 flex items-center justify-center gap-8 z-10">
|
||||||
<!-- 全部下载按钮 -->
|
<!-- 全部下载按钮 -->
|
||||||
<button id="downloadAllBtn" class="bg-indigo-600 text-white px-8 py-4 rounded-[1.8rem] text-sm font-bold shadow-xl shadow-indigo-200 hover:bg-indigo-700 hover:scale-105 active:scale-95 transition-all flex items-center gap-3">
|
<button id="downloadAllBtn" class="bg-indigo-600 text-white px-10 py-4 rounded-3xl text-sm font-bold shadow-xl shadow-indigo-100 hover:bg-indigo-700 hover:scale-105 active:scale-95 transition-all flex items-center gap-3">
|
||||||
<i data-lucide="download" class="w-5 h-5"></i>
|
<i data-lucide="download" class="w-5 h-5"></i>
|
||||||
<span>全部下载</span>
|
<span>全部下载</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- 重新生成按钮 -->
|
<!-- 重新生成按钮 -->
|
||||||
<button id="regenBtn" class="w-16 h-16 bg-white border border-slate-100 rounded-[1.8rem] flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95 group">
|
<button id="regenBtn" class="w-16 h-16 bg-white border border-slate-100 rounded-3xl flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95 group">
|
||||||
<i data-lucide="refresh-cw" class="w-6 h-6 group-hover:rotate-180 transition-transform duration-500"></i>
|
<i data-lucide="refresh-cw" class="w-6 h-6 group-hover:rotate-180 transition-transform duration-500"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
173
templates/ocr.html
Normal file
173
templates/ocr.html
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}验光单助手 - AI 视界{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- 中间:AI 功能设定区 -->
|
||||||
|
<aside class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
|
||||||
|
<div class="p-6 pb-2">
|
||||||
|
<h2 class="text-xl font-black text-slate-900 tracking-tight">验光单助手</h2>
|
||||||
|
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">智能数据提取配置</p>
|
||||||
|
|
||||||
|
<!-- 免责声明提示 - 增强版 -->
|
||||||
|
<div class="mt-6 p-4 bg-indigo-50/50 border border-indigo-100/50 rounded-2xl relative overflow-hidden group">
|
||||||
|
<div class="absolute top-0 right-0 -mt-2 -mr-2 w-16 h-16 bg-indigo-500/5 rounded-full blur-2xl group-hover:bg-indigo-500/10 transition-colors"></div>
|
||||||
|
<div class="flex items-center gap-2.5 mb-2">
|
||||||
|
<div class="w-6 h-6 bg-white rounded-lg flex items-center justify-center shadow-sm border border-indigo-50">
|
||||||
|
<i data-lucide="shield-check" class="w-3.5 h-3.5 text-indigo-500"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-black text-indigo-900 uppercase tracking-wider">使用必读</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-[11px] text-slate-600 leading-relaxed font-medium">
|
||||||
|
本助手支持单次上传多张单据(如:电脑验光+综合验光),但请确保单据均属于<span class="text-indigo-600 font-bold underline">同一个人</span>。
|
||||||
|
</p>
|
||||||
|
<div class="p-2 bg-white/60 rounded-xl border border-indigo-100/20 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] text-indigo-600 leading-relaxed font-bold flex items-start gap-1.5">
|
||||||
|
<i data-lucide="alert-triangle" class="w-3 h-3 mt-0.5 flex-shrink-0"></i>
|
||||||
|
<span>因手写单据模糊或光影识别误差,<span class="underline decoration-indigo-300 underline-offset-2">请务必以人工核对后的数据为准</span>。</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-slate-400 font-bold italic">
|
||||||
|
* 平台仅提供 AI 技术支持参考,不承担医疗诊断建议。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
|
||||||
|
<h3 class="text-sm font-bold text-slate-800">状态信息</h3>
|
||||||
|
</div>
|
||||||
|
<!-- 积分显示 -->
|
||||||
|
<div id="pointsBadge" class="hidden px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
|
||||||
|
可用积分: <span id="pointsDisplay">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="premiumToggle" class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center">
|
||||||
|
<i data-lucide="sparkles" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] font-black text-amber-700 uppercase tracking-tight">高级解析模式</div>
|
||||||
|
<div class="text-[8px] text-amber-500 font-bold">积分消耗 X2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" id="isPremium" class="sr-only peer">
|
||||||
|
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-4 pb-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
|
||||||
|
<h3 class="text-sm font-bold text-slate-800">验光单照片</h3>
|
||||||
|
</div>
|
||||||
|
<div id="dropZone" class="relative group">
|
||||||
|
<div class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
|
||||||
|
<i data-lucide="image-plus" class="w-6 h-6 mx-auto mb-2 text-slate-300"></i>
|
||||||
|
<p class="text-[10px] text-slate-400 font-bold">点击、拖拽或粘贴照片(最多3张)</p>
|
||||||
|
<p class="text-[8px] text-indigo-400 font-black mt-1 uppercase tracking-tighter">* 仅支持同一人的多张单据合拍或分传</p>
|
||||||
|
</div>
|
||||||
|
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer">
|
||||||
|
</div>
|
||||||
|
<div id="imagePreview" class="flex flex-wrap gap-3 py-1"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 bg-white/95 border-t border-slate-100">
|
||||||
|
<button id="submitBtn" class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
|
||||||
|
<i data-lucide="wand-2" class="w-5 h-5"></i>
|
||||||
|
<span class="text-base font-bold tracking-widest">开始解读验光单</span>
|
||||||
|
</button>
|
||||||
|
<p id="costPreview" class="text-center text-[10px] text-amber-600 font-bold mt-2 hidden"></p>
|
||||||
|
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 hidden">
|
||||||
|
<i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧:主展示 -->
|
||||||
|
<main class="flex-1 relative flex flex-col bg-slate-50 overflow-hidden">
|
||||||
|
<div class="h-24 flex items-center justify-between px-12 relative z-10">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full animate-ping"></div>
|
||||||
|
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest text-xs">引擎就绪</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div id="userProfile" class="hidden flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
|
||||||
|
<div class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600">
|
||||||
|
<i data-lucide="user" class="w-4 h-4"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">--</span>
|
||||||
|
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">0</span> 积分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a id="loginEntryBtn" href="/login" class="bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
|
||||||
|
立即登录
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 flex items-center justify-center p-8 relative overflow-hidden">
|
||||||
|
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
|
||||||
|
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
|
||||||
|
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
|
||||||
|
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resultCanvas" class="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<!-- 初始占位状态 -->
|
||||||
|
<div id="placeholder" class="text-center max-w-lg">
|
||||||
|
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
|
||||||
|
<i data-lucide="scan-eye" class="w-20 h-20 text-indigo-500"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-4xl font-black text-slate-900 mb-8">精准解析 · 专业建议</h2>
|
||||||
|
<p class="text-slate-500 text-lg font-medium">上传验光单照片,获取 AI 深度解读</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果展示状态:使用 flex 布局确保按钮区域固定在底部 -->
|
||||||
|
<div id="finalWrapper" class="hidden w-full h-full flex flex-col items-center">
|
||||||
|
<!-- 滚动的内容区域 -->
|
||||||
|
<div class="flex-1 w-full overflow-y-auto custom-scrollbar px-4 py-6 flex flex-col items-center">
|
||||||
|
<div id="textResult" class="w-full max-w-4xl p-10 bg-white rounded-[2.5rem] shadow-2xl border border-slate-100 prose prose-indigo max-w-none mb-6"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 固定的操作栏区域 -->
|
||||||
|
<div id="resultActions" class="w-full flex-shrink-0 bg-slate-50/80 backdrop-blur-sm border-t border-slate-200/60 py-6 flex flex-col items-center gap-3 z-10">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button id="copyJsonBtn" class="bg-slate-900 text-white px-10 py-3.5 rounded-2xl text-xs font-bold shadow-xl hover:bg-slate-800 transition-all flex items-center gap-2.5 active:scale-[0.98]">
|
||||||
|
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||||
|
复制解析数据
|
||||||
|
</button>
|
||||||
|
<button onclick="location.reload()" class="bg-white border border-slate-200 text-slate-600 px-10 py-3.5 rounded-2xl text-xs font-bold hover:bg-slate-50 transition-all flex items-center gap-2.5 active:scale-[0.98]">
|
||||||
|
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||||||
|
重新解析
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-slate-400 font-medium">
|
||||||
|
* AI 识别结果仅供参考,请务必以人工核对后的数据为准
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/config.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/prompts.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/ocr.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user