feat: Implement a comprehensive user authentication system, add video generation capabilities, and set up database migrations and API blueprints.
This commit is contained in:
parent
a47b84e009
commit
1cc3d5e37a
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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.
Binary file not shown.
Binary file not shown.
49
app.py
49
app.py
@ -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("🔧 正在检查并创建数据库表...")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
})
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
168
logs/system.log
168
logs/system.log
@ -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)
|
|
||||||
Binary file not shown.
@ -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
1
migrations/README
Normal file
@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
113
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
||||||
59
migrations/versions/9024b393e1ef_add_some_columns.py
Normal file
59
migrations/versions/9024b393e1ef_add_some_columns.py
Normal 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 ###
|
||||||
22
models.py
22
models.py
@ -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()'))
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
51
services/captcha_service.py
Normal file
51
services/captcha_service.py
Normal 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()
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -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
336
static/js/video.js
Normal 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
83
sync_videos_manual.py
Normal 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()
|
||||||
@ -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>
|
||||||
@ -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 %}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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, "'")})'
|
||||||
|
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 %}
|
||||||
@ -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
241
templates/video.html
Normal 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 %}
|
||||||
Loading…
Reference in New Issue
Block a user