Compare commits

..

22 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
f2c51f45b6 fix:修复了index错误导致的封面显示问题 2024-12-31 10:41:23 +08:00
eaec2dbad7 update:优化项目结构 2024-12-31 10:40:52 +08:00
b8252d412c feat:完善README 2024-12-25 09:29:32 +08:00
dbcb6a79de update:更新Router结构,和启动脚本文件 2024-12-25 08:43:18 +08:00
86a47a8aab Merge branch 'dev' 2024-06-04 11:51:45 +08:00
cb13e5a22b feat:实现浏览页翻页功能 2024-06-04 11:51:33 +08:00
d753e7cad4 feat:新增图像编码设置 2024-05-10 17:28:03 +08:00
c9605c392f Merge branch 'dev' of https://github.com/Kakune55/ComiPy into dev 2024-05-09 13:44:38 +08:00
903f6fecfd fix:提升缩略图处理速度 2024-05-09 12:25:03 +08:00
c49cee4c30 fix:减小图片分辨率 修复小图片被放大的问题 2024-05-09 12:17:16 +08:00
591bf9d5d8 Merge branch 'dev' of https://github.com/Kakune55/ComiPy into dev 2024-05-09 11:59:37 +08:00
267a01effc fix:修复了缩放过程中图片比例错误的问题 2024-05-09 11:58:50 +08:00
852035ca6a Merge branch 'dev' of https://github.com/Kakune55/ComiPy into dev 2024-05-09 11:53:15 +08:00
18347a5ad5 fix:修复了缩放过程中图片比例错误的问题 2024-05-09 11:53:09 +08:00
0283dbb1e6 fix:修复了缩放过程中图片比例错误的问题 2024-05-09 11:48:09 +08:00
007645e94d feat:将图像处理库从PIL改为OpenCV以优化性能 2024-05-09 11:26:31 +08:00
e44fa7fb8e Merge branch 'main' into dev 2024-05-08 20:54:00 +08:00
be5d9a8146 feat:添加评论删除功能 2024-05-08 20:53:21 +08:00
c8bae04145 feat:添加评论上传功能 未完全完成 2024-04-28 20:21:36 +08:00
34 changed files with 3018 additions and 579 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

View File

@@ -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) 文件。

View File

@@ -5,26 +5,59 @@ PYTHON_APP="main.py"
LOG_FILE="output.log" LOG_FILE="output.log"
PID_FILE="app.pid" 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() { start_app() {
if [ ! -d "$VENV_DIR" ]; then 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 exit 1
fi fi
if [ ! -f "$PYTHON_APP" ]; then 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 exit 1
fi fi
# 激活虚拟环境
source "$VENV_DIR/bin/activate" source "$VENV_DIR/bin/activate"
# 检查Python是否正确安装
check_python
# 启动应用并将输出重定向到日志文件
nohup python3 "$PYTHON_APP" > "$LOG_FILE" 2>&1 & nohup python3 "$PYTHON_APP" > "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE" 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() { stop_app() {
if [ ! -f "$PID_FILE" ]; then 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 exit 1
fi fi
@@ -32,33 +65,36 @@ stop_app() {
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" kill "$pid"
rm "$PID_FILE" rm "$PID_FILE"
echo -e "\033[32m Application ended! \033[0m" echo -e "\033[32mApplication stopped!\033[0m"
else 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 fi
} }
# 检查应用状态
check_app_status() { check_app_status() {
if [ ! -f "$PID_FILE" ]; then 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 exit 1
fi fi
pid=$(cat "$PID_FILE") pid=$(cat "$PID_FILE")
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo -e "PID: $pid" echo -e "PID: $pid"
echo -e "\033[32m Application running! \033[0m" echo -e "\033[32mApplication is running.\033[0m"
else 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 fi
} }
# 重启应用
restart_app() { restart_app() {
stop_app stop_app
start_app start_app
echo -e "\033[32m Application restarted! \033[0m" echo -e "\033[32mApplication restarted!\033[0m"
} }
# 处理命令行参数
case "$1" in case "$1" in
start) start)
start_app start_app
@@ -72,8 +108,12 @@ case "$1" in
restart) restart)
restart_app 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 exit 1
;; ;;
esac esac

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

