Compare commits

...

3 Commits

Author SHA1 Message Date
4239eb1688 feat:优化UI 2025-09-02 00:56:54 +08:00
8c4e5885c7 feat(file): 优化文件处理和缓存机制
- 重构文件处理逻辑,提高性能和可维护性
- 增加缓存机制,减少重复读取和处理
- 改进错误处理和日志记录
- 优化缩略图生成算法
- 添加性能监控和测试依赖
2025-07-11 00:21:57 +08:00
d0f9e65ad1 style: 优化页面样式和布局,提升用户体验 2025-07-10 23:50:19 +08:00
20 changed files with 2139 additions and 208 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ data
input input
test.py test.py
conf/app.ini conf/app.ini
logs/app.log
logs/error.log

115
compress_comic.py Normal file
View File

@@ -0,0 +1,115 @@
import cv2
import zipfile
import argparse
import os
import re
import numpy as np
from io import BytesIO
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor
import threading
# 线程锁,用于安全写入 ZIP 和打印
output_zip_lock = threading.Lock()
print_lock = threading.Lock()
def natural_sort_key(s):
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
def resize_long_image(img, max_short_edge=640):
"""
专为长图优化:限制短边(通常是宽度),保持比例
例如:宽 1000px, 高 5000px → 缩放为 宽 640px, 高 3200px
"""
h, w = img.shape[:2]
short_edge = min(w, h)
if short_edge <= max_short_edge:
return img # 不需要缩放
scale = max_short_edge / short_edge
new_w = int(w * scale)
new_h = int(h * scale)
# 使用高质量插值缩小
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
return resized
def convert_to_webp_data(img, quality=95):
encode_param = [int(cv2.IMWRITE_WEBP_QUALITY), quality]
success, buffer = cv2.imencode('.webp', img, encode_param)
if not success:
raise RuntimeError("WebP 编码失败")
return buffer.tobytes()
def process_image(args):
"""单张图像处理函数(用于多线程)"""
filename, img_data = args
try:
nparr = np.frombuffer(img_data, np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if img is None:
with print_lock:
tqdm.write(f"⚠️ 无法解码图像: {filename}")
return None
# 缩放长图(限制短边)
img = resize_long_image(img, max_short_edge=640)
# 转为 WebP
webp_bytes = convert_to_webp_data(img, quality=95)
webp_name = os.path.splitext(filename)[0] + '.webp'
return (webp_name, webp_bytes)
except Exception as e:
with print_lock:
tqdm.write(f"❌ 处理 {filename} 失败: {e}")
return None
def main():
parser = argparse.ArgumentParser(description="压缩漫画ZIP长图优化 + 多线程加速")
parser.add_argument('-i', '--input', required=True, help='输入的ZIP文件路径')
parser.add_argument('--workers', type=int, default=4, help='并行线程数默认4')
args = parser.parse_args()
input_zip_path = args.input
if not os.path.isfile(input_zip_path):
print(f"❌ 错误:文件 {input_zip_path} 不存在")
return
base_name = os.path.splitext(input_zip_path)[0]
output_zip_path = f"{base_name}-lite.zip"
with zipfile.ZipFile(input_zip_path, 'r') as input_zip:
image_files = [f for f in input_zip.namelist() if f.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.tiff'))]
image_files.sort(key=natural_sort_key)
if not image_files:
print("⚠️ 警告ZIP中未找到图片文件")
return
print(f"📦 找到 {len(image_files)} 张图片(包括长图),使用 {args.workers} 个线程处理...")
# 读取所有图像数据用于多线程处理
img_data_list = [(filename, input_zip.read(filename)) for filename in image_files]
# 多线程处理
results = []
with ThreadPoolExecutor(max_workers=args.workers) as executor:
# 提交所有任务
futures = [executor.submit(process_image, item) for item in img_data_list]
# 使用 tqdm 显示进度
for future in tqdm(futures, desc="🚀 压缩中", unit="img"):
result = future.result()
if result is not None:
results.append(result)
# 写入输出 ZIP主线程完成避免并发写 ZIP
with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as output_zip:
for webp_name, webp_bytes in results:
output_zip.writestr(webp_name, webp_bytes)
print(f"✅ 压缩完成!输出文件:{output_zip_path}")
if __name__ == '__main__':
main()

View File

@@ -37,8 +37,8 @@ def init():
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
time INT NOT NULL, time INT NOT NULL,
bookid TEXT NOT NULL, bookid TEXT NOT NULL,
from_uid INTEGAR NOT NULL, from_uid INTEGER NOT NULL,
score INT NOT NULL, score TEXT NOT NULL,
content TEXT content TEXT
); );
""" """

284
file.py
View File

