feat: Implement a comprehensive user authentication system, add video generation capabilities, and set up database migrations and API blueprints.

This commit is contained in:
24024 2026-01-16 22:24:14 +08:00
parent a47b84e009
commit 1cc3d5e37a
46 changed files with 2789 additions and 846 deletions

33
.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesize
# Instance specific
instance/
.env
# PyCharm
.idea/
# VS Code
.vscode/

Binary file not shown.

Binary file not shown.

Binary file not shown.

49
app.py
View File

@ -1,6 +1,6 @@
from flask import Flask, render_template
from flask import Flask, render_template, jsonify
from config import Config
from extensions import db, redis_client
from extensions import db, redis_client, migrate
from blueprints.auth import auth_bp
from blueprints.api import api_bp
from blueprints.admin import admin_bp
@ -18,6 +18,7 @@ def create_app():
# 初始化扩展
db.init_app(app)
redis_client.init_app(app)
migrate.init_app(app, db)
# 注册蓝图
app.register_blueprint(auth_bp)
@ -25,6 +26,46 @@ def create_app():
app.register_blueprint(admin_bp)
app.register_blueprint(payment_bp)
from flask import g, session
from models import User, SystemLog
@app.before_request
def load_user():
"""在每个请求前加载用户信息到 g供日志系统使用"""
user_id = session.get('user_id')
if user_id:
g.user_id = user_id
g.user = db.session.get(User, user_id)
else:
g.user_id = None
g.user = None
@app.context_processor
def inject_menu():
"""将导航菜单注入所有模板,实现服务端渲染,解决闪烁问题"""
from blueprints.auth import get_user_menu
menu = get_user_menu(g.user) if hasattr(g, 'user') else []
return dict(nav_menu=menu)
@app.route('/api/system_logs')
def get_system_logs():
"""获取系统日志数据 (供后台管理界面使用)"""
# 这里可以加入权限检查
logs = SystemLog.query.order_by(SystemLog.created_at.desc()).limit(100).all()
return jsonify([{
'id': log.id,
'user_id': log.user_id,
'level': log.level,
'module': log.module,
'message': log.message,
'extra': log.extra,
'ip': log.ip,
'path': log.path,
'method': log.method,
'user_agent': log.user_agent,
'created_at': log.created_at.strftime('%Y-%m-%d %H:%M:%S')
} for log in logs])
@app.route('/')
def index():
return render_template('index.html')
@ -37,6 +78,10 @@ def create_app():
def visualizer():
return render_template('kongzhiqi.html')
@app.route('/video')
def video_page():
return render_template('video.html')
# 自动创建数据库表
with app.app_context():
print("🔧 正在检查并创建数据库表...")

View File

@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from extensions import db
from models import User, Role, Permission, SystemDict, SystemNotification, Order
from middlewares.auth import permission_required
from services.logger import system_logger
admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
@ -30,9 +31,11 @@ def save_role():
if not role: return jsonify({"error": "角色不存在"}), 404
role.name = data['name']
role.description = data.get('description')
system_logger.info(f"管理员修改角色: {role.name}")
else:
role = Role(name=data['name'], description=data.get('description'))
db.session.add(role)
system_logger.info(f"管理员创建角色: {role.name}")
if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
@ -49,8 +52,10 @@ def delete_role():
if role:
if role.name == '超级管理员':
return jsonify({"error": "不能删除超级管理员角色"}), 400
role_name = role.name
db.session.delete(role)
db.session.commit()
system_logger.info(f"管理员删除角色: {role_name}")
return jsonify({"message": "角色删除成功"})
return jsonify({"error": "角色不存在"}), 404
@ -80,15 +85,46 @@ def get_users():
@permission_required('manage_users')
def assign_role():
data = request.json
user = User.query.get(data['user_id'])
role = Role.query.get(data['role_id'])
user = db.session.get(User, data['user_id'])
role = db.session.get(Role, data['role_id'])
if user and role:
user.role = role
db.session.commit()
system_logger.info(f"管理员分配用户角色", user_phone=user.phone, role_name=role.name)
return jsonify({"message": "角色分配成功"})
return jsonify({"error": "用户或角色不存在"}), 404
# --- 字典管理 ---
@admin_bp.route('/dict_types', methods=['GET'])
@permission_required('manage_dicts')
def get_dict_types():
# 获取唯一的字典类型及其记录数
counts = dict(db.session.query(SystemDict.dict_type, db.func.count(SystemDict.id))\
.group_by(SystemDict.dict_type).all())
# 定义类型的友好名称 (标准类型)
standard_types = {
'ai_model': 'AI 生成模型',
'aspect_ratio': '画面比例配置',
'ai_image_size': '输出尺寸设定',
'prompt_tpl': '生图提示词模板',
'video_model': '视频生成模型',
'video_prompt': '视频提示词模板',
}
# 合并数据库中存在的其他类型
all_types = {**standard_types}
for t in counts.keys():
if t not in all_types:
all_types[t] = t # 未知类型直接使用 Key 作为名称
return jsonify({
"types": [{
"type": t,
"name": name,
"count": counts.get(t, 0)
} for t, name in all_types.items()]
})
@admin_bp.route('/dicts', methods=['GET'])
@permission_required('manage_dicts')
def get_dicts():
@ -118,9 +154,11 @@ def save_dict():
if dict_id:
d = SystemDict.query.get(dict_id)
if not d: return jsonify({"error": "记录不存在"}), 404
action = "修改"
else:
d = SystemDict()
db.session.add(d)
action = "创建"
d.dict_type = data['dict_type']
d.label = data['label']
@ -130,6 +168,7 @@ def save_dict():
d.sort_order = data.get('sort_order', 0)
db.session.commit()
system_logger.info(f"管理员{action}系统配置: {d.label}")
return jsonify({"message": "保存成功"})
@admin_bp.route('/dicts/delete', methods=['POST'])
@ -138,8 +177,10 @@ def delete_dict():
data = request.json
d = SystemDict.query.get(data.get('id'))
if d:
label = d.label
db.session.delete(d)
db.session.commit()
system_logger.info(f"管理员删除系统配置: {label}")
return jsonify({"message": "删除成功"})
return jsonify({"error": "记录不存在"}), 404
@ -167,15 +208,18 @@ def save_notification():
if notif_id:
n = SystemNotification.query.get(notif_id)
if not n: return jsonify({"error": "通知不存在"}), 404
action = "修改"
else:
n = SystemNotification()
db.session.add(n)
action = "发布"
n.title = data['title']
n.content = data['content']
n.is_active = data.get('is_active', True)
db.session.commit()
system_logger.info(f"管理员{action}通知: {n.title}")
return jsonify({"message": "通知保存成功"})
@admin_bp.route('/notifications/delete', methods=['POST'])
@ -184,8 +228,10 @@ def delete_notification():
data = request.json
n = SystemNotification.query.get(data.get('id'))
if n:
title = n.title
db.session.delete(n)
db.session.commit()
system_logger.info(f"管理员删除通知: {title}")
return jsonify({"message": "通知删除成功"})
return jsonify({"error": "通知不存在"}), 404

View File

