feat(api): 实现图片生成异步任务与任务状态查询接口

- 新增异步图片生成处理函数,支持后台任务执行及积分退还机制
- 实现任务状态查询接口,支持前端实时获取生成进度和结果
- 优化生成逻辑:根据模型类型分流,聊天模型同步调用,图片模型异步执行
- 调整积分预扣除和退还逻辑,保障用户积分安全
- 后台线程同步图片至私有存储,提升响应性能和用户体验
- 新增 /visualizer 路由对应前端控制器页面,辅助3D构图和拍摄角度设置
- 优化前端上传逻辑,新增设置器模式时单图上传限制
- 移除项目中未使用的前端脚本与配置文件,简化代码库维护
This commit is contained in:
24024 2026-01-15 21:42:03 +08:00
parent 925da47118
commit a47b84e009
10 changed files with 798 additions and 729 deletions

4
app.py
View File

@ -33,6 +33,10 @@ def create_app():
def ocr():
return render_template('ocr.html')
@app.route('/visualizer')
def visualizer():
return render_template('kongzhiqi.html')
# 自动创建数据库表
with app.app_context():
print("🔧 正在检查并创建数据库表...")

View File

@ -93,6 +93,63 @@ def sync_images_background(app, record_id, raw_urls):
except Exception as e:
print(f"❌ 更新记录失败: {e}")
def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost):
"""异步执行图片生成并存入 Redis"""
with app.app_context():
try:
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
# 使用较长的超时时间 (10分钟),确保长耗时任务不被中断
resp = requests.post(target_api, json=payload, headers=headers, timeout=1000)
if resp.status_code != 200:
# 错误处理:退还积分
user = User.query.get(user_id)
if user and "sk-" in api_key:
user.points += cost
db.session.commit()
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
return
api_result = resp.json()
raw_urls = [item['url'] for item in api_result.get('data', [])]
# 持久化记录
new_record = GenerationRecord(
user_id=user_id,
prompt=payload.get('prompt'),
model=payload.get('model'),
image_urls=json.dumps(raw_urls)
)
db.session.add(new_record)
db.session.commit()
# 后台线程处理:下载 AI 原始图片并同步到私有 MinIO
threading.Thread(
target=sync_images_background,
args=(app, new_record.id, raw_urls)
).start()
# 存入 Redis 标记完成
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls}))
except Exception as e:
# 异常处理:退还积分
user = User.query.get(user_id)
if user and "sk-" in api_key:
user.points += cost
db.session.commit()
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)}))
@api_bp.route('/api/task_status/<task_id>')
@login_required
def get_task_status(task_id):
"""查询异步任务状态"""
data = redis_client.get(f"task:{task_id}")
if not data:
# 如果 Redis 里没有,可能是刚提交,也可能是过期了
return jsonify({"status": "pending"})
return jsonify(json.loads(data))
@api_bp.route('/api/config')
def get_config():
"""从本地数据库字典获取配置"""
@ -157,197 +214,99 @@ def generate():
use_trial = False
if mode == 'key':
# 自定义 Key 模式:优先使用本次输入的,否则使用数据库存的
api_key = input_key or user.api_key
if not api_key:
return jsonify({"error": "请先输入您的 API 密钥"}), 400
else:
# 积分/试用模式
if user.points > 0:
# 核心修复:优质模式使用专属 Key
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
target_api = Config.TRIAL_API
use_trial = True
else:
return jsonify({"error": "可用积分已耗尽,请充值或切换至自定义 Key 模式"}), 400
# 如果是 Key 模式且输入了新 Key则自动更新到数据库保存
if mode == 'key' and input_key and input_key != user.api_key:
user.api_key = input_key
db.session.commit()
# 获取模型及对应的消耗积分
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()
cost = model_dict.cost if model_dict else 1
# 核心修复:优质模式积分消耗 X2
if use_trial and is_premium:
cost *= 2
# --- 积分预扣除逻辑 (点击即扣) ---
if use_trial:
if user.points < cost:
return jsonify({"error": f"可用积分不足,优质模式需要 {cost} 积分,您当前剩余 {user.points} 积分"}), 400
return jsonify({"error": f"可用积分不足"}), 400
user.points -= cost
db.session.commit()
system_logger.info(f"积分预扣除 ({'优质' if is_premium else '普通'}试用)", phone=user.phone, cost=cost, remaining_points=user.points)
try:
prompt = data.get('prompt')
model = model_value
ratio = data.get('ratio')
size = data.get('size')
input_img_urls = data.get('image_urls', [])
prompt = data.get('prompt')
ratio = data.get('ratio')
size = data.get('size')
image_data = data.get('image_data', [])
payload = {
"prompt": prompt,
"model": model_value,
"response_format": "url",
"aspect_ratio": ratio
}
if image_data:
payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data]
payload = {
"prompt": prompt,
"model": model,
"response_format": "url",
"aspect_ratio": ratio
}
if input_img_urls:
payload["image"] = input_img_urls
if model == "nano-banana-2" and 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
if model_value == "nano-banana-2" and size:
payload["image_size"] = size
# 如果是聊天模型,直接同步处理
if is_chat_model:
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
resp = requests.post(target_api, json=payload, headers=headers, timeout=300)
chat_payload = {
"model": model_value,
"messages": [{"role": "user", "content": prompt}]
}
resp = requests.post(Config.CHAT_API, json=chat_payload, headers=headers, timeout=120)
if resp.status_code != 200:
# API 报错,退还积分
if use_trial:
user.points += cost
db.session.commit()
system_logger.warning(f"API 报错,积分已退还", phone=user.phone, status_code=resp.status_code)
return jsonify({"error": resp.text}), resp.status_code
api_result = resp.json()
content = api_result['choices'][0]['message']['content']
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', [])]
# 记录聊天历史
if prompt != "解读验光单":
new_record = GenerationRecord(
user_id=user_id,
prompt=prompt,
model=model_value,
image_urls=json.dumps([{"type": "text", "content": content}])
)
db.session.add(new_record)
db.session.commit()
# 立即写入数据库(先存原始 URL
new_record = GenerationRecord(
user_id=session.get('user_id'),
prompt=prompt,
model=model,
image_urls=json.dumps(raw_urls)
)
db.session.add(new_record)
db.session.commit()
# 写入系统日志
system_logger.info(
f"用户生成图片成功",
phone=user.phone,
model=model,
record_id=new_record.id
)
# 启动后台线程同步图片,不阻塞前端返回
app = current_app._get_current_object()
threading.Thread(
target=sync_images_background,
args=(app, new_record.id, raw_urls)
).start()
# 立即返回原始 URL 给前端展示
return jsonify({
"data": [{"url": url} for url in raw_urls],
"message": "生成成功!作品正在后台同步至云存储。"
"data": [{"content": content, "type": "text"}],
"message": "生成成功!"
})
except Exception as e:
# 发生系统异常,退还积分
if use_trial:
user.points += cost
db.session.commit()
system_logger.error(f"生成异常,积分已退还", phone=user.phone, error=str(e))
return jsonify({"error": str(e)}), 500
# --- 异步处理生图任务 ---
task_id = str(uuid.uuid4())
app = current_app._get_current_object()
threading.Thread(
target=process_image_generation,
args=(app, user_id, task_id, payload, api_key, target_api, cost)
).start()
return jsonify({
"task_id": task_id,
"message": "已开启异步生成任务"
})
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@ -1,26 +0,0 @@
// 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 "";
}
}
};