@@ -1,86 +1,266 @@
import shutil, os, zipfile, io, cv2, numpy as np import shutil, os, zipfile, io, cv2, numpy as np
import hashlib
import time
from functools import lru_cache
from pathlib import Path
import logging
import db.file, app_conf import db.file, app_conf
from utils.logger import get_logger
from utils.cache_manager import get_cache_manager, cache_image
from utils.performance_monitor import monitor_performance, timing_context
app_conf = app_conf.conf() # 获取配置对象
conf = app_conf.conf()
logger = get_logger(__name__)
cache_manager = get_cache_manager()
# 内存缓存 - 存储最近访问的ZIP文件列表
_zip_cache = {}
_cache_timeout = 300 # 5分钟缓存超时
def init(): def init():
"""初始化文件目录"""
paths = ("inputdir", "storedir", "tmpdir") paths = ("inputdir", "storedir", "tmpdir")
for path in paths: for path in paths:
try: try:
os.makedirs(app_conf.get("file", path)) dir_path = Path(conf.get("file", path))
dir_path.mkdir(parents=True, exist_ok=True)
logger.info(f"创建目录: {dir_path}")
except Exception as e: except Exception as e:
print(e) logger.error(f"创建目录失败 {path}: {e}")
def auotLoadFile(): @monitor_performance("file.get_image_files_from_zip")
fileList = os.listdir(app_conf.get("file", "inputdir")) def get_image_files_from_zip(zip_path: str) -> tuple:
for item in fileList: """
if zipfile.is_zipfile( 从ZIP文件中获取图片文件列表使用缓存提高性能
app_conf.get("file", "inputdir") + "/" + item 返回: (image_files_list, cache_key)
): # 判断是否为压缩包 """
with zipfile.ZipFile( cache_key = f"{zip_path}_{os.path.getmtime(zip_path)}"
app_conf.get("file", "inputdir") + "/" + item, "r" current_time = time.time()
) as zip_ref:
db.file.new(item, len(zip_ref.namelist())) # 添加数据库记录 移动到存储
shutil.move(
app_conf.get("file", "inputdir") + "/" + item,
app_conf.get("file", "storedir") + "/" + item,
)
print("已添加 " + item)
else:
print("不符合条件 " + item)
# 检查缓存
if cache_key in _zip_cache:
cache_data = _zip_cache[cache_key]
if current_time - cache_data['timestamp'] < _cache_timeout:
logger.debug(f"使用缓存的ZIP文件列表: {zip_path}")
return cache_data['files'], cache_key
def raedZip(bookid: str, index: int): # 读取ZIP文件
bookinfo = db.file.searchByid(bookid)
zippath = app_conf.get("file", "storedir") + "/" + bookinfo[0][2]
try: try:
# 创建一个ZipFile对象 with zipfile.ZipFile(zip_path, "r") as zip_ref:
with zipfile.ZipFile(zippath, "r") as zip_ref:
# 获取图片文件列表
image_files = [ image_files = [
file file for file in zip_ref.namelist()
for file in zip_ref.namelist()
if file.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp")) if file.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"))
] ]
# 缓存结果
_zip_cache[cache_key] = {
'files': image_files,
'timestamp': current_time
}
# 清理过期缓存
_cleanup_cache()
logger.debug(f"缓存ZIP文件列表: {zip_path}, 图片数量: {len(image_files)}")
return image_files, cache_key
except Exception as e:
logger.error(f"读取ZIP文件失败 {zip_path}: {e}")
return [], cache_key
def _cleanup_cache():
"""清理过期缓存"""
current_time = time.time()
expired_keys = [
key for key, data in _zip_cache.items()
if current_time - data['timestamp'] > _cache_timeout
]
for key in expired_keys:
del _zip_cache[key]
if expired_keys:
logger.debug(f"清理过期缓存: {len(expired_keys)}")
@monitor_performance("file.autoLoadFile")
def autoLoadFile():
"""自动加载文件,优化路径处理和错误处理"""
input_dir = Path(conf.get("file", "inputdir"))
store_dir = Path(conf.get("file", "storedir"))
if not input_dir.exists():
logger.warning(f"输入目录不存在: {input_dir}")
return
file_list = []
try:
file_list = [f for f in input_dir.iterdir() if f.is_file()]
except Exception as e:
logger.error(f"读取输入目录失败: {e}")
return
processed_count = 0
for file_path in file_list:
try:
if zipfile.is_zipfile(file_path):
with zipfile.ZipFile(file_path, "r") as zip_ref:
page_count = len([f for f in zip_ref.namelist()
if f.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"))])
if page_count > 0:
db.file.new(file_path.name, page_count)
# 移动文件到存储目录
target_path = store_dir / file_path.name
shutil.move(str(file_path), str(target_path))
logger.info(f"已添加漫画: {file_path.name}, 页数: {page_count}")
processed_count += 1
else:
logger.warning(f"ZIP文件中没有图片: {file_path.name}")
else:
logger.info(f"非ZIP文件跳过: {file_path.name}")
except Exception as e:
logger.error(f"处理文件失败 {file_path.name}: {e}")
logger.info(f"自动加载完成,处理了 {processed_count} 个文件")
@monitor_performance("file.readZip")
def readZip(bookid: str, index: int) -> tuple:
"""
从ZIP文件中读取指定索引的图片
优化:使用缓存的文件列表,改进错误处理
返回: (image_data, filename) 或 (error_message, "")
"""
try:
bookinfo = db.file.searchByid(bookid)
if not bookinfo:
logger.warning(f"未找到书籍ID: {bookid}")
return "Book not found", ""
zip_path = Path(conf.get("file", "storedir")) / bookinfo[0][2]
if not zip_path.exists():
logger.error(f"ZIP文件不存在: {zip_path}")
return "ZIP file not found", ""
# 使用缓存获取图片文件列表
image_files, _ = get_image_files_from_zip(str(zip_path))
if not image_files: if not image_files:
return "not imgfile in zip", "" logger.warning(f"ZIP文件中没有图片: {zip_path}")
return "No image files in zip", ""
if int(index) > len(image_files): if int(index) >= len(image_files):
return "404 not found", "" logger.warning(f"图片索引超出范围: {index}, 总数: {len(image_files)}")
return "Image index out of range", ""
# 假设我们只提取图片文件 # 读取指定图片
with zipfile.ZipFile(zip_path, "r") as zip_ref:
image_filename = image_files[int(index)] image_filename = image_files[int(index)]
# 读取图片数据
image_data = zip_ref.read(image_filename) image_data = zip_ref.read(image_filename)
zip_ref.close()
logger.debug(f"读取图片: {bookid}/{index} -> {image_filename}")
return image_data, image_filename return image_data, image_filename
except zipfile.BadZipFile: # 异常处理 except zipfile.BadZipFile:
logger.error(f"损坏的ZIP文件: {bookid}")
return "Bad ZipFile", "" return "Bad ZipFile", ""
except Exception as e: except Exception as e:
return str(e), "" logger.error(f"读取ZIP文件失败 {bookid}/{index}: {e}")
return f"Error: {str(e)}", ""
def thumbnail(input, minSize: int = 600, encode:str="webp"): @lru_cache(maxsize=128)
img = cv2.imdecode(np.frombuffer(input, np.uint8), cv2.IMREAD_COLOR) def _get_image_hash(image_data: bytes) -> str:
height = img.shape[0] # 图片高度 """生成图片数据的哈希值用于缓存"""
width = img.shape[1] # 图片宽度 return hashlib.md5(image_data).hexdigest()
if minSize < np.amin((height,width)):
@cache_image
def thumbnail(input_data: bytes, min_size: int = 600, encode: str = "webp", quality: int = 75) -> bytes:
"""
生成缩略图,优化编码逻辑和性能
"""
if not input_data:
logger.warning("输入图片数据为空")
return input_data
try:
# 解码图片
img = cv2.imdecode(np.frombuffer(input_data, np.uint8), cv2.IMREAD_COLOR)
if img is None:
logger.warning("无法解码图片数据")
return input_data
height, width = img.shape[:2]
logger.debug(f"原始图片尺寸: {width}x{height}")
# 判断是否需要缩放
min_dimension = min(height, width)
if min_size < min_dimension:
# 计算新尺寸
if height > width: if height > width:
newshape = (minSize, int(minSize / width * height)) new_width = min_size
new_height = int(min_size * height / width)
else: else:
newshape = (int(minSize / height * width), minSize) new_height = min_size
img = cv2.resize(img, newshape) new_width = int(min_size * width / height)
if encode == "webp":
success, encoded_image = cv2.imencode(".webp", img, [cv2.IMWRITE_WEBP_QUALITY, 75]) img = cv2.resize(img, (new_width, new_height), interpolation=cv2.INTER_AREA)
elif encode == "jpg" or "jpeg": logger.debug(f"缩放后图片尺寸: {new_width}x{new_height}")
success, encoded_image = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75])
# 编码图片
if encode.lower() == "webp":
success, encoded_image = cv2.imencode(
".webp", img, [cv2.IMWRITE_WEBP_QUALITY, quality]
)
elif encode.lower() in ("jpg", "jpeg"):
success, encoded_image = cv2.imencode(
".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality]
)
elif encode.lower() == "png":
success, encoded_image = cv2.imencode(
".png", img, [cv2.IMWRITE_PNG_COMPRESSION, 6]
)
else: else:
return input logger.warning(f"不支持的编码格式: {encode}, 返回原始数据")
return encoded_image.tobytes() return input_data
if not success:
logger.error(f"图片编码失败: {encode}")
return input_data
result = encoded_image.tobytes()
logger.debug(f"图片处理完成: 原始 {len(input_data)} bytes -> 处理后 {len(result)} bytes")
return result
except Exception as e:
logger.error(f"图片处理异常: {e}")
return input_data
def get_zip_image_count(bookid: str) -> int:
"""
获取ZIP文件中的图片数量使用缓存
"""
try:
bookinfo = db.file.searchByid(bookid)
if not bookinfo:
return 0
zip_path = Path(conf.get("file", "storedir")) / bookinfo[0][2]
if not zip_path.exists():
return 0
image_files, _ = get_image_files_from_zip(str(zip_path))
return len(image_files)
except Exception as e:
logger.error(f"获取图片数量失败 {bookid}: {e}")
return 0

20
main.py
View File

@@ -1,33 +1,45 @@
import app_conf import app_conf
import db.util import db.util
import db.file, file import db.file, file
from flask import * from flask import Flask
from router.api_Img import api_Img_bp from router.api_Img import api_Img_bp
from router.page import page_bp from router.page import page_bp
from router.admin_page import admin_page_bp from router.admin_page import admin_page_bp
from router.api_comment import comment_api_bp from router.api_comment import comment_api_bp
from router.performance_api import performance_bp
from utils.logger import setup_logging
from utils.performance_monitor import get_performance_monitor
app = Flask(__name__) app = Flask(__name__)
conf = app_conf.conf() conf = app_conf.conf()
def appinit(): def appinit():
"""应用初始化,集成日志和性能监控"""
# 设置日志
setup_logging(app)
# 初始化文件系统和数据库
file.init() file.init()
db.util.init() db.util.init()
file.auotLoadFile() file.autoLoadFile()
# 启动性能监控
monitor = get_performance_monitor()
app.logger.info("应用初始化完成,性能监控已启动")
# 注册蓝图
app.register_blueprint(api_Img_bp) app.register_blueprint(api_Img_bp)
app.register_blueprint(page_bp) app.register_blueprint(page_bp)
app.register_blueprint(admin_page_bp) app.register_blueprint(admin_page_bp)
app.register_blueprint(comment_api_bp) app.register_blueprint(comment_api_bp)
app.register_blueprint(performance_bp)
if __name__ == "__main__": if __name__ == "__main__":
appinit() appinit()
app.run( app.run(
debug=conf.getboolean("server", "debug"), debug=conf.getboolean("server", "debug"),
host=conf.get("server", "host"), host=conf.get("server", "host"),
port=conf.get("server", "port"), port=int(conf.get("server", "port")),
threaded=conf.getboolean("server", "threaded"), threaded=conf.getboolean("server", "threaded"),
) )

View File

@@ -1,4 +1,12 @@
shortuuid shortuuid
flask flask>=2.3.0
opencv-python opencv-python
opencv-python-headless opencv-python-headless
werkzeug>=2.3.0
Pillow>=9.0.0
python-dotenv
flask-limiter
# 性能测试依赖(可选)
requests>=2.25.0
# 如果需要更好的性能监控,可以添加
# psutil>=5.8.0

View File