@ -85,7 +85,7 @@ def sync_images_background(app, record_id, raw_urls):
# 更新数据库记录为持久化数据结构
try:
record = GenerationRecord.query.get(record_id)
record = db.session.get(GenerationRecord, record_id)
if record:
record.image_urls = json.dumps(processed_data)
db.session.commit()
@ -102,11 +102,12 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api
resp = requests.post(target_api, json=payload, headers=headers, timeout=1000)
if resp.status_code != 200:
# 错误处理:退还积分
user = User.query.get(user_id)
user = db.session.get(User, user_id)
if user and "sk-" in api_key:
user.points += cost
db.session.commit()
system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id)
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
return
@ -130,25 +131,176 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api
).start()
# 存入 Redis 标记完成
system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model'))
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)
user = db.session.get(User, user_id)
if user and "sk-" in api_key:
user.points += cost
db.session.commit()
system_logger.error(f"生图任务异常: {str(e)}", user_id=user_id, task_id=task_id)
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)}))
def sync_video_background(app, record_id, raw_url, internal_task_id=None):
"""后台同步视频至 MinIO带重试机制"""
with app.app_context():
success = False
final_url = raw_url
for attempt in range(3):
try:
# 增加了流式下载,处理大视频文件
with requests.get(raw_url, stream=True, timeout=120) as r:
r.raise_for_status()
content_type = r.headers.get('content-type', 'video/mp4')
ext = ".mp4"
if "text/html" in content_type: # 有些 API 返回的是跳转页面
continue
base_filename = f"video-{uuid.uuid4().hex}"
full_filename = f"{base_filename}{ext}"
video_io = io.BytesIO()
for chunk in r.iter_content(chunk_size=8192):
video_io.write(chunk)
video_io.seek(0)
# 上传至 MinIO
s3_client.upload_fileobj(
video_io,
Config.MINIO["bucket"],
full_filename,
ExtraArgs={"ContentType": content_type}
)
final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
success = True
break
except Exception as e:
system_logger.error(f"同步视频失败 (第{attempt+1}次): {str(e)}")
time.sleep(5)
if success:
try:
record = db.session.get(GenerationRecord, record_id)
if record:
# 更新记录为 MinIO 的 URL
record.image_urls = json.dumps([{"url": final_url, "type": "video"}])
db.session.commit()
# 关键修复:同步更新 Redis 中的缓存,这样前端轮询也能拿到最新的 MinIO 地址
if internal_task_id:
cached_data = redis_client.get(f"task:{internal_task_id}")
if cached_data:
if isinstance(cached_data, bytes):
cached_data = cached_data.decode('utf-8')
task_info = json.loads(cached_data)
task_info['video_url'] = final_url
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps(task_info))
system_logger.info(f"视频同步 MinIO 成功", video_url=final_url)
except Exception as dbe:
system_logger.error(f"更新视频记录失败: {str(dbe)}")
def process_video_generation(app, user_id, internal_task_id, payload, api_key, cost):
"""异步提交并查询视频任务状态"""
with app.app_context():
try:
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
# 1. 提交任务
submit_resp = requests.post(Config.VIDEO_GEN_API, json=payload, headers=headers, timeout=60)
if submit_resp.status_code != 200:
raise Exception(f"视频任务提交失败: {submit_resp.text}")
submit_result = submit_resp.json()
remote_task_id = submit_result.get('task_id')
if not remote_task_id:
raise Exception(f"未获取到远程任务 ID: {submit_result}")
# 2. 轮询状态
max_retries = 90 # 提升到 15 分钟
video_url = None
for i in range(max_retries):
time.sleep(10)
poll_url = Config.VIDEO_POLL_API.format(task_id=remote_task_id)
poll_resp = requests.get(poll_url, headers=headers, timeout=30)
if poll_resp.status_code != 200:
continue
poll_result = poll_resp.json()
status = poll_result.get('status', '').upper()
if status == 'SUCCESS':
# 提取视频输出地址
if 'data' in poll_result and isinstance(poll_result['data'], dict):
video_url = poll_result['data'].get('output')
if not video_url:
if 'data' in poll_result and isinstance(poll_result['data'], list) and poll_result['data']:
video_url = poll_result['data'][0].get('url')
elif 'video' in poll_result:
video_url = poll_result['video'].get('url') if isinstance(poll_result['video'], dict) else poll_result['video']
elif 'url' in poll_result:
video_url = poll_result['url']
break
elif status in ['FAILED', 'ERROR']:
raise Exception(f"视频生成失败: {poll_result.get('fail_reason') or poll_result.get('message') or '未知错误'}")
if not video_url:
raise Exception("超时未获取到视频地址")
# 3. 持久化记录
new_record = GenerationRecord(
user_id=user_id,
prompt=payload.get('prompt'),
model=payload.get('model'),
image_urls=json.dumps([{"url": video_url, "type": "video"}])
)
db.session.add(new_record)
db.session.commit()
# 后台线程异步同步到 MinIO
threading.Thread(
target=sync_video_background,
args=(app, new_record.id, video_url, internal_task_id)
).start()
# 4. 存入 Redis
redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "complete", "video_url": video_url, "record_id": new_record.id}))
system_logger.info(f"视频生成任务完成", user_id=user_id, task_id=internal_task_id)
except Exception as e:
system_logger.error(f"视频生成执行异常: {str(e)}", user_id=user_id, task_id=internal_task_id)
# 尝试退费
try:
user = db.session.get(User, user_id)
if user:
user.points += cost
db.session.commit()
except Exception as re:
system_logger.error(f"退费失败: {str(re)}")
# 确保 Redis 状态一定被更新,防止前端死循环
redis_client.setex(f"task:{internal_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))
try:
data = redis_client.get(f"task:{task_id}")
if not data:
return jsonify({"status": "pending"})
# 兼容处理 bytes 和 str
if isinstance(data, bytes):
data = data.decode('utf-8')
return jsonify(json.loads(data))
except Exception as e:
system_logger.error(f"查询任务状态异常: {str(e)}")
return jsonify({"status": "error", "message": "状态查询失败"})
@api_bp.route('/api/config')
def get_config():
@ -160,7 +312,9 @@ def get_config():
"models": [],
"ratios": [],
"prompts": [],
"sizes": []
"sizes": [],
"video_models": [],
"video_prompts": []
}
for d in dicts:
@ -174,6 +328,11 @@ def get_config():
config["prompts"].append(item)
elif d.dict_type == 'ai_image_size':
config["sizes"].append(item)
elif d.dict_type == 'video_model':
item["cost"] = d.cost
config["video_models"].append(item)
elif d.dict_type == 'video_prompt':
config["video_prompts"].append(item)
return jsonify(config)
except Exception as e:
@ -193,6 +352,8 @@ def upload():
ExtraArgs={"ContentType": f.content_type}
)
img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}")
system_logger.info(f"用户上传文件: {len(files)}", user_id=session.get('user_id'))
return jsonify({"urls": img_urls})
except Exception as e:
return jsonify({"error": str(e)}), 500
@ -202,7 +363,7 @@ def upload():
def generate():
try:
user_id = session.get('user_id')
user = User.query.get(user_id)
user = db.session.get(User, user_id)
data = request.json if request.is_json else request.form
mode = data.get('mode', 'trial')
@ -242,6 +403,7 @@ def generate():
if user.points < cost:
return jsonify({"error": f"可用积分不足"}), 400
user.points -= cost
user.has_used_points = True # 标记已使用过积分
db.session.commit()
prompt = data.get('prompt')
@ -298,6 +460,9 @@ def generate():
task_id = str(uuid.uuid4())
app = current_app._get_current_object()
log_msg = "用户发起验光单解读" if prompt == "解读验光单" else "用户发起生图任务"
system_logger.info(log_msg, model=model_value, mode=mode)
threading.Thread(
target=process_image_generation,
args=(app, user_id, task_id, payload, api_key, target_api, cost)
@ -310,6 +475,61 @@ def generate():
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/video/generate', methods=['POST'])
@login_required
def video_generate():
try:
user_id = session.get('user_id')
user = db.session.get(User, user_id)
data = request.json
# 视频生成统一使用积分模式,隐藏 Key 模式
if user.points <= 0:
return jsonify({"error": "可用积分不足,请先充值"}), 400
model_value = data.get('model', 'veo3.1')
# 确定积分消耗 (优先从字典获取)
model_dict = SystemDict.query.filter_by(dict_type='video_model', value=model_value).first()
cost = model_dict.cost if model_dict else (15 if "pro" in model_value.lower() or "3.1" in model_value else 10)
if user.points < cost:
return jsonify({"error": f"积分不足,生成该视频需要 {cost} 积分"}), 400
# 扣除积分
user.points -= cost
user.has_used_points = True
db.session.commit()
# 构建符合 API 文档的 Payload
payload = {
"model": model_value,
"prompt": data.get('prompt'),
"enhance_prompt": data.get('enhance_prompt', False),
"images": data.get('images', []),
"aspect_ratio": data.get('aspect_ratio', '9:16')
}
# 使用系统内置的 Key
api_key = Config.TRIAL_KEY # 默认使用试用/中转 Key
task_id = str(uuid.uuid4())
app = current_app._get_current_object()
system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost)
threading.Thread(
target=process_video_generation,
args=(app, user_id, task_id, payload, api_key, cost)
).start()
return jsonify({
"task_id": task_id,
"message": "视频生成任务已提交,系统正在导演中..."
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/notifications/latest', methods=['GET'])
@login_required
def get_latest_notification():
@ -341,8 +561,8 @@ def mark_notif_read():
if not notif_id:
return jsonify({"error": "缺少通知 ID"}), 400
notif = SystemNotification.query.get(notif_id)
user = User.query.get(user_id)
notif = db.session.get(SystemNotification, notif_id)
user = db.session.get(User, user_id)
if notif and user:
if user not in notif.read_by_users:
@ -382,14 +602,17 @@ def get_history():
# 旧数据:直接返回原图作为缩略图
formatted_urls.append({"url": u, "thumb": u})
else:
# 新数据:包含 url 和 thumb
# 如果是视频类型,提供默认预览图 (此处使用一个公共视频占位图或空)
if u.get('type') == 'video' and not u.get('thumb'):
u['thumb'] = "https://img.icons8.com/flat-round/64/000000/play--v1.png"
formatted_urls.append(u)
history_list.append({
"id": r.id,
"prompt": r.prompt,
"model": r.model,
"urls": formatted_urls,
"time": (r.created_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M')
"created_at": (r.created_at + timedelta(hours=8)).strftime('%b %d, %H:%M')
})
return jsonify({
@ -399,3 +622,36 @@ def get_history():
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/download_proxy', methods=['GET'])
@login_required
def download_proxy():
"""代理下载远程文件,强制浏览器弹出下载"""
url = request.args.get('url')
filename = request.args.get('filename', f"video-{int(time.time())}.mp4")
if not url:
return jsonify({"error": "缺少 URL 参数"}), 400
try:
# 流式获取远程文件
req = requests.get(url, stream=True, timeout=60)
req.raise_for_status()
headers = {}
if req.headers.get('Content-Type'):
headers['Content-Type'] = req.headers['Content-Type']
else:
headers['Content-Type'] = 'application/octet-stream'
headers['Content-Disposition'] = f'attachment; filename="{filename}"'
def generate():
for chunk in req.iter_content(chunk_size=4096):
yield chunk
return current_app.response_class(generate(), headers=headers)
except Exception as e:
system_logger.error(f"代理下载失败: {str(e)}")
return jsonify({"error": "下载失败"}), 500

View File

@ -1,7 +1,9 @@
from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for
import json
from extensions import db
from models import User
from services.sms_service import SMSService
from services.captcha_service import CaptchaService
from services.logger import system_logger
from middlewares.auth import admin_required
@ -52,7 +54,7 @@ def buy_page():
from models import Order, User
user_id = session['user_id']
user = User.query.get(user_id)
user = db.session.get(User, user_id)
# 获取用户个人充值记录
personal_orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).limit(10).all()
@ -79,18 +81,75 @@ def buy_page():
success=success,
order=order)
@auth_bp.route('/api/auth/captcha')
def get_captcha():
"""获取图形验证码并存入 Redis"""
phone = request.args.get('phone')
if not phone:
return jsonify({"error": "缺少参数"}), 400
text, img_bytes = CaptchaService.generate_captcha()
from extensions import redis_client
# 存入 Redis有效期 5 分钟
redis_client.setex(f"captcha:{phone}", 300, text.lower())
from flask import Response
return Response(img_bytes, mimetype='image/png')
@auth_bp.route('/api/auth/send_code', methods=['POST'])
def send_code():
data = request.json
phone = data.get('phone')
captcha = data.get('captcha')
ip = request.remote_addr
if not phone:
return jsonify({"error": "请输入手机号"}), 400
if not captcha:
return jsonify({"error": "请输入图形验证码", "show_captcha": True}), 403
system_logger.info(f"用户请求发送验证码", phone=phone)
from extensions import redis_client
# 1. 验证图形验证码
saved_captcha = redis_client.get(f"captcha:{phone}")
if not saved_captcha or captcha.lower() != saved_captcha.decode('utf-8'):
return jsonify({"error": "图形验证码错误或已过期", "refresh_captcha": True}), 403
# 验证后立即删除,防止被脚本重复利用来刷短信
redis_client.delete(f"captcha:{phone}")
# 2. 频率限制:单手机号 60秒 一次 (后端兜底)
if redis_client.get(f"sms_lock:{phone}"):
return jsonify({"error": "发送过于频繁,请稍后再试"}), 429
# 3. 每日限制:单手机号每天最多 10 条
day_count_key = f"sms_day_count:{phone}"
day_count = int(redis_client.get(day_count_key) or 0)
if day_count >= 10:
return jsonify({"error": "该手机号今日获取验证码次数已达上限"}), 429
# 4. 每日限制:单 IP 每天最多 20 条 (防止换号刷)
ip_count_key = f"sms_ip_count:{ip}"
ip_count = int(redis_client.get(ip_count_key) or 0)
if ip_count >= 20:
return jsonify({"error": "您的设备今日发送请求过多,请明天再试"}), 429
system_logger.info(f"用户请求发送验证码", phone=phone, ip=ip)
success, msg = SMSService.send_code(phone)
if success:
# 设置各种限制标记
from datetime import datetime
now = datetime.now()
seconds_until_midnight = ((23 - now.hour) * 3600) + ((59 - now.minute) * 60) + (60 - now.second)
redis_client.setex(f"sms_lock:{phone}", 60, "1")
redis_client.setex(day_count_key, seconds_until_midnight, day_count + 1)
redis_client.setex(ip_count_key, seconds_until_midnight, ip_count + 1)
system_logger.info(f"验证码发送成功", phone=phone)
return jsonify({"message": "验证码已发送"})
system_logger.warning(f"验证码发送失败: {msg}", phone=phone)
return jsonify({"error": f"发送失败: {msg}"}), 500
@ -128,19 +187,78 @@ def login():
data = request.json
phone = data.get('phone')
password = data.get('password')
code = data.get('code') # 可能是高频报错后强制要求的验证码
if not phone or not password:
return jsonify({"error": "请输入手机号和密码"}), 400
from extensions import redis_client
fail_key = f"login_fail_count:{phone}"
fail_count = int(redis_client.get(fail_key) or 0)
# 如果失败次数过多,强制要求图型验证码
if fail_count >= 3:
if not code:
system_logger.warning(f"触发强制安全验证", phone=phone)
return jsonify({
"error": "由于密码错误次数过多,请输入图形验证码",
"require_captcha": True
}), 403
# 验证图形验证码
saved_captcha = redis_client.get(f"captcha:{phone}")
if not saved_captcha or code.lower() != saved_captcha.decode('utf-8'):
return jsonify({"error": "验证码错误或已过期"}), 400
# 验证成功后删除,防止重复使用
redis_client.delete(f"captcha:{phone}")
system_logger.info(f"用户登录尝试", phone=phone)
user = User.query.filter_by(phone=phone).first()
if user and user.check_password(password):
session.permanent = True # 开启持久化会话 (受 Config.PERMANENT_SESSION_LIFETIME 控制)
# 登录成功,清除失败计数
redis_client.delete(fail_key)
session.permanent = True
session['user_id'] = user.id
system_logger.info(f"用户登录成功", phone=phone, user_id=user.id)
return jsonify({"message": "登录成功", "phone": phone})
system_logger.warning(f"登录失败: 手机号或密码错误", phone=phone)
# 登录失败,增加计数 (有效期 1 小时)
redis_client.setex(fail_key, 3600, fail_count + 1)
system_logger.warning(f"登录失败: 手机号或密码错误 [次数: {fail_count+1}]", phone=phone)
return jsonify({"error": "手机号或密码错误"}), 401
@auth_bp.route('/api/auth/reset_password', methods=['POST'])
def reset_password():
"""通过短信重置密码"""
data = request.json
phone = data.get('phone')
code = data.get('code')
new_password = data.get('password')
if not phone or not code or not new_password:
return jsonify({"error": "请填写完整信息"}), 400
if not SMSService.verify_code(phone, code):
return jsonify({"error": "验证码错误或已过期"}), 400
user = User.query.filter_by(phone=phone).first()
if not user:
return jsonify({"error": "该手机号尚未注册"}), 404
user.set_password(new_password)
db.session.commit()
# 重置成功后清理失败计数
from extensions import redis_client
redis_client.delete(f"login_fail_count:{phone}")
system_logger.info(f"用户通过短信重置密码成功", phone=phone, user_id=user.id)
return jsonify({"message": "密码重置成功,请使用新密码登录"})
@auth_bp.route('/api/auth/logout', methods=['POST'])
def logout():
session.pop('user_id', None)
@ -151,12 +269,22 @@ def me():
user_id = session.get('user_id')
if not user_id:
return jsonify({"logged_in": False})
user = User.query.get(user_id)
user = db.session.get(User, user_id)
if not user:
session.pop('user_id', None)
return jsonify({"logged_in": False})
# 脱敏手机号: 13812345678 -> 138****5678
phone = user.phone
masked_phone = phone[:3] + "****" + phone[-4:] if len(phone) >= 11 else phone
return jsonify({
"logged_in": True,
"phone": user.phone,
"api_key": user.api_key, # 返回已保存的 API Key
"points": user.points # 返回剩余试用积分
"phone": masked_phone, # 默认返回脱敏的供前端显示
"full_phone": phone, # 某些场景可能需要完整号
"api_key": user.api_key,
"points": user.points,
"hide_custom_key": (not user.api_key) or user.has_used_points # Key为空或使用过积分则隐藏自定义Key入口
})
@auth_bp.route('/api/auth/change_password', methods=['POST'])
@ -172,7 +300,7 @@ def change_password():
if not old_password or not new_password:
return jsonify({"error": "请填写完整信息"}), 400
user = User.query.get(user_id)
user = db.session.get(User, user_id)
if not user.check_password(old_password):
return jsonify({"error": "原密码错误"}), 400
@ -193,20 +321,14 @@ def add_points():
# return jsonify({"error": "请先登录"}), 401
# ... (原有逻辑)
@auth_bp.route('/api/auth/menu', methods=['GET'])
def get_menu():
"""获取动态导航菜单"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"menu": []})
user = User.query.get(user_id)
def get_user_menu(user):
"""根据用户权限生成菜单列表"""
if not user:
return jsonify({"menu": []})
return []
# 菜单定义库:名称, 图标, 链接, 所需权限
all_menus = [
{"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None},
{"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None},
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
@ -215,46 +337,75 @@ def get_menu():
{"name": "系统日志审计", "icon": "terminal", "url": "/logs", "perm": "view_logs"},
]
# 根据权限过滤
accessible_menu = []
for item in all_menus:
if item["perm"] is None or user.has_permission(item["perm"]):
accessible_menu.append(item)
return accessible_menu
return jsonify({"menu": accessible_menu})
@auth_bp.route('/api/auth/logs', methods=['GET'])
def get_logs():
"""获取系统日志(支持搜索和筛选)"""
@auth_bp.route('/api/auth/menu', methods=['GET'])
def get_menu():
"""获取动态导航菜单"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"error": "请先登录"}), 401
return jsonify({"menu": []})
from extensions import redis_client
import json
user = db.session.get(User, user_id)
return jsonify({"menu": get_user_menu(user)})
# 筛选参数
@auth_bp.route('/api/auth/logs', methods=['GET'])
@admin_required
def get_logs():
"""获取系统日志(支持搜索、筛选与分页)"""
from models import SystemLog, User
# 分页与筛选参数
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
level_filter = request.args.get('level')
search_query = request.args.get('search', '').lower()
search_query = request.args.get('search', '').strip()
# 从 Redis 获取日志 (从 ZSET 读取,按分数降序排列,即最新在前)
logs = redis_client.zrevrange('system_logs_zset', 0, -1)
log_list = []
query = db.session.query(SystemLog).outerjoin(User)
for log in logs:
item = json.loads(log.decode('utf-8'))
# 级别过滤
if level_filter:
query = query.filter(SystemLog.level == level_filter)
# 级别过滤
if level_filter and item['level'] != level_filter:
continue
# 关键词搜索 (支持消息、手机号、IP)
if search_query:
search_filter = db.or_(
SystemLog.message.ilike(f"%{search_query}%"),
SystemLog.ip.ilike(f"%{search_query}%"),
User.phone.ilike(f"%{search_query}%"),
SystemLog.module.ilike(f"%{search_query}%")
)
query = query.filter(search_filter)
# 关键词搜索 (搜索内容、手机号或其它 Extra 字段)
if search_query:
message_match = search_query in item['message'].lower()
extra_match = any(search_query in str(v).lower() for v in item.get('extra', {}).values())
if not (message_match or extra_match):
continue
# 执行分页查询
pagination = query.order_by(SystemLog.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
log_list.append(item)
logs_data = []
for log in pagination.items:
logs_data.append({
"id": log.id,
"time": log.created_at.strftime('%Y-%m-%d %H:%M:%S'),
"level": log.level,
"message": log.message,
"module": log.module,
"user_id": log.user_id,
"user_phone": log.user.phone if log.user else "系统/游客",
"ip": log.ip,
"path": log.path,
"method": log.method,
"extra": json.loads(log.extra) if log.extra else {}
})
return jsonify({"logs": log_list})
return jsonify({
"logs": logs_data,
"total": pagination.total,
"page": page,
"per_page": per_page,
"total_pages": pagination.pages
})

View File

@ -2,11 +2,9 @@ from flask import Blueprint, request, redirect, url_for, session, jsonify, rende
from extensions import db
from models import Order, User
from services.alipay_service import AlipayService
from services.logger import system_logger
import uuid
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
payment_bp = Blueprint('payment', __name__, url_prefix='/payment')
@ -44,8 +42,10 @@ def create_payment():
)
db.session.add(order)
db.session.commit()
system_logger.info(f"用户创建充值订单", order_id=out_trade_no, amount=package['amount'], points=package['points'])
except Exception as e:
db.session.rollback()
system_logger.error(f"订单创建失败: {str(e)}")
return f"订单创建失败: {str(e)}", 500
# 获取支付链接
@ -58,38 +58,31 @@ def create_payment():
)
return redirect(pay_url)
except Exception as e:
system_logger.error(f"支付链接生成失败: {str(e)}")
return f"支付链接生成失败: {str(e)}", 500
@payment_bp.route('/return')
def payment_return():
"""支付成功后的同步跳转页面"""
try:
logger.info(f"收到支付宝同步回调,参数: {dict(request.args)}")
data = request.args.to_dict()
signature = data.get("sign")
if not signature:
logger.error("同步回调缺少签名参数")
return "参数错误:缺少签名", 400
alipay_service = AlipayService()
# 直接传递原始字典,由 verify_notify 处理
success = alipay_service.verify_notify(data, signature)
out_trade_no = data.get('out_trade_no')
order = Order.query.filter_by(out_trade_no=out_trade_no).first()
if success:
logger.info(f"同步回调验证成功,订单号: {out_trade_no}")
# 重定向到充值页面,并带上成功参数
return redirect(url_for('auth.buy_page', success='true', out_trade_no=out_trade_no))
else:
logger.error(f"同步回调验证失败,订单号: {out_trade_no}")
system_logger.warning(f"支付同步回调验证失败", order_id=out_trade_no)
return "支付验证失败", 400
except Exception as e:
logger.error(f"处理同步回调时发生异常: {str(e)}", exc_info=True)
system_logger.error(f"处理同步回调异常: {str(e)}")
return f"处理支付回调失败: {str(e)}", 500
@payment_bp.route('/history', methods=['GET'])
@ -130,13 +123,10 @@ def api_payment_history():
def payment_notify():
"""支付宝异步通知"""
try:
logger.info(f"收到支付宝异步通知,参数: {request.form.to_dict()}")
data = request.form.to_dict()
signature = data.get("sign") # 不要pop保留原始数据
signature = data.get("sign")
if not signature:
logger.error("异步通知缺少签名参数")
return "fail"
alipay_service = AlipayService()
@ -146,34 +136,27 @@ def payment_notify():
out_trade_no = data.get('out_trade_no')
trade_no = data.get('trade_no')
logger.info(f"异步通知验证成功,订单号: {out_trade_no}, 支付宝交易号: {trade_no}")
order = Order.query.filter_by(out_trade_no=out_trade_no).first()
if order and order.status == 'PENDING':
order.status = 'PAID'
order.trade_no = trade_no
order.paid_at = datetime.utcnow()
# 给用户加积分
user = User.query.get(order.user_id)
user = db.session.get(User, order.user_id)
if user:
user.points += order.points
logger.info(f"用户 {user.id} 充值 {order.points} 积分")
system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id)
db.session.commit()
logger.info(f"订单 {out_trade_no} 处理成功")
return "success"
elif order:
logger.warning(f"订单 {out_trade_no} 状态为 {order.status},跳过处理")
return "success" # 已处理过的订单也返回success
return "success"
else:
logger.error(f"未找到订单: {out_trade_no}")
return "fail"
else:
logger.error(f"异步通知验证失败或交易状态异常: {data.get('trade_status')}")
return "fail"
except Exception as e:
logger.error(f"处理异步通知时发生异常: {str(e)}", exc_info=True)
system_logger.error(f"处理异步通知异常: {str(e)}")
db.session.rollback()
return "fail"

View File

@ -28,6 +28,8 @@ class Config:
# AI API 配置
AI_API = "https://ai.t8star.cn/v1/images/generations"
CHAT_API = "https://ai.comfly.chat/v1/chat/completions"
VIDEO_GEN_API = "https://ai.comfly.chat/v2/videos/generations"
VIDEO_POLL_API = "https://ai.comfly.chat/v2/videos/generations/{task_id}"
# 试用模式配置
TRIAL_API = "https://ai.comfly.chat/v1/images/generations"

View File

@ -5,10 +5,10 @@
用于在 PostgreSQL 服务器上创建 ai_vision 数据库
"""
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
from sqlalchemy import create_engine, text
from sqlalchemy.engine import url
# 数据库连接信息
# 数据库连接信息 (从 config 或直接指定)
DB_HOST = "331002.xyz"
DB_PORT = 2022
DB_USER = "user_xREpkJ"
@ -20,30 +20,23 @@ def create_database():
try:
# 连接到默认的 postgres 数据库
print(f"🔗 正在连接到 PostgreSQL 服务器 {DB_HOST}:{DB_PORT}...")
conn = psycopg2.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database="postgres" # 先连接到默认数据库
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
# 检查数据库是否存在
cursor.execute(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'")
exists = cursor.fetchone()
# 构造连接 URL (连接到 postgres 数据库以执行 CREATE DATABASE)
postgres_url = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/postgres"
engine = create_engine(postgres_url, isolation_level="AUTOCOMMIT")
if exists:
print(f"✅ 数据库 {DB_NAME} 已经存在")
else:
# 创建数据库
print(f"🔧 正在创建数据库 {DB_NAME}...")
cursor.execute(f'CREATE DATABASE {DB_NAME}')
print(f"✅ 数据库 {DB_NAME} 创建成功!")
with engine.connect() as conn:
# 检查数据库是否存在
result = conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'"))
exists = result.fetchone()
cursor.close()
conn.close()
if exists:
print(f"✅ 数据库 {DB_NAME} 已经存在")
else:
# 创建数据库
print(f"🔧 正在创建数据库 {DB_NAME}...")
conn.execute(text(f'CREATE DATABASE {DB_NAME}'))
print(f"✅ 数据库 {DB_NAME} 创建成功!")
print(f"\n📊 数据库信息:")
print(f" 主机: {DB_HOST}:{DB_PORT}")
@ -51,12 +44,6 @@ def create_database():
print(f" 用户: {DB_USER}")
print(f"\n💡 下一步:运行 python init_db.py 创建数据表")
except psycopg2.Error as e:
print(f"❌ 数据库操作失败: {e}")
print(f"\n可能的原因:")
print(f" 1. 用户 {DB_USER} 没有创建数据库的权限")
print(f" 2. 网络连接问题")
print(f" 3. 数据库服务器配置限制")
except Exception as e:
print(f"❌ 发生错误: {e}")

View File

@ -1,10 +1,12 @@
from flask_sqlalchemy import SQLAlchemy
from flask_redis import FlaskRedis
from flask_migrate import Migrate
import boto3
from config import Config
db = SQLAlchemy()
redis_client = FlaskRedis()
migrate = Migrate()
# MinIO Client
s3_client = boto3.client(

View File

@ -1,23 +1,20 @@
import psycopg2
from sqlalchemy import create_engine, text
from config import Config
def migrate():
# 从 URI 解析连接参数
# postgresql://user:pass@host:port/dbname
uri = Config.SQLALCHEMY_DATABASE_URI
print(f"正在手动连接数据库进行迁移...")
print(f"正在手动连接数据库进行迁移 (SQLAlchemy)... ")
engine = create_engine(uri)
try:
conn = psycopg2.connect(uri)
cur = conn.cursor()
# 添加 api_key 字段
cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);")
conn.commit()
cur.close()
conn.close()
print("✅ 数据库字段 users.api_key 手动添加成功")
with engine.connect() as conn:
# 添加 api_key 字段
print("🔧 正在检查并添加 users.api_key 字段...")
conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);"))
conn.commit()
print("✅ 数据库字段 users.api_key 处理成功")
except Exception as e:
print(f"❌ 迁移失败: {e}")

View File

@ -1,30 +1,27 @@
import psycopg2
from sqlalchemy import create_engine, text
from config import Config
def fix_db():
# 从 SQLALCHEMY_DATABASE_URI 提取连接信息
# 格式: postgresql://user:pass@host:port/db
uri = Config.SQLALCHEMY_DATABASE_URI
print(f"🔗 正在尝试连接数据库...")
print(f"🔗 正在尝试连接数据库 (SQLAlchemy)... ")
engine = create_engine(uri)
try:
conn = psycopg2.connect(uri)
cur = conn.cursor()
# 检查并添加 points 字段
cur.execute("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN
ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2;
END IF;
END $$;
""")
conn.commit()
cur.close()
conn.close()
print("✅ 数据库字段 points 处理完成 (默认值 2)")
with engine.connect() as conn:
# 检查并添加 points 字段
print("🔧 正在检查并添加 users.points 字段...")
conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN
ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2;
END IF;
END $$;
"""))
conn.commit()
print("✅ 数据库字段 points 处理完成 (默认值 2)")
except Exception as e:
print(f"❌ 数据库修复失败: {e}")

View File

@ -1,168 +0,0 @@
[2026-01-11 17:43:34] INFO - 用户请求发送验证码
[2026-01-11 17:43:36] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 912B43E3-5393-53A4-92B7-669BA1DF61A3 Response: {'RequestId': '912B43E3-5393-53A4-92B7-669BA1DF61A3', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dysmsapi&requestId=912B43E3-5393-53A4-92B7-669BA1DF61A3', 'HostId': 'dysmsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404}
[2026-01-11 17:50:36] INFO - 用户请求发送验证码
[2026-01-11 17:50:37] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 762395A5-97D5-569F-9A50-8D2AD36009C1 Response: {'RequestId': '762395A5-97D5-569F-9A50-8D2AD36009C1', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dypnsapi&requestId=762395A5-97D5-569F-9A50-8D2AD36009C1', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404}
[2026-01-11 17:52:03] INFO - 用户注册请求
[2026-01-11 17:52:03] WARNING - 注册失败: 验证码错误
[2026-01-11 17:58:53] INFO - 用户请求发送验证码
[2026-01-11 17:58:53] INFO - 验证码发送成功
[2026-01-11 17:59:51] INFO - 用户请求发送验证码
[2026-01-11 17:59:51] WARNING - 验证码发送失败: 请2秒后再试
[2026-01-11 17:59:56] INFO - 用户请求发送验证码
[2026-01-11 17:59:57] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4 Response: {'RequestId': 'E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400}
[2026-01-11 18:02:00] INFO - 用户请求发送验证码
[2026-01-11 18:02:01] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8 Response: {'RequestId': 'BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400}
[2026-01-11 18:04:11] INFO - 用户请求发送验证码
[2026-01-11 18:04:12] WARNING - 验证码发送失败: 请检查模板内容与模板参数是否匹配
[2026-01-11 18:05:41] INFO - 用户请求发送验证码
[2026-01-11 18:05:43] WARNING - 验证码发送失败: 非法参数
[2026-01-11 18:06:41] INFO - 用户请求发送验证码
[2026-01-11 18:06:42] WARNING - 验证码发送失败: check frequency failed
[2026-01-11 18:07:53] INFO - 用户请求发送验证码
[2026-01-11 18:07:53] INFO - 验证码发送成功
[2026-01-11 18:12:40] INFO - 用户请求发送验证码
[2026-01-11 18:12:42] WARNING - 验证码发送失败: 非法参数
[2026-01-11 18:13:42] INFO - 用户请求发送验证码
[2026-01-11 18:13:44] INFO - 验证码发送成功
[2026-01-11 18:14:18] INFO - 用户注册请求
[2026-01-11 18:14:19] WARNING - 注册失败: 验证码错误
[2026-01-11 18:16:05] INFO - 用户注册请求
[2026-01-11 18:16:07] WARNING - 注册失败: 验证码错误
[2026-01-11 18:17:34] INFO - 用户注册请求
[2026-01-11 18:17:35] WARNING - 注册失败: 验证码错误
[2026-01-11 18:20:57] INFO - 用户请求发送验证码
[2026-01-11 18:20:59] INFO - 验证码发送成功
[2026-01-11 18:21:11] INFO - 用户注册请求
[2026-01-11 18:21:12] INFO - 用户注册成功
[2026-01-11 18:21:14] INFO - 用户登录尝试
[2026-01-11 18:21:14] INFO - 用户登录成功
[2026-01-11 18:33:40] INFO - 用户登录尝试
[2026-01-11 18:33:40] WARNING - 登录失败: 手机号或密码错误
[2026-01-11 18:33:47] INFO - 用户登录尝试
[2026-01-11 18:33:47] INFO - 用户登录成功
[2026-01-11 18:34:19] INFO - 用户登录尝试
[2026-01-11 18:34:19] INFO - 用户登录成功
[2026-01-11 19:05:37] INFO - 用户登录尝试
[2026-01-11 19:05:37] INFO - 用户登录成功
[2026-01-11 19:14:10] INFO - 用户登录尝试
[2026-01-11 19:14:10] INFO - 用户登录成功
[2026-01-11 21:51:06] INFO - 用户登录尝试
[2026-01-11 21:51:06] INFO - 用户登录成功
[2026-01-11 21:59:14] INFO - 试用模式生成
[2026-01-11 21:59:14] INFO - 用户发起图片生成
[2026-01-11 22:09:52] INFO - 积分预扣除 (试用模式)
[2026-01-11 22:10:10] INFO - 用户生成图片成功
[2026-01-11 23:41:00] INFO - 用户登录尝试
[2026-01-11 23:41:23] INFO - 用户登录尝试
[2026-01-11 23:41:23] INFO - 用户登录成功
[2026-01-11 23:43:21] INFO - 用户修改密码成功
[2026-01-11 23:43:54] INFO - 用户登录尝试
[2026-01-11 23:43:54] INFO - 用户登录成功
[2026-01-11 23:44:01] INFO - 用户登录尝试
[2026-01-11 23:44:01] WARNING - 登录失败: 手机号或密码错误
[2026-01-11 23:44:07] INFO - 用户登录尝试
[2026-01-11 23:44:07] WARNING - 登录失败: 手机号或密码错误
[2026-01-11 23:44:10] INFO - 用户登录尝试
[2026-01-11 23:44:10] INFO - 用户登录成功
[2026-01-12 22:00:04] INFO - 用户登录尝试
[2026-01-12 22:00:04] INFO - 用户登录成功
[2026-01-12 22:00:55] INFO - 用户充值积分成功
[2026-01-12 22:32:04] INFO - 用户登录尝试
[2026-01-12 22:32:04] INFO - 用户登录成功
[2026-01-12 22:35:37] INFO - 用户登录尝试
[2026-01-12 22:35:37] INFO - 用户登录成功
[2026-01-12 22:38:54] INFO - 用户生成图片成功
[2026-01-12 22:49:54] INFO - 用户登录尝试
[2026-01-12 22:49:54] INFO - 用户登录成功
[2026-01-12 22:49:59] INFO - 用户登录尝试
[2026-01-12 22:49:59] INFO - 用户登录成功
[2026-01-12 22:53:16] INFO - 用户生成图片成功
[2026-01-12 23:05:38] INFO - 用户登录尝试
[2026-01-12 23:05:38] INFO - 用户登录成功
[2026-01-12 23:09:28] INFO - 用户登录尝试
[2026-01-12 23:09:28] INFO - 用户登录成功
[2026-01-12 23:18:01] INFO - 用户生成图片成功
[2026-01-13 22:27:07] INFO - 用户生成图片成功
[2026-01-13 22:44:18] INFO - 积分预扣除 (普通试用)
[2026-01-13 22:44:21] WARNING - API 报错,积分已退还
[2026-01-13 22:46:45] INFO - 积分预扣除 (普通试用)
[2026-01-13 22:47:04] INFO - 用户生成文本成功
[2026-01-13 22:50:18] INFO - 积分预扣除 (普通试用)
[2026-01-13 22:50:36] INFO - 用户生成文本成功
[2026-01-13 22:57:55] INFO - 积分预扣除 (普通试用)
[2026-01-13 22:58:07] INFO - 用户生成文本成功
[2026-01-13 23:02:42] INFO - 积分预扣除 (普通试用)
[2026-01-13 23:02:55] INFO - 用户生成文本成功
[2026-01-13 23:08:26] INFO - 积分预扣除 (普通试用)
[2026-01-13 23:08:43] INFO - 用户生成文本成功
[2026-01-13 23:09:40] INFO - 用户登录尝试
[2026-01-13 23:09:40] INFO - 用户登录成功
[2026-01-13 23:11:23] INFO - 积分预扣除 (普通试用)
[2026-01-13 23:11:41] INFO - 用户生成文本成功
[2026-01-13 23:13:29] INFO - 用户登录尝试
[2026-01-13 23:13:30] INFO - 用户登录成功
[2026-01-13 23:14:33] INFO - 用户生成图片成功
[2026-01-13 23:17:57] INFO - 用户生成图片成功
[2026-01-13 23:18:03] INFO - 用户生成图片成功
[2026-01-13 23:18:15] INFO - 用户生成图片成功
[2026-01-13 23:20:10] INFO - 用户生成图片成功
[2026-01-13 23:20:11] INFO - 用户生成图片成功
[2026-01-13 23:20:11] INFO - 用户生成图片成功
[2026-01-13 23:20:14] INFO - 用户生成图片成功
[2026-01-13 23:21:19] INFO - 用户生成图片成功
[2026-01-13 23:21:21] INFO - 用户生成图片成功
[2026-01-13 23:21:21] INFO - 用户生成图片成功
[2026-01-13 23:21:22] INFO - 用户生成图片成功
[2026-01-13 23:24:14] INFO - 用户生成图片成功
[2026-01-13 23:24:15] INFO - 用户生成图片成功
[2026-01-13 23:24:16] INFO - 用户生成图片成功
[2026-01-13 23:24:17] INFO - 用户生成图片成功
[2026-01-13 23:30:04] INFO - 用户生成图片成功
[2026-01-13 23:30:04] INFO - 用户生成图片成功
[2026-01-13 23:30:06] INFO - 用户生成图片成功
[2026-01-13 23:48:04] INFO - 用户生成图片成功
[2026-01-13 23:48:05] INFO - 用户生成图片成功
[2026-01-13 23:48:07] INFO - 用户生成图片成功
[2026-01-13 23:52:15] INFO - 用户生成图片成功
[2026-01-13 23:52:15] INFO - 用户生成图片成功
[2026-01-13 23:52:22] INFO - 用户生成图片成功
[2026-01-13 23:59:01] INFO - 用户登录尝试
[2026-01-13 23:59:02] INFO - 用户登录成功
[2026-01-14 16:45:23] INFO - 用户登录尝试
[2026-01-14 16:45:23] INFO - 用户登录成功
[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-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)

View File

@ -1,6 +1,7 @@
from functools import wraps
from flask import session, jsonify, redirect, url_for, request
from models import User
from services.logger import system_logger
def login_required(f):
"""登录验证装饰器"""
@ -8,6 +9,7 @@ def login_required(f):
def decorated_function(*args, **kwargs):
user_id = session.get('user_id')
if not user_id:
system_logger.warning(f"未授权访问尝试 (未登录): {request.path}", ip=request.remote_addr)
if request.path.startswith('/api/'):
return jsonify({"error": "请先登录", "code": 401}), 401
# 记录当前路径以便登录后跳转
@ -22,12 +24,14 @@ def permission_required(perm_name):
def decorated_function(*args, **kwargs):
user_id = session.get('user_id')
if not user_id:
system_logger.warning(f"未授权访问尝试 (未登录): {request.path}", ip=request.remote_addr)
if request.path.startswith('/api/'):
return jsonify({"error": "请先登录", "code": 401}), 401
return redirect(url_for('auth.login_page', next=request.path))
user = User.query.get(user_id)
if not user or not user.has_permission(perm_name):
system_logger.warning(f"未授权访问尝试 (权限不足: {perm_name}): {request.path}", user_id=user_id, ip=request.remote_addr)
if request.path.startswith('/api/'):
return jsonify({"error": f"需要权限: {perm_name}", "code": 403}), 403
# 如果没有权限,重定向到首页并提示

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,59 @@
"""add some columns
Revision ID: 9024b393e1ef
Revises:
Create Date: 2026-01-16 20:58:52.178001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9024b393e1ef'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('ai_models')
op.drop_table('aspect_ratios')
op.drop_table('prompt_templates')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('has_used_points', sa.Boolean(), nullable=True))
batch_op.drop_column('role')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('role', sa.VARCHAR(length=20), server_default=sa.text("'user'::character varying"), autoincrement=False, nullable=True))
batch_op.drop_column('has_used_points')
op.create_table('prompt_templates',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('label', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('prompt_templates_pkey'))
)
op.create_table('aspect_ratios',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('label', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('value', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('aspect_ratios_pkey'))
)
op.create_table('ai_models',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('value', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.PrimaryKeyConstraint('id', name=op.f('ai_models_pkey')),
sa.UniqueConstraint('value', name=op.f('ai_models_value_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###

View File

@ -30,6 +30,7 @@ class User(db.Model):
password_hash = db.Column(db.String(255), nullable=False)
api_key = db.Column(db.String(255)) # 存储用户的 API Key
points = db.Column(db.Integer, default=2) # 账户积分默认赠送2次试用
has_used_points = db.Column(db.Boolean, default=False) # 是否使用过积分
# 关联角色 ID
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@ -117,3 +118,24 @@ class Order(db.Model):
paid_at = db.Column(db.DateTime)
user = db.relationship('User', backref=db.backref('orders', lazy='dynamic', order_by='Order.created_at.desc()'))
class SystemLog(db.Model):
"""系统精细化日志记录"""
__tablename__ = 'system_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # 可能没有登录用户
level = db.Column(db.String(20), nullable=False) # INFO, WARNING, ERROR, DEBUG
module = db.Column(db.String(50)) # 模块名
message = db.Column(db.Text, nullable=False) # 日志内容
extra = db.Column(db.Text) # 额外信息的 JSON 字符串
# 请求上下文信息
ip = db.Column(db.String(50))
path = db.Column(db.String(255))
method = db.Column(db.String(10))
user_agent = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.now)
user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()'))

Binary file not shown.

View File

@ -0,0 +1,51 @@
import random
import string
import io
from PIL import Image, ImageDraw, ImageFont, ImageFilter
class CaptchaService:
@staticmethod
def generate_captcha():
# 生成随机 4 位字符
chars = string.ascii_uppercase + string.digits
captcha_text = ''.join(random.choices(chars, k=4))
# 图像参数
width, height = 120, 50
background = (255, 255, 255)
image = Image.new('RGB', (width, height), background)
draw = ImageDraw.Draw(image)
# 尝试加载字体,如果失败则使用默认
try:
# 在 Windows 上寻找常用路径
font_path = "C:\\Windows\\Fonts\\arial.ttf"
font = ImageFont.truetype(font_path, 32)
except:
font = ImageFont.load_default()
# 绘制文本
for i, char in enumerate(captcha_text):
# 随机颜色和轻微旋转/偏移
color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150))
draw.text((15 + i * 25, 5), char, font=font, fill=color)
# 增加噪点线
for _ in range(5):
x1 = random.randint(0, width)
y1 = random.randint(0, height)
x2 = random.randint(0, width)
y2 = random.randint(0, height)
draw.line((x1, y1, x2, y2), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)), width=1)
# 增加噪点点
for _ in range(30):
draw.point((random.randint(0, width), random.randint(0, height)), fill=(0, 0, 0))
# 轻微模糊
image = image.filter(ImageFilter.SMOOTH)
# 保存到内存
buf = io.BytesIO()
image.save(buf, format='PNG')
return captcha_text, buf.getvalue()

