mirror of
https://github.com/Kakune55/ComiPy.git
synced 2025-10-14 20:24:40 +08:00
Compare commits
7 Commits
86a47a8aab
...
main
Author | SHA1 | Date | |
---|---|---|---|
4239eb1688 | |||
8c4e5885c7 | |||
d0f9e65ad1 | |||
f2c51f45b6 | |||
eaec2dbad7 | |||
b8252d412c | |||
dbcb6a79de |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@ data
|
||||
input
|
||||
test.py
|
||||
conf/app.ini
|
||||
logs/app.log
|
||||
logs/error.log
|
||||
|
94
README.md
94
README.md
@@ -1,2 +1,94 @@
|
||||
# ComiPy
|
||||
# ComiPy - Python 漫画管理器
|
||||
|
||||
ComiPy 是一个由 Python 编写的漫画管理器,旨在简化漫画文件的管理和查看。该工具支持上传压缩的 ZIP 文件格式的漫画,并通过一个直观的 Web 页面进行浏览。其特性包括:
|
||||
|
||||
- 支持上传并处理 ZIP 打包的漫画文件
|
||||
- Web 界面查看漫画
|
||||
- 实时生成压缩后的 WebP 图像进行传输,优化加载速度
|
||||
|
||||
## 功能
|
||||
|
||||
- **漫画上传**:上传 ZIP 格式的漫画文件,自动解压并展示。
|
||||
- **Web 浏览**:通过简单易用的 Web 页面查看漫画内容。
|
||||
- **图像压缩**:实时将图像转换为 WebP 格式,以减少加载时间并提升用户体验。
|
||||
|
||||
## 安装
|
||||
|
||||
### 先决条件
|
||||
|
||||
- Python 3.8 或更高版本
|
||||
- 必须安装 `pip` 包管理工具
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. 克隆本仓库:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Kakune55/ComiPy.git
|
||||
cd ComiPy
|
||||
```
|
||||
|
||||
2. 创建虚拟环境并激活(可选):
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source venv/bin/activate # Linux/MacOS
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. 安装所需依赖:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 启动 Web 服务:
|
||||
|
||||
```bash
|
||||
bash app_control.sh start
|
||||
```
|
||||
|
||||
默认情况下,Web 服务会在 `http://127.0.0.1:8080` 启动。
|
||||
|
||||
2. 打开浏览器并访问 `http://127.0.0.1:8080`,即可上传和浏览漫画。
|
||||
|
||||
## 配置文件
|
||||
|
||||
1. 项目自带了一个模版配置文件 `app_d.ini`,使用时需要复制一份并重命名为 `app.ini`。
|
||||
2. `app.ini` 文件中包含以下配置:
|
||||
|
||||
以下是 `app_d.ini` 配置文件的说明:
|
||||
|
||||
### [server] 部分
|
||||
- **port=8080**: 服务器监听的端口号为 8080。
|
||||
- **debug=0**: 是否开启调试模式,0 表示关闭,1 表示开启。
|
||||
- **host=0.0.0.0**: 服务器绑定的主机地址,0.0.0.0 表示监听所有可用网络接口。
|
||||
- **threaded=0**: 是否启用多线程处理请求,0 表示关闭,1 表示开启。
|
||||
|
||||
### [user] 部分
|
||||
- **username=admin**: 用户名,默认为 admin。
|
||||
- **password=admin**: 密码,默认为 admin。建议在生产环境中修改此密码以增强安全性。
|
||||
|
||||
### [database] 部分
|
||||
- **path=./data/metadata.db**: 数据库文件路径,相对路径为当前目录下的 `data` 文件夹中的 `metadata.db` 文件。
|
||||
|
||||
### [file] 部分
|
||||
- **inputdir=./input**: 输入文件夹路径,用于存放输入文件。
|
||||
- **storedir=./data/file**: 存储文件夹路径,用于存放处理后的文件。
|
||||
- **tmpdir=./data/tmp**: 临时文件夹路径,用于存放临时文件。
|
||||
|
||||
### [img] 部分
|
||||
- **encode=jpg**: 图片编码格式,默认为 jpg 支持(jpg/webp)。
|
||||
- **miniSize=400**: 图片的最小边长,默认为 400 像素。
|
||||
- **fullSize=1000**: 图片的最大边长,默认为 1000 像素。
|
||||
|
||||
|
||||
## 贡献
|
||||
|
||||
欢迎贡献!如果你有任何想法或建议,欢迎提交 Issue 或 Pull Request。
|
||||
|
||||
## 许可
|
||||
|
||||
该项目遵循 MIT 许可证 - 详情请参见 [LICENSE](LICENSE) 文件。
|
@@ -5,26 +5,59 @@ PYTHON_APP="main.py"
|
||||
LOG_FILE="output.log"
|
||||
PID_FILE="app.pid"
|
||||
|
||||
# 显示帮助信息
|
||||
show_help() {
|
||||
echo -e "\033[1mUsage: $0 {start|stop|status|restart|help}\033[0m"
|
||||
echo -e "\n\033[1mCommands:\033[0m"
|
||||
echo -e " start Start the application (with virtual environment and logging)."
|
||||
echo -e " stop Stop the application (based on PID stored in $PID_FILE)."
|
||||
echo -e " status Check if the application is running (based on PID file)."
|
||||
echo -e " restart Stop and then start the application."
|
||||
echo -e " help Display this help message.\n"
|
||||
echo -e "\033[1mEnvironment:\033[0m"
|
||||
echo -e " VENV_DIR The directory for the Python virtual environment (default: .venv)."
|
||||
echo -e " PYTHON_APP The Python application to run (default: main.py)."
|
||||
echo -e " LOG_FILE The log file where the output is stored (default: output.log)."
|
||||
echo -e " PID_FILE The file where the PID of the application is stored (default: app.pid).\n"
|
||||
echo -e "Make sure to set up your Python virtual environment before running the script.\n"
|
||||
}
|
||||
|
||||
# 检查Python3是否可用
|
||||
check_python() {
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo -e "\033[31mError: Python3 is not installed or not found in PATH.\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 启动应用
|
||||
start_app() {
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo -e "\033[31m Virtual environment directory $VENV_DIR not found! \033[0m"
|
||||
echo -e "\033[31mError: Virtual environment directory $VENV_DIR not found! \033[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$PYTHON_APP" ]; then
|
||||
echo -e "\033[31m Python application $PYTHON_APP not found! \033[0m"
|
||||
echo -e "\033[31mError: Python application $PYTHON_APP not found! \033[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
# 检查Python是否正确安装
|
||||
check_python
|
||||
|
||||
# 启动应用并将输出重定向到日志文件
|
||||
nohup python3 "$PYTHON_APP" > "$LOG_FILE" 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
echo -e "\033[32m Application started! \033[0m"
|
||||
echo -e "\033[32mApplication started! Logs are being written to $LOG_FILE\033[0m"
|
||||
}
|
||||
|
||||
# 停止应用
|
||||
stop_app() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo -e "\033[31m PID file not found! \033[0m"
|
||||
echo -e "\033[31mError: PID file $PID_FILE not found! \033[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -32,33 +65,36 @@ stop_app() {
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
rm "$PID_FILE"
|
||||
echo -e "\033[32m Application ended! \033[0m"
|
||||
echo -e "\033[32mApplication stopped!\033[0m"
|
||||
else
|
||||
echo -e "\033[31m Application not running or PID not found! \033[0m"
|
||||
echo -e "\033[31mError: Application not running or PID not found! \033[0m"
|
||||
fi
|
||||
}
|
||||
|
||||
# 检查应用状态
|
||||
check_app_status() {
|
||||
if [ ! -f "$PID_FILE" ]; then
|
||||
echo -e "\033[31m Application not running! \033[0m"
|
||||
echo -e "\033[31mError: Application not running! No PID file found.\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pid=$(cat "$PID_FILE")
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
echo -e "PID: $pid"
|
||||
echo -e "\033[32m Application running! \033[0m"
|
||||
echo -e "\033[32mApplication is running.\033[0m"
|
||||
else
|
||||
echo -e "\033[31m Application not running! \033[0m"
|
||||
echo -e "\033[31mError: Application not running. PID not found or process not running.\033[0m"
|
||||
fi
|
||||
}
|
||||
|
||||
# 重启应用
|
||||
restart_app() {
|
||||
stop_app
|
||||
start_app
|
||||
echo -e "\033[32m Application restarted! \033[0m"
|
||||
echo -e "\033[32mApplication restarted!\033[0m"
|
||||
}
|
||||
|
||||
# 处理命令行参数
|
||||
case "$1" in
|
||||
start)
|
||||
start_app
|
||||
@@ -72,8 +108,12 @@ case "$1" in
|
||||
restart)
|
||||
restart_app
|
||||
;;
|
||||
help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo -e "\033[33m Usage: $0 {start|stop|status|restart} \033[0m"
|
||||
echo -e "\033[33mInvalid command.\033[0m"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
115
compress_comic.py
Normal file
115
compress_comic.py
Normal 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()
|
@@ -49,7 +49,7 @@ def new(filename: str, pagenumber:int):
|
||||
def getMetadata(form: int, num: int, search:str = None):
|
||||
conn = util.getConn()
|
||||
c = conn.cursor()
|
||||
if search is None:
|
||||
if search == None:
|
||||
cursor = c.execute(
|
||||
"SELECT * FROM Metadata ORDER BY num desc LIMIT ?, ?", (form, num)
|
||||
)
|
||||
|
@@ -37,8 +37,8 @@ def init():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time INT NOT NULL,
|
||||
bookid TEXT NOT NULL,
|
||||
from_uid INTEGAR NOT NULL,
|
||||
score INT NOT NULL,
|
||||
from_uid INTEGER NOT NULL,
|
||||
score TEXT NOT NULL,
|
||||
content TEXT
|
||||
);
|
||||
"""
|
||||
|
286
file.py
286
file.py
@@ -1,86 +1,266 @@
|
||||
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
|
||||
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():
|
||||
"""初始化文件目录"""
|
||||
paths = ("inputdir", "storedir", "tmpdir")
|
||||
for path in paths:
|
||||
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:
|
||||
print(e)
|
||||
logger.error(f"创建目录失败 {path}: {e}")
|
||||
|
||||
|
||||
def auotLoadFile():
|
||||
fileList = os.listdir(app_conf.get("file", "inputdir"))
|
||||
for item in fileList:
|
||||
if zipfile.is_zipfile(
|
||||
app_conf.get("file", "inputdir") + "/" + item
|
||||
): # 判断是否为压缩包
|
||||
with zipfile.ZipFile(
|
||||
app_conf.get("file", "inputdir") + "/" + item, "r"
|
||||
) 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)
|
||||
@monitor_performance("file.get_image_files_from_zip")
|
||||
def get_image_files_from_zip(zip_path: str) -> tuple:
|
||||
"""
|
||||
从ZIP文件中获取图片文件列表,使用缓存提高性能
|
||||
返回: (image_files_list, cache_key)
|
||||
"""
|
||||
cache_key = f"{zip_path}_{os.path.getmtime(zip_path)}"
|
||||
current_time = time.time()
|
||||
|
||||
# 检查缓存
|
||||
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):
|
||||
bookinfo = db.file.searchByid(bookid)
|
||||
zippath = app_conf.get("file", "storedir") + "/" + bookinfo[0][2]
|
||||
|
||||
# 读取ZIP文件
|
||||
try:
|
||||
# 创建一个ZipFile对象
|
||||
with zipfile.ZipFile(zippath, "r") as zip_ref:
|
||||
# 获取图片文件列表
|
||||
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
||||
image_files = [
|
||||
file
|
||||
for file in zip_ref.namelist()
|
||||
if file.lower().endswith((".png", ".jpg", ".jpeg"))
|
||||
file for file in zip_ref.namelist()
|
||||
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:
|
||||
return "not imgfile in zip", ""
|
||||
logger.warning(f"ZIP文件中没有图片: {zip_path}")
|
||||
return "No image files in zip", ""
|
||||
|
||||
if int(index) > len(image_files):
|
||||
return "404 not found", ""
|
||||
if int(index) >= len(image_files):
|
||||
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_data = zip_ref.read(image_filename)
|
||||
zip_ref.close()
|
||||
|
||||
logger.debug(f"读取图片: {bookid}/{index} -> {image_filename}")
|
||||
return image_data, image_filename
|
||||
|
||||
except zipfile.BadZipFile: # 异常处理
|
||||
except zipfile.BadZipFile:
|
||||
logger.error(f"损坏的ZIP文件: {bookid}")
|
||||
return "Bad ZipFile", ""
|
||||
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"):
|
||||
img = cv2.imdecode(np.frombuffer(input, np.uint8), cv2.IMREAD_COLOR)
|
||||
height = img.shape[0] # 图片高度
|
||||
width = img.shape[1] # 图片宽度
|
||||
if minSize < np.amin((height,width)):
|
||||
@lru_cache(maxsize=128)
|
||||
def _get_image_hash(image_data: bytes) -> str:
|
||||
"""生成图片数据的哈希值用于缓存"""
|
||||
return hashlib.md5(image_data).hexdigest()
|
||||
|
||||
|
||||
@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:
|
||||
newshape = (minSize, int(minSize / width * height))
|
||||
new_width = min_size
|
||||
new_height = int(min_size * height / width)
|
||||
else:
|
||||
newshape = (int(minSize / height * width), minSize)
|
||||
img = cv2.resize(img, newshape)
|
||||
if encode == "webp":
|
||||
success, encoded_image = cv2.imencode(".webp", img, [cv2.IMWRITE_WEBP_QUALITY, 75])
|
||||
elif encode == "jpg" or "jpeg":
|
||||
success, encoded_image = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 75])
|
||||
new_height = min_size
|
||||
new_width = int(min_size * width / height)
|
||||
|
||||
img = cv2.resize(img, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
||||
logger.debug(f"缩放后图片尺寸: {new_width}x{new_height}")
|
||||
|
||||
# 编码图片
|
||||
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:
|
||||
return input
|
||||
return encoded_image.tobytes()
|
||||
logger.warning(f"不支持的编码格式: {encode}, 返回原始数据")
|
||||
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
|
||||
|
28
main.py
28
main.py
@@ -1,33 +1,45 @@
|
||||
import app_conf
|
||||
import db.util
|
||||
import db.file, file
|
||||
from flask import *
|
||||
from flask import Flask
|
||||
|
||||
from web.api_Img import api_Img_bp
|
||||
from web.page import page_bp
|
||||
from web.admin_page import admin_page_bp
|
||||
from web.api_comment import comment_api_bp
|
||||
from router.api_Img import api_Img_bp
|
||||
from router.page import page_bp
|
||||
from router.admin_page import admin_page_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__)
|
||||
|
||||
conf = app_conf.conf()
|
||||
|
||||
def appinit():
|
||||
"""应用初始化,集成日志和性能监控"""
|
||||
# 设置日志
|
||||
setup_logging(app)
|
||||
|
||||
# 初始化文件系统和数据库
|
||||
file.init()
|
||||
db.util.init()
|
||||
file.auotLoadFile()
|
||||
file.autoLoadFile()
|
||||
|
||||
# 启动性能监控
|
||||
monitor = get_performance_monitor()
|
||||
app.logger.info("应用初始化完成,性能监控已启动")
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(api_Img_bp)
|
||||
app.register_blueprint(page_bp)
|
||||
app.register_blueprint(admin_page_bp)
|
||||
app.register_blueprint(comment_api_bp)
|
||||
app.register_blueprint(performance_bp)
|
||||
|
||||
if __name__ == "__main__":
|
||||
appinit()
|
||||
app.run(
|
||||
debug=conf.getboolean("server", "debug"),
|
||||
host=conf.get("server", "host"),
|
||||
port=conf.get("server", "port"),
|
||||
port=int(conf.get("server", "port")),
|
||||
threaded=conf.getboolean("server", "threaded"),
|
||||
)
|
||||
|
@@ -1,4 +1,12 @@
|
||||
shortuuid
|
||||
flask
|
||||
flask>=2.3.0
|
||||
opencv-python
|
||||
opencv-python-headless
|
||||
werkzeug>=2.3.0
|
||||
Pillow>=9.0.0
|
||||
python-dotenv
|
||||
flask-limiter
|
||||
# 性能测试依赖(可选)
|
||||
requests>=2.25.0
|
||||
# 如果需要更好的性能监控,可以添加
|
||||
# psutil>=5.8.0
|
40
router/api_Img.py
Normal file
40
router/api_Img.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from flask import Blueprint, request, abort, make_response
|
||||
import db.file , file, gc , app_conf
|
||||
from utils.performance_monitor import timing_context
|
||||
|
||||
api_Img_bp = Blueprint("api_Img_bp", __name__)
|
||||
|
||||
conf = app_conf.conf()
|
||||
imgencode = conf.get("img", "encode")
|
||||
miniSize = conf.getint("img", "miniSize")
|
||||
fullSize = conf.getint("img", "fullSize")
|
||||
|
||||
@api_Img_bp.route("/api/img/<bookid>/<index>")
|
||||
def img(bookid, index): # 图片接口
|
||||
with timing_context(f"api.img.{bookid}.{index}"):
|
||||
if request.cookies.get("islogin") is None:
|
||||
return abort(403)
|
||||
if len(db.file.searchByid(bookid)) == 0:
|
||||
return abort(404)
|
||||
|
||||
# 读取图片数据
|
||||
data, filename = file.readZip(bookid, index)
|
||||
if isinstance(data, str):
|
||||
abort(404)
|
||||
|
||||
# 处理图片尺寸
|
||||
if request.args.get("mini") == "yes":
|
||||
data = file.thumbnail(data, miniSize, encode=imgencode)
|
||||
else:
|
||||
data = file.thumbnail(data, fullSize, encode=imgencode)
|
||||
|
||||
# 创建响应
|
||||
response = make_response(data)
|
||||
del data # 及时释放内存
|
||||
|
||||
response.headers.set("Content-Type", f"image/{imgencode}")
|
||||
response.headers.set("Content-Disposition", "inline", filename=filename)
|
||||
response.headers.set("Cache-Control", "public, max-age=3600") # 添加缓存头
|
||||
|
||||
gc.collect() # 强制垃圾回收
|
||||
return response
|
@@ -14,7 +14,7 @@ def overview(page): # 概览
|
||||
if request.cookies.get("islogin") is None: # 验证登录状态
|
||||
return redirect("/")
|
||||
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:
|
||||
item[2] = item[2][:-4] # 去除文件扩展名
|
||||
@@ -24,7 +24,7 @@ def overview(page): # 概览
|
||||
lastPageList = range(page - 3, page)
|
||||
nextPageList = range(page + 1, page + 4)
|
||||
return render_template(
|
||||
"overview.html",
|
||||
"overview.html.j2",
|
||||
list=metaDataList,
|
||||
lastPageList=lastPageList,
|
||||
pagenow=page,
|
||||
@@ -60,7 +60,7 @@ def book(bookid): # 接口
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"book.html",
|
||||
"book.html.j2",
|
||||
id=bookid,
|
||||
data=data,
|
||||
time=time.strftime("%Y-%m-%d %H:%M:%S", local_time),
|
||||
@@ -77,7 +77,7 @@ def view(bookid): # 接口
|
||||
data = db.file.searchByid(bookid)
|
||||
if len(data) == 0:
|
||||
return abort(404)
|
||||
return render_template("view.html.j2", id=bookid, index=range(1, data[0][3]))
|
||||
return render_template("view.html.j2", id=bookid, index=range(0, data[0][3]))
|
||||
|
||||
|
||||
@page_bp.route("/upload", methods=["GET", "POST"]) # 文件上传
|
||||
@@ -89,7 +89,13 @@ def upload_file():
|
||||
uploaded_file = request.files.getlist("files[]") # 获取上传的文件列表
|
||||
print(uploaded_file)
|
||||
for fileitem in uploaded_file:
|
||||
if fileitem.filename != "":
|
||||
fileitem.save(conf.get("file", "inputdir") + "/" + fileitem.filename)
|
||||
file.auotLoadFile()
|
||||
if fileitem.filename and fileitem.filename != "":
|
||||
input_dir = conf.get("file", "inputdir")
|
||||
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"
|
61
router/performance_api.py
Normal file
61
router/performance_api.py
Normal 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"})
|
@@ -5,39 +5,57 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
||||
<title>详情页面</title>
|
||||
<title>ComiPy-详情页面</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
width: 80%;
|
||||
margin-top: 20px;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.header {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.movie-poster {
|
||||
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 {
|
||||
@@ -45,28 +63,109 @@
|
||||
}
|
||||
|
||||
.movie-details h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 400;
|
||||
color: #1a73e8;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.movie-details p {
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
width: 80%;
|
||||
margin-top: 20px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.comments-section h2 {
|
||||
color: #1a73e8;
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment {
|
||||
border: 1px solid #ccc;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.comment p {
|
||||
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>
|
||||
</head>
|
||||
@@ -106,7 +205,7 @@
|
||||
<div class="header">
|
||||
<div class="movie-poster">
|
||||
<!-- 封面 -->
|
||||
<img class="img-thumbnail" src="/api/img/{{ id }}/1?mini=yes" alt="封面" style="max-width: 100%;">
|
||||
<img class="img-thumbnail" src="/api/img/{{ id }}/0?mini=yes" alt="封面" style="max-width: 100%;">
|
||||
</div>
|
||||
<div class="movie-details">
|
||||
<!-- 详细信息 -->
|
||||
@@ -114,6 +213,7 @@
|
||||
<h3>更新时间: {{time}}</h3>
|
||||
<h2>👍{{socre["like"]}} 👎{{socre["dislike"]}}</h2>
|
||||
<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>
|
||||
{%if islogin == "admin"%}
|
||||
<button class="btn btn-danger">删除资源</button>
|
||||
@@ -178,6 +278,33 @@
|
||||
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>
|
||||
|
||||
</body>
|
@@ -3,54 +3,83 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录页面</title>
|
||||
<title>ComiPy-登录</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f0f0f0;
|
||||
font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
color: #202124;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
background-color: #ffffff;
|
||||
padding: 32px;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
margin-bottom: 8px;
|
||||
color: #202124;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
padding: 12px;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
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 {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #5cb85c;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
padding: 12px;
|
||||
background: #1a73e8;
|
||||
border: 1px solid #1a73e8;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group button:hover {
|
||||
background-color: #4cae4c;
|
||||
background: #1557b0;
|
||||
border-color: #1557b0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
@@ -5,28 +5,40 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
||||
<title>展示图片列表和封面</title>
|
||||
<title>ComiPy-概览</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f0f0f0;
|
||||
padding: 0;
|
||||
background-color: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
color: #202124;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
#gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 20px;
|
||||
font-size: small;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 当屏幕宽度大于600px时,调整列数和列的宽度 */
|
||||
@media (min-width: 600px) {
|
||||
#gallery {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
font-size: medium;
|
||||
/* 两列布局 */
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,20 +46,57 @@
|
||||
@media (min-width: 900px) {
|
||||
#gallery {
|
||||
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 {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
/* 模糊度可以根据需要调整 */
|
||||
transition: display;
|
||||
z-index: 10;
|
||||
@@ -77,20 +126,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="container">
|
||||
<div id="gallery">
|
||||
{% for item in list %}
|
||||
<div class="card">
|
||||
<div class="gallery-item" onclick="linkjump('{{ item[1] }}')">
|
||||
{% if item[4] > aftertime %}
|
||||
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">New</span>
|
||||
{% endif %}
|
||||
<img src="/api/img/{{ item[1] }}/1?mini=yes" class="img-thumbnail card-img-top"
|
||||
onclick="linkjump('{{ item[1] }}')" />
|
||||
<div class="card-body">
|
||||
<p class="card-text">{{ item[2] }}</p>
|
||||
<img src="/api/img/{{ item[1] }}/0?mini=yes" alt="{{ item[2] }}" />
|
||||
<div class="gallery-item-content">
|
||||
<h3>{{ item[2] }}</h3>
|
||||
<p>点击查看详情</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div style=display:flex;justify-content:center;align-items:center;>
|
||||
<ul class="pagination">
|
@@ -5,15 +5,88 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>上传文件</title>
|
||||
<title>ComiPy-上传文件</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="/static/css/bootstrap.min.css" rel="stylesheet">
|
||||
<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 {
|
||||
background-color: #1a73e8;
|
||||
height: 8px;
|
||||
width: 0;
|
||||
background-color: #64B587;
|
||||
height: 20px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
#loading {
|
||||
margin-top: 12px;
|
||||
color: #5f6368;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
@@ -4,84 +4,321 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>漫画详情页</title>
|
||||
<title>ComiPy-漫画详情页</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: 'Google Sans', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f0f0f0;
|
||||
padding: 0;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.comic-image {
|
||||
max-width: 100%;
|
||||
width: 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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
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;
|
||||
flex-wrap: wrap;
|
||||
justify-content: 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 {
|
||||
padding: 10px 15px;
|
||||
margin: 5px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
background: #ffffff;
|
||||
color: #1a73e8;
|
||||
border: 1px solid #dadce0;
|
||||
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 {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
padding: 10px 15px;
|
||||
margin: 0 5px;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
background: #1a73e8;
|
||||
color: #ffffff;
|
||||
border-color: #1a73e8;
|
||||
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 {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: opacity 0.1s ease;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
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>
|
||||
</head>
|
||||
|
||||
<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="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>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
@@ -91,48 +328,202 @@
|
||||
{% endfor %}
|
||||
];
|
||||
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) {
|
||||
const comicContainer = document.getElementById('comic-container');
|
||||
comicContainer.innerHTML = ''; // 清空当前内容
|
||||
|
||||
// 显示加载动画
|
||||
showLoadingPlaceholders();
|
||||
|
||||
const start = (page - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
const pageItems = imgsData.slice(start, end);
|
||||
|
||||
pageItems.forEach(item => {
|
||||
// 清空加载动画并加载图片
|
||||
setTimeout(() => {
|
||||
comicContainer.innerHTML = '';
|
||||
pageItems.forEach((item, index) => {
|
||||
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('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);
|
||||
});
|
||||
|
||||
window.scrollTo(0, 0); // 滚动到页面顶部
|
||||
// 优先恢复页内滚动位置,否则回到顶部
|
||||
const saved = loadReadingState();
|
||||
if (saved && saved.page === currentPage && typeof saved.inPageIndex === 'number') {
|
||||
// 等待下一帧,确保元素渲染后再滚动
|
||||
requestAnimationFrame(() => {
|
||||
scrollToImage(saved.inPageIndex);
|
||||
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() {
|
||||
const pagination = document.getElementById('pagination');
|
||||
pagination.innerHTML = ''; // 清空当前内容
|
||||
const topPagination = document.getElementById('top-pagination');
|
||||
const bottomPagination = document.getElementById('bottom-pagination');
|
||||
|
||||
const totalPages = Math.ceil(imgsData.length / itemsPerPage);
|
||||
// 清空当前内容
|
||||
topPagination.innerHTML = '';
|
||||
bottomPagination.innerHTML = '';
|
||||
|
||||
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');
|
||||
button.className = 'page-button';
|
||||
button.innerText = i;
|
||||
button.disabled = (i === currentPage);
|
||||
button.innerText = pageNum;
|
||||
button.disabled = (pageNum === currentPage);
|
||||
button.addEventListener('click', () => {
|
||||
currentPage = i;
|
||||
renderPage(i);
|
||||
renderPagination();
|
||||
currentPage = pageNum;
|
||||
changePage();
|
||||
});
|
||||
pagination.appendChild(button);
|
||||
container.appendChild(button);
|
||||
}
|
||||
|
||||
createPaginationButtons(topPagination);
|
||||
createPaginationButtons(bottomPagination);
|
||||
centerActivePageButtons();
|
||||
}
|
||||
|
||||
function changePage() {
|
||||
renderPage(currentPage);
|
||||
renderPagination();
|
||||
updatePageInfo();
|
||||
saveReadingState();
|
||||
updateURLHash(currentPage);
|
||||
centerActivePageButtons();
|
||||
}
|
||||
|
||||
function lazyLoad() {
|
||||
@@ -144,15 +535,118 @@
|
||||
if (img.src) return; // 如果已经加载过了就跳过
|
||||
|
||||
const imgTop = img.getBoundingClientRect().top + scrollY;
|
||||
if (windowHeight + scrollY > imgTop) {
|
||||
if (windowHeight + scrollY + 200 > imgTop) { // 提前200px开始加载
|
||||
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);
|
||||
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');
|
||||
|
||||
@@ -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);
|
||||
renderPagination();
|
||||
updateURLHash(currentPage);
|
||||
updateFabs();
|
||||
})();
|
||||
});
|
||||
|
||||
function unshowGlobalBlur() {
|
||||
|
236
utils/cache_manager.py
Normal file
236
utils/cache_manager.py
Normal 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
104
utils/config_validator.py
Normal 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
61
utils/db_pool.py
Normal 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
59
utils/logger.py
Normal 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)
|
173
utils/performance_monitor.py
Normal file
173
utils/performance_monitor.py
Normal 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
34
utils/security.py
Normal 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)
|
@@ -1,31 +0,0 @@
|
||||
from flask import *
|
||||
from flask import Blueprint
|
||||
import db.file , file, gc , app_conf
|
||||
|
||||
api_Img_bp = Blueprint("api_Img_bp", __name__)
|
||||
|
||||
conf = app_conf.conf()
|
||||
imgencode = conf.get("img", "encode")
|
||||
miniSize = conf.getint("img", "miniSize")
|
||||
fullSize = conf.getint("img", "fullSize")
|
||||
|
||||
@api_Img_bp.route("/api/img/<bookid>/<index>")
|
||||
def img(bookid, index): # 图片接口
|
||||
if request.cookies.get("islogin") is None:
|
||||
return abort(403)
|
||||
if len(db.file.searchByid(bookid)) == 0:
|
||||
return abort(404)
|
||||
# 设置响应类型为图片
|
||||
data, filename = file.raedZip(bookid, index)
|
||||
if isinstance(data, str):
|
||||
abort(404)
|
||||
if request.args.get("mini") == "yes":
|
||||
data = file.thumbnail(data,miniSize,encode=imgencode)
|
||||
else:
|
||||
data = file.thumbnail(data,fullSize,encode=imgencode)
|
||||
response = make_response(data) # 读取文件
|
||||
del data
|
||||
response.headers.set("Content-Type",f"image/{imgencode}")
|
||||
response.headers.set("Content-Disposition", "inline", filename=filename)
|
||||
gc.collect()
|
||||
return response
|
Reference in New Issue
Block a user