@@ -1,6 +1,6 @@
from flask import * from flask import Blueprint, request, abort, make_response
from flask import Blueprint
import db.file , file, gc , app_conf import db.file , file, gc , app_conf
from utils.performance_monitor import timing_context
api_Img_bp = Blueprint("api_Img_bp", __name__) api_Img_bp = Blueprint("api_Img_bp", __name__)
@@ -11,21 +11,30 @@ fullSize = conf.getint("img", "fullSize")
@api_Img_bp.route("/api/img/<bookid>/<index>") @api_Img_bp.route("/api/img/<bookid>/<index>")
def img(bookid, index): # 图片接口 def img(bookid, index): # 图片接口
with timing_context(f"api.img.{bookid}.{index}"):
if request.cookies.get("islogin") is None: if request.cookies.get("islogin") is None:
return abort(403) return abort(403)
if len(db.file.searchByid(bookid)) == 0: if len(db.file.searchByid(bookid)) == 0:
return abort(404) return abort(404)
# 设置响应类型为图片
data, filename = file.raedZip(bookid, index) # 读取图片数据
data, filename = file.readZip(bookid, index)
if isinstance(data, str): if isinstance(data, str):
abort(404) abort(404)
# 处理图片尺寸
if request.args.get("mini") == "yes": if request.args.get("mini") == "yes":
data = file.thumbnail(data, miniSize, encode=imgencode) data = file.thumbnail(data, miniSize, encode=imgencode)
else: else:
data = file.thumbnail(data, fullSize, encode=imgencode) data = file.thumbnail(data, fullSize, encode=imgencode)
response = make_response(data) # 读取文件
del data # 创建响应
response = make_response(data)
del data # 及时释放内存
response.headers.set("Content-Type", f"image/{imgencode}") response.headers.set("Content-Type", f"image/{imgencode}")
response.headers.set("Content-Disposition", "inline", filename=filename) response.headers.set("Content-Disposition", "inline", filename=filename)
gc.collect() response.headers.set("Cache-Control", "public, max-age=3600") # 添加缓存头
gc.collect() # 强制垃圾回收
return response return response

View File

@@ -14,7 +14,7 @@ def overview(page): # 概览
if request.cookies.get("islogin") is None: # 验证登录状态 if request.cookies.get("islogin") is None: # 验证登录状态
return redirect("/") return redirect("/")
metaDataList = db.file.getMetadata( metaDataList = db.file.getMetadata(
(page - 1) * 20, page * 20, request.args.get("search") (page - 1) * 20, page * 20, request.args.get("search", "")
) )
for item in metaDataList: for item in metaDataList:
item[2] = item[2][:-4] # 去除文件扩展名 item[2] = item[2][:-4] # 去除文件扩展名
@@ -89,7 +89,13 @@ def upload_file():
uploaded_file = request.files.getlist("files[]") # 获取上传的文件列表 uploaded_file = request.files.getlist("files[]") # 获取上传的文件列表
print(uploaded_file) print(uploaded_file)
for fileitem in uploaded_file: for fileitem in uploaded_file:
if fileitem.filename != "": if fileitem.filename and fileitem.filename != "":
fileitem.save(conf.get("file", "inputdir") + "/" + fileitem.filename) input_dir = conf.get("file", "inputdir")
file.auotLoadFile() if not input_dir:
return "Input directory is not configured.", 500
import os
if input_dir is None:
return "Input directory is not configured.", 500
fileitem.save(os.path.join(input_dir, fileitem.filename))
file.autoLoadFile()
return "success" return "success"

61
router/performance_api.py Normal file
View File

@@ -0,0 +1,61 @@
from flask import Blueprint, render_template, jsonify, request
from utils.performance_monitor import get_performance_monitor
from utils.cache_manager import get_cache_manager
performance_bp = Blueprint("performance_bp", __name__)
@performance_bp.route("/api/performance/stats")
def performance_stats():
"""获取性能统计信息"""
if request.cookies.get("islogin") is None:
return jsonify({"error": "Unauthorized"}), 403
monitor = get_performance_monitor()
cache_manager = get_cache_manager()
operation_name = request.args.get("operation")
stats = {
"performance": monitor.get_stats(operation_name),
"cache": cache_manager.get_stats(),
"recent_errors": monitor.get_recent_errors(5)
}
return jsonify(stats)
@performance_bp.route("/api/performance/clear")
def clear_performance_data():
"""清空性能监控数据"""
if request.cookies.get("islogin") is None:
return jsonify({"error": "Unauthorized"}), 403
monitor = get_performance_monitor()
monitor.clear_metrics()
return jsonify({"message": "Performance data cleared"})
@performance_bp.route("/api/cache/clear")
def clear_cache():
"""清空缓存"""
if request.cookies.get("islogin") is None:
return jsonify({"error": "Unauthorized"}), 403
cache_manager = get_cache_manager()
cache_manager.clear()
return jsonify({"message": "Cache cleared"})
@performance_bp.route("/api/cache/cleanup")
def cleanup_cache():
"""清理过期缓存"""
if request.cookies.get("islogin") is None:
return jsonify({"error": "Unauthorized"}), 403
cache_manager = get_cache_manager()
cache_manager.cleanup_expired()
return jsonify({"message": "Expired cache cleaned up"})

View File

@@ -7,37 +7,55 @@
<link href="/static/css/bootstrap.min.css" rel="stylesheet"> <link href="/static/css/bootstrap.min.css" rel="stylesheet">
<title>ComiPy-详情页面</title> <title>ComiPy-详情页面</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: Arial, sans-serif; font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #f8f9fa;
min-height: 100vh;
color: #202124;
line-height: 1.6;
} }
.container { .container {
display: flex; max-width: 1200px;
flex-direction: column; margin: 0 auto;
align-items: center; padding: 24px 16px;
background-color: #ffffff;
border-radius: 8px;
margin-top: 16px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between;
flex-direction: column; flex-direction: column;
width: 80%; gap: 24px;
margin-top: 20px; margin-bottom: 24px;
} }
@media (min-width: 600px) { @media (min-width: 600px) {
.header { .header {
flex-direction: row; flex-direction: row;
align-items: flex-start;
} }
} }
.movie-poster { .movie-poster {
flex: 1; flex: 1;
margin-right: 20px; max-width: 300px;
}
.movie-poster img {
width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
} }
.movie-details { .movie-details {
@@ -45,28 +63,109 @@
} }
.movie-details h1 { .movie-details h1 {
font-size: 24px; font-size: 1.75rem;
margin-bottom: 10px; font-weight: 400;
color: #1a73e8;
margin-bottom: 16px;
} }
.movie-details p { .movie-details p {
font-size: 16px; font-size: 14px;
margin-bottom: 10px; color: #5f6368;
margin-bottom: 12px;
}
.btn {
background: #1a73e8;
border: 1px solid #1a73e8;
color: #ffffff;
padding: 8px 16px;
border-radius: 4px;
font-weight: 500;
font-size: 14px;
text-decoration: none;
display: inline-block;
transition: all 0.2s ease;
cursor: pointer;
}
.btn:hover {
background: #1557b0;
border-color: #1557b0;
color: #ffffff;
text-decoration: none;
}
.btn-outline {
background: #ffffff;
border: 1px solid #dadce0;
color: #1a73e8;
}
.btn-outline:hover {
background: #f8f9fa;
border-color: #1a73e8;
color: #1a73e8;
} }
.comments-section { .comments-section {
width: 80%; margin-top: 32px;
margin-top: 20px; }
.comments-section h2 {
color: #1a73e8;
font-weight: 400;
font-size: 1.25rem;
margin-bottom: 16px;
} }
.comment { .comment {
border: 1px solid #ccc; background: #f8f9fa;
padding: 10px; border: 1px solid #dadce0;
margin-bottom: 10px; border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
} }
.comment p { .comment p {
margin: 0; margin: 0;
color: #202124;
}
.comment-meta {
font-size: 12px;
color: #5f6368;
margin-top: 8px;
}
.form-control {
border: 1px solid #dadce0;
border-radius: 4px;
padding: 12px;
font-family: inherit;
width: 100%;
margin-bottom: 12px;
}
.form-control:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}
.modal-content {
border-radius: 8px;
border: none;
box-shadow: 0 4px 8px rgba(60, 64, 67, 0.3);
}
.modal-header {
border-bottom: 1px solid #dadce0;
}
.modal-title {
color: #1a73e8;
font-weight: 400;
} }
</style> </style>
</head> </head>
@@ -114,6 +213,7 @@
<h3>更新时间: {{time}}</h3> <h3>更新时间: {{time}}</h3>
<h2>👍{{socre["like"]}} 👎{{socre["dislike"]}}</h2> <h2>👍{{socre["like"]}} 👎{{socre["dislike"]}}</h2>
<button class="btn btn-primary" onclick="window.location.href='/view/{{ id }}'">在线浏览</button> <button class="btn btn-primary" onclick="window.location.href='/view/{{ id }}'">在线浏览</button>
<button class="btn btn-outline" id="continue-read">继续阅读</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">撰写评论</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#exampleModal">撰写评论</button>
{%if islogin == "admin"%} {%if islogin == "admin"%}
<button class="btn btn-danger">删除资源</button> <button class="btn btn-danger">删除资源</button>
@@ -178,6 +278,33 @@
alert('请求失败:' + error.message); alert('请求失败:' + error.message);
}); });
} }
// 继续阅读:依据本地存储定位到上次分页
(function setupContinueRead(){
const btn = document.getElementById('continue-read');
if(!btn) return;
const key = `ComiPy:reader:{{ id }}`;
let state = null;
try {
const raw = localStorage.getItem(key);
state = raw ? JSON.parse(raw) : null;
} catch(_) {}
if(!state || !state.page){
btn.title = '尚无阅读记录';
// 保持原文案“继续阅读”
} else {
btn.title = `上次阅读到第 ${state.page} 页`;
btn.textContent = `继续阅读 第 ${state.page} 页`;
}
btn.addEventListener('click', function(){
if(state && state.page){
window.location.href = `/view/{{ id }}#p=${state.page}`;
} else {
window.location.href = `/view/{{ id }}`;
}
});
})();
</script> </script>
</body> </body>