View File

@ -2,8 +2,9 @@ import logging
import os
from datetime import datetime
from logging.handlers import RotatingFileHandler
from extensions import redis_client
from extensions import redis_client, db
import json
from flask import request, has_request_context, g
# 创建日志目录
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
@ -42,10 +43,15 @@ class SystemLogger:
"""推送到 Redis 并保留 30 天数据"""
try:
now = datetime.now()
user_id = None
if has_request_context():
user_id = g.get('user_id') or (getattr(g, 'user', None).id if hasattr(g, 'user') and g.user else None)
log_entry = {
"time": now.strftime('%Y-%m-%d %H:%M:%S'),
"level": level,
"message": message,
"user_id": user_id,
"extra": extra or {}
}
# 使用有序集合 (ZSET),分数为时间戳,方便按时间清理
@ -58,21 +64,58 @@ class SystemLogger:
except:
pass
def info(self, message, **kwargs):
def _write_to_db(self, level, message, module=None, extra=None):
"""写入数据库"""
try:
from models import SystemLog
log_data = {
'level': level,
'message': message,
'module': module,
'user_id': extra.get('user_id') if extra else None,
'extra': json.dumps(extra, ensure_ascii=False) if extra else None,
'created_at': datetime.now()
}
# 捕获请求上下文信息
if has_request_context():
log_data.update({
'ip': request.remote_addr,
'path': request.path,
'method': request.method,
'user_agent': request.headers.get('User-Agent')
})
# 如果 log_data 还没 user_id尝试从 context 获取
if not log_data['user_id']:
log_data['user_id'] = getattr(g, 'user_id', None) or (getattr(g.user, 'id', None) if hasattr(g, 'user') and g.user else None)
new_log = SystemLog(**log_data)
db.session.add(new_log)
db.session.commit()
except Exception as e:
# 避免日志错误导致程序崩溃
self.logger.error(f"Failed to write log to DB: {str(e)}")
def info(self, message, module=None, **kwargs):
self.logger.info(message)
self._push_to_redis('INFO', message, kwargs)
self._write_to_db('INFO', message, module, kwargs)
def warning(self, message, **kwargs):
def warning(self, message, module=None, **kwargs):
self.logger.warning(message)
self._push_to_redis('WARNING', message, kwargs)
self._write_to_db('WARNING', message, module, kwargs)
def error(self, message, **kwargs):
def error(self, message, module=None, **kwargs):
self.logger.error(message)
self._push_to_redis('ERROR', message, kwargs)
self._write_to_db('ERROR', message, module, kwargs)
def debug(self, message, **kwargs):
def debug(self, message, module=None, **kwargs):
self.logger.debug(message)
self._push_to_redis('DEBUG', message, kwargs)
self._write_to_db('DEBUG', message, module, kwargs)
# 全局日志实例
system_logger = SystemLogger()

View File