@@ -15,3 +15,8 @@ path=./data/metadata.db
inputdir=./input inputdir=./input
storedir=./data/file storedir=./data/file
tmpdir=./data/tmp tmpdir=./data/tmp
[img]
encode=jpg
miniSize=400
fullSize=1000

97
db/comments.py Normal file
View File

@@ -0,0 +1,97 @@
import time
import db.util as util
# 查找评论
def getById(id: str):
"通过id查找所有评论"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM Comments WHERE id = ?", (id,))
return cursor.fetchone()
# 查找评论
def listByBookid(id: str):
"通过bookid查找所有评论"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM Comments WHERE bookid = ? ORDER BY time desc", (id,))
out = []
for row in cursor:
out.append(row)
conn.close()
return out
# 获取综合评分
def getScore(bookid: str):
"获取综合评分 返回一个字典 字典有两个key like和dislike分别记录不同评论的个数"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM Comments WHERE bookid = ? ", (bookid,))
num={'like':0,'dislike':0}
for row in cursor:
if row[4] == "like":
num["like"]+=1
elif row[4] == "dislike":
num["dislike"]+=1
conn.close()
return num
# 查找评论
def searchByUid(uid: str):
"通过用户查找所有评论"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM Comments WHERE from_uid = ? ORDER BY time desc", (uid,))
out = []
for row in cursor:
out.append(row)
conn.close()
return out
# 查找评论
def searchByAll(uid: str,bookid:str):
"通过用户和BookID查找所有评论"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM Comments WHERE from_uid = ? AND bookid= ? ORDER BY time desc", (uid,bookid))
out = []
for row in cursor:
out.append(row)
conn.close()
return out
# 在数据库中添加一个新的文件记录
def new(bookid: str, from_uid: int, score: str, content=""):
"添加一条新评论 score字段可选值为[like,none,dislike] content字段非必填"
conn = util.getConn()
c = conn.cursor()
c.execute(
"""
INSERT INTO Comments
(time, bookid, from_uid, score, content)
VALUES
(?, ?, ?, ?,?);
""",
(int(time.time()), bookid, from_uid, score, content),
)
conn.commit()
conn.close()
return
# 查找评论
def remove(id:int)->bool:
"通过id删除评论"
conn = util.getConn()
c = conn.cursor()
c.execute("DELETE FROM Comments WHERE id = ?", (id,))
conn.commit()
changes = conn.total_changes
conn.close()
if changes == 0:
return False
return True

View File

@@ -49,7 +49,7 @@ def new(filename: str, pagenumber:int):
def getMetadata(form: int, num: int, search:str = None): def getMetadata(form: int, num: int, search:str = None):
conn = util.getConn() conn = util.getConn()
c = conn.cursor() c = conn.cursor()
if search is None: if search == None:
cursor = c.execute( cursor = c.execute(
"SELECT * FROM Metadata ORDER BY num desc LIMIT ?, ?", (form, num) "SELECT * FROM Metadata ORDER BY num desc LIMIT ?, ?", (form, num)
) )

View File

@@ -33,3 +33,23 @@ def check(username: str, password: int):
if cursor.fetchone() is None: if cursor.fetchone() is None:
return False return False
return True return True
def getUid(username: str):
"判断用户名是否存在 并获取用户uid 用户不存在则返回None"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM User WHERE username = ?", (username,))
out = cursor.fetchone()
if out is not None:
return out[0]
return None
def getUsername(uid:str):
"判断Uid是否存在 并获取用户名 用户不存在则返回None"
conn = util.getConn()
c = conn.cursor()
cursor = c.execute("SELECT * FROM User WHERE uid = ?", (uid,))
out = cursor.fetchone()
if out is not None:
return out[1]
return None

View File

@@ -31,5 +31,29 @@ def init():
); );
""" """
) )
c.execute(
"""
CREATE TABLE IF NOT EXISTS Comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time INT NOT NULL,
bookid TEXT NOT NULL,
from_uid INTEGER NOT NULL,
score TEXT NOT NULL,
content TEXT
);
"""
)
c.execute(
"""
INSERT INTO User (username, password)
SELECT ?, ?
WHERE NOT EXISTS (SELECT 1 FROM User WHERE username = ?);
""",
(
conf.get("user", "username"),
conf.get("user", "password"),
conf.get("user", "username"),
),
)
conn.commit() conn.commit()
conn.close() conn.close()