View File

@@ -5,52 +5,81 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComiPy-登录</title> <title>ComiPy-登录</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: Arial, sans-serif; font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
background-color: #f0f0f0; background-color: #f8f9fa;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; height: 100vh;
margin: 0; margin: 0;
color: #202124;
line-height: 1.6;
} }
.container { .container {
background-color: #fff; background-color: #ffffff;
padding: 20px; padding: 32px;
border-radius: 5px; border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
width: 100%;
max-width: 400px;
}
h1 {
text-align: center;
color: #1a73e8;
font-weight: 400;
margin-bottom: 24px;
} }
.form-group { .form-group {
margin-bottom: 15px; margin-bottom: 16px;
} }
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 5px; margin-bottom: 8px;
color: #202124;
font-weight: 500;
font-size: 14px;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 10px; padding: 12px;
border: 1px solid #ddd; border: 1px solid #dadce0;
border-radius: 3px; border-radius: 4px;
box-sizing: border-box; font-family: inherit;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
} }
.form-group button { .form-group button {
width: 100%; width: 100%;
padding: 10px; padding: 12px;
background-color: #5cb85c; background: #1a73e8;
border: none; border: 1px solid #1a73e8;
border-radius: 3px; border-radius: 4px;
color: white; color: #ffffff;
cursor: pointer; cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
} }
.form-group button:hover { .form-group button:hover {
background-color: #4cae4c; background: #1557b0;
border-color: #1557b0;
} }
</style> </style>
</head> </head>

View File

@@ -7,26 +7,38 @@
<link href="/static/css/bootstrap.min.css" rel="stylesheet"> <link href="/static/css/bootstrap.min.css" rel="stylesheet">
<title>ComiPy-概览</title> <title>ComiPy-概览</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: Arial, sans-serif; font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
margin: 0; margin: 0;
padding: 20px; padding: 0;
background-color: #f0f0f0; background-color: #f8f9fa;
min-height: 100vh;
color: #202124;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px 16px;
} }
#gallery { #gallery {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
grid-gap: 20px; gap: 16px;
font-size: small; font-size: 14px;
} }
/* 当屏幕宽度大于600px时调整列数和列的宽度 */ /* 当屏幕宽度大于600px时调整列数和列的宽度 */
@media (min-width: 600px) { @media (min-width: 600px) {
#gallery { #gallery {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
font-size: medium; font-size: 15px;
/* 两列布局 */
} }
} }
@@ -34,20 +46,57 @@
@media (min-width: 900px) { @media (min-width: 900px) {
#gallery { #gallery {
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
font-size: large; font-size: 16px;
/* 三列布局 */
} }
} }
.gallery-item {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
transition: box-shadow 0.2s ease;
cursor: pointer;
}
.gallery-item:hover {
box-shadow: 0 4px 8px rgba(60, 64, 67, 0.3);
}
.gallery-item img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px 8px 0 0;
}
.gallery-item-content {
padding: 12px;
}
.gallery-item h3 {
margin: 0 0 8px 0;
font-size: 1em;
font-weight: 500;
color: #1a73e8;
text-decoration: none;
}
.gallery-item p {
margin: 0;
color: #5f6368;
font-size: 0.875em;
}
#global-blur { #global-blur {
background-color: rgba(255, 255, 255, 0.5); background-color: rgba(255, 255, 255, 0.95);
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
backdrop-filter: blur(10px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(8px);
/* 模糊度可以根据需要调整 */ /* 模糊度可以根据需要调整 */
transition: display; transition: display;
z-index: 10; z-index: 10;
@@ -77,20 +126,22 @@
</div> </div>
</div> </div>
<hr /> <hr />
<div class="container">
<div id="gallery"> <div id="gallery">
{% for item in list %} {% for item in list %}
<div class="card"> <div class="gallery-item" onclick="linkjump('{{ item[1] }}')">
{% if item[4] > aftertime %} {% if item[4] > aftertime %}
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">New</span> <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">New</span>
{% endif %} {% endif %}
<img src="/api/img/{{ item[1] }}/0?mini=yes" class="img-thumbnail card-img-top" <img src="/api/img/{{ item[1] }}/0?mini=yes" alt="{{ item[2] }}" />
onclick="linkjump('{{ item[1] }}')" /> <div class="gallery-item-content">
<div class="card-body"> <h3>{{ item[2] }}</h3>
<p class="card-text">{{ item[2] }}</p> <p>点击查看详情</p>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
<hr /> <hr />
<div style=display:flex;justify-content:center;align-items:center;> <div style=display:flex;justify-content:center;align-items:center;>
<ul class="pagination"> <ul class="pagination">

View File

@@ -9,11 +9,84 @@
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="/static/css/bootstrap.min.css" rel="stylesheet"> <link href="/static/css/bootstrap.min.css" rel="stylesheet">
<style> <style>
/* Custom styles */ * {
box-sizing: border-box;
}
body {
font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f8f9fa;
min-height: 100vh;
color: #202124;
line-height: 1.6;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 24px 16px;
background-color: #ffffff;
border-radius: 8px;
margin-top: 40px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
}
h2 {
color: #1a73e8;
font-weight: 400;
margin-bottom: 24px;
text-align: center;
}
.form-control {
border: 1px solid #dadce0;
border-radius: 4px;
padding: 12px;
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: #1a73e8;
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}
.btn-primary {
background: #1a73e8;
border: 1px solid #1a73e8;
color: #ffffff;
padding: 12px 24px;
border-radius: 4px;
font-weight: 500;
transition: all 0.2s ease;
cursor: pointer;
}
.btn-primary:hover {
background: #1557b0;
border-color: #1557b0;
}
.progress {
background-color: #f1f3f4;
border-radius: 4px;
overflow: hidden;
margin-top: 16px;
}
#progress_bar { #progress_bar {
background-color: #1a73e8;
height: 8px;
width: 0; width: 0;
background-color: #64B587; transition: width 0.3s ease;
height: 20px; }
#loading {
margin-top: 12px;
color: #5f6368;
font-size: 14px;
} }
</style> </style>
</head> </head>

View File