@ -1,74 +1,202 @@
let isRegisterMode = false;
/**
* Auth Modes:
* 0 - Login
* 1 - Register
* 2 - Reset Password
*/
let authMode = 0;
document.getElementById('authSwitchBtn').onclick = () => {
isRegisterMode = !isRegisterMode;
document.getElementById('authTitle').innerText = isRegisterMode ? "加入视界 AI" : "欢迎回来";
document.getElementById('authSub').innerText = isRegisterMode ? "注册并开启创作" : "请登录以开启 AI 创作之旅";
document.getElementById('authSubmitBtn').querySelector('span').innerText = isRegisterMode ? "立即注册" : "立即登录";
document.getElementById('authSwitchBtn').innerText = isRegisterMode ? "已有账号?返回登录" : "没有账号?立即注册";
document.getElementById('smsGroup').classList.toggle('hidden', !isRegisterMode);
};
const getEl = (id) => document.getElementById(id);
document.getElementById('sendSmsBtn').onclick = async () => {
const phone = document.getElementById('authPhone').value;
const btn = document.getElementById('sendSmsBtn');
const updateUI = () => {
const title = getEl('authTitle');
const sub = getEl('authSub');
const submitBtnSpan = getEl('authSubmitBtn')?.querySelector('span');
const switchBtn = getEl('authSwitchBtn');
const forgotBtn = getEl('forgotPwdBtn');
const smsGroup = getEl('smsGroup');
const captchaGroup = getEl('captchaGroup');
if(!phone) return showToast('请输入手机号', 'warning');
if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return;
btn.disabled = true;
const originalText = btn.innerText;
const r = await fetch('/api/auth/send_code', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ phone })
});
const d = await r.json();
if(d.error) {
showToast(d.error, 'error');
btn.disabled = false;
} else {
showToast(d.message, 'success');
let countdown = 60;
const timer = setInterval(() => {
btn.innerText = `${countdown}秒后重试`;
countdown--;
if(countdown < 0) {
clearInterval(timer);
btn.innerText = originalText;
btn.disabled = false;
}
}, 1000);
if (authMode === 1) { // Register
title.innerText = "加入视界 AI";
sub.innerText = "注册并开启创作";
submitBtnSpan.innerText = "立即注册";
switchBtn.innerText = "已有账号?返回登录";
forgotBtn.classList.add('hidden');
smsGroup.classList.remove('hidden');
captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信
} else if (authMode === 2) { // Reset Password
title.innerText = "重置密码";
sub.innerText = "验证短信以设置新密码";
submitBtnSpan.innerText = "确认重置";
switchBtn.innerText = "我想起来了,返回登录";
forgotBtn.classList.add('hidden');
smsGroup.classList.remove('hidden');
captchaGroup.classList.remove('hidden'); // 重置模式默认显示
} else { // Login
title.innerText = "欢迎回来";
sub.innerText = "请登录以开启 AI 创作之旅";
submitBtnSpan.innerText = "立即登录";
switchBtn.innerText = "没有账号?立即注册";
forgotBtn.classList.remove('hidden');
smsGroup.classList.add('hidden');
captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败
}
};
document.getElementById('authSubmitBtn').onclick = async () => {
const phone = document.getElementById('authPhone').value;
const password = document.getElementById('authPass').value;
const code = document.getElementById('authCode').value;
const refreshCaptcha = () => {
const phone = getEl('authPhone')?.value;
const captchaImg = getEl('captchaImg');
if (!phone || !captchaImg) return;
captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`;
};
const url = isRegisterMode ? '/api/auth/register' : '/api/auth/login';
const body = isRegisterMode ? { phone, password, code } : { phone, password };
const handleAuth = async () => {
const phone = getEl('authPhone')?.value;
const password = getEl('authPass')?.value;
const r = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const d = await r.json();
if(d.error) {
showToast(d.error, 'error');
} else {
showToast(d.message, 'success');
if(isRegisterMode) {
isRegisterMode = true;
document.getElementById('authSwitchBtn').click();
} else {
// 获取来源页面路径
const urlParams = new URLSearchParams(window.location.search);
const nextUrl = urlParams.get('next') || '/';
window.location.href = nextUrl;
if (!phone || !password) {
return showToast('请输入手机号和密码', 'warning');
}
let url = '';
let body = { phone, password };
if (authMode === 1) {
url = '/api/auth/register';
body.code = getEl('authCode')?.value;
} else if (authMode === 0) {
url = '/api/auth/login';
if (!getEl('captchaGroup')?.classList.contains('hidden')) {
body.code = getEl('authCaptcha')?.value;
}
} else if (authMode === 2) {
url = '/api/auth/reset_password';
body.code = getEl('authCode')?.value;
if (!body.code) return showToast('请输入短信验证码', 'warning');
}
try {
const btn = getEl('authSubmitBtn');
if (btn) btn.disabled = true;
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const d = await r.json();
if (r.status === 403 && d.require_captcha) {
showToast(d.error, 'warning');
getEl('captchaGroup')?.classList.remove('hidden');
refreshCaptcha();
if (btn) btn.disabled = false;
return;
}
if (d.error) {
showToast(d.error, 'error');
if (getEl('captchaGroup') && !getEl('captchaGroup').classList.contains('hidden')) {
refreshCaptcha();
}
if (btn) btn.disabled = false;
} else {
showToast(d.message, 'success');
if (authMode === 1 || authMode === 2) {
authMode = 0;
updateUI();
if (btn) btn.disabled = false;
} else {
const urlParams = new URLSearchParams(window.location.search);
const nextUrl = urlParams.get('next') || '/';
window.location.href = nextUrl;
}
}
} catch (e) {
showToast('网络连接失败', 'error');
const btn = getEl('authSubmitBtn');
if (btn) btn.disabled = false;
}
};
// 初始化
document.addEventListener('DOMContentLoaded', () => {
updateUI();
getEl('authSwitchBtn').onclick = () => {
authMode = (authMode === 0) ? 1 : 0;
updateUI();
if (authMode === 1 || authMode === 2) refreshCaptcha();
};
getEl('forgotPwdBtn').onclick = () => {
authMode = 2;
updateUI();
refreshCaptcha();
};
getEl('captchaImg').onclick = refreshCaptcha;
getEl('authSubmitBtn').onclick = handleAuth;
// 回车登录支持
['authPhone', 'authPass', 'authCode', 'authCaptcha'].forEach(id => {
getEl(id)?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleAuth();
});
});
getEl('sendSmsBtn').onclick = async () => {
const phone = getEl('authPhone')?.value;
const captcha = getEl('authCaptcha')?.value;
const btn = getEl('sendSmsBtn');
if (!phone) return showToast('请输入手机号', 'warning');
if (!captcha) {
getEl('captchaGroup')?.classList.remove('hidden');
refreshCaptcha();
return showToast('请先输入图形验证码以发送短信', 'warning');
}
btn.disabled = true;
const originalText = btn.innerText;
try {
const r = await fetch('/api/auth/send_code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, captcha })
});
const d = await r.json();
if (d.error) {
showToast(d.error, 'error');
btn.disabled = false;
if (d.show_captcha || d.refresh_captcha) {
getEl('captchaGroup')?.classList.remove('hidden');
refreshCaptcha();
}
} else {
showToast(d.message, 'success');
// 发送成功后也要刷新图形验证码,防止被再次利用
refreshCaptcha();
let countdown = 60;
const timer = setInterval(() => {
btn.innerText = `${countdown}秒后重试`;
countdown--;
if (countdown < 0) {
clearInterval(timer);
btn.innerText = originalText;
btn.disabled = false;
}
}, 1000);
}
} catch (e) {
showToast('短信发送失败', 'error');
btn.disabled = false;
}
};
});

View File

@ -8,37 +8,58 @@ async function checkAuth() {
const loginHint = document.getElementById('loginHint');
const submitBtn = document.getElementById('submitBtn');
if(d.logged_in) {
if(profile) profile.classList.remove('hidden');
if(entry) entry.classList.add('hidden');
if(loginHint) loginHint.classList.add('hidden');
if (d.logged_in) {
if (profile) profile.classList.remove('hidden');
if (entry) entry.classList.add('hidden');
if (loginHint) loginHint.classList.add('hidden');
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-50', 'cursor-not-allowed');
const phoneDisp = document.getElementById('userPhoneDisplay');
if(phoneDisp) phoneDisp.innerText = d.phone;
if (phoneDisp) phoneDisp.innerText = d.phone;
// 处理积分显示
const pointsBadge = document.getElementById('pointsBadge');
const pointsDisplay = document.getElementById('pointsDisplay');
if(pointsBadge && pointsDisplay) {
if (pointsBadge && pointsDisplay) {
pointsBadge.classList.remove('hidden');
pointsDisplay.innerText = d.points;
}
const headerPoints = document.getElementById('headerPoints');
if(headerPoints) headerPoints.innerText = d.points;
if (headerPoints) headerPoints.innerText = d.points;
// 处理自定义 Key 显示逻辑
// 处理自定义 Key 显示逻辑
const keyBtn = document.getElementById('modeKeyBtn');
const modeButtonsContainer = keyBtn ? keyBtn.parentElement : null;
if (keyBtn && modeButtonsContainer) {
if (d.hide_custom_key) {
// 如果后端说要隐藏(用过积分生成),则隐藏整个按钮组
modeButtonsContainer.classList.add('hidden');
// 修改标题为“账户状态”
const authTitle = document.querySelector('#authSection h3');
if (authTitle) authTitle.innerText = '账户状态';
} else {
// 否则显示按钮组,让用户可以选择
modeButtonsContainer.classList.remove('hidden');
// 如果有 Key默认帮忙切过去方便老用户
if (d.api_key) {
switchMode('key');
const keyInput = document.getElementById('apiKey');
if (keyInput && !keyInput.value) keyInput.value = d.api_key;
return;
}
}
}
// 如果用户已经有绑定的 Key且当前没手动输入则默认切到 Key 模式
if(d.api_key) {
switchMode('key');
const keyInput = document.getElementById('apiKey');
if(keyInput && !keyInput.value) keyInput.value = d.api_key;
} else {
switchMode('trial');
}
// 强制使用积分模式
switchMode('trial');
} else {
if(profile) profile.classList.add('hidden');
if(entry) entry.classList.remove('hidden');
if(loginHint) loginHint.classList.remove('hidden');
if (profile) profile.classList.add('hidden');
if (entry) entry.classList.remove('hidden');
if (loginHint) loginHint.classList.remove('hidden');
submitBtn.disabled = true;
submitBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
@ -65,20 +86,20 @@ function switchMode(mode) {
const keyInputGroup = document.getElementById('keyInputGroup');
const premiumToggle = document.getElementById('premiumToggle');
if(mode === 'trial') {
if (mode === 'trial') {
trialBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
trialBtn.classList.remove('border-slate-200', 'text-slate-400');
keyBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
keyBtn.classList.add('border-slate-200', 'text-slate-400');
keyInputGroup.classList.add('hidden');
if(premiumToggle) premiumToggle.classList.remove('hidden');
if (premiumToggle) premiumToggle.classList.remove('hidden');
} else {
keyBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
keyBtn.classList.remove('border-slate-200', 'text-slate-400');
trialBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2');
trialBtn.classList.add('border-slate-200', 'text-slate-400');
keyInputGroup.classList.remove('hidden');
if(premiumToggle) premiumToggle.classList.add('hidden');
if (premiumToggle) premiumToggle.classList.add('hidden');
}
updateCostPreview(); // 切换模式时同步计费预览
}
@ -171,9 +192,9 @@ async function init() {
const modeTrialBtn = document.getElementById('modeTrialBtn');
const modeKeyBtn = document.getElementById('modeKeyBtn');
const isPremiumCheckbox = document.getElementById('isPremium');
if(modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial');
if(modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key');
if(isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview();
if (modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial');
if (modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key');
if (isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview();
// 历史记录控制
const historyDrawer = document.getElementById('historyDrawer');
@ -184,16 +205,16 @@ async function init() {
// 3D 构图辅助控制
const openVisualizerBtn = document.getElementById('openVisualizerBtn');
const closeVisualizerBtn = document.getElementById('closeVisualizerBtn');
if(openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
if(closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
if(showHistoryBtn) {
if (showHistoryBtn) {
showHistoryBtn.onclick = () => {
historyDrawer.classList.remove('translate-x-full');
loadHistory(false);
};
}
if(closeHistoryBtn) {
if (closeHistoryBtn) {
closeHistoryBtn.onclick = () => {
historyDrawer.classList.add('translate-x-full');
};
@ -211,11 +232,11 @@ async function init() {
// 全部下载按钮逻辑
const downloadAllBtn = document.getElementById('downloadAllBtn');
if(downloadAllBtn) {
if (downloadAllBtn) {
downloadAllBtn.onclick = async () => {
if(currentGeneratedUrls.length === 0) return;
if (currentGeneratedUrls.length === 0) return;
showToast(`正在准备下载 ${currentGeneratedUrls.length} 张作品...`, 'info');
for(const url of currentGeneratedUrls) {
for (const url of currentGeneratedUrls) {
await downloadImage(url);
// 稍微延迟一下,防止浏览器拦截
await new Promise(r => setTimeout(r, 300));
@ -225,16 +246,16 @@ async function init() {
// 重新生成按钮逻辑
const regenBtn = document.getElementById('regenBtn');
if(regenBtn) {
if (regenBtn) {
regenBtn.onclick = () => {
const submitBtn = document.getElementById('submitBtn');
if(submitBtn) submitBtn.click();
if (submitBtn) submitBtn.click();
};
}
// 检查是否有来自 URL 的错误提示
const urlParams = new URLSearchParams(window.location.search);
if(urlParams.has('error')) {
if (urlParams.has('error')) {
showToast(urlParams.get('error'), 'error');
// 清理 URL 参数以防刷新时重复提示
window.history.replaceState({}, document.title, window.location.pathname);
@ -246,13 +267,13 @@ async function init() {
fillSelect('modelSelect', d.models);
fillSelect('ratioSelect', d.ratios);
fillSelect('sizeSelect', d.sizes);
fillSelect('promptTpl', [{label:'✨ 自定义创作', value:'manual'}, ...d.prompts]);
fillSelect('promptTpl', [{ label: '✨ 自定义创作', value: 'manual' }, ...d.prompts]);
updateCostPreview(); // 初始化时显示默认模型的积分
} catch(e) { console.error(e); }
} catch (e) { console.error(e); }
// 初始化拖拽排序
const prev = document.getElementById('imagePreview');
if(prev) {
if (prev) {
new Sortable(prev, {
animation: 150,
ghostClass: 'opacity-50',
@ -271,9 +292,9 @@ async function init() {
function fillSelect(id, list) {
const el = document.getElementById(id);
if(!el) return;
if (!el) return;
// 如果是模型选择,增加积分显示
if(id === 'modelSelect') {
if (id === 'modelSelect') {
el.innerHTML = list.map(i => `<option value="${i.value}" data-cost="${i.cost || 0}">${i.label} (${i.cost || 0}积分)</option>`).join('');
} else {
el.innerHTML = list.map(i => `<option value="${i.value}">${i.label}</option>`).join('');
@ -289,7 +310,7 @@ function updateCostPreview() {
if (currentMode === 'trial' && selectedOption) {
let cost = parseInt(selectedOption.getAttribute('data-cost') || 0);
if(isPremium) cost *= 2; // 优质模式 2 倍积分
if (isPremium) cost *= 2; // 优质模式 2 倍积分
costPreview.innerText = `本次生成将消耗 ${cost} 积分`;
costPreview.classList.remove('hidden');
} else {
@ -300,7 +321,7 @@ function updateCostPreview() {
// 渲染参考图预览
function renderImagePreviews() {
const prev = document.getElementById('imagePreview');
if(!prev) return;
if (!prev) return;
prev.innerHTML = '';
uploadedFiles.forEach((file, index) => {
@ -415,7 +436,7 @@ document.getElementById('modelSelect').onchange = (e) => {
document.getElementById('promptTpl').onchange = (e) => {
const area = document.getElementById('manualPrompt');
if(e.target.value !== 'manual') {
if (e.target.value !== 'manual') {
area.value = e.target.value;
area.classList.add('hidden');
} else {
@ -433,20 +454,20 @@ document.getElementById('submitBtn').onclick = async () => {
// 检查登录状态并获取积分
const authCheck = await fetch('/api/auth/me');
const authData = await authCheck.json();
if(!authData.logged_in) {
if (!authData.logged_in) {
showToast('请先登录后再生成作品', 'warning');
return;
}
// 根据模式验证
if(currentMode === 'key') {
if(!apiKey) return showToast('请输入您的 API 密钥', 'warning');
if (currentMode === 'key') {
if (!apiKey) return showToast('请输入您的 API 密钥', 'warning');
} else {
if(authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning');
if (authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning');
}
// 允许文生图(不强制要求图片),但至少得有提示词或图片
if(!prompt && uploadedFiles.length === 0) {
if (!prompt && uploadedFiles.length === 0) {
return showToast('请至少输入提示词或上传参考图', 'warning');
}
@ -504,7 +525,7 @@ document.getElementById('submitBtn').onclick = async () => {
})
});
const res = await r.json();
if(res.error) throw new Error(res.error);
if (res.error) throw new Error(res.error);
// 如果直接返回了 data (比如聊天模型),直接显示
if (res.data) {
@ -530,7 +551,7 @@ document.getElementById('submitBtn').onclick = async () => {
displayResult(slot, { url: imgUrl });
finishedCount++;
btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`;
if(currentMode === 'trial') checkAuth();
if (currentMode === 'trial') checkAuth();
return; // 任务正常结束
} else if (statusRes.status === 'error') {
throw new Error(statusRes.message || "生成失败");
@ -543,7 +564,7 @@ document.getElementById('submitBtn').onclick = async () => {
} 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('请先登录')) {
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>`;
@ -591,7 +612,7 @@ init();
// 修改密码弹窗控制
function openPwdModal() {
const modal = document.getElementById('pwdModal');
if(!modal) return;
if (!modal) return;
modal.classList.remove('hidden');
setTimeout(() => {
modal.classList.add('opacity-100');
@ -601,7 +622,7 @@ function openPwdModal() {
function closePwdModal() {
const modal = document.getElementById('pwdModal');
if(!modal) return;
if (!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => {
@ -611,7 +632,7 @@ function closePwdModal() {
}
document.addEventListener('click', (e) => {
if(e.target.closest('#openPwdModalBtn')) {
if (e.target.closest('#openPwdModalBtn')) {
openPwdModal();
}
});
@ -624,17 +645,17 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => {
try {
const r = await fetch('/api/auth/change_password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({old_password, new_password})
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ old_password, new_password })
});
const d = await r.json();
if(r.ok) {
if (r.ok) {
showToast('密码修改成功,请记牢新密码', 'success');
closePwdModal();
} else {
showToast(d.error || '修改失败', 'error');
}
} catch(err) {
} catch (err) {
showToast('网络连接失败', 'error');
}
});
@ -642,7 +663,7 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => {
// 拍摄角度设置器弹窗控制
function openVisualizerModal() {
const modal = document.getElementById('visualizerModal');
if(!modal) return;
if (!modal) return;
// 检查是否已上传参考图
if (uploadedFiles.length === 0) {
@ -666,7 +687,7 @@ function openVisualizerModal() {
function closeVisualizerModal() {
const modal = document.getElementById('visualizerModal');
if(!modal) return;
if (!modal) return;
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => {

336
static/js/video.js Normal file
View File

@ -0,0 +1,336 @@
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
const submitBtn = document.getElementById('submitBtn');
const pointsDisplay = document.getElementById('pointsDisplay');
const headerPoints = document.getElementById('headerPoints');
const resultVideo = document.getElementById('resultVideo');
const finalWrapper = document.getElementById('finalWrapper');
const placeholder = document.getElementById('placeholder');
const statusInfo = document.getElementById('statusInfo');
const promptInput = document.getElementById('promptInput');
const fileInput = document.getElementById('fileInput');
const imagePreview = document.getElementById('imagePreview');
const modelSelect = document.getElementById('modelSelect');
const ratioSelect = document.getElementById('ratioSelect');
const promptTemplates = document.getElementById('promptTemplates');
const enhancePrompt = document.getElementById('enhancePrompt');
// 历史记录相关
const historyList = document.getElementById('historyList');
const historyCount = document.getElementById('historyCount');
const historyEmpty = document.getElementById('historyEmpty');
const loadMoreBtn = document.getElementById('loadMoreBtn');
let uploadedImageUrl = null;
let historyPage = 1;
let isLoadingHistory = false;
// 初始化配置
async function initConfig() {
try {
const r = await fetch('/api/config');
if (!r.ok) throw new Error('API 响应失败');
const d = await r.json();
if (d.video_models && d.video_models.length > 0) {
modelSelect.innerHTML = d.video_models.map(m =>
`<option value="${m.value}">${m.label} (${m.cost}积分)</option>`
).join('');
}
if (d.video_prompts && d.video_prompts.length > 0) {
promptTemplates.innerHTML = d.video_prompts.map(p =>
`<button type="button" class="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-[9px] font-bold transition-all whitespace-nowrap" onclick="applyTemplate('${p.value.replace(/'/g, "\\'")}')">${p.label}</button>`
).join('');
}
} catch (e) {
console.error('加载系统配置失败:', e);
}
}
// 载入历史记录
async function loadHistory(page = 1, append = false) {
if (isLoadingHistory) return;
isLoadingHistory = true;
try {
const r = await fetch(`/api/history?page=${page}&per_page=10`);
const d = await r.json();
// 过滤出有视频的记录
const videoRecords = d.history.filter(item => {
const urls = item.urls || [];
return urls.some(u => u.type === 'video' || (typeof u === 'string' && u.endsWith('.mp4')));
});
if (videoRecords.length > 0) {
const html = videoRecords.map(item => {
const videoObj = item.urls.find(u => u.type === 'video') || { url: item.urls[0] };
const videoUrl = typeof videoObj === 'string' ? videoObj : videoObj.url;
return `
<div class="group bg-white rounded-2xl p-3 border border-slate-100 hover:border-indigo-200 hover:shadow-md transition-all cursor-pointer animate-in fade-in slide-in-from-right-2" onclick="playHistoryVideo('${videoUrl}')">
<div class="relative aspect-video bg-black rounded-xl overflow-hidden mb-2">
<div class="absolute inset-0 flex items-center justify-center bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity">
<i data-lucide="play-circle" class="w-10 h-10 text-white"></i>
</div>
<video src="${videoUrl}" class="w-full h-full object-cover"></video>
</div>
<div class="space-y-1">
<p class="text-[10px] text-slate-600 font-medium line-clamp-2 leading-relaxed">${item.prompt || '无描述'}</p>
<div class="flex items-center justify-between pt-1">
<span class="text-[8px] text-slate-300 font-black uppercase tracking-tighter">${item.created_at}</span>
<div class="flex gap-1">
<button onclick="event.stopPropagation(); downloadUrl('${videoUrl}')" class="p-1 hover:text-indigo-600 transition-colors">
<i data-lucide="download" class="w-3 h-3"></i>
</button>
</div>
</div>
</div>
</div>
`;
}).join('');
if (append) {
historyList.insertAdjacentHTML('beforeend', html);
} else {
historyList.innerHTML = html;
}
historyEmpty.classList.add('hidden');
historyCount.innerText = videoRecords.length + (append ? parseInt(historyCount.innerText) : 0);
if (d.history.length >= 10) {
loadMoreBtn.classList.remove('hidden');
} else {
loadMoreBtn.classList.add('hidden');
}
} else if (!append) {
historyEmpty.classList.remove('hidden');
historyList.innerHTML = '';
}
lucide.createIcons();
} catch (e) {
console.error('加载历史失败:', e);
} finally {
isLoadingHistory = false;
}
}
window.playHistoryVideo = (url) => {
showVideo(url);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
window.downloadUrl = (url) => {
// 使用后端代理强制下载,绕过跨域限制
showToast('开始下载...', 'info');
const filename = `vision-video-${Date.now()}.mp4`;
const proxyUrl = `/api/download_proxy?url=${encodeURIComponent(url)}&filename=${filename}`;
// 创建隐藏的 iframe 触发下载,相比 a 标签兼容性更好
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = proxyUrl;
document.body.appendChild(iframe);
// 1分钟后清理 iframe
setTimeout(() => document.body.removeChild(iframe), 60000);
};
loadMoreBtn.onclick = () => {
historyPage++;
loadHistory(historyPage, true);
};
initConfig();
loadHistory();
window.applyTemplate = (text) => {
promptInput.value = text;
showToast('已应用提示词模板', 'success');
};
// 上传图片逻辑
fileInput.onchange = async (e) => {
const files = e.target.files;
if (files.length === 0) return;
const formData = new FormData();
formData.append('images', files[0]);
try {
submitBtn.disabled = true;
const r = await fetch('/api/upload', { method: 'POST', body: formData });
const d = await r.json();
if (d.urls && d.urls.length > 0) {
uploadedImageUrl = d.urls[0];
imagePreview.innerHTML = `
<div class="relative w-20 h-20 rounded-xl overflow-hidden border border-indigo-200 shadow-sm group">
<img src="${uploadedImageUrl}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center cursor-pointer" onclick="removeImage()">
<i data-lucide="x" class="w-5 h-5 text-white"></i>
</div>
</div>
`;
lucide.createIcons();
}
} catch (err) {
showToast('图片上传失败', 'error');
} finally {
submitBtn.disabled = false;
}
};
window.removeImage = () => {
uploadedImageUrl = null;
imagePreview.innerHTML = '';
fileInput.value = '';
};
// 提交生成任务
submitBtn.onclick = async () => {
const prompt = promptInput.value.trim();
if (!prompt) return showToast('请输入视频描述', 'warning');
const payload = {
prompt,
model: modelSelect.value,
enhance_prompt: enhancePrompt ? enhancePrompt.checked : false,
aspect_ratio: ratioSelect ? ratioSelect.value : '9:16',
images: uploadedImageUrl ? [uploadedImageUrl] : []
};
try {
setLoading(true);
const r = await fetch('/api/video/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const d = await r.json();
if (d.error) {
showToast(d.error, 'error');
setLoading(false);
} else if (d.task_id) {
showToast(d.message || '任务已提交', 'info');
pollTaskStatus(d.task_id);
} else {
showToast('未知异常,请重试', 'error');
setLoading(false);
}
} catch (err) {
console.error('提交生成异常:', err);
showToast('任务提交失败', 'error');
setLoading(false);
}
};
async function pollTaskStatus(taskId) {
let attempts = 0;
const maxAttempts = 180; // 提升到 15 分钟
const check = async () => {
try {
const r = await fetch(`/api/task_status/${taskId}?t=${Date.now()}`);
if (!r.ok) {
setTimeout(check, 5000);
return;
}
const d = await r.json();
if (d.status === 'complete') {
setLoading(false);
showVideo(d.video_url);
refreshUserPoints();
// 刷新历史列表
loadHistory(1, false);
} else if (d.status === 'error') {
showToast(d.message || '生成失败', 'error');
setLoading(false);
refreshUserPoints();
} else {
attempts++;
if (attempts >= maxAttempts) {
showToast('生成超时,请稍后在历史记录中查看', 'warning');
setLoading(false);
return;
}
setTimeout(check, 5000);
}
} catch (e) {
setTimeout(check, 5000);
}
};
check();
}
function showVideo(url) {
if (!url) return;
try {
placeholder.classList.add('hidden');
finalWrapper.classList.remove('hidden');
resultVideo.src = url;
resultVideo.load();
const playPromise = resultVideo.play();
if (playPromise !== undefined) {
playPromise.catch(e => console.warn('自动播放被拦截'));
}
} catch (err) {
console.error('展示视频失败:', err);
}
}
const closePreviewBtn = document.getElementById('closePreviewBtn');
if (closePreviewBtn) {
closePreviewBtn.onclick = () => {
finalWrapper.classList.add('hidden');
placeholder.classList.remove('hidden');
resultVideo.pause();
resultVideo.src = "";
};
}
function setLoading(isLoading) {
submitBtn.disabled = isLoading;
if (isLoading) {
statusInfo.classList.remove('hidden');
submitBtn.innerHTML = '<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i><span>导演创作中...</span>';
// 如果正在生成,确保回到预览背景状态(如果当前正在播放旧视频)
if (!finalWrapper.classList.contains('hidden')) {
finalWrapper.classList.add('hidden');
placeholder.classList.remove('hidden');
}
} else {
statusInfo.classList.add('hidden');
submitBtn.innerHTML = '<i data-lucide="clapperboard" class="w-5 h-5"></i><span class="text-base font-bold tracking-widest">开始生成视频</span>';
}
if (window.lucide) lucide.createIcons();
}
async function refreshUserPoints() {
try {
const r = await fetch('/api/auth/me');
const d = await r.json();
if (d.points !== undefined) {
if (pointsDisplay) pointsDisplay.innerText = d.points;
if (headerPoints) headerPoints.innerText = d.points;
}
} catch (e) { }
}
document.getElementById('downloadBtn').onclick = () => {
const url = resultVideo.src;
if (!url) return;
downloadUrl(url);
};
if (regenBtn) {
regenBtn.onclick = () => {
submitBtn.click();
};
}
});

