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 config import Config
from extensions import db, redis_client from extensions import db, redis_client, migrate
from blueprints.auth import auth_bp from blueprints.auth import auth_bp
from blueprints.api import api_bp from blueprints.api import api_bp
from blueprints.admin import admin_bp from blueprints.admin import admin_bp
@ -18,6 +18,7 @@ def create_app():
# 初始化扩展 # 初始化扩展
db.init_app(app) db.init_app(app)
redis_client.init_app(app) redis_client.init_app(app)
migrate.init_app(app, db)
# 注册蓝图 # 注册蓝图
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
@ -25,6 +26,46 @@ def create_app():
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(payment_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('/') @app.route('/')
def index(): def index():
return render_template('index.html') return render_template('index.html')
@ -37,6 +78,10 @@ def create_app():
def visualizer(): def visualizer():
return render_template('kongzhiqi.html') return render_template('kongzhiqi.html')
@app.route('/video')
def video_page():
return render_template('video.html')
# 自动创建数据库表 # 自动创建数据库表
with app.app_context(): with app.app_context():
print("🔧 正在检查并创建数据库表...") print("🔧 正在检查并创建数据库表...")

View File

@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify
from extensions import db from extensions import db
from models import User, Role, Permission, SystemDict, SystemNotification, Order from models import User, Role, Permission, SystemDict, SystemNotification, Order
from middlewares.auth import permission_required from middlewares.auth import permission_required
from services.logger import system_logger
admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')
@ -30,9 +31,11 @@ def save_role():
if not role: return jsonify({"error": "角色不存在"}), 404 if not role: return jsonify({"error": "角色不存在"}), 404
role.name = data['name'] role.name = data['name']
role.description = data.get('description') role.description = data.get('description')
system_logger.info(f"管理员修改角色: {role.name}")
else: else:
role = Role(name=data['name'], description=data.get('description')) role = Role(name=data['name'], description=data.get('description'))
db.session.add(role) db.session.add(role)
system_logger.info(f"管理员创建角色: {role.name}")
if 'permissions' in data: if 'permissions' in data:
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
@ -49,8 +52,10 @@ def delete_role():
if role: if role:
if role.name == '超级管理员': if role.name == '超级管理员':
return jsonify({"error": "不能删除超级管理员角色"}), 400 return jsonify({"error": "不能删除超级管理员角色"}), 400
role_name = role.name
db.session.delete(role) db.session.delete(role)
db.session.commit() db.session.commit()
system_logger.info(f"管理员删除角色: {role_name}")
return jsonify({"message": "角色删除成功"}) return jsonify({"message": "角色删除成功"})
return jsonify({"error": "角色不存在"}), 404 return jsonify({"error": "角色不存在"}), 404
@ -80,15 +85,46 @@ def get_users():
@permission_required('manage_users') @permission_required('manage_users')
def assign_role(): def assign_role():
data = request.json data = request.json
user = User.query.get(data['user_id']) user = db.session.get(User, data['user_id'])
role = Role.query.get(data['role_id']) role = db.session.get(Role, data['role_id'])
if user and role: if user and role:
user.role = role user.role = role
db.session.commit() db.session.commit()
system_logger.info(f"管理员分配用户角色", user_phone=user.phone, role_name=role.name)
return jsonify({"message": "角色分配成功"}) return jsonify({"message": "角色分配成功"})
return jsonify({"error": "用户或角色不存在"}), 404 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']) @admin_bp.route('/dicts', methods=['GET'])
@permission_required('manage_dicts') @permission_required('manage_dicts')
def get_dicts(): def get_dicts():
@ -118,9 +154,11 @@ def save_dict():
if dict_id: if dict_id:
d = SystemDict.query.get(dict_id) d = SystemDict.query.get(dict_id)
if not d: return jsonify({"error": "记录不存在"}), 404 if not d: return jsonify({"error": "记录不存在"}), 404
action = "修改"
else: else:
d = SystemDict() d = SystemDict()
db.session.add(d) db.session.add(d)
action = "创建"
d.dict_type = data['dict_type'] d.dict_type = data['dict_type']
d.label = data['label'] d.label = data['label']
@ -130,6 +168,7 @@ def save_dict():
d.sort_order = data.get('sort_order', 0) d.sort_order = data.get('sort_order', 0)
db.session.commit() db.session.commit()
system_logger.info(f"管理员{action}系统配置: {d.label}")
return jsonify({"message": "保存成功"}) return jsonify({"message": "保存成功"})
@admin_bp.route('/dicts/delete', methods=['POST']) @admin_bp.route('/dicts/delete', methods=['POST'])
@ -138,8 +177,10 @@ def delete_dict():
data = request.json data = request.json
d = SystemDict.query.get(data.get('id')) d = SystemDict.query.get(data.get('id'))
if d: if d:
label = d.label
db.session.delete(d) db.session.delete(d)
db.session.commit() db.session.commit()
system_logger.info(f"管理员删除系统配置: {label}")
return jsonify({"message": "删除成功"}) return jsonify({"message": "删除成功"})
return jsonify({"error": "记录不存在"}), 404 return jsonify({"error": "记录不存在"}), 404
@ -167,15 +208,18 @@ def save_notification():
if notif_id: if notif_id:
n = SystemNotification.query.get(notif_id) n = SystemNotification.query.get(notif_id)
if not n: return jsonify({"error": "通知不存在"}), 404 if not n: return jsonify({"error": "通知不存在"}), 404
action = "修改"
else: else:
n = SystemNotification() n = SystemNotification()
db.session.add(n) db.session.add(n)
action = "发布"
n.title = data['title'] n.title = data['title']
n.content = data['content'] n.content = data['content']
n.is_active = data.get('is_active', True) n.is_active = data.get('is_active', True)
db.session.commit() db.session.commit()
system_logger.info(f"管理员{action}通知: {n.title}")
return jsonify({"message": "通知保存成功"}) return jsonify({"message": "通知保存成功"})
@admin_bp.route('/notifications/delete', methods=['POST']) @admin_bp.route('/notifications/delete', methods=['POST'])
@ -184,8 +228,10 @@ def delete_notification():
data = request.json data = request.json
n = SystemNotification.query.get(data.get('id')) n = SystemNotification.query.get(data.get('id'))
if n: if n:
title = n.title
db.session.delete(n) db.session.delete(n)
db.session.commit() db.session.commit()
system_logger.info(f"管理员删除通知: {title}")
return jsonify({"message": "通知删除成功"}) return jsonify({"message": "通知删除成功"})
return jsonify({"error": "通知不存在"}), 404 return jsonify({"error": "通知不存在"}), 404

View File

@ -85,7 +85,7 @@ def sync_images_background(app, record_id, raw_urls):
# 更新数据库记录为持久化数据结构 # 更新数据库记录为持久化数据结构
try: try:
record = GenerationRecord.query.get(record_id) record = db.session.get(GenerationRecord, record_id)
if record: if record:
record.image_urls = json.dumps(processed_data) record.image_urls = json.dumps(processed_data)
db.session.commit() 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) resp = requests.post(target_api, json=payload, headers=headers, timeout=1000)
if resp.status_code != 200: if resp.status_code != 200:
# 错误处理:退还积分 user = db.session.get(User, user_id)
user = User.query.get(user_id)
if user and "sk-" in api_key: if user and "sk-" in api_key:
user.points += cost user.points += cost
db.session.commit() 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})) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
return return
@ -130,25 +131,176 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api
).start() ).start()
# 存入 Redis 标记完成 # 存入 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})) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls}))
except Exception as e: except Exception as e:
# 异常处理:退还积分 # 异常处理:退还积分
user = User.query.get(user_id) user = db.session.get(User, user_id)
if user and "sk-" in api_key: if user and "sk-" in api_key:
user.points += cost user.points += cost
db.session.commit() 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)})) 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>') @api_bp.route('/api/task_status/<task_id>')
@login_required @login_required
def get_task_status(task_id): def get_task_status(task_id):
"""查询异步任务状态""" """查询异步任务状态"""
try:
data = redis_client.get(f"task:{task_id}") data = redis_client.get(f"task:{task_id}")
if not data: if not data:
# 如果 Redis 里没有,可能是刚提交,也可能是过期了
return jsonify({"status": "pending"}) return jsonify({"status": "pending"})
# 兼容处理 bytes 和 str
if isinstance(data, bytes):
data = data.decode('utf-8')
return jsonify(json.loads(data)) 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') @api_bp.route('/api/config')
def get_config(): def get_config():
@ -160,7 +312,9 @@ def get_config():
"models": [], "models": [],
"ratios": [], "ratios": [],
"prompts": [], "prompts": [],
"sizes": [] "sizes": [],
"video_models": [],
"video_prompts": []
} }
for d in dicts: for d in dicts:
@ -174,6 +328,11 @@ def get_config():
config["prompts"].append(item) config["prompts"].append(item)
elif d.dict_type == 'ai_image_size': elif d.dict_type == 'ai_image_size':
config["sizes"].append(item) 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) return jsonify(config)
except Exception as e: except Exception as e:
@ -193,6 +352,8 @@ def upload():
ExtraArgs={"ContentType": f.content_type} ExtraArgs={"ContentType": f.content_type}
) )
img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}") 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}) return jsonify({"urls": img_urls})
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@ -202,7 +363,7 @@ def upload():
def generate(): def generate():
try: try:
user_id = session.get('user_id') 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 data = request.json if request.is_json else request.form
mode = data.get('mode', 'trial') mode = data.get('mode', 'trial')
@ -242,6 +403,7 @@ def generate():
if user.points < cost: if user.points < cost:
return jsonify({"error": f"可用积分不足"}), 400 return jsonify({"error": f"可用积分不足"}), 400
user.points -= cost user.points -= cost
user.has_used_points = True # 标记已使用过积分
db.session.commit() db.session.commit()
prompt = data.get('prompt') prompt = data.get('prompt')
@ -298,6 +460,9 @@ def generate():
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
app = current_app._get_current_object() app = current_app._get_current_object()
log_msg = "用户发起验光单解读" if prompt == "解读验光单" else "用户发起生图任务"
system_logger.info(log_msg, model=model_value, mode=mode)
threading.Thread( threading.Thread(
target=process_image_generation, target=process_image_generation,
args=(app, user_id, task_id, payload, api_key, target_api, cost) args=(app, user_id, task_id, payload, api_key, target_api, cost)
@ -310,6 +475,61 @@ def generate():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 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']) @api_bp.route('/api/notifications/latest', methods=['GET'])
@login_required @login_required
def get_latest_notification(): def get_latest_notification():
@ -341,8 +561,8 @@ def mark_notif_read():
if not notif_id: if not notif_id:
return jsonify({"error": "缺少通知 ID"}), 400 return jsonify({"error": "缺少通知 ID"}), 400
notif = SystemNotification.query.get(notif_id) notif = db.session.get(SystemNotification, notif_id)
user = User.query.get(user_id) user = db.session.get(User, user_id)
if notif and user: if notif and user:
if user not in notif.read_by_users: if user not in notif.read_by_users:
@ -382,14 +602,17 @@ def get_history():
# 旧数据:直接返回原图作为缩略图 # 旧数据:直接返回原图作为缩略图
formatted_urls.append({"url": u, "thumb": u}) formatted_urls.append({"url": u, "thumb": u})
else: 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) formatted_urls.append(u)
history_list.append({ history_list.append({
"id": r.id, "id": r.id,
"prompt": r.prompt,
"model": r.model, "model": r.model,
"urls": formatted_urls, "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({ return jsonify({
@ -399,3 +622,36 @@ def get_history():
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 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 from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for
import json
from extensions import db from extensions import db
from models import User from models import User
from services.sms_service import SMSService from services.sms_service import SMSService
from services.captcha_service import CaptchaService
from services.logger import system_logger from services.logger import system_logger
from middlewares.auth import admin_required from middlewares.auth import admin_required
@ -52,7 +54,7 @@ def buy_page():
from models import Order, User from models import Order, User
user_id = session['user_id'] 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() 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, success=success,
order=order) 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']) @auth_bp.route('/api/auth/send_code', methods=['POST'])
def send_code(): def send_code():
data = request.json data = request.json
phone = data.get('phone') phone = data.get('phone')
captcha = data.get('captcha')
ip = request.remote_addr
if not phone: if not phone:
return jsonify({"error": "请输入手机号"}), 400 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) success, msg = SMSService.send_code(phone)
if success: 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) system_logger.info(f"验证码发送成功", phone=phone)
return jsonify({"message": "验证码已发送"}) return jsonify({"message": "验证码已发送"})
system_logger.warning(f"验证码发送失败: {msg}", phone=phone) system_logger.warning(f"验证码发送失败: {msg}", phone=phone)
return jsonify({"error": f"发送失败: {msg}"}), 500 return jsonify({"error": f"发送失败: {msg}"}), 500
@ -128,19 +187,78 @@ def login():
data = request.json data = request.json
phone = data.get('phone') phone = data.get('phone')
password = data.get('password') 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) system_logger.info(f"用户登录尝试", phone=phone)
user = User.query.filter_by(phone=phone).first() user = User.query.filter_by(phone=phone).first()
if user and user.check_password(password): 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 session['user_id'] = user.id
system_logger.info(f"用户登录成功", phone=phone, user_id=user.id) system_logger.info(f"用户登录成功", phone=phone, user_id=user.id)
return jsonify({"message": "登录成功", "phone": phone}) 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 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']) @auth_bp.route('/api/auth/logout', methods=['POST'])
def logout(): def logout():
session.pop('user_id', None) session.pop('user_id', None)
@ -151,12 +269,22 @@ def me():
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
return jsonify({"logged_in": False}) 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({ return jsonify({
"logged_in": True, "logged_in": True,
"phone": user.phone, "phone": masked_phone, # 默认返回脱敏的供前端显示
"api_key": user.api_key, # 返回已保存的 API Key "full_phone": phone, # 某些场景可能需要完整号
"points": user.points # 返回剩余试用积分 "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']) @auth_bp.route('/api/auth/change_password', methods=['POST'])
@ -172,7 +300,7 @@ def change_password():
if not old_password or not new_password: if not old_password or not new_password:
return jsonify({"error": "请填写完整信息"}), 400 return jsonify({"error": "请填写完整信息"}), 400
user = User.query.get(user_id) user = db.session.get(User, user_id)
if not user.check_password(old_password): if not user.check_password(old_password):
return jsonify({"error": "原密码错误"}), 400 return jsonify({"error": "原密码错误"}), 400
@ -193,20 +321,14 @@ def add_points():
# return jsonify({"error": "请先登录"}), 401 # return jsonify({"error": "请先登录"}), 401
# ... (原有逻辑) # ... (原有逻辑)
@auth_bp.route('/api/auth/menu', methods=['GET']) def get_user_menu(user):
def get_menu(): """根据用户权限生成菜单列表"""
"""获取动态导航菜单"""
user_id = session.get('user_id')
if not user_id:
return jsonify({"menu": []})
user = User.query.get(user_id)
if not user: if not user:
return jsonify({"menu": []}) return []
# 菜单定义库:名称, 图标, 链接, 所需权限
all_menus = [ all_menus = [
{"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None}, {"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": "scan-eye", "url": "/ocr", "perm": None},
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None}, {"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"}, {"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
@ -215,46 +337,75 @@ def get_menu():
{"name": "系统日志审计", "icon": "terminal", "url": "/logs", "perm": "view_logs"}, {"name": "系统日志审计", "icon": "terminal", "url": "/logs", "perm": "view_logs"},
] ]
# 根据权限过滤
accessible_menu = [] accessible_menu = []
for item in all_menus: for item in all_menus:
if item["perm"] is None or user.has_permission(item["perm"]): if item["perm"] is None or user.has_permission(item["perm"]):
accessible_menu.append(item) accessible_menu.append(item)
return accessible_menu
return jsonify({"menu": accessible_menu}) @auth_bp.route('/api/auth/menu', methods=['GET'])
def get_menu():
@auth_bp.route('/api/auth/logs', methods=['GET']) """获取动态导航菜单"""
def get_logs():
"""获取系统日志(支持搜索和筛选)"""
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
return jsonify({"error": "请先登录"}), 401 return jsonify({"menu": []})
from extensions import redis_client user = db.session.get(User, user_id)
import json 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') level_filter = request.args.get('level')
search_query = request.args.get('search', '').lower() search_query = request.args.get('search', '').strip()
# 从 Redis 获取日志 (从 ZSET 读取,按分数降序排列,即最新在前) query = db.session.query(SystemLog).outerjoin(User)
logs = redis_client.zrevrange('system_logs_zset', 0, -1)
log_list = []
for log in logs:
item = json.loads(log.decode('utf-8'))
# 级别过滤 # 级别过滤
if level_filter and item['level'] != level_filter: if level_filter:
continue query = query.filter(SystemLog.level == level_filter)
# 关键词搜索 (搜索内容、手机号或其它 Extra 字段) # 关键词搜索 (支持消息、手机号、IP)
if search_query: if search_query:
message_match = search_query in item['message'].lower() search_filter = db.or_(
extra_match = any(search_query in str(v).lower() for v in item.get('extra', {}).values()) SystemLog.message.ilike(f"%{search_query}%"),
if not (message_match or extra_match): SystemLog.ip.ilike(f"%{search_query}%"),
continue User.phone.ilike(f"%{search_query}%"),
SystemLog.module.ilike(f"%{search_query}%")
)
query = query.filter(search_filter)
log_list.append(item) # 执行分页查询
pagination = query.order_by(SystemLog.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({"logs": log_list}) 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": 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 extensions import db
from models import Order, User from models import Order, User
from services.alipay_service import AlipayService from services.alipay_service import AlipayService
from services.logger import system_logger
import uuid import uuid
from datetime import datetime from datetime import datetime
import logging
logger = logging.getLogger(__name__)
payment_bp = Blueprint('payment', __name__, url_prefix='/payment') payment_bp = Blueprint('payment', __name__, url_prefix='/payment')
@ -44,8 +42,10 @@ def create_payment():
) )
db.session.add(order) db.session.add(order)
db.session.commit() db.session.commit()
system_logger.info(f"用户创建充值订单", order_id=out_trade_no, amount=package['amount'], points=package['points'])
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
system_logger.error(f"订单创建失败: {str(e)}")
return f"订单创建失败: {str(e)}", 500 return f"订单创建失败: {str(e)}", 500
# 获取支付链接 # 获取支付链接
@ -58,38 +58,31 @@ def create_payment():
) )
return redirect(pay_url) return redirect(pay_url)
except Exception as e: except Exception as e:
system_logger.error(f"支付链接生成失败: {str(e)}")
return f"支付链接生成失败: {str(e)}", 500 return f"支付链接生成失败: {str(e)}", 500
@payment_bp.route('/return') @payment_bp.route('/return')
def payment_return(): def payment_return():
"""支付成功后的同步跳转页面""" """支付成功后的同步跳转页面"""
try: try:
logger.info(f"收到支付宝同步回调,参数: {dict(request.args)}")
data = request.args.to_dict() data = request.args.to_dict()
signature = data.get("sign") signature = data.get("sign")
if not signature: if not signature:
logger.error("同步回调缺少签名参数")
return "参数错误:缺少签名", 400 return "参数错误:缺少签名", 400
alipay_service = AlipayService() alipay_service = AlipayService()
# 直接传递原始字典,由 verify_notify 处理
success = alipay_service.verify_notify(data, signature) success = alipay_service.verify_notify(data, signature)
out_trade_no = data.get('out_trade_no') out_trade_no = data.get('out_trade_no')
order = Order.query.filter_by(out_trade_no=out_trade_no).first()
if success: if success:
logger.info(f"同步回调验证成功,订单号: {out_trade_no}")
# 重定向到充值页面,并带上成功参数
return redirect(url_for('auth.buy_page', success='true', out_trade_no=out_trade_no)) return redirect(url_for('auth.buy_page', success='true', out_trade_no=out_trade_no))
else: else:
logger.error(f"同步回调验证失败,订单号: {out_trade_no}") system_logger.warning(f"支付同步回调验证失败", order_id=out_trade_no)
return "支付验证失败", 400 return "支付验证失败", 400
except Exception as e: except Exception as e:
logger.error(f"处理同步回调时发生异常: {str(e)}", exc_info=True) system_logger.error(f"处理同步回调异常: {str(e)}")
return f"处理支付回调失败: {str(e)}", 500 return f"处理支付回调失败: {str(e)}", 500
@payment_bp.route('/history', methods=['GET']) @payment_bp.route('/history', methods=['GET'])
@ -130,13 +123,10 @@ def api_payment_history():
def payment_notify(): def payment_notify():
"""支付宝异步通知""" """支付宝异步通知"""
try: try:
logger.info(f"收到支付宝异步通知,参数: {request.form.to_dict()}")
data = request.form.to_dict() data = request.form.to_dict()
signature = data.get("sign") # 不要pop保留原始数据 signature = data.get("sign")
if not signature: if not signature:
logger.error("异步通知缺少签名参数")
return "fail" return "fail"
alipay_service = AlipayService() alipay_service = AlipayService()
@ -146,34 +136,27 @@ def payment_notify():
out_trade_no = data.get('out_trade_no') out_trade_no = data.get('out_trade_no')
trade_no = data.get('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() order = Order.query.filter_by(out_trade_no=out_trade_no).first()
if order and order.status == 'PENDING': if order and order.status == 'PENDING':
order.status = 'PAID' order.status = 'PAID'
order.trade_no = trade_no order.trade_no = trade_no
order.paid_at = datetime.utcnow() order.paid_at = datetime.utcnow()
# 给用户加积分 user = db.session.get(User, order.user_id)
user = User.query.get(order.user_id)
if user: if user:
user.points += order.points 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() db.session.commit()
logger.info(f"订单 {out_trade_no} 处理成功")
return "success" return "success"
elif order: elif order:
logger.warning(f"订单 {out_trade_no} 状态为 {order.status},跳过处理") return "success"
return "success" # 已处理过的订单也返回success
else: else:
logger.error(f"未找到订单: {out_trade_no}")
return "fail" return "fail"
else: else:
logger.error(f"异步通知验证失败或交易状态异常: {data.get('trade_status')}")
return "fail" return "fail"
except Exception as e: except Exception as e:
logger.error(f"处理异步通知时发生异常: {str(e)}", exc_info=True) system_logger.error(f"处理异步通知异常: {str(e)}")
db.session.rollback() db.session.rollback()
return "fail" return "fail"

View File

@ -28,6 +28,8 @@ class Config:
# AI API 配置 # AI API 配置
AI_API = "https://ai.t8star.cn/v1/images/generations" AI_API = "https://ai.t8star.cn/v1/images/generations"
CHAT_API = "https://ai.comfly.chat/v1/chat/completions" 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" TRIAL_API = "https://ai.comfly.chat/v1/images/generations"

View File

@ -5,10 +5,10 @@
用于在 PostgreSQL 服务器上创建 ai_vision 数据库 用于在 PostgreSQL 服务器上创建 ai_vision 数据库
""" """
import psycopg2 from sqlalchemy import create_engine, text
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT from sqlalchemy.engine import url
# 数据库连接信息 # 数据库连接信息 (从 config 或直接指定)
DB_HOST = "331002.xyz" DB_HOST = "331002.xyz"
DB_PORT = 2022 DB_PORT = 2022
DB_USER = "user_xREpkJ" DB_USER = "user_xREpkJ"
@ -20,43 +20,30 @@ def create_database():
try: try:
# 连接到默认的 postgres 数据库 # 连接到默认的 postgres 数据库
print(f"🔗 正在连接到 PostgreSQL 服务器 {DB_HOST}:{DB_PORT}...") 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()
# 构造连接 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")
with engine.connect() as conn:
# 检查数据库是否存在 # 检查数据库是否存在
cursor.execute(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'") result = conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'"))
exists = cursor.fetchone() exists = result.fetchone()
if exists: if exists:
print(f"✅ 数据库 {DB_NAME} 已经存在") print(f"✅ 数据库 {DB_NAME} 已经存在")
else: else:
# 创建数据库 # 创建数据库
print(f"🔧 正在创建数据库 {DB_NAME}...") print(f"🔧 正在创建数据库 {DB_NAME}...")
cursor.execute(f'CREATE DATABASE {DB_NAME}') conn.execute(text(f'CREATE DATABASE {DB_NAME}'))
print(f"✅ 数据库 {DB_NAME} 创建成功!") print(f"✅ 数据库 {DB_NAME} 创建成功!")
cursor.close()
conn.close()
print(f"\n📊 数据库信息:") print(f"\n📊 数据库信息:")
print(f" 主机: {DB_HOST}:{DB_PORT}") print(f" 主机: {DB_HOST}:{DB_PORT}")
print(f" 数据库名: {DB_NAME}") print(f" 数据库名: {DB_NAME}")
print(f" 用户: {DB_USER}") print(f" 用户: {DB_USER}")
print(f"\n💡 下一步:运行 python init_db.py 创建数据表") 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: except Exception as e:
print(f"❌ 发生错误: {e}") print(f"❌ 发生错误: {e}")

View File

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

View File

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

View File

@ -1,29 +1,26 @@
import psycopg2 from sqlalchemy import create_engine, text
from config import Config from config import Config
def fix_db(): def fix_db():
# 从 SQLALCHEMY_DATABASE_URI 提取连接信息 # 从 SQLALCHEMY_DATABASE_URI 提取连接信息
# 格式: postgresql://user:pass@host:port/db
uri = Config.SQLALCHEMY_DATABASE_URI uri = Config.SQLALCHEMY_DATABASE_URI
print(f"🔗 正在尝试连接数据库...") print(f"🔗 正在尝试连接数据库 (SQLAlchemy)... ")
engine = create_engine(uri)
try: try:
conn = psycopg2.connect(uri) with engine.connect() as conn:
cur = conn.cursor()
# 检查并添加 points 字段 # 检查并添加 points 字段
cur.execute(""" print("🔧 正在检查并添加 users.points 字段...")
conn.execute(text("""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN 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; ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2;
END IF; END IF;
END $$; END $$;
""") """))
conn.commit() conn.commit()
cur.close()
conn.close()
print("✅ 数据库字段 points 处理完成 (默认值 2)") print("✅ 数据库字段 points 处理完成 (默认值 2)")
except Exception as e: except Exception as 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 functools import wraps
from flask import session, jsonify, redirect, url_for, request from flask import session, jsonify, redirect, url_for, request
from models import User from models import User
from services.logger import system_logger
def login_required(f): def login_required(f):
"""登录验证装饰器""" """登录验证装饰器"""
@ -8,6 +9,7 @@ def login_required(f):
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
system_logger.warning(f"未授权访问尝试 (未登录): {request.path}", ip=request.remote_addr)
if request.path.startswith('/api/'): if request.path.startswith('/api/'):
return jsonify({"error": "请先登录", "code": 401}), 401 return jsonify({"error": "请先登录", "code": 401}), 401
# 记录当前路径以便登录后跳转 # 记录当前路径以便登录后跳转
@ -22,12 +24,14 @@ def permission_required(perm_name):
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
system_logger.warning(f"未授权访问尝试 (未登录): {request.path}", ip=request.remote_addr)
if request.path.startswith('/api/'): if request.path.startswith('/api/'):
return jsonify({"error": "请先登录", "code": 401}), 401 return jsonify({"error": "请先登录", "code": 401}), 401
return redirect(url_for('auth.login_page', next=request.path)) return redirect(url_for('auth.login_page', next=request.path))
user = User.query.get(user_id) user = User.query.get(user_id)
if not user or not user.has_permission(perm_name): 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/'): if request.path.startswith('/api/'):
return jsonify({"error": f"需要权限: {perm_name}", "code": 403}), 403 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) password_hash = db.Column(db.String(255), nullable=False)
api_key = db.Column(db.String(255)) # 存储用户的 API Key api_key = db.Column(db.String(255)) # 存储用户的 API Key
points = db.Column(db.Integer, default=2) # 账户积分默认赠送2次试用 points = db.Column(db.Integer, default=2) # 账户积分默认赠送2次试用
has_used_points = db.Column(db.Boolean, default=False) # 是否使用过积分
# 关联角色 ID # 关联角色 ID
role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) role_id = db.Column(db.Integer, db.ForeignKey('roles.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
@ -117,3 +118,24 @@ class Order(db.Model):
paid_at = db.Column(db.DateTime) paid_at = db.Column(db.DateTime)
user = db.relationship('User', backref=db.backref('orders', lazy='dynamic', order_by='Order.created_at.desc()')) 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 import os
from datetime import datetime from datetime import datetime
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from extensions import redis_client from extensions import redis_client, db
import json import json
from flask import request, has_request_context, g
# 创建日志目录 # 创建日志目录
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs') LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')
@ -42,10 +43,15 @@ class SystemLogger:
"""推送到 Redis 并保留 30 天数据""" """推送到 Redis 并保留 30 天数据"""
try: try:
now = datetime.now() 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 = { log_entry = {
"time": now.strftime('%Y-%m-%d %H:%M:%S'), "time": now.strftime('%Y-%m-%d %H:%M:%S'),
"level": level, "level": level,
"message": message, "message": message,
"user_id": user_id,
"extra": extra or {} "extra": extra or {}
} }
# 使用有序集合 (ZSET),分数为时间戳,方便按时间清理 # 使用有序集合 (ZSET),分数为时间戳,方便按时间清理
@ -58,21 +64,58 @@ class SystemLogger:
except: except:
pass 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.logger.info(message)
self._push_to_redis('INFO', message, kwargs) 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.logger.warning(message)
self._push_to_redis('WARNING', message, kwargs) 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.logger.error(message)
self._push_to_redis('ERROR', message, kwargs) 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.logger.debug(message)
self._push_to_redis('DEBUG', message, kwargs) self._push_to_redis('DEBUG', message, kwargs)
self._write_to_db('DEBUG', message, module, kwargs)
# 全局日志实例 # 全局日志实例
system_logger = SystemLogger() system_logger = SystemLogger()

View File

@ -1,35 +1,188 @@
let isRegisterMode = false; /**
* Auth Modes:
* 0 - Login
* 1 - Register
* 2 - Reset Password
*/
let authMode = 0;
document.getElementById('authSwitchBtn').onclick = () => { const getEl = (id) => document.getElementById(id);
isRegisterMode = !isRegisterMode;
document.getElementById('authTitle').innerText = isRegisterMode ? "加入视界 AI" : "欢迎回来"; const updateUI = () => {
document.getElementById('authSub').innerText = isRegisterMode ? "注册并开启创作" : "请登录以开启 AI 创作之旅"; const title = getEl('authTitle');
document.getElementById('authSubmitBtn').querySelector('span').innerText = isRegisterMode ? "立即注册" : "立即登录"; const sub = getEl('authSub');
document.getElementById('authSwitchBtn').innerText = isRegisterMode ? "已有账号?返回登录" : "没有账号?立即注册"; const submitBtnSpan = getEl('authSubmitBtn')?.querySelector('span');
document.getElementById('smsGroup').classList.toggle('hidden', !isRegisterMode); const switchBtn = getEl('authSwitchBtn');
const forgotBtn = getEl('forgotPwdBtn');
const smsGroup = getEl('smsGroup');
const captchaGroup = getEl('captchaGroup');
if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return;
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('sendSmsBtn').onclick = async () => { const refreshCaptcha = () => {
const phone = document.getElementById('authPhone').value; const phone = getEl('authPhone')?.value;
const btn = document.getElementById('sendSmsBtn'); const captchaImg = getEl('captchaImg');
if (!phone || !captchaImg) return;
captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`;
};
const handleAuth = async () => {
const phone = getEl('authPhone')?.value;
const password = getEl('authPass')?.value;
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 (!phone) return showToast('请输入手机号', 'warning');
if (!captcha) {
getEl('captchaGroup')?.classList.remove('hidden');
refreshCaptcha();
return showToast('请先输入图形验证码以发送短信', 'warning');
}
btn.disabled = true; btn.disabled = true;
const originalText = btn.innerText; const originalText = btn.innerText;
try {
const r = await fetch('/api/auth/send_code', { const r = await fetch('/api/auth/send_code', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone }) body: JSON.stringify({ phone, captcha })
}); });
const d = await r.json(); const d = await r.json();
if (d.error) { if (d.error) {
showToast(d.error, 'error'); showToast(d.error, 'error');
btn.disabled = false; btn.disabled = false;
if (d.show_captcha || d.refresh_captcha) {
getEl('captchaGroup')?.classList.remove('hidden');
refreshCaptcha();
}
} else { } else {
showToast(d.message, 'success'); showToast(d.message, 'success');
// 发送成功后也要刷新图形验证码,防止被再次利用
refreshCaptcha();
let countdown = 60; let countdown = 60;
const timer = setInterval(() => { const timer = setInterval(() => {
btn.innerText = `${countdown}秒后重试`; btn.innerText = `${countdown}秒后重试`;
@ -41,34 +194,9 @@ document.getElementById('sendSmsBtn').onclick = async () => {
} }
}, 1000); }, 1000);
} }
} catch (e) {
showToast('短信发送失败', 'error');
btn.disabled = false;
}
}; };
document.getElementById('authSubmitBtn').onclick = async () => {
const phone = document.getElementById('authPhone').value;
const password = document.getElementById('authPass').value;
const code = document.getElementById('authCode').value;
const url = isRegisterMode ? '/api/auth/register' : '/api/auth/login';
const body = isRegisterMode ? { phone, password, code } : { phone, password };
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;
}
}
};

View File

@ -27,14 +27,35 @@ async function checkAuth() {
const headerPoints = document.getElementById('headerPoints'); const headerPoints = document.getElementById('headerPoints');
if (headerPoints) headerPoints.innerText = d.points; if (headerPoints) headerPoints.innerText = d.points;
// 如果用户已经有绑定的 Key且当前没手动输入则默认切到 Key 模式 // 处理自定义 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) { if (d.api_key) {
switchMode('key'); switchMode('key');
const keyInput = document.getElementById('apiKey'); const keyInput = document.getElementById('apiKey');
if (keyInput && !keyInput.value) keyInput.value = d.api_key; if (keyInput && !keyInput.value) keyInput.value = d.api_key;
} else { return;
switchMode('trial');
} }
}
}
// 如果用户已经有绑定的 Key且当前没手动输入则默认切到 Key 模式
// 强制使用积分模式
switchMode('trial');
} else { } else {
if (profile) profile.classList.add('hidden'); if (profile) profile.classList.add('hidden');
if (entry) entry.classList.remove('hidden'); if (entry) entry.classList.remove('hidden');

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> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI 视界{% endblock %}</title> <title>{% block title %}AI 视界{% endblock %}</title>
<link rel="icon" href="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" type="image/png"> <link rel="icon" href="/static/A_2IGfT6uTwlgAAAAAQmAAAAgAerF1AQ.png" type="image/png">
<script src="https://cdn.tailwindcss.com?plugins=typography"></script> <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap"
rel="stylesheet">
<script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script> <script src="{{ url_for('static', filename='js/lucide.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script> <script src="{{ url_for('static', filename='js/Sortable.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<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 %} {% block head %}{% endblock %}
</head> </head>
<body class="text-slate-700 antialiased bg-slate-50 overflow-hidden"> <body class="text-slate-700 antialiased bg-slate-50 overflow-hidden">
<div class="bg-mesh"></div> <div class="bg-mesh"></div>
<div id="toastContainer" class="toast-container"></div> <div id="toastContainer" class="toast-container"></div>
<div class="flex h-screen w-screen overflow-hidden"> <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"> <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>
<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 id="globalUserProfile" class="flex flex-col items-center gap-4 mb-4">
<!-- 联系方式 --> <!-- 联系方式 -->
<div class="relative group flex justify-center mb-2"> <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> <i data-lucide="message-circle" class="w-5 h-5"></i>
</div> </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 联系客服 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> </div>
<div class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700"> <div
class="w-10 h-10 bg-slate-800 rounded-xl flex items-center justify-center text-slate-400 border border-slate-700">
<i data-lucide="user" class="w-5 h-5"></i> <i data-lucide="user" class="w-5 h-5"></i>
</div> </div>
<button id="globalLogoutBtn" class="text-slate-500 hover:text-rose-400 transition-colors"> <button id="globalLogoutBtn" class="text-slate-500 hover:text-rose-400 transition-colors">
@ -53,16 +99,21 @@
</div> </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 id="notifModal"
<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"> 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="w-16 h-16 bg-indigo-50 text-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-2"> <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> <i data-lucide="bell-ring" class="w-8 h-8 animate-tada"></i>
</div> </div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<h2 id="notifTitle" class="text-2xl font-black text-slate-900">系统通知</h2> <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"> </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> </button>
</div> </div>
@ -71,37 +122,6 @@
<script> <script>
lucide.createIcons(); 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 () => { document.getElementById('globalLogoutBtn').onclick = async () => {
await fetch('/api/auth/logout', { method: 'POST' }); await fetch('/api/auth/logout', { method: 'POST' });
@ -150,7 +170,6 @@
}); });
}; };
initGlobalNav();
checkNotifications(); checkNotifications();
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
@ -167,4 +186,5 @@
</script> </script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View File

@ -3,45 +3,62 @@
{% block title %}系统字典管理 - AI 视界{% endblock %} {% block title %}系统字典管理 - AI 视界{% endblock %}
{% block content %} {% 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="max-w-6xl mx-auto space-y-8">
<!-- 头部 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <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> <i data-lucide="book-open" class="w-7 h-7"></i>
</div> </div>
<div> <div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">系统字典管理</h1> <h1 class="text-3xl font-black text-slate-900 tracking-tight" id="pageTitle">数据字典控制中心</h1>
<p class="text-slate-400 text-sm">统一维护模型、比例、提示词等系统参数</p> <p class="text-slate-400 text-sm font-medium" id="pageSubTitle">全局业务参数与 AI 模型配置</p>
</div> </div>
</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"> <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> <i data-lucide="plus" class="w-4 h-4"></i>
新增字典项 新增项
</button> </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>
</div> </div>
<!-- 表格 --> <!-- 视图 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>
<!-- 视图 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="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left border-collapse"> <table class="w-full text-left border-collapse">
<thead> <thead>
<tr class="bg-slate-50 border-b border-slate-100"> <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
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">显示名称</th> class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">存储值/内容</th> 内容/显示名称</th>
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">积分消耗</th> <th
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">状态</th> class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest leading-none">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest">操作</th> 存储值 (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> </tr>
</thead> </thead>
<tbody id="dictTableBody" class="text-sm font-medium"> <tbody id="dictTableBody" class="text-sm font-medium">
@ -52,48 +69,64 @@
</div> </div>
</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 id="dictModal"
<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"> 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">
<h2 id="modalTitle" class="text-2xl font-black text-slate-900">新增字典项</h2> <div
<form id="dictForm" class="space-y-5"> 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"> <input type="hidden" id="dictId">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">字典类型</label> <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"> <input type="text" id="dictType" readonly
<option value="ai_model">AI 模型 (ai_model)</option> 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">
<option value="aspect_ratio">画面比例 (aspect_ratio)</option>
<option value="ai_image_size">输出尺寸 (ai_image_size)</option>
<option value="prompt_tpl">提示词模板 (prompt_tpl)</option>
</select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">显示名称 (Label)</label> <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"> <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>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">存储值/内容 (Value)</label> <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> <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>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">积分消耗</label> <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"> <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>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">排序权重</label> <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> </div>
<div class="flex items-center gap-2 pt-2"> <div class="flex items-center justify-between bg-slate-50 p-4 rounded-2xl border border-slate-100/50">
<input type="checkbox" id="dictActive" checked class="w-4 h-4 rounded border-slate-200 text-indigo-600 focus:ring-indigo-500"> <div class="flex items-center gap-2">
<label for="dictActive" class="text-sm font-bold text-slate-600">立即启用</label> <i data-lucide="eye" class="w-4 h-4 text-indigo-500"></i>
<span class="text-sm font-bold text-slate-600">前端可见状态</span>
</div> </div>
<div class="flex gap-4 pt-4"> <label class="relative inline-flex items-center cursor-pointer">
<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> <input type="checkbox" id="dictActive" checked class="sr-only peer">
<button type="submit" class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存配置</button> <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> </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> </form>
</div> </div>
</div> </div>
@ -101,52 +134,113 @@
{% block scripts %} {% block scripts %}
<script> <script>
let currentType = ''; let currentCategory = null;
let categoriesList = [];
async function loadDicts(type = '') { // 加载所有字典类型
currentType = type; async function loadCategories() {
// 更新按钮样式 const r = await fetch('/api/admin/dict_types');
document.querySelectorAll('.dict-filter-btn').forEach(btn => { const d = await r.json();
const btnType = btn.getAttribute('onclick').match(/'(.*)'/)[1]; categoriesList = d.types;
if(btnType === type) {
btn.classList.add('bg-slate-100', 'text-slate-600'); const container = document.getElementById('typeListView');
btn.classList.remove('hover:bg-slate-50', 'text-slate-400'); container.innerHTML = d.types.map(cat => `
} else { <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">
btn.classList.remove('bg-slate-100', 'text-slate-600'); <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>
btn.classList.add('hover:bg-slate-50', 'text-slate-400'); <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>
</div>
</div>
</div>
`).join('');
lucide.createIcons();
} }
});
try { 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 r = await fetch(`/api/admin/dicts?type=${type}`);
const d = await r.json(); const d = await r.json();
const body = document.getElementById('dictTableBody'); const body = document.getElementById('dictTableBody');
body.innerHTML = d.dicts.map(item => ` body.innerHTML = d.dicts.map(item => `
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors"> <tr class="border-b border-slate-50 hover:bg-slate-50 transition-colors">
<td class="px-8 py-5"> <td class="px-8 py-5">
<span class="px-2 py-0.5 rounded text-[10px] font-black uppercase ${ <div class="text-slate-900 font-bold">${item.label}</div>
item.dict_type === 'ai_model' ? 'bg-indigo-50 text-indigo-600' : <div class="text-[9px] text-slate-300 font-black uppercase tracking-tighter">ID: ${item.id} · 排序: ${item.sort_order}</div>
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>
<td class="px-8 py-5"> <td class="px-8 py-5">
<span class="${item.is_active ? 'text-emerald-500' : 'text-slate-300'}"> <div class="text-slate-500 text-xs font-medium max-w-sm truncate" title="${item.value}">${item.value}</div>
<i data-lucide="${item.is_active ? 'check-circle-2' : 'circle'}" class="w-4 h-4"></i>
</span>
</td> </td>
<td class="px-8 py-5"> <td class="px-8 py-5">
<div class="flex gap-3"> <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>
<button onclick='editDict(${JSON.stringify(item)})' class="text-indigo-400 hover:text-indigo-600 transition-colors"> </td>
<i data-lucide="edit-3" class="w-4 h-4"></i> <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>
<button onclick="deleteDict(${item.id})" class="text-rose-300 hover:text-rose-500 transition-colors"> <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> <i data-lucide="trash-2" class="w-4 h-4"></i>
</button> </button>
</div> </div>
@ -154,22 +248,18 @@
</tr> </tr>
`).join(''); `).join('');
lucide.createIcons(); lucide.createIcons();
} catch (e) {
console.error(e);
}
} }
function openModal(data = null) { function openModal(item = null) {
const modal = document.getElementById('dictModal'); const modal = document.getElementById('dictModal');
const form = document.getElementById('dictForm'); document.getElementById('modalTitle').innerText = item ? '编辑配置项' : '新增配置项';
document.getElementById('modalTitle').innerText = data ? '编辑字典项' : '新增字典项'; document.getElementById('dictId').value = item ? item.id : '';
document.getElementById('dictId').value = data ? data.id : ''; document.getElementById('dictType').value = currentCategory ? currentCategory.type : '';
document.getElementById('dictType').value = data ? data.dict_type : 'ai_model'; document.getElementById('dictLabel').value = item ? item.label : '';
document.getElementById('dictLabel').value = data ? data.label : ''; document.getElementById('dictValue').value = item ? item.value : '';
document.getElementById('dictValue').value = data ? data.value : ''; document.getElementById('dictCost').value = item ? item.cost : 0;
document.getElementById('dictCost').value = data ? data.cost : 0; document.getElementById('dictOrder').value = item ? item.sort_order : 0;
document.getElementById('dictOrder').value = data ? data.sort_order : 0; document.getElementById('dictActive').checked = item ? item.is_active : true;
document.getElementById('dictActive').checked = data ? data.is_active : true;
modal.classList.remove('hidden'); modal.classList.remove('hidden');
setTimeout(() => { setTimeout(() => {
@ -185,13 +275,10 @@
setTimeout(() => modal.classList.add('hidden'), 300); setTimeout(() => modal.classList.add('hidden'), 300);
} }
function editDict(item) { window.editDict = (item) => openModal(item);
openModal(item);
}
async function deleteDict(id) { window.deleteDict = async (id) => {
if(!confirm('确定要删除此字典项吗?')) return; if (!confirm('确定永久删除该配置项?此操作不可逆,可能影响线上渲染。')) return;
try {
const r = await fetch('/api/admin/dicts/delete', { const r = await fetch('/api/admin/dicts/delete', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -200,10 +287,9 @@
const d = await r.json(); const d = await r.json();
if (d.message) { if (d.message) {
showToast(d.message, 'success'); showToast(d.message, 'success');
loadDicts(currentType); loadDetails(currentCategory.type);
}
} catch (e) { console.error(e); }
} }
};
document.getElementById('dictForm').onsubmit = async (e) => { document.getElementById('dictForm').onsubmit = async (e) => {
e.preventDefault(); e.preventDefault();
@ -217,7 +303,6 @@
is_active: document.getElementById('dictActive').checked is_active: document.getElementById('dictActive').checked
}; };
try {
const r = await fetch('/api/admin/dicts', { const r = await fetch('/api/admin/dicts', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -227,11 +312,10 @@
if (d.message) { if (d.message) {
showToast(d.message, 'success'); showToast(d.message, 'success');
closeModal(); closeModal();
loadDicts(currentType); loadDetails(currentCategory.type);
} }
} catch (e) { console.error(e); }
}; };
loadDicts(); loadCategories();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,31 +4,38 @@
{% block content %} {% block content %}
<!-- 中间AI 功能设定区 --> <!-- 中间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"> <div class="p-6 pb-2">
<h2 class="text-xl font-black text-slate-900 tracking-tight">创作工作台</h2> <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> <p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">智能试戴引擎配置</p>
</div> </div>
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6 custom-scrollbar"> <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 justify-between">
<div class="flex items-center gap-3"> <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> <h3 class="text-sm font-bold text-slate-800">授权验证</h3>
</div> </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"> <div id="pointsBadge"
可用积分: <span id="pointsDisplay">0</span> 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> </div>
<div class="flex gap-2"> <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="modeTrialBtn"
<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> 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>
<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="flex items-center gap-2">
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center"> <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> <i data-lucide="sparkles" class="w-4 h-4"></i>
@ -40,12 +47,15 @@
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="isPremium" class="sr-only peer"> <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> </label>
</div> </div>
<div id="keyInputGroup" class="hidden relative group animate-in slide-in-from-top-2 duration-300"> <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> <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> </div>
</section> </section>
@ -53,49 +63,70 @@
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <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> <h3 class="text-sm font-bold text-slate-800">渲染设定</h3>
</div> </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> <i data-lucide="video" class="w-3.5 h-3.5"></i>
拍摄角度设置器 拍摄角度设置器
</button> </button>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<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> <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> 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>
<div class="space-y-1.5"> <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> <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> 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>
<div id="sizeGroup" class="hidden space-y-1.5"> <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> <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> 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>
<div class="space-y-1.5"> <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> <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"> class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
<option value="1">1 张</option><option value="2">2 张</option><option value="3">3 张</option><option value="4">4 张</option> 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> </select>
</div> </div>
</div> </div>
<div class="rounded-2xl border border-slate-100 overflow-hidden bg-white shadow-sm"> <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> <option value="manual">✨ 自定义创作</option>
</select> </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> </div>
</section> </section>
<section class="space-y-4 pb-2"> <section class="space-y-4 pb-2">
<div class="flex items-center gap-3"> <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> <h3 class="text-sm font-bold text-slate-800">参考底图</h3>
</div> </div>
<div id="dropZone" class="relative group"> <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> <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> <p class="text-[10px] text-slate-400 font-bold">点击、拖拽或粘贴肖像照片</p>
</div> </div>
@ -106,12 +137,13 @@
</div> </div>
<div class="p-6 bg-white/95 border-t border-slate-100"> <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> <i data-lucide="wand-2" class="w-5 h-5"></i>
<span class="text-base font-bold tracking-widest">立即生成作品</span> <span class="text-base font-bold tracking-widest">立即生成作品</span>
</button> </button>
<p id="costPreview" class="text-center text-[10px] text-amber-600 font-bold mt-2 hidden"></p> <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> 请先登录以使用生成功能 <i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
</p> </p>
</div> </div>
@ -127,23 +159,29 @@
<div class="flex items-center gap-6"> <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> <i data-lucide="history" class="w-4 h-4 text-indigo-500"></i>
历史记录 历史记录
</button> </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"> <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> <i data-lucide="user" class="w-4 h-4"></i>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">--</span> <span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">0</span> 积分</span> 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>
<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> <i data-lucide="key-round" class="w-4 h-4"></i>
</button> </button>
</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> </a>
</div> </div>
@ -151,7 +189,8 @@
<div class="flex-1 flex flex-col p-8 relative overflow-hidden min-h-0"> <div class="flex-1 flex flex-col p-8 relative overflow-hidden min-h-0">
<div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20"> <div id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3"> <div
class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div> <div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span> <span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
</div> </div>
@ -159,7 +198,8 @@
<div id="resultCanvas" class="flex-1 w-full flex flex-col relative overflow-hidden"> <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 id="placeholder" class="flex-1 flex flex-col items-center justify-center text-center max-w-lg mx-auto">
<div class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100"> <div
class="w-48 h-48 bg-white rounded-[4.5rem] shadow-2xl flex items-center justify-center rotate-6 mx-auto mb-12 border border-slate-100">
<i data-lucide="glasses" class="w-20 h-20 text-indigo-500"></i> <i data-lucide="glasses" class="w-20 h-20 text-indigo-500"></i>
</div> </div>
<h2 class="text-4xl font-black text-slate-900 mb-8">视界进化 · 艺术呈现</h2> <h2 class="text-4xl font-black text-slate-900 mb-8">视界进化 · 艺术呈现</h2>
@ -169,19 +209,24 @@
<div id="finalWrapper" class="hidden flex-1 w-full flex flex-col overflow-hidden"> <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 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>
<!-- 固定底部操作栏 --> <!-- 固定底部操作栏 -->
<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> <i data-lucide="download" class="w-5 h-5"></i>
<span>全部下载</span> <span>全部下载</span>
</button> </button>
<!-- 重新生成按钮 --> <!-- 重新生成按钮 -->
<button id="regenBtn" class="w-16 h-16 bg-white border border-slate-100 rounded-3xl flex items-center justify-center text-slate-400 shadow-xl hover:text-indigo-600 transition-all hover:scale-110 active:scale-95 group"> <button id="regenBtn"
<i data-lucide="refresh-cw" class="w-6 h-6 group-hover:rotate-180 transition-transform duration-500"></i> 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> </button>
</div> </div>
</div> </div>
@ -189,13 +234,15 @@
</div> </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 class="p-8 border-b border-slate-100 flex items-center justify-between">
<div> <div>
<h3 class="text-xl font-black text-slate-900">创作历史</h3> <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> <p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">展示 90 天内的生成记录</p>
</div> </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> <i data-lucide="x" class="w-5 h-5"></i>
</button> </button>
</div> </div>
@ -213,29 +260,37 @@
</main> </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 id="pwdModal"
<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"> 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> <h2 class="text-2xl font-black text-slate-900">修改登录密码</h2>
<form id="pwdForm" class="space-y-5"> <form id="pwdForm" class="space-y-5">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">原密码</label> <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>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">新密码</label> <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>
<div class="flex gap-4 pt-4"> <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="button" onclick="closePwdModal()"
<button type="submit" class="flex-1 btn-primary px-8 py-4 rounded-2xl font-bold shadow-lg shadow-indigo-100">确认修改</button> 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> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- 拍摄角度设置器弹窗 --> <!-- 拍摄角度设置器弹窗 -->
<div id="visualizerModal" class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300"> <div id="visualizerModal"
<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]"> 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="p-6 border-b border-slate-100 flex items-center justify-between flex-shrink-0">
<div class="flex items-center gap-3"> <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"> <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> <p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest">相对于参考图设置相机移动路径</p>
</div> </div>
</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> <i data-lucide="x" class="w-5 h-5"></i>
</button> </button>
</div> </div>

View File

@ -4,7 +4,8 @@
{% block content %} {% block content %}
<div class="w-full h-full flex items-center justify-center p-6 bg-slate-50/50 backdrop-blur-sm"> <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="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"> <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> <i data-lucide="scan-eye" class="w-10 h-10"></i>
@ -17,28 +18,53 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="relative group"> <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"> <input id="authPhone" type="text" placeholder="手机号"
<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> 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>
<div id="smsGroup" class="hidden relative group"> <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"> <input id="authCode" type="text" placeholder="验证码"
<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> 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">
<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> <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>
<div class="relative group"> <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"> <input id="authPass" type="password" placeholder="密码"
<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> 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>
</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> <span class="text-lg font-bold">立即登录</span>
</button> </button>
<div class="text-center pt-2"> <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="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>
<div class="text-center"> <div class="text-center">

View File

@ -1,60 +1,166 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}系统日志 - AI 视界{% endblock %} {% block title %}系统审计日志 - AI 视界{% endblock %}
{% block content %} {% 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-6 lg:p-10 custom-scrollbar bg-slate-50/50">
<div class="max-w-7xl mx-auto space-y-8"> <div class="max-w-7xl mx-auto space-y-6">
<div class="flex items-center justify-between"> <!-- 头部导航与操作 -->
<div class="flex items-center gap-4"> <div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div class="w-12 h-12 bg-slate-900 text-white rounded-2xl flex items-center justify-center shadow-lg"> <div class="flex items-center gap-5">
<i data-lucide="terminal" class="w-7 h-7"></i> <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>
<div> <div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">系统审计日志</h1> <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>
<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> </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()">
<!-- 增强版筛选工具 -->
<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="">全部级别</option>
<option value="INFO">INFO</option> <option value="INFO" class="text-indigo-600">INFO (常规动作)</option>
<option value="WARNING">WARNING</option> <option value="WARNING" class="text-amber-600">WARNING (安全警告)</option>
<option value="ERROR">ERROR</option> <option value="ERROR" class="text-rose-600">ERROR (异常拦截)</option>
</select> </select>
<button onclick="loadLogs()"
<button onclick="loadLogs()" class="bg-white border border-slate-200 p-3 rounded-xl hover:bg-slate-50 transition-all shadow-sm"> 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" class="w-5 h-5 text-slate-600"></i> <i data-lucide="refresh-cw" id="refreshIcon" class="w-5 h-5 text-slate-600"></i>
</button> </button>
<a href="/" class="btn-primary px-6 py-3 rounded-xl text-sm font-bold shadow-lg">返回工作台</a>
</div> </div>
</div> </div>
<div class="bg-white rounded-[2.5rem] shadow-xl border border-slate-100 overflow-hidden"> <!-- 数据表格容器 -->
<div class="overflow-x-auto"> <div
<table class="w-full text-left border-collapse"> 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> <thead>
<tr class="bg-slate-50 border-b border-slate-100 sticky top-0 z-10 shadow-sm"> <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 bg-slate-50">时间</th> <th
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-50">级别</th> class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
<th class="px-8 py-5 text-[10px] font-black text-slate-400 uppercase tracking-widest bg-slate-50">事件消息</th> 时间</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 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> </tr>
</thead> </thead>
<tbody id="logTableBody" class="text-sm font-medium"> <tbody id="logTableBody" class="text-sm">
<!-- 动态加载 --> <!-- 骨架屏加载状态 -->
<tr> <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> </tr>
</tbody> </tbody>
</table> </table>
</div> </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> </div>
</div> </div>
@ -62,10 +168,46 @@
{% block scripts %} {% block scripts %}
<script> <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() { 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 search = document.getElementById('logSearch')?.value || '';
const level = document.getElementById('logLevel')?.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 { try {
const r = await fetch(url); const r = await fetch(url);
@ -73,45 +215,128 @@
const body = document.getElementById('logTableBody'); const body = document.getElementById('logTableBody');
if (d.error) { 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; 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) { 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; return;
} }
body.innerHTML = d.logs.map(log => ` body.innerHTML = d.logs.map(log => `
<tr class="border-b border-slate-50 hover:bg-slate-50/50 transition-colors"> <tr class="group hover:bg-slate-50/50 transition-all cursor-default">
<td class="px-8 py-5 text-slate-400 font-mono text-xs">${log.time}</td> <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-5"> <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 ${ <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 === 'INFO' ? 'bg-indigo-50 text-indigo-600' :
log.level === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-rose-50 text-rose-600' log.level === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-rose-50 text-rose-600'
}">${log.level}</span> }">${log.level === 'INFO' ? '常规' : log.level === 'WARNING' ? '安全' : '异常'}</span>
</td> </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-6 border-b border-slate-50">
<td class="px-8 py-5 text-slate-400 font-mono text-[10px] min-w-[200px]"> <div class="flex flex-col">
${Object.entries(log.extra).map(([k, v]) => { <span class="text-slate-900 font-bold text-xs">${log.user_phone}</span>
const val = typeof v === 'object' ? JSON.stringify(v) : v; <span class="text-slate-400 text-[10px] font-mono">${log.ip || 'Unknown IP'}</span>
return `<span class="inline-block bg-slate-100 rounded px-1.5 py-0.5 mr-1 mb-1 break-all">${k}: ${val}</span>`; </div>
}).join('')} </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> </td>
</tr> </tr>
`).join(''); `).join('');
lucide.createIcons();
} catch (e) { } catch (e) {
console.error(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(); loadLogs();
// 自动刷新逻辑如果搜索框为空则每5秒刷新一次 // 自动刷新逻辑:如果搜索框为空且在第一页则每10秒刷新一次
setInterval(() => { setInterval(() => {
const search = document.getElementById('logSearch')?.value || ''; const search = document.getElementById('logSearch')?.value || '';
if (!search) loadLogs(); if (!search && currentPage === 1) loadLogs();
}, 5000); }, 10000);
</script> </script>
{% endblock %} {% endblock %}

View File

@ -4,16 +4,20 @@
{% block content %} {% block content %}
<!-- 中间AI 功能设定区 --> <!-- 中间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"> <div class="p-6 pb-2">
<h2 class="text-xl font-black text-slate-900 tracking-tight">验光单助手</h2> <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> <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="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="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> <i data-lucide="shield-check" class="w-3.5 h-3.5 text-indigo-500"></i>
</div> </div>
<span class="text-xs font-black text-indigo-900 uppercase tracking-wider">使用必读</span> <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"> <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"> <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> <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> </p>
</div> </div>
<p class="text-[10px] text-slate-400 font-bold italic"> <p class="text-[10px] text-slate-400 font-bold italic">
@ -39,16 +44,19 @@
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <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> <h3 class="text-sm font-bold text-slate-800">状态信息</h3>
</div> </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"> <div id="pointsBadge"
可用积分: <span id="pointsDisplay">0</span> 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> </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="flex items-center gap-2">
<div class="w-7 h-7 bg-amber-100 text-amber-600 rounded-lg flex items-center justify-center"> <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> <i data-lucide="sparkles" class="w-4 h-4"></i>
@ -60,21 +68,26 @@
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="isPremium" class="sr-only peer"> <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> </label>
</div> </div>
</section> </section>
<section class="space-y-4 pb-2"> <section class="space-y-4 pb-2">
<div class="flex items-center gap-3"> <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> <h3 class="text-sm font-bold text-slate-800">验光单照片</h3>
</div> </div>
<div id="dropZone" class="relative group"> <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> <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-[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> </div>
<input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer"> <input id="fileInput" type="file" multiple class="absolute inset-0 opacity-0 cursor-pointer">
</div> </div>
@ -83,12 +96,13 @@
</div> </div>
<div class="p-6 bg-white/95 border-t border-slate-100"> <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> <i data-lucide="wand-2" class="w-5 h-5"></i>
<span class="text-base font-bold tracking-widest">开始解读验光单</span> <span class="text-base font-bold tracking-widest">开始解读验光单</span>
</button> </button>
<p id="costPreview" class="text-center text-[10px] text-amber-600 font-bold mt-2 hidden"></p> <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> 请先登录以使用生成功能 <i data-lucide="lock" class="w-2.5 h-2.5 inline"></i> 请先登录以使用生成功能
</p> </p>
</div> </div>
@ -103,16 +117,20 @@
</div> </div>
<div class="flex items-center gap-6"> <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"> <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> <i data-lucide="user" class="w-4 h-4"></i>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">--</span> <span id="userPhoneDisplay" class="text-xs font-bold text-slate-600">{{ g.user.phone[:3] ~ "****" ~
<span class="text-[9px] font-black text-amber-600 uppercase">余额: <span id="headerPoints">0</span> 积分</span> 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>
</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> </a>
</div> </div>
@ -120,7 +138,8 @@
<div class="flex-1 flex items-center justify-center p-8 relative overflow-hidden"> <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 id="statusInfo" class="absolute top-8 left-1/2 -translate-x-1/2 hidden z-20">
<div class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3"> <div
class="bg-white/90 backdrop-blur-xl border border-indigo-100 px-6 py-3 rounded-2xl shadow-xl flex items-center gap-3">
<div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div> <div class="w-2 h-2 bg-indigo-500 rounded-full animate-bounce"></div>
<span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span> <span class="text-xs font-black text-slate-600 uppercase tracking-widest">AI 正在处理您的请求...</span>
</div> </div>
@ -129,7 +148,8 @@
<div id="resultCanvas" class="w-full h-full flex flex-col items-center justify-center"> <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 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> <i data-lucide="scan-eye" class="w-20 h-20 text-indigo-500"></i>
</div> </div>
<h2 class="text-4xl font-black text-slate-900 mb-8">精准解析 · 专业建议</h2> <h2 class="text-4xl font-black text-slate-900 mb-8">精准解析 · 专业建议</h2>
@ -140,17 +160,22 @@
<div id="finalWrapper" class="hidden w-full h-full flex flex-col items-center"> <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 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>
<!-- 固定的操作栏区域 --> <!-- 固定的操作栏区域 -->
<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"> <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> <i data-lucide="copy" class="w-4 h-4"></i>
复制解析数据 复制解析数据
</button> </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> <i data-lucide="refresh-cw" class="w-4 h-4"></i>
重新解析 重新解析
</button> </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 %}