View File

@ -134,3 +134,35 @@
[2026-01-14 20:12:31] INFO - 用户登录成功
[2026-01-14 20:17:10] INFO - 用户登录尝试
[2026-01-14 20:17:10] INFO - 用户登录成功
[2026-01-15 20:17:15] INFO - 用户登录尝试
[2026-01-15 20:17:15] WARNING - 登录失败: 手机号或密码错误
[2026-01-15 20:17:17] INFO - 用户登录尝试
[2026-01-15 20:17:17] WARNING - 登录失败: 手机号或密码错误
[2026-01-15 20:17:21] INFO - 用户登录尝试
[2026-01-15 20:17:22] WARNING - 登录失败: 手机号或密码错误
[2026-01-15 20:17:32] INFO - 用户登录尝试
[2026-01-15 20:17:32] INFO - 用户登录成功
[2026-01-15 20:22:42] INFO - 用户生成图片成功
[2026-01-15 20:31:31] INFO - 用户生成图片成功
[2026-01-15 20:45:22] INFO - 用户生成图片成功
[2026-01-15 20:49:12] INFO - 用户生成图片成功
[2026-01-15 20:50:53] INFO - 用户生成图片成功
[2026-01-15 20:52:35] INFO - 用户生成图片成功
[2026-01-15 20:52:35] INFO - 用户生成图片成功
[2026-01-15 20:52:37] INFO - 用户生成图片成功
[2026-01-15 20:52:37] INFO - 用户生成图片成功
[2026-01-15 20:56:42] INFO - 用户生成图片成功
[2026-01-15 20:57:31] INFO - 用户生成图片成功
[2026-01-15 20:59:14] INFO - 用户生成图片成功
[2026-01-15 21:00:00] INFO - 用户生成图片成功
[2026-01-15 21:01:05] INFO - 用户生成图片成功
[2026-01-15 21:01:05] INFO - 用户生成图片成功
[2026-01-15 21:01:05] INFO - 用户生成图片成功
[2026-01-15 21:05:31] INFO - 用户生成图片成功
[2026-01-15 21:05:31] INFO - 用户生成图片成功
[2026-01-15 21:05:31] INFO - 用户生成图片成功
[2026-01-15 21:05:31] INFO - 用户生成图片成功
[2026-01-15 21:08:09] INFO - 积分预扣除 (普通试用)
[2026-01-15 21:09:55] INFO - 用户生成图片成功 (Base64模式)
[2026-01-15 21:13:38] INFO - 积分预扣除 (普通试用)
[2026-01-15 21:13:59] INFO - 用户生成图片成功 (上传:Base64 / 接收:URL)

