feat(api): 实现图片生成异步任务与任务状态查询接口
- 新增异步图片生成处理函数,支持后台任务执行及积分退还机制 - 实现任务状态查询接口,支持前端实时获取生成进度和结果 - 优化生成逻辑:根据模型类型分流,聊天模型同步调用,图片模型异步执行 - 调整积分预扣除和退还逻辑,保障用户积分安全 - 后台线程同步图片至私有存储,提升响应性能和用户体验 - 新增 /visualizer 路由对应前端控制器页面,辅助3D构图和拍摄角度设置 - 优化前端上传逻辑,新增设置器模式时单图上传限制 - 移除项目中未使用的前端脚本与配置文件,简化代码库维护
This commit is contained in:
parent
925da47118
commit
a47b84e009
4
app.py
4
app.py
@ -33,6 +33,10 @@ def create_app():
|
|||||||
def ocr():
|
def ocr():
|
||||||
return render_template('ocr.html')
|
return render_template('ocr.html')
|
||||||
|
|
||||||
|
@app.route('/visualizer')
|
||||||
|
def visualizer():
|
||||||
|
return render_template('kongzhiqi.html')
|
||||||
|
|
||||||
# 自动创建数据库表
|
# 自动创建数据库表
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
print("🔧 正在检查并创建数据库表...")
|
print("🔧 正在检查并创建数据库表...")
|
||||||
|
|||||||
Binary file not shown.
@ -93,6 +93,63 @@ def sync_images_background(app, record_id, raw_urls):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ 更新记录失败: {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')
|
@api_bp.route('/api/config')
|
||||||
def get_config():
|
def get_config():
|
||||||
"""从本地数据库字典获取配置"""
|
"""从本地数据库字典获取配置"""
|
||||||
@ -157,197 +214,99 @@ def generate():
|
|||||||
use_trial = False
|
use_trial = False
|
||||||
|
|
||||||
if mode == 'key':
|
if mode == 'key':
|
||||||
# 自定义 Key 模式:优先使用本次输入的,否则使用数据库存的
|
|
||||||
api_key = input_key or user.api_key
|
api_key = input_key or user.api_key
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return jsonify({"error": "请先输入您的 API 密钥"}), 400
|
return jsonify({"error": "请先输入您的 API 密钥"}), 400
|
||||||
else:
|
else:
|
||||||
# 积分/试用模式
|
|
||||||
if user.points > 0:
|
if user.points > 0:
|
||||||
# 核心修复:优质模式使用专属 Key
|
|
||||||
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
|
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
|
||||||
target_api = Config.TRIAL_API
|
target_api = Config.TRIAL_API
|
||||||
use_trial = True
|
use_trial = True
|
||||||
else:
|
else:
|
||||||
return jsonify({"error": "可用积分已耗尽,请充值或切换至自定义 Key 模式"}), 400
|
return jsonify({"error": "可用积分已耗尽,请充值或切换至自定义 Key 模式"}), 400
|
||||||
|
|
||||||
# 如果是 Key 模式且输入了新 Key,则自动更新到数据库保存
|
|
||||||
if mode == 'key' and input_key and input_key != user.api_key:
|
if mode == 'key' and input_key and input_key != user.api_key:
|
||||||
user.api_key = input_key
|
user.api_key = input_key
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# 获取模型及对应的消耗积分
|
|
||||||
model_value = data.get('model')
|
model_value = data.get('model')
|
||||||
is_chat_model = "gemini" in model_value.lower() or "gpt" in model_value.lower()
|
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
|
||||||
|
|
||||||
# 核心修复:优质模式积分消耗 X2
|
|
||||||
if use_trial and is_premium:
|
if use_trial and is_premium:
|
||||||
cost *= 2
|
cost *= 2
|
||||||
|
|
||||||
# --- 积分预扣除逻辑 (点击即扣) ---
|
|
||||||
if use_trial:
|
if use_trial:
|
||||||
if user.points < cost:
|
if user.points < cost:
|
||||||
return jsonify({"error": f"可用积分不足,优质模式需要 {cost} 积分,您当前剩余 {user.points} 积分"}), 400
|
return jsonify({"error": f"可用积分不足"}), 400
|
||||||
|
|
||||||
user.points -= cost
|
user.points -= cost
|
||||||
db.session.commit()
|
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')
|
||||||
prompt = data.get('prompt')
|
ratio = data.get('ratio')
|
||||||
model = model_value
|
size = data.get('size')
|
||||||
ratio = data.get('ratio')
|
image_data = data.get('image_data', [])
|
||||||
size = data.get('size')
|
|
||||||
input_img_urls = data.get('image_urls', [])
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model_value,
|
||||||
"response_format": "url",
|
"response_format": "url",
|
||||||
"aspect_ratio": ratio
|
"aspect_ratio": ratio
|
||||||
}
|
}
|
||||||
if input_img_urls:
|
if image_data:
|
||||||
payload["image"] = input_img_urls
|
payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data]
|
||||||
|
|
||||||
if model == "nano-banana-2" and size:
|
if model_value == "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
|
|
||||||
|
|
||||||
|
# 如果是聊天模型,直接同步处理
|
||||||
|
if is_chat_model:
|
||||||
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)
|
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:
|
if resp.status_code != 200:
|
||||||
# API 报错,退还积分
|
|
||||||
if use_trial:
|
if use_trial:
|
||||||
user.points += cost
|
user.points += cost
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
system_logger.warning(f"API 报错,积分已退还", phone=user.phone, status_code=resp.status_code)
|
|
||||||
return jsonify({"error": resp.text}), resp.status_code
|
return jsonify({"error": resp.text}), resp.status_code
|
||||||
|
|
||||||
api_result = resp.json()
|
api_result = resp.json()
|
||||||
|
content = api_result['choices'][0]['message']['content']
|
||||||
|
|
||||||
if is_chat_model:
|
# 记录聊天历史
|
||||||
# 聊天模型返回的是文本
|
if prompt != "解读验光单":
|
||||||
content = api_result['choices'][0]['message']['content']
|
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()
|
||||||
|
|
||||||
# 核心修复:如果是验光单解读(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', [])]
|
|
||||||
|
|
||||||
# 立即写入数据库(先存原始 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({
|
return jsonify({
|
||||||
"data": [{"url": url} for url in raw_urls],
|
"data": [{"content": content, "type": "text"}],
|
||||||
"message": "生成成功!作品正在后台同步至云存储。"
|
"message": "生成成功!"
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
# --- 异步处理生图任务 ---
|
||||||
# 发生系统异常,退还积分
|
task_id = str(uuid.uuid4())
|
||||||
if use_trial:
|
app = current_app._get_current_object()
|
||||||
user.points += cost
|
|
||||||
db.session.commit()
|
threading.Thread(
|
||||||
system_logger.error(f"生成异常,积分已退还", phone=user.phone, error=str(e))
|
target=process_image_generation,
|
||||||
return jsonify({"error": str(e)}), 500
|
args=(app, user_id, task_id, payload, api_key, target_api, cost)
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"task_id": task_id,
|
||||||
|
"message": "已开启异步生成任务"
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|||||||
26
config.js
26
config.js
@ -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 "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -134,3 +134,35 @@
|
|||||||
[2026-01-14 20:12:31] INFO - 用户登录成功
|
[2026-01-14 20:12:31] INFO - 用户登录成功
|
||||||
[2026-01-14 20:17:10] INFO - 用户登录尝试
|
[2026-01-14 20:17:10] 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
415
main.js
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
103
prompts.js
103
prompts.js
@ -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度防蓝光眼镜】,既能阻隔辐射保护这么好的视力,平时戴着也很好看呢!"
|
|
||||||
|
|
||||||
* **场景 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" }
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -56,6 +56,7 @@ let isHistoryLoading = false;
|
|||||||
let currentGeneratedUrls = [];
|
let currentGeneratedUrls = [];
|
||||||
let currentMode = 'trial'; // 'trial' 或 'key'
|
let currentMode = 'trial'; // 'trial' 或 'key'
|
||||||
let uploadedFiles = []; // 存储当前待上传的参考图
|
let uploadedFiles = []; // 存储当前待上传的参考图
|
||||||
|
let isSetterActive = false; // 是否激活了拍摄角度设置器模式
|
||||||
|
|
||||||
function switchMode(mode) {
|
function switchMode(mode) {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
@ -180,6 +181,12 @@ async function init() {
|
|||||||
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
const closeHistoryBtn = document.getElementById('closeHistoryBtn');
|
||||||
const historyList = document.getElementById('historyList');
|
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) {
|
if(showHistoryBtn) {
|
||||||
showHistoryBtn.onclick = () => {
|
showHistoryBtn.onclick = () => {
|
||||||
historyDrawer.classList.remove('translate-x-full');
|
historyDrawer.classList.remove('translate-x-full');
|
||||||
@ -339,14 +346,22 @@ function handleNewFiles(files) {
|
|||||||
const newFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
|
const newFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
|
||||||
if (newFiles.length === 0) return;
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
if (uploadedFiles.length + newFiles.length > 3) {
|
// 如果处于设置器模式,严格限制为 1 张
|
||||||
showToast('最多只能上传 3 张参考图', 'warning');
|
if (isSetterActive) {
|
||||||
const remaining = 3 - uploadedFiles.length;
|
if (newFiles.length > 0) {
|
||||||
if (remaining > 0) {
|
uploadedFiles = [newFiles[0]];
|
||||||
uploadedFiles = uploadedFiles.concat(newFiles.slice(0, remaining));
|
showToast('设置器模式已开启,仅保留第一张参考图', 'info');
|
||||||
}
|
}
|
||||||
} else {
|
} 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();
|
renderImagePreviews();
|
||||||
}
|
}
|
||||||
@ -448,16 +463,18 @@ document.getElementById('submitBtn').onclick = async () => {
|
|||||||
currentGeneratedUrls = []; // 重置当前生成列表
|
currentGeneratedUrls = []; // 重置当前生成列表
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let image_urls = [];
|
let image_data = [];
|
||||||
|
|
||||||
// 1. 如果有图则先上传
|
// 1. 将图片转换为 Base64
|
||||||
if (uploadedFiles.length > 0) {
|
if (uploadedFiles.length > 0) {
|
||||||
const uploadData = new FormData();
|
btnText.innerText = "正在准备图片数据...";
|
||||||
for(let f of uploadedFiles) uploadData.append('images', f);
|
const readFileAsBase64 = (file) => new Promise((resolve, reject) => {
|
||||||
const upR = await fetch('/api/upload', { method: 'POST', body: uploadData });
|
const reader = new FileReader();
|
||||||
const upRes = await upR.json();
|
reader.onload = () => resolve(reader.result);
|
||||||
if(upRes.error) throw new Error(upRes.error);
|
reader.onerror = reject;
|
||||||
image_urls = upRes.urls;
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
image_data = await Promise.all(uploadedFiles.map(f => readFileAsBase64(f)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 并行启动多个生成任务
|
// 2. 并行启动多个生成任务
|
||||||
@ -467,10 +484,11 @@ 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-[200px] 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-[10px] font-bold italic">AI 构思中...</div>`;
|
slot.innerHTML = `<div class="text-slate-400 text-[10px] font-bold italic">正在排队中...</div>`;
|
||||||
grid.appendChild(slot);
|
grid.appendChild(slot);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. 发起生成请求,获取任务 ID
|
||||||
const r = await fetch('/api/generate', {
|
const r = await fetch('/api/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -482,17 +500,64 @@ document.getElementById('submitBtn').onclick = async () => {
|
|||||||
model: document.getElementById('modelSelect').value,
|
model: document.getElementById('modelSelect').value,
|
||||||
ratio: document.getElementById('ratioSelect').value,
|
ratio: document.getElementById('ratioSelect').value,
|
||||||
size: document.getElementById('sizeSelect').value,
|
size: document.getElementById('sizeSelect').value,
|
||||||
image_urls
|
image_data // 发送 Base64 数组
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const res = await r.json();
|
const res = await r.json();
|
||||||
if(res.error) throw new Error(res.error);
|
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;
|
// 2. 轮询任务状态
|
||||||
currentGeneratedUrls.push(imgUrl);
|
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.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 = `
|
||||||
<div class="w-full h-full flex items-center justify-center bg-slate-50/20 p-2">
|
<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>
|
</button>
|
||||||
</div>
|
</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));
|
const tasks = Array.from({ length: num }, (_, i) => startTask(i));
|
||||||
@ -585,3 +638,79 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => {
|
|||||||
showToast('网络连接失败', 'error');
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -51,9 +51,15 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center justify-between">
|
||||||
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-sm font-bold text-slate-800">渲染设定</h3>
|
<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>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-1.5">
|
<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">
|
<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>
|
<option value="manual">✨ 自定义创作</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -226,6 +232,29 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|||||||
460
templates/kongzhiqi.html
Normal file
460
templates/kongzhiqi.html
Normal 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">0°</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">0°</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>
|
||||||
Loading…
Reference in New Issue
Block a user