304
file.py
View File

@@ -1,92 +1,266 @@
import shutil, os, zipfile, io import shutil, os, zipfile, io, cv2, numpy as np
import db.file, app_conf import hashlib
from PIL import Image 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
# 获取配置对象
conf = app_conf.conf()
logger = get_logger(__name__)
cache_manager = get_cache_manager()
# 内存缓存 - 存储最近访问的ZIP文件列表
_zip_cache = {}
_cache_timeout = 300 # 5分钟缓存超时
app_conf = app_conf.conf()
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"))
] ]
# 缓存结果
_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,size=(420,600)): @lru_cache(maxsize=128)
im = Image.open(io.BytesIO(input)) def _get_image_hash(image_data: bytes) -> str:
del input """生成图片数据的哈希值用于缓存"""
newimg = im.convert('RGB') return hashlib.md5(image_data).hexdigest()
im.close()
newimg.thumbnail(size)
output_io = io.BytesIO()
newimg.save(output_io,format='WEBP')
newimg.close()
output_io.seek(0)
return output_io
def imageToWebP(input,size=(2100,3000)):
with Image.open(io.BytesIO(input)) as img:
newimg = img.convert('RGB')
img.close()
output_io = io.BytesIO()
newimg.thumbnail(size)
newimg.save(output_io,format='WEBP')
newimg.close()
output_io.seek(0)
return output_io
@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:
new_width = min_size
new_height = int(min_size * height / width)
else:
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:
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
View File