83
sync_videos_manual.py Normal file
View File

@ -0,0 +1,83 @@
import json
import io
import requests
import uuid
import time
from urllib.parse import quote
from app import create_app
from extensions import db, s3_client
from config import Config
from models import GenerationRecord
app = create_app()
def sync_old_videos():
with app.app_context():
print("🔍 开始扫描未同步的视频记录...")
# 获取所有包含 'video' 字样的记录 (简单过滤)
records = GenerationRecord.query.filter(GenerationRecord.image_urls.like('%video%')).all()
count = 0
success_count = 0
for r in records:
try:
data = json.loads(r.image_urls)
updated = False
new_data = []
for item in data:
# 检查是否是视频且 URL 不是 MinIO 的地址
if isinstance(item, dict) and item.get('type') == 'video':
url = item.get('url')
if url and Config.MINIO['public_url'] not in url:
print(f"⏳ 正在同步记录 {r.id}: {url[:50]}...")
# 尝试下载并转存
try:
with requests.get(url, stream=True, timeout=60) as req:
if req.status_code == 200:
content_type = req.headers.get('content-type', 'video/mp4')
ext = ".mp4"
base_filename = f"video-{uuid.uuid4().hex}"
full_filename = f"{base_filename}{ext}"
video_io = io.BytesIO()
for chunk in req.iter_content(chunk_size=8192):
video_io.write(chunk)
video_io.seek(0)
# 上传至 MinIO
s3_client.upload_fileobj(
video_io,
Config.MINIO["bucket"],
full_filename,
ExtraArgs={"ContentType": content_type}
)
final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
item['url'] = final_url
updated = True
print(f"✅ 同步成功: {final_url}")
else:
print(f"❌ 下载失败 (Status {req.status_code}),可能链接已过期")
except Exception as e:
print(f"❌ 同步异常: {e}")
new_data.append(item)
if updated:
r.image_urls = json.dumps(new_data)
db.session.commit()
success_count += 1
count += 1
except Exception as e:
print(f"处理记录 {r.id} 出错: {e}")
print(f"\n🎉 扫描完成! 成功同步了 {success_count} 个视频。")
if __name__ == "__main__":
sync_old_videos()

View File