415
main.js
View File

@ -1,415 +0,0 @@
// 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);
}
});

View File

@ -1,103 +0,0 @@
// 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度防蓝光眼镜既能阻隔辐射保护这么好的视力平时戴着也很好看呢"
* **场景 CPD (瞳距) 缺失**
* **触发条件**手写区无 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" }
}))
];
}
};

View File

@ -56,6 +56,7 @@ let isHistoryLoading = false;
let currentGeneratedUrls = [];
let currentMode = 'trial'; // 'trial' 或 'key'
let uploadedFiles = []; // 存储当前待上传的参考图
let isSetterActive = false; // 是否激活了拍摄角度设置器模式
function switchMode(mode) {
currentMode = mode;
@ -180,6 +181,12 @@ async function init() {
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
const historyList = document.getElementById('historyList');
// 3D 构图辅助控制
const openVisualizerBtn = document.getElementById('openVisualizerBtn');
const closeVisualizerBtn = document.getElementById('closeVisualizerBtn');
if(openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
if(closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
if(showHistoryBtn) {
showHistoryBtn.onclick = () => {
historyDrawer.classList.remove('translate-x-full');
@ -339,14 +346,22 @@ 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));
// 如果处于设置器模式,严格限制为 1 张
if (isSetterActive) {
if (newFiles.length > 0) {
uploadedFiles = [newFiles[0]];
showToast('设置器模式已开启,仅保留第一张参考图', 'info');
}
} else {
uploadedFiles = uploadedFiles.concat(newFiles);
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();
}
@ -448,16 +463,18 @@ document.getElementById('submitBtn').onclick = async () => {
currentGeneratedUrls = []; // 重置当前生成列表
try {
let image_urls = [];
let image_data = [];
// 1. 如果有图则先上传
// 1. 将图片转换为 Base64
if (uploadedFiles.length > 0) {
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;
btnText.innerText = "正在准备图片数据...";
const readFileAsBase64 = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
image_data = await Promise.all(uploadedFiles.map(f => readFileAsBase64(f)));
}
// 2. 并行启动多个生成任务
@ -467,10 +484,11 @@ document.getElementById('submitBtn').onclick = async () => {
const startTask = async (index) => {
const slot = document.createElement('div');
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-[10px] font-bold italic">AI 构思中...</div>`;
slot.innerHTML = `<div class="text-slate-400 text-[10px] font-bold italic">正在排队中...</div>`;
grid.appendChild(slot);
try {
// 1. 发起生成请求,获取任务 ID
const r = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -482,17 +500,64 @@ document.getElementById('submitBtn').onclick = async () => {
model: document.getElementById('modelSelect').value,
ratio: document.getElementById('ratioSelect').value,
size: document.getElementById('sizeSelect').value,
image_urls
image_data // 发送 Base64 数组
})
});
const res = await r.json();
if(res.error) throw new Error(res.error);
if(res.message) showToast(res.message, 'success');
// 如果直接返回了 data (比如聊天模型),直接显示
if (res.data) {
displayResult(slot, res.data[0]);
return;
}
const imgUrl = res.data[0].url;
currentGeneratedUrls.push(imgUrl);
// 2. 轮询任务状态
const taskId = res.task_id;
let pollCount = 0;
const maxPolls = 500; // 最多轮询约 16 分钟 (2s * 500 = 1000s)
while (pollCount < maxPolls) {
await new Promise(resolve => setTimeout(resolve, 2000));
pollCount++;
const statusR = await fetch(`/api/task_status/${taskId}`);
const statusRes = await statusR.json();
if (statusRes.status === 'complete') {
const imgUrl = statusRes.urls[0];
currentGeneratedUrls.push(imgUrl);
displayResult(slot, { url: imgUrl });
finishedCount++;
btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`;
if(currentMode === 'trial') checkAuth();
return; // 任务正常结束
} else if (statusRes.status === 'error') {
throw new Error(statusRes.message || "生成失败");
} else {
// 继续轮询状态显示
slot.innerHTML = `<div class="text-slate-400 text-[10px] font-bold italic">AI 正在努力创作中 (${pollCount * 2}s)...</div>`;
}
}
throw new Error("生成超时,请稍后在历史记录中查看");
} catch (e) {
slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6';
if(e.message.includes('401') || e.message.includes('请先登录')) {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">登录已过期,请重新登录</div>`;
} else {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">生成异常: ${e.message}</div>`;
}
}
};
// 提取结果展示逻辑
const displayResult = (slot, data) => {
if (data.type === 'text') {
slot.className = 'image-frame relative bg-white border border-slate-100 p-8 rounded-[2.5rem] shadow-xl overflow-y-auto max-h-[600px]';
slot.innerHTML = `<div class="prose prose-slate prose-sm max-w-none text-slate-600 font-medium leading-relaxed">${data.content.replace(/\n/g, '<br>')}</div>`;
} else {
const imgUrl = data.url;
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 = `
<div class="w-full h-full flex items-center justify-center bg-slate-50/20 p-2">
@ -504,20 +569,8 @@ document.getElementById('submitBtn').onclick = async () => {
</button>
</div>
`;
lucide.createIcons();
} catch (e) {
slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6';
if(e.message.includes('401') || e.message.includes('请先登录')) {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">登录已过期,请重新登录</div>`;
} else {
slot.innerHTML = `<div class="text-red-400 text-[10px] font-bold text-center">渲染异常: ${e.message}</div>`;
}
} finally {
finishedCount++;
btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`;
// 每次生成任务结束后,刷新一次积分显示
if(currentMode === 'trial') checkAuth();
}
lucide.createIcons();
};
const tasks = Array.from({ length: num }, (_, i) => startTask(i));
@ -585,3 +638,79 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => {
showToast('网络连接失败', 'error');
}
});
// 拍摄角度设置器弹窗控制
function openVisualizerModal() {
const modal = document.getElementById('visualizerModal');
if(!modal) return;
// 检查是否已上传参考图
if (uploadedFiles.length === 0) {
return showToast('请先上传一张参考图作为基准', 'warning');
}
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
// 将主页面的图片同步到设置器 iframe
const reader = new FileReader();
reader.onload = (e) => {
const iframe = document.getElementById('visualizerFrame');
iframe.contentWindow.postMessage({ type: 'sync_image', dataUrl: e.target.result }, '*');
};
reader.readAsDataURL(uploadedFiles[0]);
}, 10);
}
function closeVisualizerModal() {
const modal = document.getElementById('visualizerModal');
if(!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => {
modal.classList.add('hidden');
}, 300);
}
// 监听来自设置器的消息
window.addEventListener('message', (e) => {
if (e.data.type === 'apply_prompt') {
isSetterActive = true;
const area = document.getElementById('manualPrompt');
const promptTpl = document.getElementById('promptTpl');
const modelSelect = document.getElementById('modelSelect');
const sizeGroup = document.getElementById('sizeGroup');
// 1. 强制切换并锁定版本 2.0 (nano-banana-2)
if (modelSelect) {
modelSelect.value = 'nano-banana-2';
modelSelect.disabled = true; // 锁定选择
sizeGroup.classList.remove('hidden');
updateCostPreview();
}
// 2. 隐藏模板选择器
if (promptTpl) {
promptTpl.classList.add('hidden');
promptTpl.value = 'manual';
}
// 3. 强制图片数量为 1
if (uploadedFiles.length > 1) {
uploadedFiles = uploadedFiles.slice(0, 1);
renderImagePreviews();
}
// 4. 替换提示词
if (area) {
area.value = e.data.prompt;
area.classList.remove('hidden');
showToast('已同步拍摄角度并切换至 2.0 引擎', 'success');
}
closeVisualizerModal();
}
});

View File

@ -51,9 +51,15 @@
</section>
<section class="space-y-4">
<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 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">02</span>
<h3 class="text-sm font-bold text-slate-800">渲染设定</h3>
</div>
<button id="openVisualizerBtn" class="text-[10px] font-bold text-indigo-500 hover:text-indigo-600 flex items-center gap-1 transition-colors">
<i data-lucide="video" class="w-3.5 h-3.5"></i>
拍摄角度设置器
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
@ -79,7 +85,7 @@
<select id="promptTpl" class="w-full bg-slate-50 border-b border-slate-100 p-3 text-[10px] font-bold text-indigo-600 outline-none cursor-pointer">
<option value="manual">✨ 自定义创作</option>
</select>
<textarea id="manualPrompt" rows="2" class="w-full p-3 text-xs outline-none resize-none leading-relaxed" placeholder="描述您的需求..."></textarea>
<textarea id="manualPrompt" rows="4" class="w-full p-3 text-xs outline-none resize-none leading-relaxed" placeholder="描述您的需求..."></textarea>
</div>
</section>
@ -226,6 +232,29 @@
</form>
</div>
</div>
<!-- 拍摄角度设置器弹窗 -->
<div id="visualizerModal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div class="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[85vh]">
<div class="p-6 border-b border-slate-100 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-indigo-50 text-indigo-600 rounded-xl flex items-center justify-center">
<i data-lucide="video" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-lg font-black text-slate-900">拍摄角度设置器</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">相对于参考图设置相机移动路径</p>
</div>
</div>
<button id="closeVisualizerBtn" class="w-10 h-10 rounded-xl hover:bg-slate-100 flex items-center justify-center text-slate-400 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="flex-1 overflow-hidden relative">
<iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}

460
templates/kongzhiqi.html Normal file
View File

@ -0,0 +1,460 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 3D Camera Visualizer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.158.0/three.min.js"></script>
<style>
:root {
--bg-body: #f8fafc;
--bg-card: #ffffff;
--primary: #4f46e5;
--primary-hover: #4338ca;
--text-main: #1e293b;
--text-dim: #64748b;
--border: #e2e8f0;
--accent-cyan: #06b6d4;
--accent-pink: #d946ef;
--accent-orange: #f59e0b;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: transparent;
color: var(--text-main);
display: flex;
justify-content: center;
align-items: flex-start;
padding: 0;
overflow-x: hidden;
}
.container {
width: 100%;
max-width: 100%;
background: transparent;
padding: 20px;
box-sizing: border-box;
}
header h2 {
margin: 0 0 4px 0;
font-size: 1rem;
font-weight: 800;
color: #0f172a;
}
.legend {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
/* 3D 预览区 */
#viewport {
width: 100%;
height: 280px;
background: #f1f5f9;
border-radius: 24px;
position: relative;
overflow: hidden;
border: 1px solid var(--border);
margin-bottom: 24px;
}
.prompt-overlay {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
padding: 8px 12px;
border-radius: 12px;
color: var(--primary);
font-weight: 700;
font-size: 10px;
text-align: center;
pointer-events: none;
border: 1px solid rgba(79, 70, 229, 0.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
}
/* 控制器样式 */
.controls {
display: flex;
flex-direction: column;
gap: 20px;
}
.control-group { position: relative; }
.label-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.tag {
font-size: 10px;
font-weight: 800;
padding: 2px 8px;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.val-display { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 700; color: var(--text-main); }
input[type=range] {
width: 100%;
height: 4px;
background: #e2e8f0;
border-radius: 10px;
outline: none;
-webkit-appearance: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: white;
border: 2px solid var(--primary);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.1s;
}
input[type=range]:active::-webkit-slider-thumb { transform: scale(1.2); }
.range-labels {
display: flex;
justify-content: space-between;
font-size: 9px;
font-weight: 700;
color: #94a3b8;
margin-top: 6px;
text-transform: uppercase;
}
.generate-btn {
background: var(--primary);
color: white;
border: none;
padding: 16px;
width: 100%;
border-radius: 16px;
font-size: 14px;
font-weight: 800;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.2);
margin-top: 10px;
}
.generate-btn:hover { background: var(--primary-hover); transform: translateY(-1px); }
.generate-btn:active { transform: translateY(0); }
/* 输出结果 (简化) */
.output-section {
margin-top: 24px;
background: #f8fafc;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--border);
display: none; /* 简洁考虑,默认隐藏预览 */
}
/* 颜色类 */
.bg-cyan { background: #ecfeff; color: #0891b2; border: 1px solid #cffafe; }
.bg-pink { background: #fdf2f8; color: #db2777; border: 1px solid #fce7f3; }
.bg-orange { background: #fffbeb; color: #d97706; border: 1px solid #fef3c7; }
.dot-cyan { background: var(--accent-cyan); }
.dot-pink { background: var(--accent-pink); }
.dot-orange { background: var(--accent-orange); }
.file-input-wrapper { margin-bottom: 20px; text-align: left; }
.file-label {
font-size: 11px;
font-weight: 800;
color: var(--primary);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
background: #f5f3ff;
padding: 6px 12px;
border-radius: 8px;
transition: all 0.2s;
}
.file-label:hover { background: #ede9fe; }
</style>
</head>
<body>
<div class="container">
<div class="file-input-wrapper">
<label class="file-label" for="fileInput">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
更换参考图
</label>
<input type="file" id="fileInput" hidden accept="image/*">
</div>
<div id="viewport">
<div class="prompt-overlay" id="overlayPrompt">准备就绪</div>
</div>
<div class="controls">
<!-- Azimuth -->
<div class="control-group">
<div class="label-row">
<span class="tag bg-cyan">Azimuth (水平)</span>
<span class="val-display" id="val-azimuth"></span>
</div>
<input type="range" id="slider-azimuth" min="0" max="315" step="45" value="0">
<div class="range-labels">
<span>正前</span><span>右侧</span><span>正后</span><span>左侧</span>
</div>
</div>
<!-- Elevation -->
<div class="control-group">
<div class="label-row">
<span class="tag bg-pink">Elevation (高度)</span>
<span class="val-display" id="val-elevation"></span>
</div>
<input type="range" id="slider-elevation" min="-90" max="90" step="5" value="0">
<div class="range-labels">
<span>仰拍</span><span>平视</span><span>俯拍</span>
</div>
</div>
<!-- Distance -->
<div class="control-group">
<div class="label-row">
<span class="tag bg-orange">Distance (距离)</span>
<span class="val-display" id="val-distance">1.0</span>
</div>
<input type="range" id="slider-distance" min="2" max="25" step="1" value="10">
<div class="range-labels">
<span>特写</span><span>标准</span><span>远景</span>
</div>
</div>
<button class="generate-btn" id="applyBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7"/></svg>
确认拍摄角度
</button>
</div>
<div class="output-section">
<div class="output-content" id="finalPrompt">...</div>
</div>
</div>
<script>
// --- 1. Three.js 初始化 ---
const viewport = document.getElementById('viewport');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, viewport.clientWidth / viewport.clientHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(viewport.clientWidth, viewport.clientHeight);
renderer.setClearColor(0xf1f5f9, 1); // 设置背景色与 viewport 一致
viewport.appendChild(renderer.domElement);
camera.position.set(6, 5, 8);
camera.lookAt(0, 0.5, 0);
scene.add(new THREE.AmbientLight(0xffffff, 1.2));
const grid = new THREE.GridHelper(10, 20, 0xccd6e0, 0xe2e8f0);
scene.add(grid);
// --- 2. 场景物体 ---
// 参考图 Plane
const targetGeom = new THREE.PlaneGeometry(1.5, 2);
const targetMat = new THREE.MeshBasicMaterial({ color: 0x22222a, side: THREE.DoubleSide });
const targetPlane = new THREE.Mesh(targetGeom, targetMat);
targetPlane.position.y = 1;
scene.add(targetPlane);
// 相机小模型
const camGroup = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.35, 0.4), new THREE.MeshStandardMaterial({color: 0x3b82f6}));
const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.2), new THREE.MeshStandardMaterial({color: 0x111111}));
lens.rotation.x = Math.PI / 2;
lens.position.z = -0.25;
camGroup.add(body, lens);
scene.add(camGroup);
// 轨道可视化
const aziRing = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(new THREE.EllipseCurve(0,0, 3,3).getPoints(64)),
new THREE.LineBasicMaterial({ color: 0x2dd4bf })
);
aziRing.rotation.x = Math.PI/2;
scene.add(aziRing);
let elevArc;
const distLine = new THREE.Line(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xfbbf24 }));
scene.add(distLine);
// --- 3. 逻辑控制 ---
const sAzi = document.getElementById('slider-azimuth');
const sElev = document.getElementById('slider-elevation');
const sDist = document.getElementById('slider-distance');
function update() {
const azi = parseInt(sAzi.value);
const elev = parseInt(sElev.value);
const distVal = parseInt(sDist.value) / 10;
document.getElementById('val-azimuth').innerText = azi + '°';
document.getElementById('val-elevation').innerText = elev + '°';
document.getElementById('val-distance').innerText = distVal.toFixed(1);
// 计算坐标
const visualR = distVal * 3;
const phi = (90 - elev) * (Math.PI / 180);
const theta = (azi) * (Math.PI / 180);
const x = visualR * Math.sin(phi) * Math.sin(theta);
const y = visualR * Math.cos(phi);
const z = visualR * Math.sin(phi) * Math.cos(theta);
camGroup.position.set(x, y, z);
camGroup.lookAt(0, 1, 0);
// 更新距离线
distLine.geometry.setFromPoints([new THREE.Vector3(0,0,0), new THREE.Vector3(x,y,z)]);
// 动态绘制高度弧线
if(elevArc) scene.remove(elevArc);
const arcPoints = [];
for(let i=-30; i<=90; i+=5){
const p = (90-i)*(Math.PI/180);
arcPoints.push(new THREE.Vector3(visualR*Math.sin(p)*Math.sin(theta), visualR*Math.cos(p), visualR*Math.sin(p)*Math.cos(theta)));
}
elevArc = new THREE.Line(new THREE.BufferGeometry().setFromPoints(arcPoints), new THREE.LineBasicMaterial({color: 0xe879f9}));
scene.add(elevArc);
renderPrompt(azi, elev, distVal);
}
function getAziDesc(a) {
if (a === 0) return "no horizontal rotation";
if (a > 0 && a < 180) return `rotate ${a}° clockwise to the right`;
if (a === 180) return "rotate 180° to the opposite side";
if (a > 180) return `rotate ${360 - a}° counter-clockwise to the left`;
return "angled view";
}
function renderPrompt(azi, elev, dist) {
// 1. 生成最纯粹的相对位移指令
let aziCmd = azi === 0 ? "no horizontal rotation" : `rotate camera ${azi}° clockwise around the subject`;
if (azi > 180) {
aziCmd = `rotate camera ${360 - azi}° counter-clockwise to the left`;
}
const elevCmd = elev === 0 ? "maintain height" : (elev > 0 ? `tilt camera up ${elev}°` : `tilt camera down ${Math.abs(elev)}°`);
// 2. 核心视角标签 (用于辅助定位)
let viewTag = "front view";
if (azi === 45) viewTag = "front-right 3/4 view";
else if (azi === 90) viewTag = "right side view";
else if (azi === 135) viewTag = "back-right view";
else if (azi === 180) viewTag = "back view";
else if (azi === 225) viewTag = "back-left view";
else if (azi === 270) viewTag = "left side view";
else if (azi === 315) viewTag = "front-left 3/4 view";
let elevTag = "eye-level shot";
if (elev >= 75) elevTag = "top-down view";
else if (elev > 20) elevTag = "high angle shot";
else if (elev < -75) elevTag = "bottom-up view";
else if (elev < -20) elevTag = "low angle shot";
let distTag = "medium shot";
if (dist <= 0.4) distTag = "close-up shot";
else if (dist >= 1.6) distTag = "wide shot";
// 3. 构建提示词块
// 格式:[机位移动指令]. <sks> [目标视角标签], level horizon.
const positive = `Camera adjustment from reference: ${aziCmd} and ${elevCmd}. <sks> ${viewTag}, ${elevTag}, ${distTag}, level horizon`;
const visualizer = `<Visualizer> Azimuth:${azi}| Elevation:${elev} | Distance:${dist.toFixed(1)}`;
const negative = `[Negative Prompt]: tilted, rotated, distorted viewpoint, inconsistent angle`;
const fullPrompt = `${positive}
${visualizer}
${negative}`;
document.getElementById('overlayPrompt').innerText = positive;
document.getElementById('finalPrompt').innerText = fullPrompt;
}
// 图片上传
document.getElementById('fileInput').onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => {
applyTexture(ev.target.result);
};
reader.readAsDataURL(file);
}
};
function applyTexture(dataUrl) {
new THREE.TextureLoader().load(dataUrl, (tex) => {
targetPlane.material = new THREE.MeshBasicMaterial({ map: tex, transparent: true });
});
}
// 监听来自主页面的图片同步消息
window.addEventListener('message', (e) => {
if (e.data.type === 'sync_image' && e.data.dataUrl) {
applyTexture(e.data.dataUrl);
}
});
[sAzi, sElev, sDist].forEach(s => s.addEventListener('input', update));
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
// 应用到主窗口
document.getElementById('applyBtn').onclick = () => {
const prompt = document.getElementById('finalPrompt').innerText;
window.parent.postMessage({ type: 'apply_prompt', prompt: prompt }, '*');
// 提示用户已应用
const btn = document.getElementById('applyBtn');
const oldContent = btn.innerHTML;
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg> 已应用同步';
btn.style.background = '#10b981';
btn.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.2)';
setTimeout(() => {
btn.innerHTML = oldContent;
btn.style.background = '';
btn.style.boxShadow = '';
}, 1500);
};
update();
animate();
window.onresize = () => {
camera.aspect = viewport.clientWidth / viewport.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(viewport.clientWidth, viewport.clientHeight);
};
</script>
</body>
</html>