@@ -1,31 +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 web.api_Img import api_Img_bp from router.api_Img import api_Img_bp
from web.page import page_bp from router.page import page_bp
from web.admin_page import admin_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__) 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(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,3 +1,12 @@
shortuuid shortuuid
flask flask>=2.3.0
Pillow 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

View File

@@ -1,7 +1,8 @@
from flask import * from flask import *
from flask import Blueprint from flask import Blueprint
import time import time
import db.file, file , app_conf import db.user
import db.file, file, app_conf
admin_page_bp = Blueprint("admin_page_bp", __name__) admin_page_bp = Blueprint("admin_page_bp", __name__)
@@ -9,6 +10,7 @@ conf = app_conf.conf()
# 管理页 # 管理页
@admin_page_bp.route("/", methods=["GET", "POST"]) @admin_page_bp.route("/", methods=["GET", "POST"])
def login(): # 登录页面 def login(): # 登录页面
if request.method == "GET": if request.method == "GET":
@@ -16,11 +18,10 @@ def login(): # 登录页面
return redirect("/overview/1") return redirect("/overview/1")
return render_template("login.html") return render_template("login.html")
elif request.method == "POST": elif request.method == "POST":
if request.form["username"] == conf.get("user", "username") and request.form[ if db.user.check(request.form["username"], request.form["password"]):
"password"
] == conf.get("user", "password"):
resp = make_response(redirect("/overview/1")) resp = make_response(redirect("/overview/1"))
resp.set_cookie("islogin", "True") resp.set_cookie("islogin", request.form["username"])
resp.set_cookie("uid", str(db.user.getUid(request.form["username"])))
return resp return resp
else: else:
return redirect("/") return redirect("/")

40
router/api_Img.py Normal file
View 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

44
router/api_comment.py Normal file
View File

@@ -0,0 +1,44 @@
from flask import *
from flask import Blueprint
import time
import db.comments, db.file, app_conf
comment_api_bp = Blueprint("comment_api_bp", __name__)
conf = app_conf.conf()
@comment_api_bp.route("/api/comment/upload", methods=["POST"])
def comment_api(): # 概览
if request.cookies.get("islogin") is None: # 验证登录状态
return redirect("/")
if request.form["score"] != "none" and request.form["text"].isspace():
return "评论不能为空"
if len(request.form["text"]) > 200:
return "评论过长(需要小于200字)"
if db.comments.searchByAll(request.cookies.get("uid"), request.form["bookid"]):
return "你已经完成了评论 不可重复提交"
db.comments.new(
request.form["bookid"],
request.cookies.get("uid"),
request.form["score"],
request.form["text"],
)
return redirect("/book/" + request.form["bookid"])
@comment_api_bp.route("/api/comment/remove")
def remove(): # 删除api
if request.cookies.get("islogin") is None: # 验证登录状态
return abort(403)
try:
id = int(request.args.get("id"))
except:
return abort(400)
commentInfo = db.comments.getById(id)
if commentInfo is None:
return abort(404)
if int(request.cookies.get("uid")) == commentInfo[3]:
if db.comments.remove(id):
return "OK"
return abort(404)
return abort(400)

101
router/page.py Normal file
View File

@@ -0,0 +1,101 @@
from flask import *
from flask import Blueprint
import time
import db.comments, db.user, db.file, file, app_conf
page_bp = Blueprint("page_bp", __name__)
conf = app_conf.conf()
@page_bp.route("/overview/<page>")
def overview(page): # 概览
page = int(page)
if request.cookies.get("islogin") is None: # 验证登录状态
return redirect("/")
metaDataList = db.file.getMetadata(
(page - 1) * 20, page * 20, request.args.get("search", "")
)
for item in metaDataList:
item[2] = item[2][:-4] # 去除文件扩展名
if page <= 3:
lastPageList = range(1, page)
else:
lastPageList = range(page - 3, page)
nextPageList = range(page + 1, page + 4)
return render_template(
"overview.html.j2",
list=metaDataList,
lastPageList=lastPageList,
pagenow=page,
nextPageList=nextPageList,
aftertime=int(time.time()) - 3 * 86400,
username=request.cookies.get("islogin"),
)
@page_bp.route("/book/<bookid>")
def book(bookid): # 接口
if request.cookies.get("islogin") is None: # 验证登录状态
return redirect("/")
data = db.file.searchByid(bookid)
if len(data) == 0:
return abort(404)
data[0] = list(data[0])
data[0][2] = data[0][2][0:-4] # 把文件扩展名去掉
local_time = time.localtime(float(data[0][4]))
raw_com = db.comments.listByBookid(bookid)
comments = []
for i in raw_com:
print(request.cookies.get("islogin"))
comments.append(
{
"id": i[0],
"time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(i[1]))),
"from": db.user.getUsername(i[3]),
"socre":i[4],
"text":i[5]
}
)
return render_template(
"book.html.j2",
id=bookid,
data=data,
time=time.strftime("%Y-%m-%d %H:%M:%S", local_time),
socre=db.comments.getScore(bookid),
comments=comments,
islogin=request.cookies.get("islogin")
)
@page_bp.route("/view/<bookid>")
def view(bookid): # 接口
if request.cookies.get("islogin") is None: # 验证登录状态
return redirect("/")
data = db.file.searchByid(bookid)
if len(data) == 0:
return abort(404)
return render_template("view.html.j2", id=bookid, index=range(0, data[0][3]))
@page_bp.route("/upload", methods=["GET", "POST"]) # 文件上传
def upload_file():
if request.cookies.get("islogin") is None: # 验证登录状态
return redirect("/")
if request.method == "GET":
return render_template("upload.html")
uploaded_file = request.files.getlist("files[]") # 获取上传的文件列表
print(uploaded_file)
for fileitem in uploaded_file:
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
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"})

File diff suppressed because one or more lines are too long

View File

@@ -1,104 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<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>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.header {
display: flex;
justify-content: space-between;
flex-direction: column;
width: 80%;
margin-top: 20px;
}
@media (min-width: 600px) {
.header {
flex-direction: row;
}
}
.movie-poster {
flex: 1;
margin-right: 20px;
}
.movie-details {
flex: 2;
}
.movie-details h1 {
font-size: 24px;
margin-bottom: 10px;
}
.movie-details p {
font-size: 16px;
margin-bottom: 10px;
}
.comments-section {
width: 80%;
margin-top: 20px;
}
.comment {
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 10px;
}
.comment p {
margin: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="movie-poster">
<!-- 封面 -->
<img class="img-thumbnail" src="/api/img/{{ id }}/1?mini=yes" alt="封面" style="max-width: 100%;">
</div>
<div class="movie-details">
<!-- 详细信息 -->
<h1>{{ data[0][2] }}</h1>
<h2>时间: {{time}}</h2>
<h2>暂无评价</h2>
<button class="btn btn-primary" onclick="window.location.href='/view/{{ id }}'">在线浏览</button>
</div>
</div>
<div class="comments-section">
<h2>评论区</h2>
<!-- 评论 -->
<div class="comment">
<p>用户ALorem ipsum dolor sit amet consectetur adipisicing elit. Nobis, quam!</p>
</div>
<div class="comment">
<p>用户BLorem, ipsum dolor sit amet consectetur adipisicing elit. Aut sunt tempore architecto minus, cum
mollitia voluptatibus repellendus aliquid id reprehenderit.</p>
</div>
<!-- 在此添加更多评论 -->
</div>
</div>
</body>
</html>

312
templates/book.html.j2 Normal file
View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<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>ComiPy-详情页面</title>
<style>
* {
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: 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;
flex-direction: column;
gap: 24px;
margin-bottom: 24px;
}
@media (min-width: 600px) {
.header {
flex-direction: row;
align-items: flex-start;
}
}
.movie-poster {
flex: 1;
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 {
flex: 2;
}
.movie-details h1 {
font-size: 1.75rem;
font-weight: 400;
color: #1a73e8;
margin-bottom: 16px;
}
.movie-details p {
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 {
margin-top: 32px;
}
.comments-section h2 {
color: #1a73e8;
font-weight: 400;
font-size: 1.25rem;
margin-bottom: 16px;
}
.comment {
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>
<body>
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="exampleModalLabel">评论</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="comment" action="/api/comment/upload" method="post">
<select class="form-select" aria-label="Default select example" name="score">
<option value="none">无评价</option>
<option value="like">赞👍</option>
<option value="dislike">踩👎</option>
</select>
<div class="input-group" style="margin-top: 10px;">
<span class="input-group-text">评论</span>
<textarea class="form-control" name="text" aria-label="With textarea"></textarea>
</div>
<input type="hidden" name="bookid" value="{{data[0][1]}}" />
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="commentSubmit()">提交</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="header">
<div class="movie-poster">
<!-- 封面 -->
<img class="img-thumbnail" src="/api/img/{{ id }}/0?mini=yes" alt="封面" style="max-width: 100%;">
</div>
<div class="movie-details">
<!-- 详细信息 -->
<h1>{{ data[0][2] }}</h1>
<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>
{% endif %}
</div>
</div>
</div>
<hr />
<div class="container">
<div class="comments-section">
<h2>评论区</h2>
<!-- 评论 -->
{% if not comments%}
<p>暂无评论</p>
{%endif%}
{% for item in comments %}
<div class="comment">
<h3>{{item["from"]}}:
{%if item["socre"] == "like"%}
<small class="text-muted">觉得很赞👍</small>
{%endif%}
{%if item["socre"] == "dislike"%}
<small class="text-muted">点了个踩👎</small>
{%endif%}
</h3>
<h3>{{item["text"]}}</h3>
<small class="text-muted">id:{{item["id"]}} {{item["time"]}}</small>
{% if islogin == item["from"] %}
<button class="btn btn-danger" id="{{item['id']}}" onclick="delComment(id)">删除</button>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="/static/js/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script>
const comment = document.getElementById("comment")
function commentSubmit() {
comment.submit()
}
function delComment(id) {
fetch("/api/comment/remove?id=" + id, {
method: 'GET'
})
.then(response => {
// 首先检查HTTP状态码
if (response.ok) {
// 请求成功,刷新页面
location.reload();
} else {
// 请求失败,抛出一个错误
throw new Error('网络请求失败,状态码:' + response.status);
}
})
.catch(error => {
// 处理任何在请求过程中发生的错误
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>
</html>

View File

@@ -3,54 +3,83 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录页面</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

@@ -1,133 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<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>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f0f0;
}
#gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 20px;
font-size: small;
}
/* 当屏幕宽度大于600px时调整列数和列的宽度 */
@media (min-width: 600px) {
#gallery {
grid-template-columns: repeat(3, 1fr);
font-size: medium;
/* 两列布局 */
}
}
/* 当屏幕宽度大于900px时进一步调整列数和列的宽度 */
@media (min-width: 900px) {
#gallery {
grid-template-columns: repeat(5, 1fr);
font-size: large;
/* 三列布局 */
}
}
#global-blur {
background-color: rgba(255, 255, 255, 0.5);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
/* 模糊度可以根据需要调整 */
transition: display;
z-index: 1;
/* 保证遮罩在页面上方 */
pointer-events: none;
/* 确保遮罩不影响下方元素的交互 */
opacity: 0;
transition: opacity 0.1s ease
}
</style>
</head>
<body>
<div class="input-group">
<input type="text" class="form-control" id="search_text" placeholder="键入以搜索">
<button class="btn btn-secondary" type="button"
onclick="window.location.href='/overview/1?search='+document.getElementById('search_text').value">Search</button>
</div>
<hr />
<div id="gallery">
{% for item in list %}
<div class="card">
{% 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>
</div>
</div>
{% endfor %}
</div>
<hr />
<div style=display:flex;justify-content:center;align-items:center;>
<ul class="pagination">
<li class="page-item">
<a class="page-link">Page</a>
</li>
{% for item in lastPageList %}
<li class="page-item"><a class="page-link" onclick="switchPage('{{item}}')">{{item}}</a></li>
{% endfor %}
<li class="page-item active" aria-current="page">
<a class="page-link">{{pagenow}}</a>
</li>
{% for item in nextPageList %}
<li class="page-item"><a class="page-link" onclick="switchPage('{{item}}')">{{item}}</a></li>
{% endfor %}
<ul>
</div>
<div id="global-blur" onclick="unshow_global_blur()"></div>
<script>
function linkjump(url) {
window.open("/book/" + url)
}
function switchPage(pagemun) {
window.location.replace("/overview/" + pagemun)
}
document.addEventListener('visibilitychange', documentVisibilityChange)
global_blur = document.getElementById("global-blur")
function documentVisibilityChange() {
if (document.visibilityState === "hidden") {
global_blur.style.opacity = 1;
global_blur.style.pointerEvents = "auto";
}
if (document.visibilityState === "visible") {
}
}
function unshow_global_blur() {
global_blur.style.opacity = 0;
global_blur.style.pointerEvents = "none";
}
</script>
</body>
</html>

202
templates/overview.html.j2 Normal file
View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<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>ComiPy-概览</title>
<style>
* {
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: 1400px;
margin: 0 auto;
padding: 24px 16px;
}
#gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
font-size: 14px;
}
/* 当屏幕宽度大于600px时调整列数和列的宽度 */
@media (min-width: 600px) {
#gallery {
grid-template-columns: repeat(3, 1fr);
font-size: 15px;
}
}
/* 当屏幕宽度大于900px时进一步调整列数和列的宽度 */
@media (min-width: 900px) {
#gallery {
grid-template-columns: repeat(5, 1fr);
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.95);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
/* 模糊度可以根据需要调整 */
transition: display;
z-index: 10;
/* 保证遮罩在页面上方 */
pointer-events: none;
/* 确保遮罩不影响下方元素的交互 */
opacity: 0;
transition: opacity 0.1s ease
}
</style>
</head>
<body>
<div style="display: flex;">
<div class="input-group">
<input type="text" class="form-control" id="search_text" placeholder="键入以搜索">
<button class="btn btn-secondary" type="button"
onclick="window.location.href='/overview/1?search='+document.getElementById('search_text').value">Search</button>
</div>
<div class="dropdown" style="margin-left: 10px;">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{username}}
</button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" href="#" onclick="logout()">Log out</button></li>
</ul>
</div>
</div>
<hr />
<div class="container">
<div id="gallery">
{% for item in list %}
<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] }}/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">
<li class="page-item">
<a class="page-link">Page</a>
</li>
{% for item in lastPageList %}
<li class="page-item"><a class="page-link" onclick="switchPage('{{item}}')">{{item}}</a></li>
{% endfor %}
<li class="page-item active" aria-current="page">
<a class="page-link">{{pagenow}}</a>
</li>
{% for item in nextPageList %}
<li class="page-item"><a class="page-link" onclick="switchPage('{{item}}')">{{item}}</a></li>
{% endfor %}
<ul>
</div>
<div id="global-blur" onclick="unshow_global_blur()"></div>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="/static/js/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<script>
function linkjump(url) {
window.open("/book/" + url)
}
function switchPage(pagemun) {
window.location.replace("/overview/" + pagemun)
}
document.addEventListener('visibilitychange', documentVisibilityChange)
global_blur = document.getElementById("global-blur")
function documentVisibilityChange() {
if (document.visibilityState === "hidden") {
global_blur.style.opacity = 1;
global_blur.style.pointerEvents = "auto";
}
if (document.visibilityState === "visible") {
}
}
function unshow_global_blur() {
global_blur.style.opacity = 0;
global_blur.style.pointerEvents = "none";
}
function logout() {
document.cookie = "islogin=1; max-age=0"
//location.href = "/";
}
</script>
</body>
</html>