@ -1,43 +1,89 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI 视界{% endblock %}</title>
<link rel="icon" href="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" type="image/png">
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap"
rel="stylesheet">
<script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<style>
/* 关键路径 CSS防止侧边栏及图标闪烁 */
#globalNav {
width: 5rem;
flex-shrink: 0;
display: flex;
}
#globalNav.hidden {
display: none !important;
}
[data-lucide] {
width: 1.25rem;
height: 1.25rem;
display: inline-block;
}
</style>
{% block head %}{% endblock %}
</head>
<body class="text-slate-700 antialiased bg-slate-50 overflow-hidden">
<div class="bg-mesh"></div>
<div id="toastContainer" class="toast-container"></div>
<div class="flex h-screen w-screen overflow-hidden">
<!-- 全局系统菜单栏 (默认隐藏,仅在 initGlobalNav 成功后显示) -->
<nav id="globalNav" class="hidden w-20 flex-shrink-0 bg-slate-900 flex flex-col items-center py-8 z-40 shadow-2xl transition-all duration-500">
<!-- 全局系统菜单栏 (服务端渲染,防止闪烁) -->
<nav id="globalNav"
class="{% if not nav_menu %}hidden{% endif %} w-20 flex-shrink-0 bg-slate-900 flex flex-col items-center py-8 z-40 shadow-2xl">
<div class="w-12 h-12 mb-12 flex items-center justify-center p-1">
<img src="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" alt="Logo" class="w-full h-full object-contain rounded-xl shadow-lg">
<img src="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" alt="Logo"
class="w-full h-full object-contain rounded-xl shadow-lg">
</div>
<div id="dynamicMenuList" class="flex-1 w-full px-2 space-y-4"></div>
<div id="dynamicMenuList" class="flex-1 w-full px-2 space-y-4">
{% for item in nav_menu %}
{% set isActive = request.path == item.url %}
<div class="relative group flex justify-center">
<a href="{{ item.url }}"
class="w-12 h-12 flex items-center justify-center rounded-2xl transition-all duration-300 {{ 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/40' if isActive else 'text-slate-500 hover:bg-slate-800 hover:text-white' }}">
<i data-lucide="{{ item.icon }}" class="w-5 h-5"></i>
</a>
<div
class="absolute left-full ml-4 px-3 py-2 bg-slate-800 text-white text-[10px] font-black rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl">
{{ item.name }}
<div
class="absolute right-full top-1/2 -translate-y-1/2 border-8 border-transparent border-r-slate-800">
</div>
</div>
</div>
{% endfor %}
</div>
<div id="globalUserProfile" class="flex flex-col items-center gap-4 mb-4">
<!-- 联系方式 -->
<div class="relative group flex justify-center mb-2">
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700 hover:text-indigo-400 transition-colors cursor-help">
<div
class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700 hover:text-indigo-400 transition-colors cursor-help">
<i data-lucide="message-circle" class="w-5 h-5"></i>
</div>
<div class="absolute left-full ml-4 px-3 py-2 bg-slate-800 text-white text-[10px] font-black rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl border border-slate-700">
<div
class="absolute left-full ml-4 px-3 py-2 bg-slate-800 text-white text-[10px] font-black rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl border border-slate-700">
联系客服 QQ: 240241002
<div class="absolute right-full top-1/2 -translate-y-1/2 border-8 border-transparent border-r-slate-800"></div>
<div
class="absolute right-full top-1/2 -translate-y-1/2 border-8 border-transparent border-r-slate-800">
</div>
</div>
</div>
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700">
<div
class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700">
<i data-lucide="user" class="w-5 h-5"></i>
</div>
<button id="globalLogoutBtn" class="text-slate-500 hover:text-rose-400 transition-colors">
@ -53,16 +99,21 @@
</div>
<!-- 全局通知弹窗 -->
<div id="notifModal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-md z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-500">
<div class="bg-white w-full max-w-2xl rounded-[2.5rem] shadow-2xl p-10 space-y-6 transform scale-90 transition-transform duration-500">
<div class="w-16 h-16 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-2">
<div id="notifModal"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-md z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-500">
<div
class="bg-white w-full max-w-2xl rounded-[2.5rem] shadow-2xl p-10 space-y-6 transform scale-90 transition-transform duration-500">
<div
class="w-16 h-16 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-2">
<i data-lucide="bell-ring" class="w-8 h-8 animate-tada"></i>
</div>
<div class="text-center space-y-2">
<h2 id="notifTitle" class="text-2xl font-black text-slate-900">系统通知</h2>
<div id="notifContent" class="text-slate-500 text-sm font-bold leading-relaxed whitespace-pre-wrap"></div>
<div id="notifContent" class="text-slate-500 text-sm font-bold leading-relaxed whitespace-pre-wrap">
</div>
</div>
<button id="closeNotifBtn" class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm hover:bg-slate-800 transition-all shadow-xl shadow-slate-200">
<button id="closeNotifBtn"
class="w-full py-4 rounded-2xl bg-slate-900 text-white font-black text-sm hover:bg-slate-800 transition-all shadow-xl shadow-slate-200">
我已收到
</button>
</div>
@ -71,37 +122,6 @@
<script>
lucide.createIcons();
// 自动加载菜单
async function initGlobalNav() {
try {
const r = await fetch('/api/auth/menu');
const d = await r.json();
if(d.menu && d.menu.length > 0) {
document.getElementById('globalNav').classList.remove('hidden');
const list = document.getElementById('dynamicMenuList');
list.innerHTML = d.menu.map(item => {
const isActive = window.location.pathname === item.url;
return `
<div class="relative group flex justify-center">
<a href="${item.url}"
class="w-12 h-12 flex items-center justify-center rounded-2xl transition-all duration-300 ${
isActive
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/40'
: 'text-slate-500 hover:bg-slate-800 hover:text-white'
}">
<i data-lucide="${item.icon}" class="w-5 h-5"></i>
</a>
<div class="absolute left-full ml-4 px-3 py-2 bg-slate-800 text-white text-[10px] font-black rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl">
${item.name}
<div class="absolute right-full top-1/2 -translate-y-1/2 border-8 border-transparent border-r-slate-800"></div>
</div>
</div>
`;
}).join('');
lucide.createIcons();
}
} catch(e) { console.error('菜单加载失败', e); }
}
document.getElementById('globalLogoutBtn').onclick = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
@ -150,7 +170,6 @@
});
};
initGlobalNav();
checkNotifications();
function showToast(message, type = 'info') {
@ -167,4 +186,5 @@
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -3,97 +3,130 @@
{% block title %}系统字典管理 - AI 视界{% endblock %}
{% block content %}
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar">
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar transition-all duration-500" id="mainContainer">
<div class="max-w-6xl mx-auto space-y-8">
<!-- 头部 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center shadow-lg">
<div
class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center shadow-lg transition-transform hover:rotate-6">
<i data-lucide="book-open" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">系统字典管理</h1>
<p class="text-slate-400 text-sm">统一维护模型、比例、提示词等系统参数</p>
<h1 class="text-3xl font-black text-slate-900 tracking-tight" id="pageTitle">数据字典控制中心</h1>
<p class="text-slate-400 text-sm font-medium" id="pageSubTitle">全局业务参数与 AI 模型配置</p>
</div>
</div>
<button onclick="openModal()" class="btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i>
新增字典项
</button>
<div class="flex items-center gap-3">
<button id="backBtn"
class="hidden px-5 py-3 rounded-xl border border-slate-200 text-slate-400 text-sm font-bold hover:bg-slate-50 transition-all flex items-center gap-2">
<i data-lucide="arrow-left" class="w-4 h-4"></i>
返回列表
</button>
<button id="addBtn" onclick="openModal()"
class="hidden btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg flex items-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i>
新增项
</button>
</div>
</div>
<!-- 筛选栏 -->
<div class="flex gap-4 bg-white p-4 rounded-3xl shadow-sm border border-slate-100">
<button onclick="loadDicts('')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all bg-slate-100 text-slate-600 active-filter">全部</button>
<button onclick="loadDicts('ai_model')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">AI 模型</button>
<button onclick="loadDicts('aspect_ratio')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">画面比例</button>
<button onclick="loadDicts('ai_image_size')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">输出尺寸</button>
<button onclick="loadDicts('prompt_tpl')" class="dict-filter-btn px-4 py-2 rounded-xl text-xs font-bold transition-all hover:bg-slate-50 text-slate-400">提示词模板</button>
<!-- 视图 1字典类型列表 -->
<div id="typeListView"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<!-- 动态加载类型卡片 -->
</div>
<!-- 表格 -->
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 border-b border-slate-100">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">类型</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">显示名称</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">存储值/内容</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">积分消耗</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">操作</th>
</tr>
</thead>
<tbody id="dictTableBody" class="text-sm font-medium">
<!-- 动态加载 -->
</tbody>
</table>
<!-- 视图 2详情表格 -->
<div id="detailView" class="hidden space-y-4 animate-in fade-in slide-in-from-right-4 duration-500">
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 border-b border-slate-100">
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
内容/显示名称</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
存储值 (Value)</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
计费 (Cost)</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
状态</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
操作</th>
</tr>
</thead>
<tbody id="dictTableBody" class="text-sm font-medium">
<!-- 动态加载 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div id="dictModal" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 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 p-10 space-y-8 transform scale-95 transition-transform duration-300">
<h2 id="modalTitle" class="text-2xl font-black text-slate-900">新增字典项</h2>
<form id="dictForm" class="space-y-5">
<div id="dictModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-md z-50 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 p-10 space-y-8 transform scale-95 transition-transform duration-300 border border-white/50">
<div class="flex items-center justify-between">
<h2 id="modalTitle" class="text-2xl font-black text-slate-900 tracking-tight">配置项详情</h2>
<button onclick="closeModal()"
class="w-8 h-8 rounded-full bg-slate-50 text-slate-400 flex items-center justify-center hover:bg-slate-100 transition-all">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<form id="dictForm" class="space-y-6">
<input type="hidden" id="dictId">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">字典类型</label>
<select id="dictType" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<option value="ai_model">AI 模型 (ai_model)</option>
<option value="aspect_ratio">画面比例 (aspect_ratio)</option>
<option value="ai_image_size">输出尺寸 (ai_image_size)</option>
<option value="prompt_tpl">提示词模板 (prompt_tpl)</option>
</select>
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">所属类型</label>
<input type="text" id="dictType" readonly
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none text-slate-400 text-sm font-bold cursor-not-allowed">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">显示名称 (Label)</label>
<input type="text" id="dictLabel" required class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">显示标签 (Label)</label>
<input type="text" id="dictLabel" required placeholder="如: 高清 4K"
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">存储值/内容 (Value)</label>
<textarea id="dictValue" required rows="3" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold resize-none"></textarea>
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">实际取值 (Value)</label>
<textarea id="dictValue" required rows="2" placeholder="API 所需的参数值"
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">积分消耗</label>
<input type="number" id="dictCost" value="0" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">消耗积分</label>
<input type="number" id="dictCost" value="0"
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">排序权重</label>
<input type="number" id="dictOrder" value="0" class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<input type="number" id="dictOrder" value="0"
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<input type="checkbox" id="dictActive" checked class="w-4 h-4 rounded border-slate-200 text-indigo-600 focus:ring-indigo-500">
<label for="dictActive" class="text-sm font-bold text-slate-600">立即启用</label>
</div>
<div class="flex gap-4 pt-4">
<button type="button" onclick="closeModal()" class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit" class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存配置</button>
<div class="flex items-center justify-between bg-slate-50 p-4 rounded-2xl border border-slate-100/50">
<div class="flex items-center gap-2">
<i data-lucide="eye" class="w-4 h-4 text-indigo-500"></i>
<span class="text-sm font-bold text-slate-600">前端可见状态</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="dictActive" checked class="sr-only peer">
<div
class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600">
</div>
</label>
</div>
<button type="submit"
class="w-full btn-primary py-4 rounded-2xl font-bold shadow-xl shadow-indigo-100 hover:scale-[1.02] active:scale-[0.98] transition-all">保存配置变更</button>
</form>
</div>
</div>
@ -101,75 +134,132 @@
{% block scripts %}
<script>
let currentType = '';
let currentCategory = null;
let categoriesList = [];
async function loadDicts(type = '') {
currentType = type;
// 更新按钮样式
document.querySelectorAll('.dict-filter-btn').forEach(btn => {
const btnType = btn.getAttribute('onclick').match(/'(.*)'/)[1];
if(btnType === type) {
btn.classList.add('bg-slate-100', 'text-slate-600');
btn.classList.remove('hover:bg-slate-50', 'text-slate-400');
} else {
btn.classList.remove('bg-slate-100', 'text-slate-600');
btn.classList.add('hover:bg-slate-50', 'text-slate-400');
}
});
// 加载所有字典类型
async function loadCategories() {
const r = await fetch('/api/admin/dict_types');
const d = await r.json();
categoriesList = d.types;
try {
const r = await fetch(`/api/admin/dicts?type=${type}`);
const d = await r.json();
const body = document.getElementById('dictTableBody');
body.innerHTML = d.dicts.map(item => `
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors">
<td class="px-8 py-5">
<span class="px-2 py-0.5 rounded text-[10px] font-black uppercase ${
item.dict_type === 'ai_model' ? 'bg-indigo-50 text-indigo-600' :
item.dict_type === 'aspect_ratio' ? 'bg-emerald-50 text-emerald-600' :
item.dict_type === 'ai_image_size' ? 'bg-rose-50 text-rose-600' : 'bg-amber-50 text-amber-600'
}">${item.dict_type}</span>
</td>
<td class="px-8 py-5 text-slate-700 font-bold">${item.label}</td>
<td class="px-8 py-5 text-slate-400 text-xs truncate max-w-xs" title="${item.value}">${item.value}</td>
<td class="px-8 py-5">
<span class="text-amber-600 font-black">${item.cost}</span>
</td>
<td class="px-8 py-5">
<span class="${item.is_active ? 'text-emerald-500' : 'text-slate-300'}">
<i data-lucide="${item.is_active ? 'check-circle-2' : 'circle'}" class="w-4 h-4"></i>
</span>
</td>
<td class="px-8 py-5">
<div class="flex gap-3">
<button onclick='editDict(${JSON.stringify(item)})' class="text-indigo-400 hover:text-indigo-600 transition-colors">
<i data-lucide="edit-3" class="w-4 h-4"></i>
</button>
<button onclick="deleteDict(${item.id})" class="text-rose-300 hover:text-rose-500 transition-colors">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
const container = document.getElementById('typeListView');
container.innerHTML = d.types.map(cat => `
<div onclick="enterCategory('${cat.type}', '${cat.name}')" class="group bg-white p-8 rounded-[2.5rem] shadow-xl border border-slate-100 hover:border-indigo-500 transition-all cursor-pointer relative overflow-hidden">
<div class="absolute top-0 right-0 w-32 h-32 bg-indigo-50 rounded-full -mr-16 -mt-16 group-hover:scale-110 transition-transform duration-500"></div>
<div class="relative z-10 space-y-4">
<div class="w-12 h-12 bg-slate-900 text-white rounded-2xl flex items-center justify-center group-hover:bg-indigo-600 transition-colors shadow-lg">
<i data-lucide="${getIconForType(cat.type)}" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-xl font-black text-slate-900 tracking-tight">${cat.name}</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">${cat.type}</p>
</div>
<div class="flex items-center justify-between pt-2">
<span class="px-3 py-1 bg-slate-50 text-slate-400 rounded-lg text-xs font-black uppercase tracking-tight">${cat.count} 个配置项</span>
<div class="w-8 h-8 rounded-full border border-slate-100 flex items-center justify-center text-slate-300 group-hover:text-indigo-600 group-hover:border-indigo-100 transition-all">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</div>
</td>
</tr>
`).join('');
lucide.createIcons();
} catch (e) {
console.error(e);
}
</div>
</div>
</div>
`).join('');
lucide.createIcons();
}
function openModal(data = null) {
function getIconForType(type) {
const map = {
'ai_model': 'cpu',
'aspect_ratio': 'maximize',
'ai_image_size': 'layout',
'prompt_tpl': 'file-text',
'video_model': 'video',
'video_prompt': 'clapperboard'
};
return map[type] || 'settings-2';
}
// 进入某个分类的详情视图
async function enterCategory(type, name) {
currentCategory = { type, name };
// 更新 UI 状态
document.getElementById('typeListView').classList.add('hidden');
document.getElementById('detailView').classList.remove('hidden');
document.getElementById('backBtn').classList.remove('hidden');
document.getElementById('addBtn').classList.remove('hidden');
document.getElementById('pageTitle').innerText = name;
document.getElementById('pageSubTitle').innerText = `正在管理 ${type} 类型的详细参数`;
await loadDetails(type);
}
// 返回列表视图
function exitCategory() {
currentCategory = null;
document.getElementById('detailView').classList.add('hidden');
document.getElementById('typeListView').classList.remove('hidden');
document.getElementById('backBtn').classList.add('hidden');
document.getElementById('addBtn').classList.add('hidden');
document.getElementById('pageTitle').innerText = '数据字典控制中心';
document.getElementById('pageSubTitle').innerText = '全局业务参数与 AI 模型配置';
loadCategories(); // 刷新计数
}
document.getElementById('backBtn').onclick = exitCategory;
// 获取分类详情
async function loadDetails(type) {
const r = await fetch(`/api/admin/dicts?type=${type}`);
const d = await r.json();
const body = document.getElementById('dictTableBody');
body.innerHTML = d.dicts.map(item => `
<tr class="border-b border-slate-50 hover:bg-slate-50 transition-colors">
<td class="px-8 py-5">
<div class="text-slate-900 font-bold">${item.label}</div>
<div class="text-[9px] text-slate-300 font-black uppercase tracking-tighter">ID: ${item.id} · 排序: ${item.sort_order}</div>
</td>
<td class="px-8 py-5">
<div class="text-slate-500 text-xs font-medium max-w-sm truncate" title="${item.value}">${item.value}</div>
</td>
<td class="px-8 py-5">
<span class="px-2.5 py-1 bg-amber-50 text-amber-600 rounded-lg text-xs font-black">${item.cost} <small class="text-[8px]">POINTS</small></span>
</td>
<td class="px-8 py-5">
<div class="flex items-center gap-2 ${item.is_active ? 'text-emerald-500' : 'text-slate-300'}">
<div class="w-2 h-2 rounded-full ${item.is_active ? 'bg-emerald-500 animate-pulse' : 'bg-slate-300'}"></div>
<span class="text-[10px] font-black uppercase">${item.is_active ? 'Active' : 'Disabled'}</span>
</div>
</td>
<td class="px-8 py-5">
<div class="flex gap-2">
<button onclick='editDict(${JSON.stringify(item)})' class="p-2 text-indigo-400 hover:bg-indigo-50 rounded-xl transition-all">
<i data-lucide="settings-2" class="w-4 h-4"></i>
</button>
<button onclick="deleteDict(${item.id})" class="p-2 text-rose-300 hover:bg-rose-50 rounded-xl transition-all">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</td>
</tr>
`).join('');
lucide.createIcons();
}
function openModal(item = null) {
const modal = document.getElementById('dictModal');
const form = document.getElementById('dictForm');
document.getElementById('modalTitle').innerText = data ? '编辑字典项' : '新增字典项';
document.getElementById('dictId').value = data ? data.id : '';
document.getElementById('dictType').value = data ? data.dict_type : 'ai_model';
document.getElementById('dictLabel').value = data ? data.label : '';
document.getElementById('dictValue').value = data ? data.value : '';
document.getElementById('dictCost').value = data ? data.cost : 0;
document.getElementById('dictOrder').value = data ? data.sort_order : 0;
document.getElementById('dictActive').checked = data ? data.is_active : true;
document.getElementById('modalTitle').innerText = item ? '编辑配置项' : '新增配置项';
document.getElementById('dictId').value = item ? item.id : '';
document.getElementById('dictType').value = currentCategory ? currentCategory.type : '';
document.getElementById('dictLabel').value = item ? item.label : '';
document.getElementById('dictValue').value = item ? item.value : '';
document.getElementById('dictCost').value = item ? item.cost : 0;
document.getElementById('dictOrder').value = item ? item.sort_order : 0;
document.getElementById('dictActive').checked = item ? item.is_active : true;
modal.classList.remove('hidden');
setTimeout(() => {
@ -185,25 +275,21 @@
setTimeout(() => modal.classList.add('hidden'), 300);
}
function editDict(item) {
openModal(item);
}
window.editDict = (item) => openModal(item);
async function deleteDict(id) {
if(!confirm('确定要删除此字典项吗?')) return;
try {
const r = await fetch('/api/admin/dicts/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const d = await r.json();
if(d.message) {
showToast(d.message, 'success');
loadDicts(currentType);
}
} catch (e) { console.error(e); }
}
window.deleteDict = async (id) => {
if (!confirm('确定永久删除该配置项?此操作不可逆,可能影响线上渲染。')) return;
const r = await fetch('/api/admin/dicts/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const d = await r.json();
if (d.message) {
showToast(d.message, 'success');
loadDetails(currentCategory.type);
}
};
document.getElementById('dictForm').onsubmit = async (e) => {
e.preventDefault();
@ -217,21 +303,19 @@
is_active: document.getElementById('dictActive').checked
};
try {
const r = await fetch('/api/admin/dicts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const d = await r.json();
if(d.message) {
showToast(d.message, 'success');
closeModal();
loadDicts(currentType);
}
} catch (e) { console.error(e); }
const r = await fetch('/api/admin/dicts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const d = await r.json();
if (d.message) {
showToast(d.message, 'success');
closeModal();
loadDetails(currentCategory.type);
}
};
loadDicts();
loadCategories();
</script>
{% endblock %}

View File

@ -4,31 +4,38 @@
{% block content %}
<!-- 中间AI 功能设定区 -->
<aside class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
<aside
class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
<div class="p-6 pb-2">
<h2 class="text-xl font-black text-slate-900 tracking-tight">创作工作台</h2>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">智能试戴引擎配置</p>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar">
<section class="space-y-4">
<section id="authSection" class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
<h3 class="text-sm font-bold text-slate-800">授权验证</h3>
</div>
<!-- 积分显示 -->
<div id="pointsBadge" class="hidden px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
可用积分: <span id="pointsDisplay">0</span>
<div id="pointsBadge"
class="{% if not g.user %}hidden{% endif %} px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
可用积分: <span id="pointsDisplay">{{ g.user.points if g.user else 0 }}</span>
</div>
</div>
<div class="flex gap-2">
<button id="modeTrialBtn" class="flex-1 py-2 rounded-xl text-[10px] font-bold border-2 border-indigo-500 bg-indigo-50 text-indigo-600 transition-all">积分/试用模式</button>
<button id="modeKeyBtn" class="flex-1 py-2 rounded-xl text-[10px] font-bold border border-slate-200 text-slate-400 hover:bg-slate-50 transition-all">自定义 Key</button>
<div class="flex gap-2 hidden">
<button id="modeTrialBtn"
class="flex-1 py-2 rounded-xl text-[10px] font-bold border-2 border-indigo-500 bg-indigo-50 text-indigo-600 transition-all">积分/试用模式</button>
<button id="modeKeyBtn"
class="flex-1 py-2 rounded-xl text-[10px] font-bold border border-slate-200 text-slate-400 hover:bg-slate-50 transition-all">自定义
Key</button>
</div>
<div id="premiumToggle" class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
<div id="premiumToggle"
class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center">
<i data-lucide="sparkles" class="w-4 h-4"></i>
@ -40,12 +47,15 @@
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="isPremium" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
<div
class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500">
</div>
</label>
</div>
<div id="keyInputGroup" class="hidden relative group animate-in slide-in-from-top-2 duration-300">
<input id="apiKey" type="password" placeholder="输入您的 OpenAI API 密钥" class="w-full bg-white border border-slate-200 rounded-2xl p-3.5 pl-11 text-xs outline-none focus:border-indigo-500 transition-all shadow-sm">
<input id="apiKey" type="password" placeholder="输入您的 OpenAI API 密钥"
class="w-full bg-white border border-slate-200 rounded-2xl p-3.5 pl-11 text-xs outline-none focus:border-indigo-500 transition-all shadow-sm">
<i data-lucide="key-round" class="w-4 h-4 absolute left-4 top-1/2 -translate-y-1/2 text-indigo-400"></i>
</div>
</section>
@ -53,49 +63,70 @@
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
<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">
<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">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="cpu" class="w-3 h-3"></i>计算模型</label>
<select id="modelSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="cpu" class="w-3 h-3"></i>计算模型</label>
<select id="modelSelect"
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="layout" class="w-3 h-3"></i>画面比例</label>
<select id="ratioSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="layout" class="w-3 h-3"></i>画面比例</label>
<select id="ratioSelect"
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
</div>
<div id="sizeGroup" class="hidden space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="maximize" class="w-3 h-3"></i>输出尺寸</label>
<select id="sizeSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="maximize" class="w-3 h-3"></i>输出尺寸</label>
<select id="sizeSelect"
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i data-lucide="copy" class="w-3 h-3"></i>生成数量</label>
<select id="numSelect" class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all">
<option value="1">1 张</option><option value="2">2 张</option><option value="3">3 张</option><option value="4">4 张</option>
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="copy" class="w-3 h-3"></i>生成数量</label>
<select id="numSelect"
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all">
<option value="1">1 张</option>
<option value="2">2 张</option>
<option value="3">3 张</option>
<option value="4">4 张</option>
</select>
</div>
</div>
<div class="rounded-2xl border border-slate-100 overflow-hidden bg-white shadow-sm">
<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>
</select>
<textarea id="manualPrompt" rows="4" 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>
<section class="space-y-4 pb-2">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">03</span>
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">03</span>
<h3 class="text-sm font-bold text-slate-800">参考底图</h3>
</div>
<div id="dropZone" class="relative group">
<div class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
<div
class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
<i data-lucide="image-plus" class="w-6 h-6 mx-auto mb-2 text-slate-300"></i>
<p class="text-[10px] text-slate-400 font-bold">点击、拖拽或粘贴肖像照片</p>
</div>
@ -106,12 +137,13 @@
</div>
<div class="p-6 bg-white/95 border-t border-slate-100">
<button id="submitBtn" class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
<button id="submitBtn"
class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
<i data-lucide="wand-2" class="w-5 h-5"></i>
<span class="text-base font-bold tracking-widest">立即生成作品</span>
</button>
<p id="costPreview" class="text-center text-[10px] text-amber-600 font-bold mt-2 hidden"></p>
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 hidden">
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 {% if g.user %}hidden{% endif %}">
<i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
</p>
</div>
@ -127,23 +159,29 @@
<div class="flex items-center gap-6">
<!-- 历史记录触发按钮 -->
<button id="showHistoryBtn" class="flex items-center gap-2 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm cursor-pointer hover:bg-white transition-all text-xs font-bold text-slate-600">
<button id="showHistoryBtn"
class="flex items-center gap-2 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm cursor-pointer hover:bg-white transition-all text-xs font-bold text-slate-600">
<i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
历史记录
</button>
<div id="userProfile" class="hidden flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
<div id="userProfile"
class="{% if not g.user %}hidden{% endif %} flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
<div class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600">
<i data-lucide="user" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">--</span>
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">0</span> 积分</span>
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
g.user.phone[-4:] if g.user else "--" }}</span>
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">{{
g.user.points if g.user else 0 }}</span> 积分</span>
</div>
<button id="openPwdModalBtn" title="修改密码" class="ml-1 p-1.5 text-slate-400 hover:text-indigo-600 transition-colors">
<button id="openPwdModalBtn" title="修改密码"
class="ml-1 p-1.5 text-slate-400 hover:text-indigo-600 transition-colors">
<i data-lucide="key-round" class="w-4 h-4"></i>
</button>
</div>
<a id="loginEntryBtn" href="/login" class="bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
<a id="loginEntryBtn" href="/login"
class="{% if g.user %}hidden{% endif %} bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
立即登录
</a>
</div>
@ -151,7 +189,8 @@
<div class="flex-1 flex flex-col p-8 relative overflow-hidden min-h-0">
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div
class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
</div>
@ -159,7 +198,8 @@
<div id="resultCanvas" class="flex-1 w-full flex flex-col relative overflow-hidden">
<!-- 初始占位状态 -->
<div id="placeholder" class="flex-1 flex flex-col items-center justify-center text-center max-w-lg mx-auto">
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
<div
class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
<i data-lucide="glasses" class="w-20 h-20 text-indigo-500"></i>
</div>
<h2 class="text-4xl font-black text-slate-900 mb-8">视界进化 · 艺术呈现</h2>
@ -169,19 +209,24 @@
<div id="finalWrapper" class="hidden flex-1 w-full flex flex-col overflow-hidden">
<!-- 滚动区域 -->
<div class="flex-1 w-full overflow-y-auto custom-scrollbar px-8 py-10">
<div id="imageGrid" class="grid grid-cols-1 md:grid-cols-2 gap-10 w-full max-w-6xl mx-auto items-start"></div>
<div id="imageGrid"
class="grid grid-cols-1 md:grid-cols-2 gap-10 w-full max-w-6xl mx-auto items-start"></div>
</div>
<!-- 固定底部操作栏 -->
<div id="resultActions" class="w-full flex-shrink-0 bg-slate-50/80 backdrop-blur-sm border-t border-slate-200/60 py-6 flex items-center justify-center gap-8 z-10">
<div id="resultActions"
class="w-full flex-shrink-0 bg-slate-50/80 backdrop-blur-sm border-t border-slate-200/60 py-6 flex items-center justify-center gap-8 z-10">
<!-- 全部下载按钮 -->
<button id="downloadAllBtn" class="bg-indigo-600 text-white px-10 py-4 rounded-3xl text-sm font-bold shadow-xl shadow-indigo-100 hover:bg-indigo-700 hover:scale-105 active:scale-95 transition-all flex items-center gap-3">
<button id="downloadAllBtn"
class="bg-indigo-600 text-white px-10 py-4 rounded-3xl text-sm font-bold shadow-xl shadow-indigo-100 hover:bg-indigo-700 hover:scale-105 active:scale-95 transition-all flex items-center gap-3">
<i data-lucide="download" class="w-5 h-5"></i>
<span>全部下载</span>
</button>
<!-- 重新生成按钮 -->
<button id="regenBtn" class="w-16 h-16 bg-white border border-slate-100 rounded-3xl flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95 group">
<i data-lucide="refresh-cw" class="w-6 h-6 group-hover:rotate-180 transition-transform duration-500"></i>
<button id="regenBtn"
class="w-16 h-16 bg-white border border-slate-100 rounded-3xl flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95 group">
<i data-lucide="refresh-cw"
class="w-6 h-6 group-hover:rotate-180 transition-transform duration-500"></i>
</button>
</div>
</div>
@ -189,13 +234,15 @@
</div>
<!-- 历史记录滑出抽屉 (子页面) -->
<div id="historyDrawer" class="absolute inset-y-0 right-0 w-96 bg-white/95 backdrop-blur-2xl border-l border-slate-100 shadow-2xl z-50 translate-x-full transition-transform duration-500 flex flex-col">
<div id="historyDrawer"
class="absolute inset-y-0 right-0 w-96 bg-white/95 backdrop-blur-2xl border-l border-slate-100 shadow-2xl z-50 translate-x-full transition-transform duration-500 flex flex-col">
<div class="p-8 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 class="text-xl font-black text-slate-900">创作历史</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">展示 90 天内的生成记录</p>
</div>
<button id="closeHistoryBtn" class="w-10 h-10 rounded-xl hover:bg-slate-100 flex items-center justify-center text-slate-400 transition-colors">
<button id="closeHistoryBtn"
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>
@ -213,29 +260,37 @@
</main>
<!-- 修改密码弹窗 -->
<div id="pwdModal" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300">
<div id="pwdModal"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div
class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300">
<h2 class="text-2xl font-black text-slate-900">修改登录密码</h2>
<form id="pwdForm" class="space-y-5">
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">原密码</label>
<input type="password" id="oldPwd" required class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<input type="password" id="oldPwd" required
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">新密码</label>
<input type="password" id="newPwd" required class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
<input type="password" id="newPwd" required
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
</div>
<div class="flex gap-4 pt-4">
<button type="button" onclick="closePwdModal()" class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit" class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">确认修改</button>
<button type="button" onclick="closePwdModal()"
class="flex-1 px-8 py-4 rounded-2xl border border-slate-100 font-bold text-slate-400 hover:bg-slate-50 transition-all">取消</button>
<button type="submit"
class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">确认修改</button>
</div>
</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 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">
@ -246,7 +301,8 @@
<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">
<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>

View File

@ -4,7 +4,8 @@
{% block content %}
<div class="w-full h-full flex items-center justify-center p-6 bg-slate-50/50 backdrop-blur-sm">
<div class="auth-card w-full max-w-md space-y-8 bg-white p-10 rounded-[2.5rem] shadow-2xl border border-slate-100 relative z-10">
<div
class="auth-card w-full max-w-md space-y-8 bg-white p-10 rounded-[2.5rem] shadow-2xl border border-slate-100 relative z-10">
<div class="text-center space-y-4">
<div class="w-16 h-16 btn-primary mx-auto rounded-2xl flex items-center justify-center shadow-lg rotate-3">
<i data-lucide="scan-eye" class="w-10 h-10"></i>
@ -17,28 +18,53 @@
<div class="space-y-4">
<div class="relative group">
<input id="authPhone" type="text" placeholder="手机号" class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="phone" class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
<input id="authPhone" type="text" placeholder="手机号"
class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="phone"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div>
<div id="smsGroup" class="hidden relative group">
<input id="authCode" type="text" placeholder="验证码" class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="shield-check" class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
<button id="sendSmsBtn" class="absolute right-4 top-1/2 -translate-y-1/2 text-indigo-600 text-xs font-bold hover:text-indigo-700">发送验证码</button>
<input id="authCode" type="text" placeholder="验证码"
class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="shield-check"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
<button id="sendSmsBtn"
class="absolute right-4 top-1/2 -translate-y-1/2 text-indigo-600 text-xs font-bold hover:text-indigo-700">发送验证码</button>
</div>
<!-- 图形验证码组 (仅在失败次数过多时显示) -->
<div id="captchaGroup" class="hidden flex items-center gap-3">
<div class="relative group flex-1">
<input id="authCaptcha" type="text" placeholder="图形验证码"
class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="image"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div>
<div class="w-32 h-14 bg-slate-50 rounded-2xl overflow-hidden cursor-pointer border border-slate-200"
title="点击刷新验证码">
<img id="captchaImg" src="" class="w-full h-full object-cover">
</div>
</div>
<div class="relative group">
<input id="authPass" type="password" placeholder="密码" class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="lock" class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
<input id="authPass" type="password" placeholder="密码"
class="w-full bg-slate-50 border border-slate-200 rounded-2xl p-4 pl-12 text-sm outline-none focus:border-indigo-500 transition-all">
<i data-lucide="lock"
class="w-5 h-5 absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-indigo-500 transition-colors"></i>
</div>
</div>
<button id="authSubmitBtn" class="w-full btn-primary py-5 rounded-2xl shadow-xl shadow-indigo-100 hover:scale-[1.02] active:scale-[0.98] transition-all">
<button id="authSubmitBtn"
class="w-full btn-primary py-5 rounded-2xl shadow-xl shadow-indigo-100 hover:scale-[1.02] active:scale-[0.98] transition-all">
<span class="text-lg font-bold">立即登录</span>
</button>
<div class="text-center pt-2">
<button id="authSwitchBtn" class="text-sm text-slate-400 hover:text-indigo-600 font-bold transition-colors">没有账号?立即注册</button>
<div class="text-center pt-2 flex flex-col gap-3">
<button id="authSwitchBtn"
class="text-sm text-slate-400 hover:text-indigo-600 font-bold transition-colors">没有账号?立即注册</button>
<button id="forgotPwdBtn"
class="text-xs text-slate-300 hover:text-indigo-400 transition-colors">忘记密码?通过短信重置</button>
</div>
<div class="text-center">

View File

@ -1,60 +1,166 @@
{% extends "base.html" %}
{% block title %}系统日志 - AI 视界{% endblock %}
{% block title %}系统审计日志 - AI 视界{% endblock %}
{% block content %}
<div class="w-full h-full overflow-y-auto p-8 lg:p-12 custom-scrollbar">
<div class="max-w-7xl mx-auto space-y-8">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-slate-900 text-white rounded-2xl flex items-center justify-center shadow-lg">
<i data-lucide="terminal" class="w-7 h-7"></i>
<div class="w-full h-full overflow-y-auto p-6 lg:p-10 custom-scrollbar bg-slate-50/50">
<div class="max-w-7xl mx-auto space-y-6">
<!-- 头部导航与操作 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div class="flex items-center gap-5">
<div
class="w-14 h-14 bg-gradient-to-br from-slate-800 to-slate-900 text-white rounded-2xl flex items-center justify-center shadow-2xl ring-4 ring-white">
<i data-lucide="shield-check" class="w-8 h-8"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">系统审计日志</h1>
<p class="text-slate-400 text-sm">实时监控系统运行状态与安全事件</p>
<div class="flex items-center gap-2 mt-1">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-green-100 text-green-600">
<span class="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5 animate-pulse"></span>
系统监控中
</span>
<p class="text-slate-400 text-xs font-medium">记录用户关键动作与系统安全审计</p>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<!-- 筛选工具栏 -->
<div class="flex items-center bg-white border border-slate-200 rounded-xl px-4 py-2 shadow-sm">
<i data-lucide="search" class="w-4 h-4 text-slate-400 mr-2"></i>
<input type="text" id="logSearch" placeholder="搜索消息或手机号..."
class="outline-none text-sm w-48 text-slate-600" oninput="loadLogs()">
</div>
<select id="logLevel" class="bg-white border border-slate-200 rounded-xl px-4 py-2 text-sm text-slate-600 outline-none shadow-sm" onchange="loadLogs()">
<option value="">全部级别</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select>
<button onclick="loadLogs()" class="bg-white border border-slate-200 p-3 rounded-xl hover:bg-slate-50 transition-all shadow-sm">
<i data-lucide="refresh-cw" class="w-5 h-5 text-slate-600"></i>
<!-- 增强版筛选工具 -->
<div class="flex flex-wrap items-center gap-3">
<div class="relative group">
<i data-lucide="search"
class="w-4 h-4 text-slate-400 absolute left-4 top-1/2 -translate-y-1/2 transition-colors group-focus-within:text-slate-900"></i>
<input type="text" id="logSearch" placeholder="搜索动作、手机号..."
class="bg-white border border-slate-200 rounded-2xl pl-11 pr-4 py-3 text-sm w-64 focus:ring-4 focus:ring-slate-100 focus:border-slate-400 transition-all outline-none shadow-sm"
oninput="debounceLoad()">
</div>
<select id="logLevel"
class="bg-white border border-slate-200 rounded-2xl px-5 py-3 text-sm font-bold text-slate-700 outline-none focus:ring-4 focus:ring-slate-100 shadow-sm appearance-none cursor-pointer"
onchange="resetAndLoad()">
<option value="">全部级别</option>
<option value="INFO" class="text-indigo-600">INFO (常规动作)</option>
<option value="WARNING" class="text-amber-600">WARNING (安全警告)</option>
<option value="ERROR" class="text-rose-600">ERROR (异常拦截)</option>
</select>
<button onclick="loadLogs()"
class="bg-white border border-slate-200 p-3 rounded-2xl hover:bg-slate-50 hover:shadow-md transition-all active:scale-95 shadow-sm">
<i data-lucide="refresh-cw" id="refreshIcon" class="w-5 h-5 text-slate-600"></i>
</button>
<a href="/" class="btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg">返回工作台</a>
</div>
</div>
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<!-- 数据表格容器 -->
<div
class="bg-white rounded-[2.5rem] shadow-2xl shadow-slate-200/50 border border-slate-100 overflow-hidden min-h-[500px] flex flex-col">
<div class="flex-grow overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-0">
<thead>
<tr class="bg-slate-50 border-b border-slate-100 sticky top-0 z-10 shadow-sm">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-50">时间</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-50">级别</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-50">事件消息</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-50">关键参数</th>
<tr class="bg-slate-50/80 backdrop-blur-md">
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
时间</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
级别</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
操作人</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
动作详情</th>
<th
class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 text-right">
操作</th>
</tr>
</thead>
<tbody id="logTableBody" class="text-sm font-medium">
<!-- 动态加载 -->
<tbody id="logTableBody" class="text-sm">
<!-- 骨架屏加载状态 -->
<tr>
<td colspan="4" class="px-8 py-20 text-center text-slate-400 italic">正在连接日志服务器...</td>
<td colspan="5" class="px-8 py-32 text-center">
<div class="flex flex-col items-center gap-4">
<div
class="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin">
</div>
<p class="text-slate-400 font-bold tracking-tight">正在调取审计数据...</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控制栏 -->
<div class="px-8 py-6 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between">
<div class="text-xs font-bold text-slate-400">
<span id="totalCount" class="text-slate-900">0</span> 条记录
</div>
<div class="flex items-center gap-2">
<button id="prevBtn" onclick="changePage(-1)"
class="p-2 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
<i data-lucide="chevron-left" class="w-5 h-5"></i>
</button>
<div id="pageNumbers" class="flex items-center gap-1">
<!-- 页码 -->
</div>
<button id="nextBtn" onclick="changePage(1)"
class="p-2 rounded-xl border border-slate-200 bg-white hover:bg-slate-50 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
<i data-lucide="chevron-right" class="w-5 h-5"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 详情模态框 -->
<div id="logModal"
class="fixed inset-0 z-[100] hidden flex items-center justify-center p-6 backdrop-blur-sm bg-slate-900/20">
<div
class="bg-white rounded-[3rem] shadow-3xl max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col animate-in fade-in zoom-in duration-300">
<div class="px-10 py-8 border-b border-slate-100 flex items-center justify-between bg-slate-50/30">
<div class="flex items-center gap-4">
<div id="modalLevelIcon" class="w-10 h-10 rounded-xl flex items-center justify-center"></div>
<div>
<h3 class="text-xl font-black text-slate-900">日志详细参数</h3>
<p id="modalTime" class="text-slate-400 text-xs font-mono"></p>
</div>
</div>
<button onclick="closeModal()"
class="w-10 h-10 rounded-full hover:bg-slate-200/50 flex items-center justify-center transition-colors">
<i data-lucide="x" class="w-6 h-6 text-slate-400"></i>
</button>
</div>
<div class="p-10 overflow-y-auto custom-scrollbar flex-grow">
<div id="modalMessage" class="text-lg font-bold text-slate-800 mb-8 border-l-4 border-slate-900 pl-6 py-2">
</div>
<!-- 更多请求详情 -->
<div class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-slate-50 p-4 rounded-2xl">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">操作账户</p>
<p id="modalUser" class="text-sm font-bold text-slate-700"></p>
</div>
<div class="bg-slate-50 p-4 rounded-2xl">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">访问 IP</p>
<p id="modalIP" class="text-sm font-mono text-slate-700"></p>
</div>
<div class="bg-slate-50 p-4 rounded-2xl">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">接口路径</p>
<p id="modalPath" class="text-sm font-mono text-slate-700"></p>
</div>
<div class="bg-slate-50 p-4 rounded-2xl">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">操作模块</p>
<p id="modalModule" class="text-sm font-bold text-slate-700"></p>
</div>
</div>
<div class="space-y-4">
<h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest">关键上下文 Data</h4>
<div id="modalExtra"
class="bg-slate-900 rounded-3xl p-8 font-mono text-sm text-indigo-300 break-all whitespace-pre-wrap leading-relaxed shadow-inner">
<!-- JSON 详情 -->
</div>
</div>
</div>
</div>
</div>
@ -62,10 +168,46 @@
{% block scripts %}
<script>
let currentPage = 1;
let totalPages = 1;
let is_loading = false;
let debounceTimer;
function debounceLoad() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => resetAndLoad(), 500);
}
function resetAndLoad() {
currentPage = 1;
loadLogs();
}
function changePage(delta) {
if (is_loading) return;
const targetPage = currentPage + delta;
if (targetPage >= 1 && targetPage <= totalPages) {
currentPage = targetPage;
loadLogs();
}
}
function goToPage(p) {
if (currentPage === p || is_loading) return;
currentPage = p;
loadLogs();
}
async function loadLogs() {
if (is_loading) return;
is_loading = true;
const refreshIcon = document.getElementById('refreshIcon');
refreshIcon?.classList.add('animate-spin');
const search = document.getElementById('logSearch')?.value || '';
const level = document.getElementById('logLevel')?.value || '';
const url = `/api/auth/logs?search=${encodeURIComponent(search)}&level=${level}`;
const url = `/api/auth/logs?search=${encodeURIComponent(search)}&level=${level}&page=${currentPage}&per_page=15`;
try {
const r = await fetch(url);
@ -73,45 +215,128 @@
const body = document.getElementById('logTableBody');
if (d.error) {
body.innerHTML = `<tr><td colspan="4" class="px-8 py-20 text-center text-rose-500 font-bold">${d.error}</td></tr>`;
body.innerHTML = `<tr><td colspan="5" class="px-8 py-32 text-center text-rose-500 font-bold">${d.error}</td></tr>`;
return;
}
// 更新页信息
totalPages = d.total_pages;
document.getElementById('totalCount').innerText = d.total;
document.getElementById('prevBtn').disabled = currentPage <= 1;
document.getElementById('nextBtn').disabled = currentPage >= totalPages;
// 渲染页码按钮
renderPagination(d.page, d.total_pages);
if (d.logs.length === 0) {
body.innerHTML = `<tr><td colspan="4" class="px-8 py-20 text-center text-slate-400 italic">没有找到符合条件的日志</td></tr>`;
body.innerHTML = `<tr><td colspan="5" class="px-8 py-32 text-center"><div class="flex flex-col items-center gap-3 opacity-30"><i data-lucide="inbox" class="w-12 h-12"></i><p class="font-bold">未找到匹配的审计记录</p></div></td></tr>`;
lucide.createIcons();
return;
}
body.innerHTML = d.logs.map(log => `
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors">
<td class="px-8 py-5 text-slate-400 font-mono text-xs">${log.time}</td>
<td class="px-8 py-5">
<span class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase ${
log.level === 'INFO' ? 'bg-indigo-50 text-indigo-600' :
log.level === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-rose-50 text-rose-600'
}">${log.level}</span>
<tr class="group hover:bg-slate-50/50 transition-all cursor-default">
<td class="px-8 py-6 text-slate-400 font-mono text-[11px] border-b border-slate-50">${log.time}</td>
<td class="px-8 py-6 border-b border-slate-50">
<span class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-tight ${log.level === 'INFO' ? 'bg-indigo-50 text-indigo-600' :
log.level === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-rose-50 text-rose-600'
}">${log.level === 'INFO' ? '常规' : log.level === 'WARNING' ? '安全' : '异常'}</span>
</td>
<td class="px-8 py-5 text-slate-700 font-bold break-all whitespace-pre-wrap min-w-[300px]">${log.message}</td>
<td class="px-8 py-5 text-slate-400 font-mono text-[10px] min-w-[200px]">
${Object.entries(log.extra).map(([k, v]) => {
const val = typeof v === 'object' ? JSON.stringify(v) : v;
return `<span class="inline-block bg-slate-100 rounded px-1.5 py-0.5 mr-1 mb-1 break-all">${k}: ${val}</span>`;
}).join('')}
<td class="px-8 py-6 border-b border-slate-50">
<div class="flex flex-col">
<span class="text-slate-900 font-bold text-xs">${log.user_phone}</span>
<span class="text-slate-400 text-[10px] font-mono">${log.ip || 'Unknown IP'}</span>
</div>
</td>
<td class="px-8 py-6 border-b border-slate-50">
<div class="flex flex-col gap-1">
<span class="text-slate-900 font-black tracking-tight text-sm">${log.message}</span>
<span class="text-slate-400 text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity">
路径: ${log.method} ${log.path} | 模块: ${log.module || 'system'}
</span>
</div>
</td>
<td class="px-8 py-6 text-right border-b border-slate-50">
<button onclick='showDetails(${JSON.stringify(log).replace(/'/g, "&apos;")})'
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-slate-100 text-slate-600 text-[11px] font-black hover:bg-slate-900 hover:text-white transition-all">
查看详情 <i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
</button>
</td>
</tr>
`).join('');
lucide.createIcons();
} catch (e) {
console.error(e);
} finally {
is_loading = false;
refreshIcon?.classList.remove('animate-spin');
}
}
function renderPagination(current, total) {
const wrapper = document.getElementById('pageNumbers');
let html = '';
// 简单的分页逻辑显示当前页及前后2页
for (let i = Math.max(1, current - 2); i <= Math.min(total, current + 2); i++) {
html += `<button onclick="goToPage(${i})" class="w-10 h-10 rounded-xl font-bold text-xs transition-all ${i === current ? 'bg-slate-900 text-white shadow-lg' : 'bg-white text-slate-400 hover:text-slate-900 border border-slate-200'
}">${i}</button>`;
}
wrapper.innerHTML = html;
}
function showDetails(log) {
const modal = document.getElementById('logModal');
const iconWrap = document.getElementById('modalLevelIcon');
document.getElementById('modalTime').innerText = log.time;
document.getElementById('modalMessage').innerText = log.message;
document.getElementById('modalExtra').innerText = JSON.stringify(log.extra, null, 4);
// 填充新增的详细信息
document.getElementById('modalUser').innerText = log.user_phone || '系统/游客';
document.getElementById('modalIP').innerText = log.ip || 'Unknown';
document.getElementById('modalPath').innerText = (log.method || '') + ' ' + (log.path || '');
document.getElementById('modalModule').innerText = log.module || 'system';
if (log.level === 'INFO') {
iconWrap.className = 'w-10 h-10 rounded-xl flex items-center justify-center bg-indigo-50 text-indigo-600';
iconWrap.innerHTML = '<i data-lucide="info" class="w-6 h-6"></i>';
} else if (log.level === 'WARNING') {
iconWrap.className = 'w-10 h-10 rounded-xl flex items-center justify-center bg-amber-50 text-amber-600';
iconWrap.innerHTML = '<i data-lucide="alert-triangle" class="w-6 h-6"></i>';
} else {
iconWrap.className = 'w-10 h-10 rounded-xl flex items-center justify-center bg-rose-50 text-rose-600';
iconWrap.innerHTML = '<i data-lucide="x-circle" class="w-6 h-6"></i>';
}
modal.classList.remove('hidden');
modal.classList.add('flex');
lucide.createIcons();
document.body.style.overflow = 'hidden';
}
function closeModal() {
const modal = document.getElementById('logModal');
modal.classList.add('hidden');
modal.classList.remove('flex');
document.body.style.overflow = '';
}
// 点击外侧关闭
document.getElementById('logModal').onclick = (e) => {
if (e.target === document.getElementById('logModal')) closeModal();
}
// 初始化加载
loadLogs();
// 自动刷新逻辑如果搜索框为空则每5秒刷新一次
// 自动刷新逻辑:如果搜索框为空且在第一页则每10秒刷新一次
setInterval(() => {
const search = document.getElementById('logSearch')?.value || '';
if (!search) loadLogs();
}, 5000);
if (!search && currentPage === 1) loadLogs();
}, 10000);
</script>
{% endblock %}

View File

@ -4,16 +4,20 @@
{% block content %}
<!-- 中间AI 功能设定区 -->
<aside class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
<aside
class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
<div class="p-6 pb-2">
<h2 class="text-xl font-black text-slate-900 tracking-tight">验光单助手</h2>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">智能数据提取配置</p>
<!-- 免责声明提示 - 增强版 -->
<div class="mt-6 p-4 bg-indigo-50/50 border border-indigo-100/50 rounded-2xl relative overflow-hidden group">
<div class="absolute top-0 right-0 -mt-2 -mr-2 w-16 h-16 bg-indigo-500/5 rounded-full blur-2xl group-hover:bg-indigo-500/10 transition-colors"></div>
<div
class="absolute top-0 right-0 -mt-2 -mr-2 w-16 h-16 bg-indigo-500/5 rounded-full blur-2xl group-hover:bg-indigo-500/10 transition-colors">
</div>
<div class="flex items-center gap-2.5 mb-2">
<div class="w-6 h-6 bg-white rounded-lg flex items-center justify-center shadow-sm border border-indigo-50">
<div
class="w-6 h-6 bg-white rounded-lg flex items-center justify-center shadow-sm border border-indigo-50">
<i data-lucide="shield-check" class="w-3.5 h-3.5 text-indigo-500"></i>
</div>
<span class="text-xs font-black text-indigo-900 uppercase tracking-wider">使用必读</span>
@ -25,7 +29,8 @@
<div class="p-2 bg-white/60 rounded-xl border border-indigo-100/20 backdrop-blur-sm">
<p class="text-[11px] text-indigo-600 leading-relaxed font-bold flex items-start gap-1.5">
<i data-lucide="alert-triangle" class="w-3 h-3 mt-0.5 flex-shrink-0"></i>
<span>因手写单据模糊或光影识别误差,<span class="underline decoration-indigo-300 underline-offset-2">请务必以人工核对后的数据为准</span></span>
<span>因手写单据模糊或光影识别误差,<span
class="underline decoration-indigo-300 underline-offset-2">请务必以人工核对后的数据为准</span></span>
</p>
</div>
<p class="text-[10px] text-slate-400 font-bold italic">
@ -39,16 +44,19 @@
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
<h3 class="text-sm font-bold text-slate-800">状态信息</h3>
</div>
<!-- 积分显示 -->
<div id="pointsBadge" class="hidden px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
可用积分: <span id="pointsDisplay">0</span>
<div id="pointsBadge"
class="{% if not g.user %}hidden{% endif %} px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
可用积分: <span id="pointsDisplay">{{ g.user.points if g.user else 0 }}</span>
</div>
</div>
<div id="premiumToggle" class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
<div id="premiumToggle"
class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center">
<i data-lucide="sparkles" class="w-4 h-4"></i>
@ -60,21 +68,26 @@
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="isPremium" class="sr-only peer">
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500"></div>
<div
class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500">
</div>
</label>
</div>
</section>
<section class="space-y-4 pb-2">
<div class="flex items-center gap-3">
<span class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
<h3 class="text-sm font-bold text-slate-800">验光单照片</h3>
</div>
<div id="dropZone" class="relative group">
<div class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
<div
class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
<i data-lucide="image-plus" class="w-6 h-6 mx-auto mb-2 text-slate-300"></i>
<p class="text-[10px] text-slate-400 font-bold">点击、拖拽或粘贴照片最多3张</p>
<p class="text-[8px] text-indigo-400 font-black mt-1 uppercase tracking-tighter">* 仅支持同一人的多张单据合拍或分传</p>
<p class="text-[8px] text-indigo-400 font-black mt-1 uppercase tracking-tighter">* 仅支持同一人的多张单据合拍或分传
</p>
</div>
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer">
</div>
@ -83,12 +96,13 @@
</div>
<div class="p-6 bg-white/95 border-t border-slate-100">
<button id="submitBtn" class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
<button id="submitBtn"
class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
<i data-lucide="wand-2" class="w-5 h-5"></i>
<span class="text-base font-bold tracking-widest">开始解读验光单</span>
</button>
<p id="costPreview" class="text-center text-[10px] text-amber-600 font-bold mt-2 hidden"></p>
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 hidden">
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 {% if g.user %}hidden{% endif %}">
<i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
</p>
</div>
@ -103,16 +117,20 @@
</div>
<div class="flex items-center gap-6">
<div id="userProfile" class="hidden flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
<div id="userProfile"
class="{% if not g.user %}hidden{% endif %} flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
<div class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600">
<i data-lucide="user" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">--</span>
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">0</span> 积分</span>
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
g.user.phone[-4:] if g.user else "--" }}</span>
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">{{
g.user.points if g.user else 0 }}</span> 积分</span>
</div>
</div>
<a id="loginEntryBtn" href="/login" class="bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
<a id="loginEntryBtn" href="/login"
class="{% if g.user %}hidden{% endif %} bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
立即登录
</a>
</div>
@ -120,7 +138,8 @@
<div class="flex-1 flex items-center justify-center p-8 relative overflow-hidden">
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div
class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
</div>
@ -129,7 +148,8 @@
<div id="resultCanvas" class="w-full h-full flex flex-col items-center justify-center">
<!-- 初始占位状态 -->
<div id="placeholder" class="text-center max-w-lg">
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
<div
class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
<i data-lucide="scan-eye" class="w-20 h-20 text-indigo-500"></i>
</div>
<h2 class="text-4xl font-black text-slate-900 mb-8">精准解析 · 专业建议</h2>
@ -140,17 +160,22 @@
<div id="finalWrapper" class="hidden w-full h-full flex flex-col items-center">
<!-- 滚动的内容区域 -->
<div class="flex-1 w-full overflow-y-auto custom-scrollbar px-4 py-6 flex flex-col items-center">
<div id="textResult" class="w-full max-w-4xl p-10 bg-white rounded-[2.5rem] shadow-2xl border border-slate-100 prose prose-indigo max-w-none mb-6"></div>
<div id="textResult"
class="w-full max-w-4xl p-10 bg-white rounded-[2.5rem] shadow-2xl border border-slate-100 prose prose-indigo max-w-none mb-6">
</div>
</div>
<!-- 固定的操作栏区域 -->
<div id="resultActions" class="w-full flex-shrink-0 bg-slate-50/80 backdrop-blur-sm border-t border-slate-200/60 py-6 flex flex-col items-center gap-3 z-10">
<div id="resultActions"
class="w-full flex-shrink-0 bg-slate-50/80 backdrop-blur-sm border-t border-slate-200/60 py-6 flex flex-col items-center gap-3 z-10">
<div class="flex items-center gap-4">
<button id="copyJsonBtn" class="bg-slate-900 text-white px-10 py-3.5 rounded-2xl text-xs font-bold shadow-xl hover:bg-slate-800 transition-all flex items-center gap-2.5 active:scale-[0.98]">
<button id="copyJsonBtn"
class="bg-slate-900 text-white px-10 py-3.5 rounded-2xl text-xs font-bold shadow-xl hover:bg-slate-800 transition-all flex items-center gap-2.5 active:scale-[0.98]">
<i data-lucide="copy" class="w-4 h-4"></i>
复制解析数据
</button>
<button onclick="location.reload()" class="bg-white border border-slate-200 text-slate-600 px-10 py-3.5 rounded-2xl text-xs font-bold hover:bg-slate-50 transition-all flex items-center gap-2.5 active:scale-[0.98]">
<button onclick="location.reload()"
class="bg-white border border-slate-200 text-slate-600 px-10 py-3.5 rounded-2xl text-xs font-bold hover:bg-slate-50 transition-all flex items-center gap-2.5 active:scale-[0.98]">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
重新解析
</button>