@@ -6,82 +6,319 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComiPy-漫画详情页</title> <title>ComiPy-漫画详情页</title>
<style> <style>
* {
box-sizing: border-box;
}
body { body {
font-family: Arial, sans-serif; font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
margin: 0; margin: 0;
padding: 20px; padding: 0;
background-color: #f0f0f0; background-color: #f8f9fa;
min-height: 100vh;
color: #202124;
line-height: 1.6;
}
.header {
background: #ffffff;
padding: 12px 20px;
text-align: center;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
border-bottom: 1px solid #dadce0;
transform: translateY(0);
transition: transform 0.3s ease-in-out;
}
.header.hidden {
transform: translateY(-100%);
}
.header h1 {
display: none;
}
.page-info {
font-size: 0.8rem;
color: #5f6368;
margin-bottom: 8px;
font-weight: 400;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px;
background-color: #ffffff;
border-radius: 8px;
margin-top: 80px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
} }
#comic-container { #comic-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16px;
margin-bottom: 32px;
} }
.comic-image { .comic-image {
max-width: 100%; max-width: 100%;
width: auto;
height: auto; height: auto;
margin-bottom: 20px; border-radius: 8px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
transition: box-shadow 0.3s ease;
background: #fff;
overflow: hidden;
}
.comic-image:hover {
box-shadow: 0 4px 8px rgba(60, 64, 67, 0.3);
} }
img { img {
display: block; display: block;
width: 100%; width: 100%;
min-height: 200px; min-height: 200px;
margin-top: 10px; border-radius: 4px;
background: linear-gradient(45deg, #f1f3f4 25%, transparent 25%, transparent 75%, #f1f3f4 75%),
linear-gradient(45deg, #f1f3f4 25%, transparent 25%, transparent 75%, #f1f3f4 75%);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
} }
#pagination { .pagination-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin-top: 20px; gap: 8px;
margin: 16px 0;
padding: 0 8px; /* 给两侧留白,避免被屏幕边缘贴边或裁剪 */
box-sizing: border-box;
scroll-padding-inline: 12px; /* 滚动对齐时保留左右内边距 */
scrollbar-gutter: stable both-edges; /* 预留滚动条空间,避免内容被遮挡 */
} }
.page-button { .page-button {
padding: 10px 15px; padding: 8px 16px;
margin: 5px; background: #ffffff;
background-color: #007bff; color: #1a73e8;
color: white; border: 1px solid #dadce0;
border: none;
cursor: pointer; cursor: pointer;
border-radius: 5px; border-radius: 4px;
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
min-width: 36px;
font-family: inherit;
white-space: nowrap; /* 防止中文自动换行成竖排 */
flex: 0 0 auto; /* 在可横向滚动容器中保持宽度,不被压缩 */
}
.page-button:hover:not(:disabled) {
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
border-color: #1a73e8;
}
.page-button:active:not(:disabled) {
background: #f8f9fa;
} }
.page-button:disabled { .page-button:disabled {
background-color: #cccccc; background: #1a73e8;
} color: #ffffff;
border-color: #1a73e8;
.ellipsis {
padding: 10px 15px;
margin: 0 5px;
background-color: transparent;
border: none;
cursor: default; cursor: default;
} }
.page-button.nav-button {
background: #1a73e8;
color: #ffffff;
border-color: #1a73e8;
padding: 8px 24px;
font-size: 14px;
min-width: 88px; /* 桌面端保证能容纳“上一页/下一页” */
}
.page-button.nav-button:hover:not(:disabled) {
background: #1557b0;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3);
}
.page-button:disabled.nav-button {
background: #dadce0;
color: #80868b;
border-color: #dadce0;
}
.ellipsis {
padding: 8px 16px;
margin: 0;
background: transparent;
border: none;
cursor: default;
color: #5f6368;
font-weight: 400;
}
.page-jump {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 8px;
flex: 0 0 auto; /* 避免被压缩导致输入框过小 */
}
.page-input {
width: 84px;
padding: 7px 8px;
border: 1px solid #dadce0;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
outline: none;
}
.page-input:focus {
border-color: #1a73e8;
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.15);
}
.loading-placeholder {
background: linear-gradient(90deg, #f1f3f4 25%, #e8eaed 50%, #f1f3f4 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
#global-blur { #global-blur {
background-color: rgba(255, 255, 255, 0.8); background-color: rgba(255, 255, 255, 0.95);
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
backdrop-filter: blur(12px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(8px);
transition: opacity 0.1s ease; transition: opacity 0.3s ease;
z-index: 1; z-index: 1000;
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
} }
@media (max-width: 768px) {
.header {
padding: 16px;
}
.header h1 {
font-size: 1.5rem;
}
.container {
margin: 8px;
padding: 12px 10px;
}
.page-button {
padding: 8px 12px; /* 提高触摸目标尺寸 */
font-size: 13px;
min-width: 32px;
}
.page-button.nav-button {
padding: 8px 16px;
font-size: 13px;
min-width: 72px; /* 移动端最小宽度,避免断行 */
}
.page-input {
width: 72px;
padding: 6px 8px;
font-size: 13px;
}
/* 小屏分页支持横向滚动,减少换行高度 */
.pagination-container {
flex-wrap: nowrap;
justify-content: flex-start; /* 小屏靠左对齐,避免左侧被裁剪 */
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
gap: 6px;
padding: 0 12px; /* 左右更充足的可视留白 */
scroll-padding-inline: 12px; /* 小屏滚动对齐保留内边距 */
overscroll-behavior-x: contain; /* 避免横向滚动影响页面整体 */
}
.pagination-container::-webkit-scrollbar { height: 6px; }
.pagination-container::-webkit-scrollbar-thumb { background: #dadce0; border-radius: 3px; }
}
/* 移动端悬浮操作按钮FAB */
.mobile-fabs {
position: fixed;
right: 12px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
display: none; /* 桌面端隐藏 */
flex-direction: column;
gap: 10px;
z-index: 1100;
}
.mobile-fabs .fab {
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid #dadce0;
background: #ffffff;
color: #1a73e8;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(60,64,67,.3);
cursor: pointer;
user-select: none;
font-size: 18px;
}
.mobile-fabs .fab:disabled {
color: #9aa0a6;
border-color: #e0e0e0;
}
@media (max-width: 768px) {
.mobile-fabs { display: inline-flex; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="header" id="header">
<div class="page-info" id="page-info">第 <span id="current-page-display">1</span> 页,共 <span id="total-pages-display">1</span> 页</div>
<div class="pagination-container" id="top-pagination"></div>
</div>
<div class="container">
<div id="comic-container"></div> <div id="comic-container"></div>
<div id="pagination"></div> <div class="pagination-container" id="bottom-pagination"></div>
</div>
<!-- 移动端悬浮操作按钮:上一页、下一页、回到顶部 -->
<div class="mobile-fabs" aria-label="阅读操作" id="mobile-fabs" role="toolbar">
<button class="fab" id="fab-prev" title="上一页" aria-label="上一页">⟨</button>
<button class="fab" id="fab-next" title="下一页" aria-label="下一页">⟩</button>
<button class="fab" id="fab-top" title="回到顶部" aria-label="回到顶部">↑</button>
</div>
<div id="global-blur" onclick="unshowGlobalBlur()"></div> <div id="global-blur" onclick="unshowGlobalBlur()"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
@@ -91,48 +328,202 @@
{% endfor %} {% endfor %}
]; ];
const itemsPerPage = 25; // 每页显示的图片数量 const itemsPerPage = 25; // 每页显示的图片数量
let currentPage = 1; let currentPage = 1; // 逻辑分页每25张图为一页
const totalPages = Math.ceil(imgsData.length / itemsPerPage);
const contentId = "{{ id }}";
const storageKey = `ComiPy:reader:${contentId}`;
let ignoreHashChange = false; // 避免程序更新 hash 触发重复处理
let pendingScrollSave = null; // 节流保存滚动位置
let lastVisibleImageIndex = 0; // 记录当前页内大致位置(第几张图)
function renderPage(page) { function renderPage(page) {
const comicContainer = document.getElementById('comic-container'); const comicContainer = document.getElementById('comic-container');
comicContainer.innerHTML = ''; // 清空当前内容 comicContainer.innerHTML = ''; // 清空当前内容
// 显示加载动画
showLoadingPlaceholders();
const start = (page - 1) * itemsPerPage; const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage; const end = start + itemsPerPage;
const pageItems = imgsData.slice(start, end); const pageItems = imgsData.slice(start, end);
pageItems.forEach(item => { // 清空加载动画并加载图片
setTimeout(() => {
comicContainer.innerHTML = '';
pageItems.forEach((item, index) => {
const img = document.createElement('img'); const img = document.createElement('img');
img.className = 'imgs comic-image'; img.className = 'imgs comic-image loading-placeholder';
img.setAttribute('data-src', item.src); img.setAttribute('data-src', item.src);
img.setAttribute('alt', item.alt); img.setAttribute('alt', item.alt);
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
img.setAttribute('fetchpriority', 'low');
img.onload = () => img.classList.remove('loading-placeholder');
comicContainer.appendChild(img); comicContainer.appendChild(img);
}); });
window.scrollTo(0, 0); // 滚动到页面顶部 // 优先恢复页内滚动位置,否则回到顶部
const saved = loadReadingState();
if (saved && saved.page === currentPage && typeof saved.inPageIndex === 'number') {
// 等待下一帧,确保元素渲染后再滚动
requestAnimationFrame(() => {
scrollToImage(saved.inPageIndex);
lazyLoad(); // 确保惰性加载生效 lazyLoad(); // 确保惰性加载生效
});
} else {
window.scrollTo({ top: 0, behavior: 'smooth' }); // 平滑滚动到页面顶部
lazyLoad(); // 确保惰性加载生效
}
}, 300);
}
function showLoadingPlaceholders() {
const comicContainer = document.getElementById('comic-container');
comicContainer.innerHTML = '';
for (let i = 0; i < Math.min(itemsPerPage, 5); i++) {
const placeholder = document.createElement('div');
placeholder.className = 'comic-image loading-placeholder';
placeholder.style.height = '400px';
comicContainer.appendChild(placeholder);
}
}
function updatePageInfo() {
document.getElementById('current-page-display').textContent = currentPage;
document.getElementById('total-pages-display').textContent = totalPages;
} }
function renderPagination() { function renderPagination() {
const pagination = document.getElementById('pagination'); const topPagination = document.getElementById('top-pagination');
pagination.innerHTML = ''; // 清空当前内容 const bottomPagination = document.getElementById('bottom-pagination');
const totalPages = Math.ceil(imgsData.length / itemsPerPage); // 清空当前内容
topPagination.innerHTML = '';
bottomPagination.innerHTML = '';
if (totalPages <= 1) return; if (totalPages <= 1) return;
for (let i = 1; i <= totalPages; i++) { // 创建分页按钮
function createPaginationButtons(container) {
const isMobile = window.innerWidth <= 768;
const pageWindow = isMobile ? 1 : 2; // 手机端收窄页码窗口
// 上一页按钮
const prevButton = document.createElement('button');
prevButton.className = 'page-button nav-button';
prevButton.innerHTML = '上一页';
prevButton.disabled = (currentPage === 1);
prevButton.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
changePage();
}
});
container.appendChild(prevButton);
// 页码按钮
const startPage = Math.max(1, currentPage - pageWindow);
const endPage = Math.min(totalPages, currentPage + pageWindow);
// 第一页
if (startPage > 1) {
createPageButton(container, 1);
if (startPage > 2) {
const ellipsis = document.createElement('span');
ellipsis.className = 'ellipsis';
ellipsis.textContent = '...';
container.appendChild(ellipsis);
}
}
// 中间页码
for (let i = startPage; i <= endPage; i++) {
createPageButton(container, i);
}
// 最后一页
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
const ellipsis = document.createElement('span');
ellipsis.className = 'ellipsis';
ellipsis.textContent = '...';
container.appendChild(ellipsis);
}
createPageButton(container, totalPages);
}
// 下一页按钮
const nextButton = document.createElement('button');
nextButton.className = 'page-button nav-button';
nextButton.innerHTML = '下一页';
nextButton.disabled = (currentPage === totalPages);
nextButton.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
changePage();
}
});
container.appendChild(nextButton);
// 跳转控件
const jumpWrap = document.createElement('span');
jumpWrap.className = 'page-jump';
const input = document.createElement('input');
input.type = 'number';
input.min = '1';
input.max = String(totalPages);
input.placeholder = `跳转 1-${totalPages}`;
input.className = 'page-input';
input.value = String(currentPage);
const goBtn = document.createElement('button');
goBtn.className = 'page-button';
goBtn.textContent = '跳转';
function doJump() {
let val = parseInt(input.value, 10);
if (isNaN(val)) return;
val = Math.max(1, Math.min(totalPages, val));
if (val !== currentPage) {
currentPage = val;
changePage();
}
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doJump();
});
goBtn.addEventListener('click', doJump);
jumpWrap.appendChild(input);
jumpWrap.appendChild(goBtn);
container.appendChild(jumpWrap);
}
function createPageButton(container, pageNum) {
const button = document.createElement('button'); const button = document.createElement('button');
button.className = 'page-button'; button.className = 'page-button';
button.innerText = i; button.innerText = pageNum;
button.disabled = (i === currentPage); button.disabled = (pageNum === currentPage);
button.addEventListener('click', () => { button.addEventListener('click', () => {
currentPage = i; currentPage = pageNum;
renderPage(i); changePage();
renderPagination();
}); });
pagination.appendChild(button); container.appendChild(button);
} }
createPaginationButtons(topPagination);
createPaginationButtons(bottomPagination);
centerActivePageButtons();
}
function changePage() {
renderPage(currentPage);
renderPagination();
updatePageInfo();
saveReadingState();
updateURLHash(currentPage);
centerActivePageButtons();
} }
function lazyLoad() { function lazyLoad() {
@@ -144,15 +535,118 @@
if (img.src) return; // 如果已经加载过了就跳过 if (img.src) return; // 如果已经加载过了就跳过
const imgTop = img.getBoundingClientRect().top + scrollY; const imgTop = img.getBoundingClientRect().top + scrollY;
if (windowHeight + scrollY > imgTop) { if (windowHeight + scrollY + 200 > imgTop) { // 提前200px开始加载
img.src = img.getAttribute('data-src'); img.src = img.getAttribute('data-src');
} }
}); });
} }
window.addEventListener('scroll', lazyLoad); // 键盘快捷键支持
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft' && currentPage > 1) {
currentPage--;
changePage();
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
currentPage++;
changePage();
}
});
// 滚动隐藏顶栏功能
let lastScrollTop = 0;
const header = document.getElementById('header');
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (scrollTop > lastScrollTop && scrollTop > 100) {
// 向下滚动且滚动距离超过100px时隐藏
header.classList.add('hidden');
} else {
// 向上滚动时显示
header.classList.remove('hidden');
}
lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; // 防止负数
// 记录页内可见图片索引并惰性加载
updateVisibleImageIndex();
lazyLoad();
});
window.addEventListener('resize', lazyLoad); window.addEventListener('resize', lazyLoad);
lazyLoad(); // 页面加载时初始化调用
function centerActivePageButtons() {
const containers = [
document.getElementById('top-pagination'),
document.getElementById('bottom-pagination')
];
containers.forEach((c) => {
if (!c) return;
const active = c.querySelector('.page-button[disabled]');
if (!active) return;
// 仅在容器可横向滚动时执行
if (c.scrollWidth <= c.clientWidth) return;
// 计算按钮相对容器的中心偏移,使用 scrollLeft 精确定位
const containerCenter = c.clientWidth / 2;
const activeCenter = active.offsetLeft + active.offsetWidth / 2;
let targetScrollLeft = activeCenter - containerCenter;
const maxScroll = c.scrollWidth - c.clientWidth;
const padding = 8; // 与容器左右 padding 保持一致
targetScrollLeft = Math.max(0, Math.min(maxScroll, targetScrollLeft));
// 保证左侧留有可视 padding不至于看起来被切掉
if (targetScrollLeft < padding) targetScrollLeft = 0;
c.scrollTo({ left: targetScrollLeft, behavior: 'smooth' });
});
}
// 移动端悬浮按钮事件
const fabPrev = document.getElementById('fab-prev');
const fabNext = document.getElementById('fab-next');
const fabTop = document.getElementById('fab-top');
if (fabPrev && fabNext && fabTop) {
fabPrev.addEventListener('click', () => {
if (currentPage > 1) { currentPage--; changePage(); }
});
fabNext.addEventListener('click', () => {
if (currentPage < totalPages) { currentPage++; changePage(); }
});
fabTop.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
function updateFabs() {
if (!fabPrev || !fabNext) return;
fabPrev.disabled = (currentPage === 1);
fabNext.disabled = (currentPage === totalPages);
}
// 简单左右滑动切页(阈值:水平>60px 且 垂直<30px
let touchStartX = 0, touchStartY = 0, touchStartTime = 0;
const swipeEl = document.getElementById('comic-container');
if (swipeEl) {
swipeEl.addEventListener('touchstart', (e) => {
const t = e.changedTouches[0];
touchStartX = t.clientX;
touchStartY = t.clientY;
touchStartTime = Date.now();
}, { passive: true });
swipeEl.addEventListener('touchend', (e) => {
const t = e.changedTouches[0];
const dx = t.clientX - touchStartX;
const dy = Math.abs(t.clientY - touchStartY);
const dt = Date.now() - touchStartTime;
if (dt < 600 && Math.abs(dx) > 60 && dy < 30) {
if (dx < 0 && currentPage < totalPages) { // 向左滑 -> 下一页
currentPage++;
changePage();
} else if (dx > 0 && currentPage > 1) { // 向右滑 -> 上一页
currentPage--;
changePage();
}
}
}, { passive: true });
}
const globalBlur = document.getElementById('global-blur'); const globalBlur = document.getElementById('global-blur');
@@ -166,8 +660,105 @@
} }
}); });
// --- 位置记忆 & URL 同步 ---
function saveReadingState() {
const state = {
page: currentPage,
inPageIndex: lastVisibleImageIndex
};
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch (_) {}
}
function loadReadingState() {
try {
const raw = localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : null;
} catch (_) { return null; }
}
function parsePageFromURL() {
// 1) hash: #p=3 或 #page=3
const hash = (location.hash || '').replace(/^#/, '');
const hashParams = new URLSearchParams(hash.includes('=') ? hash : `p=${hash}`);
let p = parseInt(hashParams.get('p') || hashParams.get('page') || '', 10);
if (!isNaN(p)) return p;
// 2) search: ?p=3 或 ?page=3
const sp = new URLSearchParams(location.search);
p = parseInt(sp.get('p') || sp.get('page') || '', 10);
return isNaN(p) ? null : p;
}
function updateURLHash(page) {
// 使用 hash 避免后端路由干扰
const newHash = `p=${page}`;
if (location.hash !== `#${newHash}`) {
ignoreHashChange = true;
location.hash = newHash;
// 短暂忽略这次 hashchange 事件
setTimeout(() => { ignoreHashChange = false; }, 50);
}
}
window.addEventListener('hashchange', () => {
if (ignoreHashChange) return;
const p = parsePageFromURL();
if (typeof p === 'number' && p >= 1 && p <= totalPages && p !== currentPage) {
currentPage = p;
changePage();
}
});
function scrollToImage(inPageIdx) {
const imgs = document.querySelectorAll('#comic-container .imgs');
if (!imgs.length) return;
const idx = Math.max(0, Math.min(imgs.length - 1, inPageIdx | 0));
const el = imgs[idx];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
function updateVisibleImageIndex() {
const imgs = document.querySelectorAll('#comic-container .imgs');
if (!imgs.length) return;
const scrollY = window.scrollY || window.pageYOffset;
let idx = 0;
for (let i = 0; i < imgs.length; i++) {
const rect = imgs[i].getBoundingClientRect();
const top = rect.top + scrollY;
if (top - scrollY >= -50) { // 视口附近的第一张
idx = i;
break;
}
}
lastVisibleImageIndex = idx;
// 节流保存
if (pendingScrollSave) clearTimeout(pendingScrollSave);
pendingScrollSave = setTimeout(saveReadingState, 400);
}
window.addEventListener('scroll', () => {
updateVisibleImageIndex();
}, { passive: true });
// 初始化页面:从 URL 或本地存储恢复
(function init() {
let initialPage = parsePageFromURL();
const saved = loadReadingState();
if (!initialPage && saved && typeof saved.page === 'number') {
initialPage = saved.page;
}
if (typeof initialPage === 'number') {
currentPage = Math.max(1, Math.min(totalPages, initialPage));
}
updatePageInfo();
renderPage(currentPage); renderPage(currentPage);
renderPagination(); renderPagination();
updateURLHash(currentPage);
updateFabs();
})();
}); });
function unshowGlobalBlur() { function unshowGlobalBlur() {

236
utils/cache_manager.py Normal file
View File

@@ -0,0 +1,236 @@
"""
缓存管理器 - 用于图片和数据缓存
提供内存缓存和磁盘缓存功能
"""
import os
import hashlib
import json
import time
import threading
from pathlib import Path
from typing import Optional, Dict, Any
import pickle
from utils.logger import get_logger
import app_conf
logger = get_logger(__name__)
conf = app_conf.conf()
class CacheManager:
"""缓存管理器"""
def __init__(self, cache_dir: Optional[str] = None, max_memory_size: int = 100):
"""
初始化缓存管理器
Args:
cache_dir: 磁盘缓存目录
max_memory_size: 内存缓存最大条目数
"""
self.cache_dir = Path(cache_dir or conf.get("file", "tmpdir")) / "cache"
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.memory_cache = {}
self.max_memory_size = max_memory_size
self.cache_access_times = {}
self.lock = threading.RLock()
# 缓存统计
self.stats = {
'hits': 0,
'misses': 0,
'memory_hits': 0,
'disk_hits': 0
}
logger.info(f"缓存管理器初始化: 目录={self.cache_dir}, 内存限制={max_memory_size}")
def _generate_key(self, *args) -> str:
"""生成缓存键"""
key_string = "_".join(str(arg) for arg in args)
return hashlib.md5(key_string.encode('utf-8')).hexdigest()
def _cleanup_memory_cache(self):
"""清理内存缓存,移除最久未访问的项目"""
if len(self.memory_cache) <= self.max_memory_size:
return
# 按访问时间排序,移除最旧的项目
sorted_items = sorted(
self.cache_access_times.items(),
key=lambda x: x[1]
)
# 移除最旧的20%
remove_count = len(self.memory_cache) - self.max_memory_size + 1
for key, _ in sorted_items[:remove_count]:
if key in self.memory_cache:
del self.memory_cache[key]
del self.cache_access_times[key]
logger.debug(f"清理内存缓存: 移除 {remove_count}")
def get(self, key: str, default=None) -> Any:
"""获取缓存数据"""
with self.lock:
current_time = time.time()
# 检查内存缓存
if key in self.memory_cache:
self.cache_access_times[key] = current_time
self.stats['hits'] += 1
self.stats['memory_hits'] += 1
logger.debug(f"内存缓存命中: {key}")
return self.memory_cache[key]
# 检查磁盘缓存
cache_file = self.cache_dir / f"{key}.cache"
if cache_file.exists():
try:
with open(cache_file, 'rb') as f:
data = pickle.load(f)
# 将数据加载到内存缓存
self.memory_cache[key] = data
self.cache_access_times[key] = current_time
self._cleanup_memory_cache()
self.stats['hits'] += 1
self.stats['disk_hits'] += 1
logger.debug(f"磁盘缓存命中: {key}")
return data
except Exception as e:
logger.error(f"读取磁盘缓存失败 {key}: {e}")
# 删除损坏的缓存文件
try:
cache_file.unlink()
except:
pass
self.stats['misses'] += 1
logger.debug(f"缓存未命中: {key}")
return default
def set(self, key: str, value: Any, disk_cache: bool = True):
"""设置缓存数据"""
with self.lock:
current_time = time.time()
# 存储到内存缓存
self.memory_cache[key] = value
self.cache_access_times[key] = current_time
self._cleanup_memory_cache()
# 存储到磁盘缓存
if disk_cache:
try:
cache_file = self.cache_dir / f"{key}.cache"
with open(cache_file, 'wb') as f:
pickle.dump(value, f)
logger.debug(f"数据已缓存: {key}")
except Exception as e:
logger.error(f"写入磁盘缓存失败 {key}: {e}")
def delete(self, key: str):
"""删除缓存数据"""
with self.lock:
# 删除内存缓存
if key in self.memory_cache:
del self.memory_cache[key]
del self.cache_access_times[key]
# 删除磁盘缓存
cache_file = self.cache_dir / f"{key}.cache"
if cache_file.exists():
try:
cache_file.unlink()
logger.debug(f"删除缓存: {key}")
except Exception as e:
logger.error(f"删除磁盘缓存失败 {key}: {e}")
def clear(self):
"""清空所有缓存"""
with self.lock:
# 清空内存缓存
self.memory_cache.clear()
self.cache_access_times.clear()
# 清空磁盘缓存
try:
for cache_file in self.cache_dir.glob("*.cache"):
cache_file.unlink()
logger.info("清空所有缓存")
except Exception as e:
logger.error(f"清空磁盘缓存失败: {e}")
def get_stats(self) -> Dict[str, Any]:
"""获取缓存统计信息"""
with self.lock:
total_requests = self.stats['hits'] + self.stats['misses']
hit_rate = (self.stats['hits'] / total_requests * 100) if total_requests > 0 else 0
return {
'total_requests': total_requests,
'hits': self.stats['hits'],
'misses': self.stats['misses'],
'hit_rate': f"{hit_rate:.2f}%",
'memory_hits': self.stats['memory_hits'],
'disk_hits': self.stats['disk_hits'],
'memory_cache_size': len(self.memory_cache),
'disk_cache_files': len(list(self.cache_dir.glob("*.cache")))
}
def cleanup_expired(self, max_age_hours: int = 24):
"""清理过期的磁盘缓存文件"""
try:
current_time = time.time()
max_age_seconds = max_age_hours * 3600
removed_count = 0
for cache_file in self.cache_dir.glob("*.cache"):
if current_time - cache_file.stat().st_mtime > max_age_seconds:
cache_file.unlink()
removed_count += 1
if removed_count > 0:
logger.info(f"清理过期缓存文件: {removed_count}")
except Exception as e:
logger.error(f"清理过期缓存失败: {e}")
# 全局缓存管理器实例
_cache_manager = None
def get_cache_manager() -> CacheManager:
"""获取全局缓存管理器实例"""
global _cache_manager
if _cache_manager is None:
_cache_manager = CacheManager()
return _cache_manager
def cache_image(func):
"""图片缓存装饰器"""
def wrapper(*args, **kwargs):
cache_manager = get_cache_manager()
# 生成缓存键
cache_key = cache_manager._generate_key(func.__name__, *args, *kwargs.items())
# 尝试从缓存获取
cached_result = cache_manager.get(cache_key)
if cached_result is not None:
return cached_result
# 执行函数并缓存结果
result = func(*args, **kwargs)
if result: # 只缓存有效结果
cache_manager.set(cache_key, result)
return result
return wrapper

104
utils/config_validator.py Normal file
View File

@@ -0,0 +1,104 @@
import os
import sys
from typing import Dict, Any
import app_conf
def validate_config() -> Dict[str, Any]:
"""
验证配置文件的有效性
返回验证结果字典
"""
conf = app_conf.conf()
issues = []
warnings = []
# 检查必需的配置项
required_sections = {
'server': ['port', 'host'],
'user': ['username', 'password'],
'database': ['path'],
'file': ['inputdir', 'storedir', 'tmpdir'],
'img': ['encode', 'miniSize', 'fullSize']
}
for section, keys in required_sections.items():
if not conf.has_section(section):
issues.append(f"缺少配置节: [{section}]")
continue
for key in keys:
if not conf.has_option(section, key):
issues.append(f"缺少配置项: [{section}].{key}")
# 检查安全性问题
if conf.has_section('user'):
username = conf.get('user', 'username', fallback='')
password = conf.get('user', 'password', fallback='')
if username == 'admin' and password == 'admin':
warnings.append("使用默认用户名和密码不安全,建议修改")
if len(password) < 8:
warnings.append("密码过于简单建议使用8位以上的复杂密码")
# 检查端口配置
if conf.has_section('server'):
try:
port = conf.getint('server', 'port')
if port < 1024 or port > 65535:
warnings.append(f"端口号 {port} 可能不合适建议使用1024-65535范围内的端口")
except:
issues.append("服务器端口配置无效")
# 检查目录权限
if conf.has_section('file'):
directories = ['inputdir', 'storedir', 'tmpdir']
for dir_key in directories:
dir_path = conf.get('file', dir_key, fallback='')
if dir_path:
abs_path = os.path.abspath(dir_path)
parent_dir = os.path.dirname(abs_path)
if not os.path.exists(parent_dir):
issues.append(f"父目录不存在: {parent_dir} (配置: {dir_key})")
elif not os.access(parent_dir, os.W_OK):
issues.append(f"没有写入权限: {parent_dir} (配置: {dir_key})")
# 检查数据库路径
if conf.has_section('database'):
db_path = conf.get('database', 'path', fallback='')
if db_path:
db_dir = os.path.dirname(os.path.abspath(db_path))
if not os.path.exists(db_dir):
issues.append(f"数据库目录不存在: {db_dir}")
elif not os.access(db_dir, os.W_OK):
issues.append(f"数据库目录没有写入权限: {db_dir}")
return {
'valid': len(issues) == 0,
'issues': issues,
'warnings': warnings
}
def print_validation_results(results: Dict[str, Any]):
"""打印配置验证结果"""
if results['valid']:
print("✅ 配置验证通过")
else:
print("❌ 配置验证失败")
print("\n严重问题:")
for issue in results['issues']:
print(f"{issue}")
if results['warnings']:
print("\n⚠️ 警告:")
for warning in results['warnings']:
print(f"{warning}")
if __name__ == "__main__":
# 当直接运行此文件时,执行配置验证
results = validate_config()
print_validation_results(results)
if not results['valid']:
sys.exit(1)

61
utils/db_pool.py Normal file
View File

@@ -0,0 +1,61 @@
import sqlite3
import threading
from contextlib import contextmanager
from queue import Queue, Empty
import app_conf
class ConnectionPool:
def __init__(self, database_path: str, max_connections: int = 10):
self.database_path = database_path
self.max_connections = max_connections
self.pool = Queue(maxsize=max_connections)
self.lock = threading.Lock()
self._initialize_pool()
def _initialize_pool(self):
"""初始化连接池"""
for _ in range(self.max_connections):
conn = sqlite3.connect(self.database_path, check_same_thread=False)
conn.row_factory = sqlite3.Row # 允许按列名访问
self.pool.put(conn)
@contextmanager
def get_connection(self):
"""获取数据库连接的上下文管理器"""
conn = None
try:
conn = self.pool.get(timeout=5) # 5秒超时
yield conn
except Empty:
raise Exception("无法获取数据库连接:连接池已满")
finally:
if conn:
self.pool.put(conn)
def close_all(self):
"""关闭所有连接"""
while not self.pool.empty():
try:
conn = self.pool.get_nowait()
conn.close()
except Empty:
break
# 全局连接池实例
_pool = None
_pool_lock = threading.Lock()
def get_pool():
"""获取全局连接池实例"""
global _pool
if _pool is None:
with _pool_lock:
if _pool is None:
conf = app_conf.conf()
database_path = conf.get("database", "path")
_pool = ConnectionPool(database_path)
return _pool
def get_connection():
"""获取数据库连接"""
return get_pool().get_connection()

59
utils/logger.py Normal file
View File

@@ -0,0 +1,59 @@
import logging
import sys
from logging.handlers import RotatingFileHandler
import os
def setup_logging(app=None, log_level=logging.INFO):
"""
设置应用程序的日志记录
"""
# 创建logs目录
if not os.path.exists('logs'):
os.makedirs('logs')
# 设置日志格式
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# 文件处理器 - 应用日志
file_handler = RotatingFileHandler(
'logs/app.log',
maxBytes=10*1024*1024, # 10MB
backupCount=5
)
file_handler.setFormatter(formatter)
file_handler.setLevel(log_level)
# 错误日志处理器
error_handler = RotatingFileHandler(
'logs/error.log',
maxBytes=10*1024*1024, # 10MB
backupCount=5
)
error_handler.setFormatter(formatter)
error_handler.setLevel(logging.ERROR)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(log_level)
# 配置根日志记录器
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
root_logger.addHandler(file_handler)
root_logger.addHandler(error_handler)
root_logger.addHandler(console_handler)
# 如果是Flask应用也配置Flask的日志
if app:
app.logger.addHandler(file_handler)
app.logger.addHandler(error_handler)
app.logger.setLevel(log_level)
return logging.getLogger(__name__)
def get_logger(name):
"""获取指定名称的日志记录器"""
return logging.getLogger(name)

View File

@@ -0,0 +1,173 @@
"""
性能监控模块
用于监控应用程序的性能指标
"""
import time
import threading
from functools import wraps
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, field
from datetime import datetime
from utils.logger import get_logger
logger = get_logger(__name__)
@dataclass
class PerformanceMetric:
"""性能指标数据类"""
name: str
start_time: float
end_time: float = 0
duration: float = 0
memory_before: float = 0
memory_after: float = 0
success: bool = True
error_message: str = ""
class PerformanceMonitor:
"""性能监控器"""
def __init__(self):
self.metrics: List[PerformanceMetric] = []
self.lock = threading.Lock()
def start_monitoring(self, name: str) -> PerformanceMetric:
"""开始监控一个操作"""
metric = PerformanceMetric(
name=name,
start_time=time.time(),
memory_before=self.get_memory_usage()
)
return metric
def end_monitoring(self, metric: PerformanceMetric, success: bool = True, error_message: str = ""):
"""结束监控操作"""
metric.end_time = time.time()
metric.duration = metric.end_time - metric.start_time
metric.memory_after = self.get_memory_usage()
metric.success = success
metric.error_message = error_message
with self.lock:
self.metrics.append(metric)
# 保持最近1000条记录
if len(self.metrics) > 1000:
self.metrics = self.metrics[-1000:]
logger.debug(f"性能监控: {metric.name} - 耗时: {metric.duration:.3f}s, "
f"内存变化: {metric.memory_after - metric.memory_before:.2f}MB")
def get_memory_usage(self) -> float:
"""获取当前内存使用量(MB)"""
try:
# 简单的内存使用量估算
# 在Windows上可以使用其他方法这里先返回0
return 0.0
except:
return 0.0
def get_stats(self, operation_name: Optional[str] = None) -> Dict[str, Any]:
"""获取性能统计信息"""
with self.lock:
filtered_metrics = self.metrics
if operation_name:
filtered_metrics = [m for m in self.metrics if m.name == operation_name]
if not filtered_metrics:
return {}
durations = [m.duration for m in filtered_metrics if m.success]
success_count = len([m for m in filtered_metrics if m.success])
error_count = len([m for m in filtered_metrics if not m.success])
stats = {
'operation_name': operation_name or 'All Operations',
'total_calls': len(filtered_metrics),
'success_calls': success_count,
'error_calls': error_count,
'success_rate': f"{(success_count / len(filtered_metrics) * 100):.2f}%" if filtered_metrics else "0%",
'avg_duration': f"{(sum(durations) / len(durations)):.3f}s" if durations else "0s",
'min_duration': f"{min(durations):.3f}s" if durations else "0s",
'max_duration': f"{max(durations):.3f}s" if durations else "0s",
'current_memory': f"{self.get_memory_usage():.2f}MB"
}
return stats
def get_recent_errors(self, count: int = 10) -> List[Dict[str, Any]]:
"""获取最近的错误"""
with self.lock:
error_metrics = [m for m in self.metrics if not m.success][-count:]
return [
{
'name': m.name,
'time': datetime.fromtimestamp(m.start_time).strftime('%Y-%m-%d %H:%M:%S'),
'duration': f"{m.duration:.3f}s",
'error': m.error_message
}
for m in error_metrics
]
def clear_metrics(self):
"""清空监控数据"""
with self.lock:
self.metrics.clear()
logger.info("清空性能监控数据")
# 全局性能监控器
_performance_monitor = None
def get_performance_monitor() -> PerformanceMonitor:
"""获取全局性能监控器实例"""
global _performance_monitor
if _performance_monitor is None:
_performance_monitor = PerformanceMonitor()
return _performance_monitor
def monitor_performance(operation_name: Optional[str] = None):
"""性能监控装饰器"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
monitor = get_performance_monitor()
name = operation_name or f"{func.__module__}.{func.__name__}"
metric = monitor.start_monitoring(name)
try:
result = func(*args, **kwargs)
monitor.end_monitoring(metric, success=True)
return result
except Exception as e:
monitor.end_monitoring(metric, success=False, error_message=str(e))
raise
return wrapper
return decorator
def timing_context(operation_name: str):
"""性能监控上下文管理器"""
class TimingContext:
def __init__(self, name: str):
self.name = name
self.monitor = get_performance_monitor()
self.metric: Optional[PerformanceMetric] = None
def __enter__(self):
self.metric = self.monitor.start_monitoring(self.name)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.metric:
if exc_type is None:
self.monitor.end_monitoring(self.metric, success=True)
else:
self.monitor.end_monitoring(self.metric, success=False, error_message=str(exc_val))
return TimingContext(operation_name)

34
utils/security.py Normal file
View File

@@ -0,0 +1,34 @@
import hashlib
import secrets
import hmac
from typing import Optional
def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
"""
哈希密码并返回哈希值和盐值
"""
if salt is None:
salt = secrets.token_hex(32)
password_hash = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt.encode('utf-8'),
100000 # 迭代次数
)
return password_hash.hex(), salt
def verify_password(password: str, hashed_password: str, salt: str) -> bool:
"""
验证密码是否正确
"""
test_hash, _ = hash_password(password, salt)
return hmac.compare_digest(test_hash, hashed_password)
def generate_session_token() -> str:
"""
生成安全的会话令牌
"""
return secrets.token_urlsafe(32)