View File

@@ -5,15 +5,88 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>上传文件</title> <title>ComiPy-上传文件</title>
<!-- 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>
@@ -36,7 +109,7 @@
<!-- Bootstrap JS --> <!-- Bootstrap JS -->
<script src="/static/js/jquery.min.js"></script> <script src="/static/js/jquery.min.js"></script>
<script src="/static/js/popper.min.js"></script> <script src="https://unpkg.com/@popperjs/core@2"></script>
<script src="/static/js/bootstrap.min.js"></script> <script src="/static/js/bootstrap.min.js"></script>
<script> <script>

View File

@@ -1,109 +0,0 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>漫画详情页</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f0f0f0;
}
#comic-container {
display: flex;
flex-direction: column;
align-items: center;
}
.comic-image {
max-width: 100%;
height: auto;
margin-bottom: 20px;
}
img {
display: block;
width: 100%;
min-height: 300px;
margin-top: 10px;
}
#global-blur {
background-color: rgba(255, 255, 255, 0.8);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
/* 模糊度可以根据需要调整 */
transition: display;
z-index: 1;
/* 保证遮罩在页面上方 */
pointer-events: none;
/* 确保遮罩不影响下方元素的交互 */
opacity: 0;
transition: opacity 0.1s ease
}
</style>
</head>
<body>
{% for i in index %}
<img data-src="/api/img/{{ id }}/{{ i }}" loading="lazy" alt="{{ i }}" class="imgs">
{% endfor %}
<div style="display:flex;justify-content: center; align-items:center;">
<img src="/static/loading.gif" id="loadingGIF">
</div>
<div id="global-blur" onclick="unshow_global_blur()"></div>
<script>
var imgs = document.querySelectorAll('.imgs');
//offsetTop是元素与offsetParent的距离循环获取直到页面顶部
function getTop(e) {
var T = e.offsetTop;
while (e = e.offsetParent) {
T += e.offsetTop;
}
return T;
}
function lazyLoad(imgs) {
var H = document.documentElement.clientHeight;//获取可视区域高度
var S = document.documentElement.scrollTop || document.body.scrollTop;
for (var i = 0; i < imgs.length; i++) {
if (H + S > getTop(imgs[i])) {
imgs[i].src = imgs[i].getAttribute('data-src');
}
}
}
window.onload = window.onscroll = function () { //onscroll()在滚动条滚动的时候触发
lazyLoad(imgs);
}
document.addEventListener('visibilitychange', documentVisibilityChange)
global_blur = document.getElementById("global-blur")
function documentVisibilityChange() {
if (document.visibilityState === "hidden") {
global_blur.style.opacity = 1;
global_blur.style.pointerEvents = "auto";
}
if (document.visibilityState === "visible") {
}
}
function unshow_global_blur() {
global_blur.style.opacity = 0;
global_blur.style.pointerEvents = "none";
}
</script>
</body>
</html>