241
templates/video.html Normal file
View File

@ -0,0 +1,241 @@
{% extends "base.html" %}
{% block title %}AI 视频创作 - AI 视界{% endblock %}
{% block content %}
<!-- 中间AI 功能设定区 -->
<aside
class="w-80 lg:w-[340px] flex-shrink-0 glass-sidebar flex flex-col z-30 border-r border-slate-200/60 shadow-xl bg-white/50 backdrop-blur-xl">
<div class="p-6 pb-2">
<h2 class="text-xl font-black text-slate-900 tracking-tight">AI 视频创作</h2>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">智能视频生成引擎</p>
</div>
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar">
<section class="space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">01</span>
<h3 class="text-sm font-bold text-slate-800">账户状态</h3>
</div>
<!-- 积分显示 -->
<div id="pointsBadge"
class="{% if not g.user %}hidden{% endif %} px-2 py-0.5 bg-amber-50 text-amber-600 border border-amber-100 rounded-lg text-[10px] font-black uppercase">
可用积分: <span id="pointsDisplay">{{ g.user.points if g.user else 0 }}</span>
</div>
</div>
<div id="premiumToggle"
class="flex items-center justify-between bg-amber-50/50 border border-amber-100/50 p-3 rounded-2xl animate-in fade-in duration-500">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center">
<i data-lucide="sparkles" class="w-4 h-4"></i>
</div>
<div>
<div class="text-[10px] font-black text-amber-700 uppercase tracking-tight">提示词增强优化</div>
<div class="text-[8px] text-amber-500 font-bold">针对镜像/英文模型深度优化</div>
</div>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="enhancePrompt" checked class="sr-only peer">
<div
class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-amber-500">
</div>
</label>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center gap-3">
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">02</span>
<h3 class="text-sm font-bold text-slate-800">生成设定</h3>
</div>
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="cpu" class="w-3 h-3"></i>选择模型</label>
<select id="modelSelect"
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-[10px] font-bold outline-none focus:border-indigo-500 transition-all">
<!-- 由 JS 动态填充 -->
</select>
</div>
<div class="space-y-1.5">
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="layout" class="w-3 h-3"></i>画面比例</label>
<select id="ratioSelect"
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-[10px] font-bold outline-none focus:border-indigo-500 transition-all">
<option value="9:16">9:16 (竖屏)</option>
<option value="16:9">16:9 (横屏)</option>
</select>
</div>
</div>
<!-- 提示词模板列表 -->
<div class="space-y-1.5">
<label
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
data-lucide="sparkles" class="w-3 h-3"></i>提示词推荐</label>
<div id="promptTemplates" class="flex flex-wrap gap-2 py-0.5">
<!-- 由 JS 动态填充 -->
</div>
</div>
<div class="rounded-2xl border border-slate-100 overflow-hidden bg-white shadow-sm">
<div
class="bg-slate-50 border-b border-slate-100 p-2 text-[10px] font-bold text-indigo-600 flex items-center gap-1">
<i data-lucide="text-quote" class="w-3.5 h-3.5"></i> 视频描述 (Prompt)
</div>
<textarea id="promptInput" rows="4"
class="w-full p-3 text-xs outline-none resize-none leading-relaxed"
placeholder="描述您想要生成的视频场景 (Veo 模型建议使用英文描述)..."></textarea>
</div>
</div>
</section>
<section class="space-y-4 pb-2">
<div class="flex items-center gap-3">
<span
class="w-6 h-6 rounded-full bg-indigo-600 text-[10px] text-white flex items-center justify-center font-bold">03</span>
<h3 class="text-sm font-bold text-slate-800">首帧/参考图 (可选)</h3>
</div>
<div id="dropZone" class="relative group">
<div
class="border-2 border-dashed border-slate-100 rounded-3xl p-6 text-center bg-white/30 hover:border-indigo-200 transition-all cursor-pointer">
<i data-lucide="image-plus" class="w-6 h-6 mx-auto mb-2 text-slate-300"></i>
<p class="text-[10px] text-slate-400 font-bold">点击或拖拽首帧图片</p>
</div>
<input id="fileInput" type="file" class="absolute inset-0 opacity-0 cursor-pointer">
</div>
<div id="imagePreview" class="flex flex-wrap gap-3 py-1"></div>
<div class="flex items-start gap-1.5 p-3 rounded-xl bg-amber-50 border border-amber-100/50">
<i data-lucide="alert-triangle" class="w-3.5 h-3.5 text-amber-500 flex-shrink-0 mt-0.5"></i>
<p class="text-[10px] text-amber-600 font-bold leading-relaxed">
请确保上传图片的比例与上方选择的画面比例一致,否则可能会被自动裁切导致生成效果偏差。</p>
</div>
</section>
</div>
<div class="p-6 bg-white/95 border-t border-slate-100">
<button id="submitBtn"
class="w-full btn-primary py-4 rounded-2xl shadow-lg hover:scale-[1.02] active:scale-[0.98] gap-2 flex items-center justify-center">
<i data-lucide="clapperboard" class="w-5 h-5"></i>
<span class="text-base font-bold tracking-widest">开始生成视频</span>
</button>
<p id="loginHint" class="text-center text-[10px] text-slate-400 mt-2 {% if g.user %}hidden{% endif %}">
<i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
</p>
</div>
</aside>
<!-- 右侧:主展示 -->
<main class="flex-1 relative flex flex-col bg-slate-50 overflow-hidden">
<div class="h-24 flex items-center justify-between px-12 relative z-10">
<div class="flex items-center gap-3">
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full animate-ping"></div>
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest text-xs">渲染引擎就绪</span>
</div>
<div class="flex items-center gap-6">
<div id="userProfile"
class="{% if not g.user %}hidden{% endif %} flex items-center gap-3 bg-white/80 backdrop-blur-md px-4 py-2 rounded-2xl border border-white shadow-sm hover:bg-white transition-all">
<div class="w-8 h-8 bg-indigo-100 rounded-xl flex items-center justify-center text-indigo-600">
<i data-lucide="user" class="w-4 h-4"></i>
</div>
<div class="flex flex-col">
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
g.user.phone[-4:] if g.user else "--" }}</span>
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">{{
g.user.points if g.user else 0 }}</span> 积分</span>
</div>
</div>
<a id="loginEntryBtn" href="/login"
class="{% if g.user %}hidden{% endif %} bg-indigo-600 text-white px-6 py-2.5 rounded-2xl text-xs font-bold shadow-lg shadow-indigo-200 hover:bg-indigo-700 transition-all">
立即登录
</a>
</div>
</div>
<div class="flex-1 flex flex-col pt-4 relative overflow-hidden min-h-0">
<!-- 状态提示 -->
<div id="statusInfo" class="absolute top-4 left-1/2 -translate-x-1/2 hidden z-20">
<div
class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在导演您的视频...</span>
</div>
</div>
<div id="resultCanvas" class="flex-1 w-full px-8 pb-12 overflow-y-auto custom-scrollbar">
<!-- 初始/空状态 + 历史列表容器 -->
<div id="placeholder" class="space-y-12">
<!-- 欢迎头部 -->
<div class="text-center pt-8 pb-4">
<div
class="w-24 h-24 bg-white rounded-[2.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-6 border border-slate-100">
<i data-lucide="video" class="w-10 h-10 text-indigo-500"></i>
</div>
<h2 class="text-3xl font-black text-slate-900 mb-2 leading-tight">动态光影 · 视觉盛宴</h2>
<p class="text-slate-400 text-sm font-medium">输入一段描述,见证 AI 创造的电影瞬间</p>
</div>
<!-- 历史栅格 -->
<div class="max-w-6xl mx-auto space-y-6">
<div class="flex items-center justify-between px-2">
<h3 class="text-sm font-black text-slate-900 flex items-center gap-2">
<i data-lucide="history" class="w-4 h-4 text-indigo-500"></i> 我的创作历史
<span id="historyCount"
class="ml-1 px-2 py-0.5 bg-indigo-50 text-indigo-400 rounded-md text-[9px] font-bold">0</span>
</h3>
</div>
<div id="historyList" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<!-- 历史记录小卡片由 JS 填充 -->
</div>
<div id="historyEmpty"
class="hidden py-20 text-center bg-white/50 rounded-[2.5rem] border-2 border-dashed border-slate-100">
<i data-lucide="inbox" class="w-8 h-8 mx-auto mb-3 text-slate-200"></i>
<p class="text-xs font-bold text-slate-300">暂无作品,立即开始您的第一次创作</p>
</div>
<div id="loadMoreBtn" class="hidden flex justify-center py-8">
<button
class="px-8 py-3 bg-white border border-slate-100 hover:border-indigo-200 text-slate-400 hover:text-indigo-600 text-xs font-bold rounded-2xl shadow-sm transition-all hover:scale-105 active:scale-95">查看更多历史作品</button>
</div>
</div>
</div>
<!-- 视频播放区 -->
<div id="finalWrapper"
class="hidden min-h-[70vh] w-full flex flex-col items-center justify-center animate-in fade-in zoom-in-95 duration-700">
<div
class="w-full max-w-4xl aspect-video bg-black rounded-[2.5rem] shadow-2xl overflow-hidden relative group">
<video id="resultVideo" class="w-full h-full object-contain" controls></video>
</div>
<!-- 操作栏 -->
<div class="mt-8 flex items-center gap-6">
<button id="downloadBtn"
class="btn-primary px-10 py-4 rounded-3xl text-sm font-bold shadow-xl shadow-indigo-100 flex items-center gap-3">
<i data-lucide="download" class="w-5 h-5"></i>
<span>保存此视频</span>
</button>
<button id="closePreviewBtn"
class="w-16 h-16 bg-white border border-slate-100 rounded-3xl flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95">
<i data-lucide="layout-grid" class="w-6 h-6"></i>
</button>
</div>
</div>
</div>
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/video.js') }}"></script>
{% endblock %}