772
templates/view.html.j2 Normal file
View File

@@ -0,0 +1,772 @@
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ComiPy-漫画详情页</title>
<style>
* {
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;
}
.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;
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;
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-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 8px;
margin: 16px 0;
padding: 0 8px; /* 给两侧留白,避免被屏幕边缘贴边或裁剪 */
box-sizing: border-box;
scroll-padding-inline: 12px; /* 滚动对齐时保留左右内边距 */
scrollbar-gutter: stable both-edges; /* 预留滚动条空间,避免内容被遮挡 */
}
.page-button {
padding: 8px 16px;
background: #ffffff;
color: #1a73e8;
border: 1px solid #dadce0;
cursor: pointer;
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: #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.95);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
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 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 () {
const imgsData = [
{% for i in index %}
{ src: "/api/img/{{ id }}/{{ i }}", alt: "漫画页面 {{ i }}" },
{% endfor %}
];
const itemsPerPage = 25; // 每页显示的图片数量
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);
// 清空加载动画并加载图片
setTimeout(() => {
comicContainer.innerHTML = '';
pageItems.forEach((item, index) => {
const img = document.createElement('img');
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);
});
// 优先恢复页内滚动位置,否则回到顶部
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 topPagination = document.getElementById('top-pagination');
const bottomPagination = document.getElementById('bottom-pagination');
// 清空当前内容
topPagination.innerHTML = '';
bottomPagination.innerHTML = '';
if (totalPages <= 1) return;
// 创建分页按钮
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 = pageNum;
button.disabled = (pageNum === currentPage);
button.addEventListener('click', () => {
currentPage = pageNum;
changePage();
});
container.appendChild(button);
}
createPaginationButtons(topPagination);
createPaginationButtons(bottomPagination);
centerActivePageButtons();
}
function changePage() {
renderPage(currentPage);
renderPagination();
updatePageInfo();
saveReadingState();
updateURLHash(currentPage);
centerActivePageButtons();
}
function lazyLoad() {
const imgs = document.querySelectorAll('.imgs');
const windowHeight = window.innerHeight;
const scrollY = window.scrollY || window.pageYOffset;
imgs.forEach(img => {
if (img.src) return; // 如果已经加载过了就跳过
const imgTop = img.getBoundingClientRect().top + scrollY;
if (windowHeight + scrollY + 200 > imgTop) { // 提前200px开始加载
img.src = img.getAttribute('data-src');
}
});
}
// 键盘快捷键支持
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);
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');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
globalBlur.style.opacity = 1;
globalBlur.style.pointerEvents = 'auto';
} else if (document.visibilityState === 'visible') {
globalBlur.style.opacity = 0;
globalBlur.style.pointerEvents = 'none';
}
});
// --- 位置记忆 & 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() {
const globalBlur = document.getElementById('global-blur');
globalBlur.style.opacity = 0;
globalBlur.style.pointerEvents = 'none';
}
</script>
</body>
</html>

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)

View File

@@ -1,27 +0,0 @@
from flask import *
from flask import Blueprint
import db.file , file, gc
api_Img_bp = Blueprint("api_Img_bp", __name__)
@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)
else:
data = file.imageToWebP(data)
response = make_response(data) # 读取文件
del data
response.headers.set("Content-Type", "image/Webp")
response.headers.set("Content-Disposition", "inline", filename=filename)
gc.collect()
return response

View File

@@ -1,77 +0,0 @@
from flask import *
from flask import Blueprint
import time
import db.file, file , app_conf
page_bp = Blueprint("page_bp", __name__)
conf = app_conf.conf()
@page_bp.route("/overview/<page>")
def overview(page): # 概览
page = int(page)
if request.cookies.get("islogin") is None: #验证登录状态
return redirect("/")
metaDataList = db.file.getMetadata((page - 1) * 20, page * 20, request.args.get("search"))
for item in metaDataList:
item[2] = item[2][:-4] #去除文件扩展名
if page <= 3:
lastPageList = range(1, page)
else:
lastPageList = range(page - 3, page)
nextPageList = range(page + 1, page + 4)
return render_template(
"overview.html",
list=metaDataList,
lastPageList=lastPageList,
pagenow=page,
nextPageList=nextPageList,
aftertime=int(time.time())-3*86400
)
@page_bp.route("/book/<bookid>")
def book(bookid): # 接口
if request.cookies.get("islogin") is None: #验证登录状态
return redirect("/")
data = db.file.searchByid(bookid)
if len(data) == 0:
return abort(404)
data[0] = list(data[0])
data[0][2] = data[0][2][0:-4] # 把文件扩展名去掉
local_time = time.localtime(float(data[0][4]))
return render_template(
"book.html",
id=bookid,
data=data,
time=time.strftime("%Y-%m-%d %H:%M:%S",local_time),
)
@page_bp.route("/view/<bookid>")
def view(bookid): # 接口
if request.cookies.get("islogin") is None: #验证登录状态
return redirect("/")
data = db.file.searchByid(bookid)
if len(data) == 0:
return abort(404)
return render_template("view.html", id=bookid, index=range(1, data[0][3]))
@page_bp.route("/upload", methods=["GET", "POST"]) # 文件上传
def upload_file():
if request.cookies.get("islogin") is None: #验证登录状态
return redirect("/")
if request.method == "GET":
return render_template("upload.html")
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()
return "success"