add_statistic_and_log
This commit is contained in:
parent
cb191ec379
commit
c75521becd
@ -6,6 +6,8 @@ from app.controllers.book import book_bp
|
||||
from app.controllers.borrow import borrow_bp
|
||||
from app.controllers.inventory import inventory_bp
|
||||
from flask_login import LoginManager, current_user
|
||||
from app.controllers.statistics import statistics_bp
|
||||
from app.controllers.log import log_bp
|
||||
import os
|
||||
|
||||
login_manager = LoginManager()
|
||||
@ -49,7 +51,9 @@ def create_app(config=None):
|
||||
app.register_blueprint(user_bp, url_prefix='/user')
|
||||
app.register_blueprint(book_bp, url_prefix='/book')
|
||||
app.register_blueprint(borrow_bp, url_prefix='/borrow')
|
||||
app.register_blueprint(statistics_bp)
|
||||
app.register_blueprint(inventory_bp)
|
||||
app.register_blueprint(log_bp)
|
||||
|
||||
# 创建数据库表
|
||||
with app.app_context():
|
||||
@ -63,6 +67,7 @@ def create_app(config=None):
|
||||
# 再导入依赖模型 - 但不在这里定义关系
|
||||
from app.models.borrow import BorrowRecord
|
||||
from app.models.inventory import InventoryLog
|
||||
from app.models.log import Log
|
||||
|
||||
# 移除这些重复的关系定义
|
||||
# Book.borrow_records = db.relationship('BorrowRecord', backref='book', lazy='dynamic')
|
||||
|
||||
@ -8,9 +8,11 @@ from werkzeug.utils import secure_filename
|
||||
import datetime
|
||||
import pandas as pd
|
||||
import uuid
|
||||
from app.models.log import Log # 导入日志模型
|
||||
|
||||
book_bp = Blueprint('book', __name__)
|
||||
|
||||
|
||||
@book_bp.route('/admin/list')
|
||||
@login_required
|
||||
@admin_required
|
||||
@ -43,16 +45,26 @@ def admin_book_list():
|
||||
books = pagination.items
|
||||
# 获取所有分类供筛选使用
|
||||
categories = Category.query.all()
|
||||
|
||||
# 记录访问日志
|
||||
Log.add_log(
|
||||
action='访问管理图书列表',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"查询条件: 搜索={search}, 分类={category_id}, 排序={sort} {order}"
|
||||
)
|
||||
|
||||
return render_template('book/list.html',
|
||||
books=books,
|
||||
pagination=pagination,
|
||||
search=search,
|
||||
categories=categories,
|
||||
category_id=category_id,
|
||||
sort=sort,
|
||||
order=order,
|
||||
current_user=g.user,
|
||||
is_admin_view=True) # 指明这是管理视图
|
||||
books=books,
|
||||
pagination=pagination,
|
||||
search=search,
|
||||
categories=categories,
|
||||
category_id=category_id,
|
||||
sort=sort,
|
||||
order=order,
|
||||
current_user=g.user,
|
||||
is_admin_view=True) # 指明这是管理视图
|
||||
|
||||
|
||||
# 图书列表页面
|
||||
@book_bp.route('/list')
|
||||
@ -94,6 +106,14 @@ def book_list():
|
||||
# 获取所有分类供筛选使用
|
||||
categories = Category.query.all()
|
||||
|
||||
# 记录访问日志
|
||||
Log.add_log(
|
||||
action='访问图书列表',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"查询条件: 搜索={search}, 分类={category_id}, 排序={sort} {order}"
|
||||
)
|
||||
|
||||
return render_template('book/list.html',
|
||||
books=books,
|
||||
pagination=pagination,
|
||||
@ -122,6 +142,16 @@ def book_detail(book_id):
|
||||
borrow_records = BorrowRecord.query.filter_by(book_id=book_id).order_by(BorrowRecord.borrow_date.desc()).limit(
|
||||
10).all()
|
||||
|
||||
# 记录访问日志
|
||||
Log.add_log(
|
||||
action='查看图书详情',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"查看图书: {book.title}"
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'book/detail.html',
|
||||
book=book,
|
||||
@ -131,7 +161,6 @@ def book_detail(book_id):
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 添加图书页面
|
||||
@book_bp.route('/add', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@ -196,7 +225,7 @@ def add_book():
|
||||
ext = '.jpg' # 默认扩展名
|
||||
|
||||
filename = f"{uuid.uuid4()}{ext}"
|
||||
upload_folder = os.path.join(current_app.static_folder, 'covers')
|
||||
upload_folder = os.path.join(current_app.static_folder, 'covers')
|
||||
|
||||
# 确保上传目录存在
|
||||
if not os.path.exists(upload_folder):
|
||||
@ -247,6 +276,16 @@ def add_book():
|
||||
db.session.add(inventory_log)
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='添加图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"添加图书: {title}, ISBN: {isbn}, 初始库存: {stock}"
|
||||
)
|
||||
|
||||
flash(f'《{title}》添加成功', 'success')
|
||||
return redirect(url_for('book.book_list'))
|
||||
|
||||
@ -255,6 +294,15 @@ def add_book():
|
||||
error_msg = str(e)
|
||||
# 记录详细错误日志
|
||||
current_app.logger.error(f"添加图书失败: {error_msg}")
|
||||
|
||||
# 记录操作失败日志
|
||||
Log.add_log(
|
||||
action='添加图书失败',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"添加图书失败: {title}, 错误: {error_msg}"
|
||||
)
|
||||
|
||||
flash(f'添加图书失败: {error_msg}', 'danger')
|
||||
|
||||
categories = Category.query.all()
|
||||
@ -277,6 +325,7 @@ def add_book():
|
||||
categories = Category.query.all()
|
||||
return render_template('book/add.html', categories=categories, current_user=g.user)
|
||||
|
||||
|
||||
# 编辑图书
|
||||
@book_bp.route('/edit/<int:book_id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@ -395,6 +444,9 @@ def edit_book(book_id):
|
||||
cover_file.save(file_path)
|
||||
book.cover_url = f'/static/covers/{filename}'
|
||||
|
||||
# 记录更新前的图书信息
|
||||
old_info = f"原信息: 书名={book.title}, 作者={book.author}, ISBN={book.isbn}, 库存={book.stock}"
|
||||
|
||||
# 更新图书信息
|
||||
book.title = title
|
||||
book.author = author
|
||||
@ -410,10 +462,32 @@ def edit_book(book_id):
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='编辑图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"编辑图书: {title}, ISBN: {isbn}, 新库存: {new_stock}\n{old_info}"
|
||||
)
|
||||
|
||||
flash('图书信息更新成功', 'success')
|
||||
return redirect(url_for('book.book_list'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
# 记录操作失败日志
|
||||
Log.add_log(
|
||||
action='编辑图书失败',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"编辑图书失败: {title}, 错误: {str(e)}"
|
||||
)
|
||||
|
||||
flash(f'保存失败: {str(e)}', 'danger')
|
||||
categories = Category.query.all()
|
||||
return render_template('book/edit.html', book=book, categories=categories, current_user=g.user)
|
||||
@ -435,6 +509,15 @@ def delete_book(book_id):
|
||||
active_borrows = BorrowRecord.query.filter_by(book_id=book_id, status=1).count()
|
||||
|
||||
if active_borrows > 0:
|
||||
# 记录操作失败日志
|
||||
Log.add_log(
|
||||
action='删除图书失败',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"删除图书失败: {book.title}, 原因: 该图书有未归还的借阅记录"
|
||||
)
|
||||
return jsonify({'success': False, 'message': '该图书有未归还的借阅记录,无法删除'})
|
||||
|
||||
# 考虑软删除而不是物理删除
|
||||
@ -442,6 +525,16 @@ def delete_book(book_id):
|
||||
book.updated_at = datetime.datetime.now()
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='下架图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"下架图书: {book.title}, ISBN: {book.isbn}"
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '图书已成功下架'})
|
||||
|
||||
|
||||
@ -451,6 +544,15 @@ def delete_book(book_id):
|
||||
@admin_required
|
||||
def category_list():
|
||||
categories = Category.query.all()
|
||||
|
||||
# 记录访问日志
|
||||
Log.add_log(
|
||||
action='访问分类管理',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description="访问图书分类管理页面"
|
||||
)
|
||||
|
||||
return render_template('book/categories.html', categories=categories, current_user=g.user)
|
||||
|
||||
|
||||
@ -470,6 +572,14 @@ def add_category():
|
||||
db.session.add(category)
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='添加图书分类',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"添加图书分类: {name}, 上级分类ID: {parent_id}, 排序: {sort}"
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '分类添加成功', 'id': category.id, 'name': category.name})
|
||||
|
||||
|
||||
@ -480,6 +590,10 @@ def add_category():
|
||||
def edit_category(category_id):
|
||||
category = Category.query.get_or_404(category_id)
|
||||
|
||||
old_name = category.name
|
||||
old_parent_id = category.parent_id
|
||||
old_sort = category.sort
|
||||
|
||||
name = request.form.get('name')
|
||||
parent_id = request.form.get('parent_id') or None
|
||||
sort = request.form.get('sort', 0, type=int)
|
||||
@ -492,6 +606,16 @@ def edit_category(category_id):
|
||||
category.sort = sort
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='编辑图书分类',
|
||||
user_id=current_user.id,
|
||||
target_type='category',
|
||||
target_id=category_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"编辑图书分类: 从 [名称={old_name}, 上级={old_parent_id}, 排序={old_sort}] 修改为 [名称={name}, 上级={parent_id}, 排序={sort}]"
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '分类更新成功'})
|
||||
|
||||
|
||||
@ -505,16 +629,46 @@ def delete_category(category_id):
|
||||
# 检查是否有书籍使用此分类
|
||||
books_count = Book.query.filter_by(category_id=category_id).count()
|
||||
if books_count > 0:
|
||||
# 记录操作失败日志
|
||||
Log.add_log(
|
||||
action='删除图书分类失败',
|
||||
user_id=current_user.id,
|
||||
target_type='category',
|
||||
target_id=category_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"删除图书分类失败: {category.name}, 原因: 该分类下有{books_count}本图书"
|
||||
)
|
||||
return jsonify({'success': False, 'message': f'该分类下有{books_count}本图书,无法删除'})
|
||||
|
||||
# 检查是否有子分类
|
||||
children_count = Category.query.filter_by(parent_id=category_id).count()
|
||||
if children_count > 0:
|
||||
# 记录操作失败日志
|
||||
Log.add_log(
|
||||
action='删除图书分类失败',
|
||||
user_id=current_user.id,
|
||||
target_type='category',
|
||||
target_id=category_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"删除图书分类失败: {category.name}, 原因: 该分类下有{children_count}个子分类"
|
||||
)
|
||||
return jsonify({'success': False, 'message': f'该分类下有{children_count}个子分类,无法删除'})
|
||||
|
||||
category_name = category.name # 保存分类名称以便记录日志
|
||||
|
||||
db.session.delete(category)
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='删除图书分类',
|
||||
user_id=current_user.id,
|
||||
target_type='category',
|
||||
target_id=category_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"删除图书分类: {category_name}"
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '分类删除成功'})
|
||||
|
||||
|
||||
@ -599,6 +753,15 @@ def import_books():
|
||||
error_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='批量导入图书',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"批量导入图书: 成功{success_count}条,失败{error_count}条,文件名:{file.filename}"
|
||||
)
|
||||
|
||||
flash(f'导入完成: 成功{success_count}条,失败{error_count}条', 'info')
|
||||
if errors:
|
||||
flash('<br>'.join(errors[:10]) + (f'<br>...等共{len(errors)}个错误' if len(errors) > 10 else ''),
|
||||
@ -607,6 +770,14 @@ def import_books():
|
||||
return redirect(url_for('book.book_list'))
|
||||
|
||||
except Exception as e:
|
||||
# 记录操作失败日志
|
||||
Log.add_log(
|
||||
action='批量导入图书失败',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"批量导入图书失败: {str(e)}, 文件名:{file.filename}"
|
||||
)
|
||||
|
||||
flash(f'导入失败: {str(e)}', 'danger')
|
||||
return redirect(request.url)
|
||||
else:
|
||||
@ -673,6 +844,14 @@ def export_books():
|
||||
# 写入Excel
|
||||
df.to_excel(filepath, index=False)
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='导出图书',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"导出图书数据: {len(data)}条记录, 查询条件: 搜索={search}, 分类ID={category_id}"
|
||||
)
|
||||
|
||||
# 提供下载链接
|
||||
return redirect(url_for('static', filename=f'temp/{filename}'))
|
||||
|
||||
@ -691,6 +870,7 @@ def test_permissions():
|
||||
<p><a href="/book/admin/list">尝试访问管理页面</a></p>
|
||||
"""
|
||||
|
||||
|
||||
# 添加到app/controllers/book.py文件中
|
||||
|
||||
@book_bp.route('/browse')
|
||||
@ -732,6 +912,14 @@ def browse_books():
|
||||
# 获取所有分类供筛选使用
|
||||
categories = Category.query.all()
|
||||
|
||||
# 记录访问日志
|
||||
Log.add_log(
|
||||
action='浏览图书',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"浏览图书: 搜索={search}, 分类={category_id}, 排序={sort} {order}"
|
||||
)
|
||||
|
||||
return render_template('book/browse.html',
|
||||
books=books,
|
||||
pagination=pagination,
|
||||
@ -739,4 +927,93 @@ def browse_books():
|
||||
categories=categories,
|
||||
category_id=category_id,
|
||||
sort=sort,
|
||||
order=order,)
|
||||
order=order, )
|
||||
|
||||
|
||||
@book_bp.route('/template/download')
|
||||
@login_required
|
||||
@admin_required
|
||||
def download_template():
|
||||
"""生成并下载Excel图书导入模板"""
|
||||
# 创建一个简单的DataFrame作为模板
|
||||
data = {
|
||||
'title': ['三体', '解忧杂货店'],
|
||||
'author': ['刘慈欣', '东野圭吾'],
|
||||
'publisher': ['重庆出版社', '南海出版公司'],
|
||||
'category_id': [2, 1], # 对应于科幻小说和文学分类
|
||||
'tags': ['科幻,宇宙', '治愈,悬疑'],
|
||||
'isbn': ['9787229100605', '9787544270878'],
|
||||
'publish_year': ['2008', '2014'],
|
||||
'description': ['中国著名科幻小说,三体世界的故事。', '通过信件为人们解忧的杂货店故事。'],
|
||||
'cover_url': ['', ''], # 示例中为空
|
||||
'stock': [10, 5],
|
||||
'price': [45.00, 39.80]
|
||||
}
|
||||
|
||||
# 创建一个分类ID和名称的映射
|
||||
categories = Category.query.all()
|
||||
category_map = {cat.id: cat.name for cat in categories}
|
||||
|
||||
# 创建一个pandas DataFrame
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# 添加说明工作表
|
||||
with pd.ExcelWriter('book_import_template.xlsx', engine='openpyxl') as writer:
|
||||
df.to_excel(writer, sheet_name='图书数据示例', index=False)
|
||||
|
||||
# 创建说明工作表
|
||||
info_df = pd.DataFrame({
|
||||
'字段名': ['title', 'author', 'publisher', 'category_id', 'tags',
|
||||
'isbn', 'publish_year', 'description', 'cover_url', 'stock', 'price'],
|
||||
'说明': ['图书标题 (必填)', '作者名称 (必填)', '出版社', '分类ID (对应系统分类)',
|
||||
'标签 (多个标签用逗号分隔)', 'ISBN编号', '出版年份', '图书简介',
|
||||
'封面图片URL', '库存数量', '价格'],
|
||||
'示例': ['三体', '刘慈欣', '重庆出版社', '2 (科幻小说)', '科幻,宇宙',
|
||||
'9787229100605', '2008', '中国著名科幻小说...', 'http://example.com/cover.jpg', '10', '45.00'],
|
||||
'是否必填': ['是', '是', '否', '否', '否', '否', '否', '否', '否', '否', '否']
|
||||
})
|
||||
info_df.to_excel(writer, sheet_name='填写说明', index=False)
|
||||
|
||||
# 创建分类ID工作表
|
||||
category_df = pd.DataFrame({
|
||||
'分类ID': list(category_map.keys()),
|
||||
'分类名称': list(category_map.values())
|
||||
})
|
||||
category_df.to_excel(writer, sheet_name='分类对照表', index=False)
|
||||
|
||||
# 设置响应
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
filename = f'book_import_template_{timestamp}.xlsx'
|
||||
|
||||
# 记录操作日志
|
||||
Log.add_log(
|
||||
action='下载图书导入模板',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description="下载图书批量导入Excel模板"
|
||||
)
|
||||
|
||||
# 直接返回生成的文件
|
||||
from flask import send_file
|
||||
from io import BytesIO
|
||||
|
||||
# 将文件转换为二进制流
|
||||
output = BytesIO()
|
||||
with open('book_import_template.xlsx', 'rb') as f:
|
||||
output.write(f.read())
|
||||
output.seek(0)
|
||||
|
||||
# 删除临时文件
|
||||
try:
|
||||
os.remove('book_import_template.xlsx')
|
||||
except:
|
||||
pass
|
||||
|
||||
# 通过send_file返回
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ from app.models.book import Book
|
||||
from app.models.borrow import BorrowRecord
|
||||
from app.models.inventory import InventoryLog
|
||||
from app.models.user import db, User
|
||||
from app.models.log import Log # 导入日志模型
|
||||
import datetime
|
||||
from app.utils.auth import admin_required
|
||||
|
||||
@ -72,6 +73,17 @@ def borrow_book():
|
||||
changed_at=now
|
||||
)
|
||||
db.session.add(inventory_log)
|
||||
|
||||
# 添加系统操作日志
|
||||
Log.add_log(
|
||||
action='借阅图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'用户借阅图书《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'成功借阅《{book.title}》,请在 {due_date.strftime("%Y-%m-%d")} 前归还', 'success')
|
||||
@ -145,6 +157,17 @@ def add_borrow(book_id):
|
||||
changed_at=now
|
||||
)
|
||||
db.session.add(inventory_log)
|
||||
|
||||
# 添加系统操作日志
|
||||
Log.add_log(
|
||||
action='借阅图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'用户借阅图书《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
@ -207,6 +230,21 @@ def return_book(borrow_id):
|
||||
changed_at=now
|
||||
)
|
||||
db.session.add(inventory_log)
|
||||
|
||||
# 添加系统操作日志
|
||||
# 判断是否逾期归还
|
||||
is_overdue = now > borrow_record.due_date
|
||||
overdue_msg = '(逾期归还)' if is_overdue else ''
|
||||
|
||||
Log.add_log(
|
||||
action='归还图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=borrow_record.book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'用户归还图书《{book.title}》{overdue_msg}'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
@ -252,6 +290,7 @@ def renew_book(borrow_id):
|
||||
|
||||
try:
|
||||
now = datetime.datetime.now()
|
||||
book = Book.query.get(borrow_record.book_id)
|
||||
|
||||
# 检查是否已逾期
|
||||
if now > borrow_record.due_date:
|
||||
@ -268,6 +307,16 @@ def renew_book(borrow_id):
|
||||
borrow_record.renew_count += 1
|
||||
borrow_record.updated_at = now
|
||||
|
||||
# 添加系统操作日志
|
||||
Log.add_log(
|
||||
action='续借图书',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=borrow_record.book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'用户续借图书《{book.title}》,新归还日期: {new_due_date.strftime("%Y-%m-%d")}'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
@ -307,6 +356,14 @@ def my_borrows():
|
||||
current_borrows_count = BorrowRecord.query.filter_by(user_id=current_user.id, status=1).count()
|
||||
history_borrows_count = BorrowRecord.query.filter_by(user_id=current_user.id, status=0).count()
|
||||
|
||||
# 记录日志 - 用户查看借阅记录
|
||||
Log.add_log(
|
||||
action='查看借阅记录',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description='用户查看个人借阅记录'
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'borrow/my_borrows.html',
|
||||
pagination=pagination,
|
||||
@ -317,7 +374,6 @@ def my_borrows():
|
||||
)
|
||||
|
||||
|
||||
|
||||
@borrow_bp.route('/manage')
|
||||
@login_required
|
||||
@admin_required
|
||||
@ -364,6 +420,14 @@ def manage_borrows():
|
||||
# 获取所有用户(用于筛选)
|
||||
users = User.query.all()
|
||||
|
||||
# 记录日志 - 管理员查看借阅记录
|
||||
Log.add_log(
|
||||
action='管理借阅记录',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description='管理员查看借阅管理页面'
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'borrow/borrow_management.html',
|
||||
pagination=pagination,
|
||||
@ -378,7 +442,6 @@ def manage_borrows():
|
||||
)
|
||||
|
||||
|
||||
|
||||
@borrow_bp.route('/admin/add', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
@ -445,6 +508,17 @@ def admin_add_borrow():
|
||||
changed_at=now
|
||||
)
|
||||
db.session.add(inventory_log)
|
||||
|
||||
# 添加系统操作日志
|
||||
Log.add_log(
|
||||
action='管理员借阅操作',
|
||||
user_id=current_user.id,
|
||||
target_type='book',
|
||||
target_id=book_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'管理员为用户 {user.username} 借阅图书《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'成功为用户 {user.username} 借阅《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}', 'success')
|
||||
@ -475,6 +549,14 @@ def overdue_borrows():
|
||||
# 计算逾期总数
|
||||
overdue_count = query.count()
|
||||
|
||||
# 记录日志 - 管理员查看逾期记录
|
||||
Log.add_log(
|
||||
action='查看逾期记录',
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description='管理员查看逾期借阅记录'
|
||||
)
|
||||
|
||||
return render_template(
|
||||
'borrow/overdue.html',
|
||||
pagination=pagination,
|
||||
@ -508,6 +590,9 @@ def notify_overdue(borrow_id):
|
||||
})
|
||||
|
||||
try:
|
||||
book = Book.query.get(borrow_record.book_id)
|
||||
user = User.query.get(borrow_record.user_id)
|
||||
|
||||
# 创建通知
|
||||
notification = Notification(
|
||||
user_id=borrow_record.user_id,
|
||||
@ -519,11 +604,21 @@ def notify_overdue(borrow_id):
|
||||
)
|
||||
|
||||
db.session.add(notification)
|
||||
db.session.commit()
|
||||
|
||||
# 更新借阅记录备注
|
||||
borrow_record.remark = f'{borrow_record.remark or ""}[{now.strftime("%Y-%m-%d")} 已发送逾期通知]'
|
||||
borrow_record.updated_at = now
|
||||
|
||||
# 添加系统操作日志
|
||||
Log.add_log(
|
||||
action='发送逾期通知',
|
||||
user_id=current_user.id,
|
||||
target_type='notification',
|
||||
target_id=borrow_record.user_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f'管理员向用户 {user.username} 发送图书《{book.title}》逾期通知'
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
|
||||
@ -3,6 +3,7 @@ from flask import Blueprint, render_template, request, jsonify, flash, redirect,
|
||||
from flask_login import login_required, current_user
|
||||
from app.models.book import Book
|
||||
from app.models.inventory import InventoryLog
|
||||
from app.models.log import Log # 导入日志模型
|
||||
from app.models.user import db
|
||||
from app.utils.auth import admin_required
|
||||
from datetime import datetime
|
||||
@ -40,6 +41,15 @@ def inventory_list():
|
||||
pagination = query.paginate(page=page, per_page=per_page)
|
||||
books = pagination.items
|
||||
|
||||
# 记录系统日志 - 访问库存管理页面
|
||||
Log.add_log(
|
||||
action="访问库存管理",
|
||||
user_id=current_user.id,
|
||||
target_type="inventory",
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员访问库存管理页面,搜索条件:{search if search else '无'}"
|
||||
)
|
||||
|
||||
return render_template('inventory/list.html',
|
||||
books=books,
|
||||
pagination=pagination,
|
||||
@ -55,6 +65,17 @@ def adjust_inventory(book_id):
|
||||
"""调整图书库存"""
|
||||
book = Book.query.get_or_404(book_id)
|
||||
|
||||
# GET请求记录日志
|
||||
if request.method == 'GET':
|
||||
Log.add_log(
|
||||
action="查看库存调整",
|
||||
user_id=current_user.id,
|
||||
target_type="book",
|
||||
target_id=book.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员查看图书《{book.title}》的库存调整页面"
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
change_type = request.form.get('change_type')
|
||||
change_amount = int(request.form.get('change_amount', 0))
|
||||
@ -69,12 +90,14 @@ def adjust_inventory(book_id):
|
||||
if change_type == 'in':
|
||||
book.stock += change_amount
|
||||
after_stock = book.stock
|
||||
operation_desc = "入库"
|
||||
elif change_type == 'out':
|
||||
if book.stock < change_amount:
|
||||
flash('出库数量不能大于当前库存', 'danger')
|
||||
return redirect(url_for('inventory.adjust_inventory', book_id=book_id))
|
||||
book.stock -= change_amount
|
||||
after_stock = book.stock
|
||||
operation_desc = "出库"
|
||||
else:
|
||||
flash('无效的操作类型', 'danger')
|
||||
return redirect(url_for('inventory.adjust_inventory', book_id=book_id))
|
||||
@ -92,6 +115,18 @@ def adjust_inventory(book_id):
|
||||
|
||||
try:
|
||||
db.session.add(log)
|
||||
|
||||
# 记录系统日志 - 库存调整
|
||||
Log.add_log(
|
||||
action=f"库存{operation_desc}",
|
||||
user_id=current_user.id,
|
||||
target_type="book",
|
||||
target_id=book.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员对图书《{book.title}》进行{operation_desc}操作,数量:{change_amount},"
|
||||
f"原库存:{original_stock},现库存:{after_stock},备注:{remark}"
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'图书《{book.title}》库存调整成功!原库存:{original_stock},现库存:{after_stock}', 'success')
|
||||
return redirect(url_for('inventory.inventory_list'))
|
||||
@ -116,6 +151,7 @@ def inventory_logs():
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
query = InventoryLog.query
|
||||
|
||||
if book_id:
|
||||
query = query.filter_by(book_id=book_id)
|
||||
if change_type:
|
||||
@ -124,24 +160,49 @@ def inventory_logs():
|
||||
query = query.filter(InventoryLog.changed_at >= datetime.strptime(date_from, '%Y-%m-%d'))
|
||||
if date_to:
|
||||
query = query.filter(InventoryLog.changed_at <= datetime.strptime(date_to + ' 23:59:59', '%Y-%m-%d %H:%M:%S'))
|
||||
|
||||
# 默认按时间倒序
|
||||
query = query.order_by(InventoryLog.changed_at.desc())
|
||||
pagination = query.paginate(page=page, per_page=per_page)
|
||||
logs = pagination.items
|
||||
|
||||
# 获取所有图书用于筛选
|
||||
books = Book.query.all()
|
||||
|
||||
# 如果特定 book_id 被指定,也获取该书的详细信息
|
||||
book = Book.query.get(book_id) if book_id else None
|
||||
|
||||
# 记录系统日志 - 查看库存日志
|
||||
filter_desc = []
|
||||
if book_id:
|
||||
book_title = book.title if book else f"ID:{book_id}"
|
||||
filter_desc.append(f"图书:{book_title}")
|
||||
if change_type:
|
||||
change_type_text = "入库" if change_type == "in" else "出库"
|
||||
filter_desc.append(f"操作类型:{change_type_text}")
|
||||
if date_from or date_to:
|
||||
date_range = f"{date_from or '无限制'} 至 {date_to or '无限制'}"
|
||||
filter_desc.append(f"日期范围:{date_range}")
|
||||
|
||||
Log.add_log(
|
||||
action="查看库存日志",
|
||||
user_id=current_user.id,
|
||||
target_type="inventory_log",
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员查看库存变动日志,筛选条件:{', '.join(filter_desc) if filter_desc else '无'}"
|
||||
)
|
||||
|
||||
return render_template('inventory/logs.html',
|
||||
logs=logs,
|
||||
pagination=pagination,
|
||||
books=books,
|
||||
book=book, # 添加这个变量
|
||||
book=book,
|
||||
book_id=book_id,
|
||||
change_type=change_type,
|
||||
date_from=date_from,
|
||||
date_to=date_to)
|
||||
|
||||
|
||||
@inventory_bp.route('/book/<int:book_id>/logs')
|
||||
@login_required
|
||||
@admin_required
|
||||
@ -155,6 +216,16 @@ def book_inventory_logs(book_id):
|
||||
.order_by(InventoryLog.changed_at.desc()) \
|
||||
.paginate(page=page, per_page=per_page)
|
||||
|
||||
# 记录系统日志 - 查看特定图书的库存日志
|
||||
Log.add_log(
|
||||
action="查看图书库存日志",
|
||||
user_id=current_user.id,
|
||||
target_type="book",
|
||||
target_id=book.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员查看图书《{book.title}》的库存变动日志"
|
||||
)
|
||||
|
||||
return render_template('inventory/book_logs.html',
|
||||
book=book,
|
||||
logs=logs.items,
|
||||
|
||||
@ -0,0 +1,200 @@
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask_login import current_user, login_required
|
||||
from app.models.log import Log
|
||||
from app.models.user import User, db # 导入db
|
||||
from app.controllers.user import admin_required # 导入admin_required装饰器
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 创建蓝图
|
||||
log_bp = Blueprint('log', __name__, url_prefix='/log')
|
||||
|
||||
|
||||
@log_bp.route('/list')
|
||||
@login_required
|
||||
@admin_required
|
||||
def log_list():
|
||||
"""日志列表页面"""
|
||||
# 获取筛选参数
|
||||
page = request.args.get('page', 1, type=int)
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
action = request.args.get('action')
|
||||
target_type = request.args.get('target_type')
|
||||
|
||||
# 处理日期范围参数
|
||||
date_range = request.args.get('date_range', '7') # 默认显示7天内的日志
|
||||
end_date = datetime.now()
|
||||
start_date = None
|
||||
|
||||
if date_range == '1':
|
||||
start_date = end_date - timedelta(days=1)
|
||||
elif date_range == '7':
|
||||
start_date = end_date - timedelta(days=7)
|
||||
elif date_range == '30':
|
||||
start_date = end_date - timedelta(days=30)
|
||||
elif date_range == 'custom':
|
||||
start_date_str = request.args.get('start_date')
|
||||
end_date_str = request.args.get('end_date')
|
||||
|
||||
if start_date_str:
|
||||
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
||||
if end_date_str:
|
||||
end_date = datetime.strptime(end_date_str + ' 23:59:59', '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 获取分页数据
|
||||
pagination = Log.get_logs(
|
||||
page=page,
|
||||
per_page=20,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
# 获取用户列表和操作类型列表,用于筛选
|
||||
users = User.query.all()
|
||||
|
||||
# 统计各类操作的数量
|
||||
action_types = db.session.query(Log.action, db.func.count(Log.id)) \
|
||||
.group_by(Log.action).all()
|
||||
|
||||
target_types = db.session.query(Log.target_type, db.func.count(Log.id)) \
|
||||
.filter(Log.target_type != None) \
|
||||
.group_by(Log.target_type).all()
|
||||
|
||||
return render_template(
|
||||
'log/list.html',
|
||||
pagination=pagination,
|
||||
users=users,
|
||||
action_types=action_types,
|
||||
target_types=target_types,
|
||||
filters={
|
||||
'user_id': user_id,
|
||||
'action': action,
|
||||
'target_type': target_type,
|
||||
'date_range': date_range,
|
||||
'start_date': start_date.strftime('%Y-%m-%d') if start_date else '',
|
||||
'end_date': end_date.strftime('%Y-%m-%d') if end_date != datetime.now() else ''
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@log_bp.route('/detail/<int:log_id>')
|
||||
@login_required
|
||||
@admin_required
|
||||
def log_detail(log_id):
|
||||
"""日志详情页面"""
|
||||
log = Log.query.get_or_404(log_id)
|
||||
return render_template('log/detail.html', log=log)
|
||||
|
||||
|
||||
@log_bp.route('/api/export', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def export_logs():
|
||||
"""导出日志API"""
|
||||
import csv
|
||||
from io import StringIO
|
||||
from flask import Response
|
||||
|
||||
data = request.get_json()
|
||||
user_id = data.get('user_id')
|
||||
action = data.get('action')
|
||||
target_type = data.get('target_type')
|
||||
start_date_str = data.get('start_date')
|
||||
end_date_str = data.get('end_date')
|
||||
|
||||
# 处理日期范围
|
||||
start_date = None
|
||||
end_date = datetime.now()
|
||||
|
||||
if start_date_str:
|
||||
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
||||
if end_date_str:
|
||||
end_date = datetime.strptime(end_date_str + ' 23:59:59', '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 查询日志
|
||||
query = Log.query.order_by(Log.created_at.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.filter(Log.user_id == user_id)
|
||||
if action:
|
||||
query = query.filter(Log.action == action)
|
||||
if target_type:
|
||||
query = query.filter(Log.target_type == target_type)
|
||||
if start_date:
|
||||
query = query.filter(Log.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Log.created_at <= end_date)
|
||||
|
||||
logs = query.all()
|
||||
|
||||
# 生成CSV文件
|
||||
si = StringIO()
|
||||
csv_writer = csv.writer(si)
|
||||
|
||||
# 写入标题行
|
||||
csv_writer.writerow(['ID', '用户', '操作类型', '目标类型', '目标ID', 'IP地址', '描述', '创建时间'])
|
||||
|
||||
# 写入数据行
|
||||
for log in logs:
|
||||
username = log.user.username if log.user else "未登录"
|
||||
csv_writer.writerow([
|
||||
log.id,
|
||||
username,
|
||||
log.action,
|
||||
log.target_type or '',
|
||||
log.target_id or '',
|
||||
log.ip_address or '',
|
||||
log.description or '',
|
||||
log.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
])
|
||||
|
||||
# 设置响应头,使浏览器将其识别为下载文件
|
||||
filename = f"system_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
output = si.getvalue()
|
||||
|
||||
# 返回Base64编码的CSV数据
|
||||
import base64
|
||||
encoded_data = base64.b64encode(output.encode('utf-8')).decode('utf-8')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'已生成 {len(logs)} 条日志记录',
|
||||
'count': len(logs),
|
||||
'filename': filename,
|
||||
'filedata': encoded_data,
|
||||
'filetype': 'text/csv'
|
||||
})
|
||||
|
||||
|
||||
@log_bp.route('/api/clear', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def clear_logs():
|
||||
"""清空日志API"""
|
||||
data = request.get_json()
|
||||
days = data.get('days', 0)
|
||||
|
||||
try:
|
||||
if days > 0:
|
||||
# 清除指定天数前的日志
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
deleted = Log.query.filter(Log.created_at < cutoff_date).delete()
|
||||
else:
|
||||
# 清空全部日志
|
||||
deleted = Log.query.delete()
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'成功清除 {deleted} 条日志记录',
|
||||
'count': deleted
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'清除日志失败: {str(e)}'
|
||||
}), 500
|
||||
@ -0,0 +1,446 @@
|
||||
# app/controllers/statistics.py
|
||||
from flask import Blueprint, render_template, jsonify, request
|
||||
from flask_login import login_required, current_user
|
||||
from app.models.book import Book, db
|
||||
from app.models.borrow import BorrowRecord
|
||||
from app.models.user import User
|
||||
from app.utils.auth import admin_required
|
||||
from app.models.log import Log # 导入日志模型
|
||||
from sqlalchemy import func, case, desc, and_
|
||||
from datetime import datetime, timedelta
|
||||
import calendar
|
||||
|
||||
statistics_bp = Blueprint('statistics', __name__, url_prefix='/statistics')
|
||||
|
||||
|
||||
@statistics_bp.route('/')
|
||||
@login_required
|
||||
@admin_required
|
||||
def index():
|
||||
"""统计分析首页"""
|
||||
# 记录访问统计分析首页的日志
|
||||
Log.add_log(
|
||||
action="访问统计分析",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="访问统计分析首页"
|
||||
)
|
||||
return render_template('statistics/index.html')
|
||||
|
||||
|
||||
@statistics_bp.route('/book-ranking')
|
||||
@login_required
|
||||
@admin_required
|
||||
def book_ranking():
|
||||
"""热门图书排行榜页面"""
|
||||
# 记录访问热门图书排行的日志
|
||||
Log.add_log(
|
||||
action="查看统计数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="查看热门图书排行榜"
|
||||
)
|
||||
return render_template('statistics/book_ranking.html')
|
||||
|
||||
|
||||
@statistics_bp.route('/api/book-ranking')
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_book_ranking():
|
||||
"""获取热门图书排行数据API"""
|
||||
time_range = request.args.get('time_range', 'month')
|
||||
limit = request.args.get('limit', 10, type=int)
|
||||
|
||||
# 记录获取热门图书排行数据的日志
|
||||
Log.add_log(
|
||||
action="获取数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description=f"获取热门图书排行数据(时间范围:{time_range}, 数量:{limit})"
|
||||
)
|
||||
|
||||
# 根据时间范围设置过滤条件
|
||||
if time_range == 'week':
|
||||
start_date = datetime.now() - timedelta(days=7)
|
||||
elif time_range == 'month':
|
||||
start_date = datetime.now() - timedelta(days=30)
|
||||
elif time_range == 'year':
|
||||
start_date = datetime.now() - timedelta(days=365)
|
||||
else: # all time
|
||||
start_date = datetime(1900, 1, 1)
|
||||
|
||||
# 查询借阅次数最多的图书
|
||||
popular_books = db.session.query(
|
||||
Book.id, Book.title, Book.author, Book.cover_url,
|
||||
func.count(BorrowRecord.id).label('borrow_count')
|
||||
).join(
|
||||
BorrowRecord, Book.id == BorrowRecord.book_id
|
||||
).filter(
|
||||
BorrowRecord.borrow_date >= start_date
|
||||
).group_by(
|
||||
Book.id
|
||||
).order_by(
|
||||
desc('borrow_count')
|
||||
).limit(limit).all()
|
||||
|
||||
result = [
|
||||
{
|
||||
'id': book.id,
|
||||
'title': book.title,
|
||||
'author': book.author,
|
||||
'cover_url': book.cover_url,
|
||||
'borrow_count': book.borrow_count
|
||||
} for book in popular_books
|
||||
]
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@statistics_bp.route('/borrow-statistics')
|
||||
@login_required
|
||||
@admin_required
|
||||
def borrow_statistics():
|
||||
"""借阅统计分析页面"""
|
||||
# 记录访问借阅统计分析的日志
|
||||
Log.add_log(
|
||||
action="查看统计数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="查看借阅统计分析"
|
||||
)
|
||||
return render_template('statistics/borrow_statistics.html')
|
||||
|
||||
|
||||
@statistics_bp.route('/api/borrow-trend')
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_borrow_trend():
|
||||
"""获取借阅趋势数据API"""
|
||||
time_range = request.args.get('time_range', 'month')
|
||||
|
||||
# 记录获取借阅趋势数据的日志
|
||||
Log.add_log(
|
||||
action="获取数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description=f"获取借阅趋势数据(时间范围:{time_range})"
|
||||
)
|
||||
|
||||
if time_range == 'week':
|
||||
# 获取过去7天每天的借阅和归还数量
|
||||
start_date = datetime.now() - timedelta(days=6)
|
||||
results = []
|
||||
|
||||
for i in range(7):
|
||||
day = start_date + timedelta(days=i)
|
||||
day_start = day.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
# 查询当天借阅量
|
||||
borrow_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.borrow_date >= day_start,
|
||||
BorrowRecord.borrow_date <= day_end
|
||||
).count()
|
||||
|
||||
# 查询当天归还量
|
||||
return_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.return_date >= day_start,
|
||||
BorrowRecord.return_date <= day_end
|
||||
).count()
|
||||
|
||||
# 当天逾期未还的数量
|
||||
overdue_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.due_date < day_end,
|
||||
BorrowRecord.return_date.is_(None)
|
||||
).count()
|
||||
|
||||
results.append({
|
||||
'date': day.strftime('%m-%d'),
|
||||
'borrow': borrow_count,
|
||||
'return': return_count,
|
||||
'overdue': overdue_count
|
||||
})
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
elif time_range == 'month':
|
||||
# 获取过去30天每天的借阅和归还数量
|
||||
start_date = datetime.now() - timedelta(days=29)
|
||||
results = []
|
||||
|
||||
for i in range(30):
|
||||
day = start_date + timedelta(days=i)
|
||||
day_start = day.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
# 查询当天借阅量
|
||||
borrow_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.borrow_date >= day_start,
|
||||
BorrowRecord.borrow_date <= day_end
|
||||
).count()
|
||||
|
||||
# 查询当天归还量
|
||||
return_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.return_date >= day_start,
|
||||
BorrowRecord.return_date <= day_end
|
||||
).count()
|
||||
|
||||
# 当天逾期未还的数量
|
||||
overdue_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.due_date < day_end,
|
||||
BorrowRecord.return_date.is_(None)
|
||||
).count()
|
||||
|
||||
results.append({
|
||||
'date': day.strftime('%m-%d'),
|
||||
'borrow': borrow_count,
|
||||
'return': return_count,
|
||||
'overdue': overdue_count
|
||||
})
|
||||
|
||||
return jsonify(results)
|
||||
|
||||
elif time_range == 'year':
|
||||
# 获取过去12个月每月的借阅和归还数量
|
||||
current_month = datetime.now().month
|
||||
current_year = datetime.now().year
|
||||
results = []
|
||||
|
||||
for i in range(12):
|
||||
# 计算月份和年份
|
||||
month = (current_month - i) % 12
|
||||
if month == 0:
|
||||
month = 12
|
||||
year = current_year - ((i - (current_month - 1)) // 12)
|
||||
|
||||
# 计算该月的开始和结束日期
|
||||
days_in_month = calendar.monthrange(year, month)[1]
|
||||
month_start = datetime(year, month, 1)
|
||||
month_end = datetime(year, month, days_in_month, 23, 59, 59, 999999)
|
||||
|
||||
# 查询当月借阅量
|
||||
borrow_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.borrow_date >= month_start,
|
||||
BorrowRecord.borrow_date <= month_end
|
||||
).count()
|
||||
|
||||
# 查询当月归还量
|
||||
return_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.return_date >= month_start,
|
||||
BorrowRecord.return_date <= month_end
|
||||
).count()
|
||||
|
||||
# 当月逾期未还的数量
|
||||
overdue_count = BorrowRecord.query.filter(
|
||||
BorrowRecord.due_date < month_end,
|
||||
BorrowRecord.return_date.is_(None)
|
||||
).count()
|
||||
|
||||
results.append({
|
||||
'date': f'{year}-{month:02d}',
|
||||
'borrow': borrow_count,
|
||||
'return': return_count,
|
||||
'overdue': overdue_count
|
||||
})
|
||||
|
||||
# 按时间顺序排序
|
||||
results.reverse()
|
||||
return jsonify(results)
|
||||
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@statistics_bp.route('/api/category-distribution')
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_category_distribution():
|
||||
"""获取图书分类分布数据API"""
|
||||
# 记录获取图书分类分布数据的日志
|
||||
Log.add_log(
|
||||
action="获取数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="获取图书分类分布数据"
|
||||
)
|
||||
|
||||
# 计算每个分类的总借阅次数
|
||||
category_stats = db.session.query(
|
||||
Book.category_id,
|
||||
func.count(BorrowRecord.id).label('borrow_count')
|
||||
).join(
|
||||
BorrowRecord, Book.id == BorrowRecord.book_id
|
||||
).group_by(
|
||||
Book.category_id
|
||||
).all()
|
||||
|
||||
# 获取分类名称
|
||||
from app.models.book import Category
|
||||
categories = {cat.id: cat.name for cat in Category.query.all()}
|
||||
|
||||
# 准备结果
|
||||
result = [
|
||||
{
|
||||
'category': categories.get(stat.category_id, '未分类'),
|
||||
'count': stat.borrow_count
|
||||
} for stat in category_stats if stat.category_id is not None
|
||||
]
|
||||
|
||||
# 添加未分类数据
|
||||
uncategorized = next((stat for stat in category_stats if stat.category_id is None), None)
|
||||
if uncategorized:
|
||||
result.append({'category': '未分类', 'count': uncategorized.borrow_count})
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@statistics_bp.route('/user-activity')
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_activity():
|
||||
"""用户活跃度分析页面"""
|
||||
# 记录访问用户活跃度分析的日志
|
||||
Log.add_log(
|
||||
action="查看统计数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="查看用户活跃度分析"
|
||||
)
|
||||
return render_template('statistics/user_activity.html')
|
||||
|
||||
|
||||
@statistics_bp.route('/api/user-activity')
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_user_activity():
|
||||
"""获取用户活跃度数据API"""
|
||||
# 记录获取用户活跃度数据的日志
|
||||
Log.add_log(
|
||||
action="获取数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="获取用户活跃度数据"
|
||||
)
|
||||
|
||||
# 查询最活跃的用户(借阅量最多)
|
||||
active_users = db.session.query(
|
||||
User.id, User.username, User.nickname,
|
||||
func.count(BorrowRecord.id).label('borrow_count')
|
||||
).join(
|
||||
BorrowRecord, User.id == BorrowRecord.user_id
|
||||
).group_by(
|
||||
User.id
|
||||
).order_by(
|
||||
desc('borrow_count')
|
||||
).limit(10).all()
|
||||
|
||||
result = [
|
||||
{
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'nickname': user.nickname or user.username,
|
||||
'borrow_count': user.borrow_count
|
||||
} for user in active_users
|
||||
]
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@statistics_bp.route('/overdue-analysis')
|
||||
@login_required
|
||||
@admin_required
|
||||
def overdue_analysis():
|
||||
"""逾期分析页面"""
|
||||
# 记录访问逾期分析的日志
|
||||
Log.add_log(
|
||||
action="查看统计数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="查看借阅逾期分析"
|
||||
)
|
||||
return render_template('statistics/overdue_analysis.html')
|
||||
|
||||
|
||||
@statistics_bp.route('/api/overdue-statistics')
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_overdue_statistics():
|
||||
"""获取逾期统计数据API"""
|
||||
# 记录获取逾期统计数据的日志
|
||||
Log.add_log(
|
||||
action="获取数据",
|
||||
user_id=current_user.id,
|
||||
target_type="statistics",
|
||||
description="获取借阅逾期统计数据"
|
||||
)
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# 计算总借阅量
|
||||
total_borrows = BorrowRecord.query.count()
|
||||
|
||||
# 计算已归还的逾期借阅
|
||||
returned_overdue = BorrowRecord.query.filter(
|
||||
BorrowRecord.return_date.isnot(None),
|
||||
BorrowRecord.return_date > BorrowRecord.due_date
|
||||
).count()
|
||||
|
||||
# 计算未归还的逾期借阅
|
||||
current_overdue = BorrowRecord.query.filter(
|
||||
BorrowRecord.return_date.is_(None),
|
||||
BorrowRecord.due_date < now
|
||||
).count()
|
||||
|
||||
# 计算总逾期率
|
||||
overdue_rate = round((returned_overdue + current_overdue) / total_borrows * 100, 2) if total_borrows > 0 else 0
|
||||
|
||||
# 计算各逾期时长区间的数量
|
||||
overdue_range_data = []
|
||||
|
||||
# 1-7天逾期
|
||||
range1 = BorrowRecord.query.filter(
|
||||
and_(
|
||||
BorrowRecord.return_date.is_(None),
|
||||
BorrowRecord.due_date < now,
|
||||
BorrowRecord.due_date >= now - timedelta(days=7)
|
||||
)
|
||||
).count()
|
||||
overdue_range_data.append({'range': '1-7天', 'count': range1})
|
||||
|
||||
# 8-14天逾期
|
||||
range2 = BorrowRecord.query.filter(
|
||||
and_(
|
||||
BorrowRecord.return_date.is_(None),
|
||||
BorrowRecord.due_date < now - timedelta(days=7),
|
||||
BorrowRecord.due_date >= now - timedelta(days=14)
|
||||
)
|
||||
).count()
|
||||
overdue_range_data.append({'range': '8-14天', 'count': range2})
|
||||
|
||||
# 15-30天逾期
|
||||
range3 = BorrowRecord.query.filter(
|
||||
and_(
|
||||
BorrowRecord.return_date.is_(None),
|
||||
BorrowRecord.due_date < now - timedelta(days=14),
|
||||
BorrowRecord.due_date >= now - timedelta(days=30)
|
||||
)
|
||||
).count()
|
||||
overdue_range_data.append({'range': '15-30天', 'count': range3})
|
||||
|
||||
# 30天以上逾期
|
||||
range4 = BorrowRecord.query.filter(
|
||||
and_(
|
||||
BorrowRecord.return_date.is_(None),
|
||||
BorrowRecord.due_date < now - timedelta(days=30)
|
||||
)
|
||||
).count()
|
||||
overdue_range_data.append({'range': '30天以上', 'count': range4})
|
||||
|
||||
result = {
|
||||
'total_borrows': total_borrows,
|
||||
'returned_overdue': returned_overdue,
|
||||
'current_overdue': current_overdue,
|
||||
'overdue_rate': overdue_rate,
|
||||
'overdue_ranges': overdue_range_data
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
@ -1,6 +1,7 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.models.user import User, db
|
||||
from app.models.log import Log # 导入日志模型
|
||||
from app.utils.email import send_verification_email, generate_verification_code
|
||||
import logging
|
||||
from functools import wraps
|
||||
@ -102,14 +103,35 @@ def login():
|
||||
user = User.query.filter((User.username == username) | (User.email == username)).first()
|
||||
|
||||
if not user or not user.check_password(password):
|
||||
# 记录登录失败日志
|
||||
Log.add_log(
|
||||
action="登录失败",
|
||||
ip_address=request.remote_addr,
|
||||
description=f"尝试使用用户名/邮箱 {username} 登录失败"
|
||||
)
|
||||
return render_template('login.html', error='用户名或密码错误')
|
||||
|
||||
if user.status == 0:
|
||||
# 记录禁用账号登录尝试
|
||||
Log.add_log(
|
||||
action="登录失败",
|
||||
user_id=user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"禁用账号 {username} 尝试登录"
|
||||
)
|
||||
return render_template('login.html', error='账号已被禁用,请联系管理员')
|
||||
|
||||
# 使用 Flask-Login 的 login_user 函数
|
||||
login_user(user, remember=remember_me)
|
||||
|
||||
# 记录登录成功日志
|
||||
Log.add_log(
|
||||
action="用户登录",
|
||||
user_id=user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"用户 {user.username} 登录成功"
|
||||
)
|
||||
|
||||
# 这些session信息仍然可以保留,但不再用于认证
|
||||
session['username'] = user.username
|
||||
session['role_id'] = user.role_id
|
||||
@ -168,6 +190,14 @@ def register():
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
# 记录用户注册日志
|
||||
Log.add_log(
|
||||
action="用户注册",
|
||||
user_id=new_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"新用户 {username} 注册成功"
|
||||
)
|
||||
|
||||
# 清除验证码
|
||||
verification_codes.delete(email)
|
||||
|
||||
@ -184,6 +214,17 @@ def register():
|
||||
@user_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
username = current_user.username
|
||||
user_id = current_user.id
|
||||
|
||||
# 先记录日志,再登出
|
||||
Log.add_log(
|
||||
action="用户登出",
|
||||
user_id=user_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"用户 {username} 登出系统"
|
||||
)
|
||||
|
||||
logout_user()
|
||||
return redirect(url_for('user.login'))
|
||||
|
||||
@ -209,6 +250,12 @@ def send_verification_code():
|
||||
|
||||
# 发送验证码邮件
|
||||
if send_verification_email(email, code):
|
||||
# 记录发送验证码日志
|
||||
Log.add_log(
|
||||
action="发送验证码",
|
||||
ip_address=request.remote_addr,
|
||||
description=f"向邮箱 {email} 发送验证码"
|
||||
)
|
||||
return jsonify({'success': True, 'message': '验证码已发送'})
|
||||
else:
|
||||
return jsonify({'success': False, 'message': '邮件发送失败,请稍后重试'})
|
||||
@ -232,6 +279,14 @@ def user_list():
|
||||
role_id=role_id
|
||||
)
|
||||
|
||||
# 记录管理员访问用户列表日志
|
||||
Log.add_log(
|
||||
action="访问用户管理",
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 访问用户管理列表"
|
||||
)
|
||||
|
||||
roles = UserService.get_all_roles()
|
||||
|
||||
return render_template(
|
||||
@ -271,11 +326,31 @@ def user_edit(user_id):
|
||||
|
||||
success, message = UserService.update_user(user_id, data)
|
||||
if success:
|
||||
# 记录管理员编辑用户信息日志
|
||||
Log.add_log(
|
||||
action="编辑用户",
|
||||
user_id=current_user.id,
|
||||
target_type="用户",
|
||||
target_id=user_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 编辑用户 {user.username} 的信息"
|
||||
)
|
||||
flash(message, 'success')
|
||||
return redirect(url_for('user.user_list'))
|
||||
else:
|
||||
flash(message, 'error')
|
||||
|
||||
# 记录访问用户编辑页面日志
|
||||
if request.method == 'GET':
|
||||
Log.add_log(
|
||||
action="访问用户编辑",
|
||||
user_id=current_user.id,
|
||||
target_type="用户",
|
||||
target_id=user_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 访问用户 {user.username} 的编辑页面"
|
||||
)
|
||||
|
||||
return render_template('user/edit.html', user=user, roles=roles)
|
||||
|
||||
|
||||
@ -294,7 +369,25 @@ def user_status(user_id):
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'success': False, 'message': '不能修改自己的状态'})
|
||||
|
||||
# 查询用户获取用户名(用于日志)
|
||||
target_user = User.query.get(user_id)
|
||||
if not target_user:
|
||||
return jsonify({'success': False, 'message': '用户不存在'})
|
||||
|
||||
success, message = UserService.change_user_status(user_id, status)
|
||||
|
||||
if success:
|
||||
# 记录修改用户状态日志
|
||||
status_text = "启用" if status == 1 else "禁用"
|
||||
Log.add_log(
|
||||
action=f"用户{status_text}",
|
||||
user_id=current_user.id,
|
||||
target_type="用户",
|
||||
target_id=user_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} {status_text}用户 {target_user.username}"
|
||||
)
|
||||
|
||||
return jsonify({'success': success, 'message': message})
|
||||
|
||||
|
||||
@ -307,7 +400,25 @@ def user_delete(user_id):
|
||||
if user_id == current_user.id:
|
||||
return jsonify({'success': False, 'message': '不能删除自己的账号'})
|
||||
|
||||
# 查询用户获取用户名(用于日志)
|
||||
target_user = User.query.get(user_id)
|
||||
if not target_user:
|
||||
return jsonify({'success': False, 'message': '用户不存在'})
|
||||
|
||||
target_username = target_user.username # 保存用户名以便记录在日志中
|
||||
|
||||
success, message = UserService.delete_user(user_id)
|
||||
|
||||
if success:
|
||||
# 记录删除用户日志
|
||||
Log.add_log(
|
||||
action="删除用户",
|
||||
user_id=current_user.id,
|
||||
target_type="用户",
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 删除用户 {target_username}"
|
||||
)
|
||||
|
||||
return jsonify({'success': success, 'message': message})
|
||||
|
||||
|
||||
@ -339,9 +450,23 @@ def user_profile():
|
||||
return render_template('user/profile.html', user=user)
|
||||
|
||||
data['password'] = new_password
|
||||
password_changed = True
|
||||
else:
|
||||
password_changed = False
|
||||
|
||||
success, message = UserService.update_user(user.id, data)
|
||||
if success:
|
||||
# 记录用户修改个人信息日志
|
||||
log_description = f"用户 {user.username} 修改了个人信息"
|
||||
if password_changed:
|
||||
log_description += ",包括密码修改"
|
||||
|
||||
Log.add_log(
|
||||
action="修改个人信息",
|
||||
user_id=user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=log_description
|
||||
)
|
||||
flash(message, 'success')
|
||||
else:
|
||||
flash(message, 'error')
|
||||
@ -355,6 +480,15 @@ def user_profile():
|
||||
@admin_required
|
||||
def role_list():
|
||||
roles = UserService.get_all_roles()
|
||||
|
||||
# 记录访问角色管理页面日志
|
||||
Log.add_log(
|
||||
action="访问角色管理",
|
||||
user_id=current_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 访问角色管理页面"
|
||||
)
|
||||
|
||||
return render_template('user/roles.html', roles=roles)
|
||||
|
||||
|
||||
@ -373,20 +507,35 @@ def role_save():
|
||||
|
||||
if role_id: # 更新
|
||||
success, message = UserService.update_role(role_id, role_name, description)
|
||||
if success:
|
||||
# 记录编辑角色日志
|
||||
Log.add_log(
|
||||
action="编辑角色",
|
||||
user_id=current_user.id,
|
||||
target_type="角色",
|
||||
target_id=role_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 编辑角色 {role_name}"
|
||||
)
|
||||
else: # 创建
|
||||
success, message = UserService.create_role(role_name, description)
|
||||
if success:
|
||||
# 获取新创建的角色ID
|
||||
new_role = db.session.query(User.Role).filter_by(role_name=role_name).first()
|
||||
role_id = new_role.id if new_role else None
|
||||
|
||||
# 记录创建角色日志
|
||||
Log.add_log(
|
||||
action="创建角色",
|
||||
user_id=current_user.id,
|
||||
target_type="角色",
|
||||
target_id=role_id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 创建新角色 {role_name}"
|
||||
)
|
||||
|
||||
return jsonify({'success': success, 'message': message})
|
||||
|
||||
"""
|
||||
@user_bp.route('/api/role/<int:role_id>/user-count')
|
||||
@login_required
|
||||
@admin_required
|
||||
def get_role_user_count(role_id):
|
||||
count = User.query.filter_by(role_id=role_id).count()
|
||||
return jsonify({'count': count})
|
||||
"""
|
||||
|
||||
|
||||
@user_bp.route('/user/role/<int:role_id>/count', methods=['GET'])
|
||||
@login_required
|
||||
@ -460,6 +609,16 @@ def add_user():
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
# 记录管理员添加用户日志
|
||||
Log.add_log(
|
||||
action="添加用户",
|
||||
user_id=current_user.id,
|
||||
target_type="用户",
|
||||
target_id=new_user.id,
|
||||
ip_address=request.remote_addr,
|
||||
description=f"管理员 {current_user.username} 添加新用户 {username}"
|
||||
)
|
||||
|
||||
# 清除验证码
|
||||
verification_codes.delete(email)
|
||||
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
from datetime import datetime
|
||||
from app.models.user import db, User # 从user模块导入db,而不是从utils导入
|
||||
|
||||
|
||||
class Log(db.Model):
|
||||
__tablename__ = 'logs'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
action = db.Column(db.String(64), nullable=False)
|
||||
target_type = db.Column(db.String(32), nullable=True)
|
||||
target_id = db.Column(db.Integer, nullable=True)
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
# 关联用户
|
||||
user = db.relationship('User', backref=db.backref('logs', lazy=True))
|
||||
|
||||
def __init__(self, action, user_id=None, target_type=None, target_id=None,
|
||||
ip_address=None, description=None):
|
||||
self.user_id = user_id
|
||||
self.action = action
|
||||
self.target_type = target_type
|
||||
self.target_id = target_id
|
||||
self.ip_address = ip_address
|
||||
self.description = description
|
||||
self.created_at = datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def add_log(action, user_id=None, target_type=None, target_id=None,
|
||||
ip_address=None, description=None):
|
||||
"""添加一条日志记录"""
|
||||
try:
|
||||
log = Log(
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
ip_address=ip_address,
|
||||
description=description
|
||||
)
|
||||
db.session.add(log)
|
||||
db.session.commit()
|
||||
return True, "日志记录成功"
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, f"日志记录失败: {str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def get_logs(page=1, per_page=20, user_id=None, action=None,
|
||||
target_type=None, start_date=None, end_date=None):
|
||||
"""查询日志记录"""
|
||||
query = Log.query.order_by(Log.created_at.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.filter(Log.user_id == user_id)
|
||||
if action:
|
||||
query = query.filter(Log.action == action)
|
||||
if target_type:
|
||||
query = query.filter(Log.target_type == target_type)
|
||||
if start_date:
|
||||
query = query.filter(Log.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Log.created_at <= end_date)
|
||||
|
||||
return query.paginate(page=page, per_page=per_page)
|
||||
575
app/static/css/book-import.css
vendored
Normal file
575
app/static/css/book-import.css
vendored
Normal file
@ -0,0 +1,575 @@
|
||||
/* 图书批量导入页面样式 - 女性风格优化版 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500&family=Playfair+Display:wght@400;700&display=swap');
|
||||
|
||||
:root {
|
||||
--primary-color: #e083b8;
|
||||
--primary-light: #f8d7e9;
|
||||
--secondary-color: #89c2d9;
|
||||
--accent-color: #a76eb8;
|
||||
--text-color: #555;
|
||||
--light-text: #888;
|
||||
--dark-text: #333;
|
||||
--border-radius: 12px;
|
||||
--box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #fff6f9;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.import-container {
|
||||
padding: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 页眉样式 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #f0d3e6;
|
||||
}
|
||||
|
||||
.fancy-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 2.5rem;
|
||||
color: var(--accent-color);
|
||||
text-shadow: 1px 1px 2px rgba(167, 110, 184, 0.2);
|
||||
letter-spacing: 1px;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fancy-title::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
left: 0;
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
color: var(--light-text);
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.btn-return {
|
||||
padding: 8px 20px;
|
||||
background-color: transparent;
|
||||
color: var(--accent-color);
|
||||
border: 2px solid var(--primary-light);
|
||||
border-radius: 25px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 3px 8px rgba(167, 110, 184, 0.1);
|
||||
}
|
||||
|
||||
.btn-return:hover {
|
||||
background-color: var(--primary-light);
|
||||
color: var(--accent-color);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 12px rgba(167, 110, 184, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
background-color: #ffffff;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #f9f1f7, #fcf6fa);
|
||||
padding: 20px 25px;
|
||||
border-bottom: 1px solid #f0e1ea;
|
||||
}
|
||||
|
||||
.card-header h4 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
color: var(--accent-color);
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.sparkle {
|
||||
color: var(--primary-color);
|
||||
margin-right: 8px;
|
||||
animation: sparkle 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.elegant-label {
|
||||
font-weight: 500;
|
||||
color: var(--dark-text);
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-file {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.custom-file-input {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.custom-file-label {
|
||||
padding: 15px 20px;
|
||||
background-color: #f9f2f7;
|
||||
color: var(--light-text);
|
||||
border: 2px dashed #e9d6e5;
|
||||
border-radius: var(--border-radius);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-file-label:hover {
|
||||
background-color: #f4e8f0;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.has-file .custom-file-label {
|
||||
background-color: #e6f3ff;
|
||||
border-color: var(--secondary-color);
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.import-btn {
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--accent-color));
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
color: white;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
border-radius: 30px;
|
||||
margin-top: 15px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 8px 15px rgba(167, 110, 184, 0.3);
|
||||
}
|
||||
|
||||
.import-btn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 20px rgba(167, 110, 184, 0.4);
|
||||
background: linear-gradient(45deg, var(--accent-color), var(--primary-color));
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 30px 0;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.divider:before,
|
||||
.divider:after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid #f0d3e6;
|
||||
}
|
||||
|
||||
.divider-content {
|
||||
padding: 0 10px;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* 导入说明样式 */
|
||||
.import-instructions {
|
||||
margin-top: 10px;
|
||||
padding: 25px;
|
||||
background: linear-gradient(to bottom right, #fff, #fafafa);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.instruction-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.4rem;
|
||||
border-bottom: 2px solid var(--primary-light);
|
||||
padding-bottom: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.instruction-content {
|
||||
color: var(--text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.elegant-list {
|
||||
list-style-type: none;
|
||||
padding-left: 5px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.elegant-list li {
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
padding-left: 25px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.elegant-list li:before {
|
||||
content: "\f054";
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
color: var(--primary-color);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #f6f6f6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #9c5bb5;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
background-color: #fce1e9;
|
||||
color: #e25a86;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
margin-left: 5px;
|
||||
vertical-align: middle;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 模板下载样式 */
|
||||
.template-download {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f0f9ff, #f5f0ff);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid #e0f0ff;
|
||||
}
|
||||
|
||||
.template-download p {
|
||||
color: var(--dark-text);
|
||||
margin-bottom: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background-color: white;
|
||||
color: var(--accent-color);
|
||||
border: 2px solid var(--primary-light);
|
||||
padding: 10px 25px;
|
||||
border-radius: 25px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border-color: var(--accent-color);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(167, 110, 184, 0.2);
|
||||
}
|
||||
|
||||
/* 悬浮元素 - 冰雪奇缘和天空之城风格 */
|
||||
.floating-elements {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
opacity: 0.7;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, rgba(230,240,255,0.3) 70%, transparent 100%);
|
||||
animation: float 20s linear infinite;
|
||||
}
|
||||
|
||||
.snowflake-1 {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 10%;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.snowflake-2 {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
top: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
.snowflake-3 {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
bottom: 30%;
|
||||
left: 30%;
|
||||
}
|
||||
|
||||
.snowflake-4 {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
bottom: 15%;
|
||||
right: 15%;
|
||||
}
|
||||
|
||||
.flower {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath fill='%23e083b8' d='M50 15c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10zm-25 25c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10zm50 0c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10zm-25 25c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10z'/%3E%3Ccircle fill='%23f8d7e9' cx='50' cy='50' r='10'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0.5;
|
||||
animation: rotate 25s linear infinite, float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.flower-1 {
|
||||
top: 70%;
|
||||
left: 5%;
|
||||
}
|
||||
|
||||
.flower-2 {
|
||||
top: 15%;
|
||||
right: 5%;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translateY(0) translateX(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(30px) translateX(15px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(50px) translateX(-15px);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(20px) translateX(25px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 992px) {
|
||||
.import-container {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.fancy-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.import-instructions {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.fancy-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
margin-left: 0;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 添加到book-import.css文件末尾 */
|
||||
|
||||
/* 导入消息样式 */
|
||||
.import-message {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.import-message .alert {
|
||||
border-radius: var(--border-radius);
|
||||
padding: 15px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.import-message .alert-success {
|
||||
background-color: #e6f7ee;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.import-message .alert-warning {
|
||||
background-color: #fff8e6;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.import-message .alert-danger {
|
||||
background-color: #feecf0;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.import-message .alert-info {
|
||||
background-color: #e6f3f8;
|
||||
color: #17a2b8;
|
||||
}
|
||||
|
||||
.import-message .alert i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 导入过程中的飘落元素 */
|
||||
.falling-element {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.falling-flower {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath fill='%23e083b8' d='M50 15c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10zm-25 25c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10zm50 0c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10zm-25 25c-5 0-10 5-10 10s5 10 10 10 10-5 10-10-5-10-10-10z'/%3E%3Ccircle fill='%23f8d7e9' cx='50' cy='50' r='10'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: fallAndSpin 5s linear forwards;
|
||||
}
|
||||
|
||||
.falling-snowflake {
|
||||
background: radial-gradient(circle at center, rgba(255,255,255,0.8) 0%, rgba(230,240,255,0.3) 70%, transparent 100%);
|
||||
border-radius: 50%;
|
||||
animation: fall 5s linear forwards;
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-50px) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(100vh - 100px)) rotate(359deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fallAndSpin {
|
||||
0% {
|
||||
transform: translateY(-50px) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(100vh - 100px)) rotate(720deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 导入过程中按钮样式 */
|
||||
.import-btn:disabled {
|
||||
background: linear-gradient(45deg, #f089b7, #b989d9);
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.import-btn:disabled .fa-spinner {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/* 文件上传成功状态样式 */
|
||||
.has-file .custom-file-label {
|
||||
background-color: #e6f7ee;
|
||||
border-color: #28a745;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* 添加文件类型图标 */
|
||||
.has-file .custom-file-label::before {
|
||||
content: "\f56f"; /* Excel文件图标 */
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
margin-right: 8px;
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
/* 图书批量导入页面样式 */
|
||||
.import-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(0,0,0,0.125);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background-color: rgba(0,0,0,0.03);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.125);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.import-instructions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.import-instructions h5 {
|
||||
margin-bottom: 15px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.import-instructions ul {
|
||||
margin-bottom: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.import-instructions li {
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.required-field {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.template-download {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
296
app/static/css/book_ranking.css
Normal file
296
app/static/css/book_ranking.css
Normal file
@ -0,0 +1,296 @@
|
||||
/* app/static/css/book_ranking.css */
|
||||
.table-container {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.table-container h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: var(--accent-color);
|
||||
font-family: 'Ma Shan Zheng', cursive, Arial, sans-serif;
|
||||
font-size: 1.6em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.table-container h3:before,
|
||||
.table-container h3:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--primary-color), transparent);
|
||||
width: 120px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.table-container h3:before {
|
||||
right: 100%;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.table-container h3:after {
|
||||
left: 100%;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.data-table img {
|
||||
width: 55px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.data-table tr:hover img {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.data-table .rank {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 前三名特殊样式 */
|
||||
.data-table tr:nth-child(1) .rank:before {
|
||||
content: '👑';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(2) .rank:before {
|
||||
content: '✨';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(3) .rank:before {
|
||||
content: '🌟';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.data-table .book-title {
|
||||
font-weight: 500;
|
||||
color: var(--accent-color);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.data-table tr:hover .book-title {
|
||||
color: #d06b9c;
|
||||
}
|
||||
|
||||
.data-table .author {
|
||||
font-style: italic;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.data-table .borrow-count {
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-table .borrow-count:after {
|
||||
content: '❤️';
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transform: translateY(5px);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-table tr:hover .borrow-count:after {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--light-text);
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 12px;
|
||||
font-style: italic;
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
/* 书籍行动画 */
|
||||
#ranking-table-body tr {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#ranking-table-body tr:hover {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* 加载动画美化 */
|
||||
.loading-row td {
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--accent-color);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 书名悬停效果 */
|
||||
.book-title {
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.book-title:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
background-color: var(--accent-color);
|
||||
transform: scaleX(0);
|
||||
transform-origin: bottom right;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
tr:hover .book-title:after {
|
||||
transform: scaleX(1);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
/* 特殊效果:波浪下划线 */
|
||||
@keyframes wave {
|
||||
0%, 100% { background-position-x: 0%; }
|
||||
50% { background-position-x: 100%; }
|
||||
}
|
||||
|
||||
.page-title:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 5px;
|
||||
margin: 10px auto 0;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--accent-color), var(--primary-color));
|
||||
background-size: 200% 100%;
|
||||
border-radius: 5px;
|
||||
animation: wave 3s infinite linear;
|
||||
}
|
||||
|
||||
.book-list-title {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
color: var(--accent-color);
|
||||
font-family: 'Ma Shan Zheng', cursive, Arial, sans-serif;
|
||||
font-size: 1.6em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
font-size: 0.9em;
|
||||
margin: 0 8px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.column-icon {
|
||||
font-size: 0.9em;
|
||||
margin-right: 5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.book-list-title:before,
|
||||
.book-list-title:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--primary-color), transparent);
|
||||
width: 80px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.book-list-title:before {
|
||||
right: 100%;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.book-list-title:after {
|
||||
left: 100%;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
/* 表格中的图标样式 */
|
||||
.data-table .borrow-count:after {
|
||||
content: '📚';
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transform: translateY(5px);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 前三名特殊样式 */
|
||||
.data-table tr:nth-child(1) .rank:before {
|
||||
content: '🏆';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(2) .rank:before {
|
||||
content: '🥈';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(3) .rank:before {
|
||||
content: '🥉';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 加载动画美化 */
|
||||
.loading-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-animation:before {
|
||||
content: '📖';
|
||||
margin-right: 10px;
|
||||
animation: bookFlip 2s infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes bookFlip {
|
||||
0% { transform: rotateY(0deg); }
|
||||
50% { transform: rotateY(180deg); }
|
||||
100% { transform: rotateY(360deg); }
|
||||
}
|
||||
245
app/static/css/borrow_statistics.css
Normal file
245
app/static/css/borrow_statistics.css
Normal file
@ -0,0 +1,245 @@
|
||||
/* app/static/css/borrow_statistics.css */
|
||||
/* 确保与 statistics.css 兼容的样式 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px 15px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px var(--shadow-color);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.stats-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -15px;
|
||||
right: -15px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stats-item:hover::after {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: var(--accent-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stats-value::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.stats-title {
|
||||
font-size: 14px;
|
||||
color: var(--light-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--light-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 4px solid rgba(244, 188, 204, 0.3);
|
||||
border-top: 4px solid var(--accent-color);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 修复图表容器 */
|
||||
.chart-container {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-chart .chart-wrapper {
|
||||
height: 330px;
|
||||
}
|
||||
|
||||
/* 确保图表正确渲染 */
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* 添加一些女性化的装饰元素 */
|
||||
.chart-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: -15px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||
opacity: 0.4;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.chart-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -15px;
|
||||
right: -15px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||
opacity: 0.4;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 新增部分 */
|
||||
.intro-text {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
color: var(--light-text);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.insights-container {
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
margin-top: 30px;
|
||||
box-shadow: 0 5px 20px var(--shadow-color);
|
||||
border: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insights-container h3 {
|
||||
color: var(--accent-color);
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.insights-container h3::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, var(--secondary-color), var(--accent-color), var(--secondary-color));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.insights-content {
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.insights-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: -30px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
/* 优雅的动画效果 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.8s ease forwards;
|
||||
}
|
||||
|
||||
/* 确保响应式布局 */
|
||||
@media (max-width: 768px) {
|
||||
.chart-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.half {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 250px;
|
||||
}
|
||||
}
|
||||
52
app/static/css/log-detail.css
Normal file
52
app/static/css/log-detail.css
Normal file
@ -0,0 +1,52 @@
|
||||
/* 日志详情样式 */
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
width: 100px;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.description {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-top: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.description .label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.description .value {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
427
app/static/css/log-list.css
Normal file
427
app/static/css/log-list.css
Normal file
@ -0,0 +1,427 @@
|
||||
/* 全局风格与颜色 */
|
||||
:root {
|
||||
--primary-color: #9c88ff;
|
||||
--secondary-color: #f8a5c2;
|
||||
--accent-color: #78e08f;
|
||||
--light-pink: #ffeef8;
|
||||
--soft-blue: #e5f1ff;
|
||||
--soft-purple: #f3e5ff;
|
||||
--soft-pink: #ffeef5;
|
||||
--soft-red: #ffd8d8;
|
||||
--text-color: #4a4a4a;
|
||||
--light-text: #8a8a8a;
|
||||
--border-radius: 12px;
|
||||
--box-shadow: 0 6px 15px rgba(0,0,0,0.05);
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 整体容器 */
|
||||
.content-container {
|
||||
padding: 20px;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
color: var(--text-color);
|
||||
background-image: linear-gradient(to bottom, var(--soft-blue) 0%, rgba(255,255,255,0.8) 20%, rgba(255,255,255,0.9) 100%);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 25px;
|
||||
background: linear-gradient(120deg, var(--soft-purple), var(--soft-pink));
|
||||
padding: 15px 20px;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.content-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: #6a3093;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.content-header .actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 闪烁星星效果 */
|
||||
.sparkle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
animation: sparkle 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.btn-blossom {
|
||||
background: linear-gradient(45deg, #ffcee0, #b5c0ff);
|
||||
color: #634a7a;
|
||||
}
|
||||
|
||||
.btn-primary-soft {
|
||||
background: linear-gradient(135deg, #a1c4fd, #c2e9fb);
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.btn-secondary-soft {
|
||||
background: linear-gradient(135deg, #e2c9fa, #d3f9fb);
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.btn-danger-soft {
|
||||
background: linear-gradient(135deg, #ffb8c6, #ffdfd3);
|
||||
color: #a55;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.btn-glow {
|
||||
animation: glow 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from {
|
||||
box-shadow: 0 0 5px rgba(156, 136, 255, 0.3);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 15px rgba(156, 136, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* 筛选面板 */
|
||||
.filter-panel {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
|
||||
border: 1px solid rgba(248, 200, 220, 0.3);
|
||||
}
|
||||
|
||||
.filter-panel-header {
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
font-size: 18px;
|
||||
color: #9c88ff;
|
||||
font-weight: 500;
|
||||
font-family: 'Dancing Script', cursive;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.snowflake-divider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin: 8px 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-item label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #7e6d94;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.elegant-select,
|
||||
.elegant-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #e0d0f0;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
color: var(--text-color);
|
||||
transition: var(--transition);
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.elegant-select:focus,
|
||||
.elegant-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(156, 136, 255, 0.2);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* 日期范围样式 */
|
||||
.date-range-inputs {
|
||||
padding-top: 15px;
|
||||
margin-top: 5px;
|
||||
border-top: 1px dashed #e0d0f0;
|
||||
}
|
||||
|
||||
/* 卡片效果 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.elegant-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.elegant-table th {
|
||||
background: linear-gradient(to right, var(--soft-purple), var(--soft-pink));
|
||||
color: #6a4c93;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
padding: 12px 15px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.elegant-table th:first-child {
|
||||
border-top-left-radius: 8px;
|
||||
}
|
||||
|
||||
.elegant-table th:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
.elegant-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid rgba(224, 208, 240, 0.3);
|
||||
font-size: 14px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.elegant-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.elegant-table tr:hover td {
|
||||
background-color: rgba(248, 239, 255, 0.6);
|
||||
}
|
||||
|
||||
/* 用户徽章样式 */
|
||||
.user-badge {
|
||||
background: linear-gradient(45deg, #a1c4fd, #c2e9fb);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #4a4a4a;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 空数据提示 */
|
||||
.empty-container {
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.empty-container i {
|
||||
font-size: 40px;
|
||||
color: #d0c0e0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.empty-container p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 分页样式 */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: rgba(248, 239, 255, 0.5);
|
||||
padding: 15px 20px;
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 6px 15px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(45deg, #e2bbec, #b6cefd);
|
||||
color: #634a7a;
|
||||
border: none;
|
||||
transition: var(--transition);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,0.1);
|
||||
text-decoration: none;
|
||||
color: #4a3a5a;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: var(--light-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.modal-elegant {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 15px;
|
||||
border: none;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #f8c8dc, #c8e7f8);
|
||||
border-bottom: none;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
.modal-header .modal-title {
|
||||
color: #634a7a;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
color: #7e6d94;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.elegant-alert {
|
||||
background-color: rgba(255, 248, 225, 0.7);
|
||||
border: 1px solid #ffeeba;
|
||||
color: #856404;
|
||||
border-radius: 8px;
|
||||
padding: 12px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
background: rgba(248, 239, 255, 0.5);
|
||||
border-top: none;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
|
||||
/* 行动画效果 */
|
||||
.fade-in-row {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.filter-item {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content-header .actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
101
app/static/css/overdue_analysis.css
Normal file
101
app/static/css/overdue_analysis.css
Normal file
@ -0,0 +1,101 @@
|
||||
/* app/static/css/overdue_analysis.css */
|
||||
/* 保留您现有的 CSS 样式 */
|
||||
.stats-cards .stats-card {
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
#current-overdue {
|
||||
border-left-color: #dc3545;
|
||||
}
|
||||
|
||||
#current-overdue .card-value {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
#returned-overdue {
|
||||
border-left-color: #ffc107;
|
||||
}
|
||||
|
||||
#returned-overdue .card-value {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
#overdue-rate {
|
||||
border-left-color: #28a745;
|
||||
}
|
||||
|
||||
#overdue-rate .card-value {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 15px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 4px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
/* 添加下面的 CSS 修复图表容器问题 */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 400px; /* 固定高度 */
|
||||
overflow: hidden; /* 防止内容溢出 */
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-container.half {
|
||||
min-height: 350px;
|
||||
max-height: 380px; /* 最大高度限制 */
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 100%;
|
||||
width: 100% !important;
|
||||
height: 320px !important; /* 确保固定高度 */
|
||||
}
|
||||
|
||||
/* 修复图表行的问题 */
|
||||
.chart-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
align-items: stretch; /* 确保两个容器高度一致 */
|
||||
}
|
||||
|
||||
.chart-row .half {
|
||||
flex: 1 1 calc(50% - 10px);
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 添加一个明确的底部间距,防止页面无限延伸 */
|
||||
.statistics-container {
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.chart-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-container.half {
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
856
app/static/css/statistics.css
Normal file
856
app/static/css/statistics.css
Normal file
@ -0,0 +1,856 @@
|
||||
/* app/static/css/statistics.css */
|
||||
:root {
|
||||
--primary-color: #f8c4d4;
|
||||
--secondary-color: #fde9f1;
|
||||
--accent-color: #e684ae;
|
||||
--text-color: #7a4b56;
|
||||
--light-text: #a67b84;
|
||||
--border-color: #f3d1dc;
|
||||
--shadow-color: rgba(244, 188, 204, 0.25);
|
||||
--hover-color: #f4bccc;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #fff9fb;
|
||||
color: var(--text-color);
|
||||
font-family: 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
.statistics-container {
|
||||
padding: 25px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 5px 20px var(--shadow-color);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px dotted var(--border-color);
|
||||
text-align: center;
|
||||
font-family: 'Ma Shan Zheng', cursive, Arial, sans-serif;
|
||||
font-size: 2.2em;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* 波浪下划线动画 */
|
||||
.page-title:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 5px;
|
||||
margin: 10px auto 0;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--accent-color), var(--primary-color));
|
||||
background-size: 200% 100%;
|
||||
border-radius: 5px;
|
||||
animation: wave 3s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% { background-position-x: 0%; }
|
||||
50% { background-position-x: 100%; }
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
color: #d06b9c;
|
||||
}
|
||||
|
||||
.breadcrumb .current-page {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 原始卡片菜单 */
|
||||
.stats-menu {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* 原始卡片样式 */
|
||||
.stats-card {
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.4s;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 8px 20px var(--shadow-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
font-size: 14px;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--secondary-color);
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: 500;
|
||||
margin-right: 10px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23e684ae' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(230, 132, 174, 0.25);
|
||||
}
|
||||
|
||||
.ml-20 {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 15px var(--shadow-color);
|
||||
margin-bottom: 35px;
|
||||
position: relative;
|
||||
height: 400px; /* 添加固定高度 */
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-container canvas {
|
||||
max-height: 100%;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 图表装饰元素 */
|
||||
.chart-decoration {
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||
opacity: 0.6;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.chart-decoration.left {
|
||||
top: -15px;
|
||||
left: -15px;
|
||||
}
|
||||
|
||||
.chart-decoration.right {
|
||||
bottom: -15px;
|
||||
right: -15px;
|
||||
}
|
||||
|
||||
.floating {
|
||||
animation: floating 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes floating {
|
||||
0% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(10px, 10px) scale(1.1); }
|
||||
100% { transform: translate(0, 0) scale(1); }
|
||||
}
|
||||
|
||||
/* 适配小图表 */
|
||||
.chart-container.half {
|
||||
height: auto;
|
||||
min-height: 400px;
|
||||
padding-bottom: 40px; /* 增加底部空间 */
|
||||
}
|
||||
|
||||
/* 特别针对分类图表的调整 */
|
||||
.chart-container.half .chart-wrapper {
|
||||
height: 340px; /* 增加图表容器高度 */
|
||||
padding-bottom: 20px; /* 增加底部填充 */
|
||||
}
|
||||
|
||||
/* 确保图表完整显示 */
|
||||
canvas#category-chart {
|
||||
max-height: 100%;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
/* 移除图表装饰元素在分类饼图中的影响 */
|
||||
.chart-container.half::before,
|
||||
.chart-container.half::after {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
/* 调整图例位置,确保其正确显示 */
|
||||
.chart-container.half .chart-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
.chart-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.half {
|
||||
flex: 1 1 calc(50% - 10px);
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px var(--shadow-color);
|
||||
}
|
||||
|
||||
.data-table th, .data-table td {
|
||||
padding: 14px 18px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(odd) {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background-color: #fceef3;
|
||||
}
|
||||
|
||||
.loading-row td {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.loading-animation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-animation:before {
|
||||
content: '📖';
|
||||
margin-right: 10px;
|
||||
animation: bookFlip 2s infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@keyframes bookFlip {
|
||||
0% { transform: rotateY(0deg); }
|
||||
50% { transform: rotateY(180deg); }
|
||||
100% { transform: rotateY(360deg); }
|
||||
}
|
||||
|
||||
.dot-animation {
|
||||
display: inline-block;
|
||||
animation: dotAnimation 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes dotAnimation {
|
||||
0% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stats-card .card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 5px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* 引用容器 */
|
||||
.quote-container {
|
||||
text-align: center;
|
||||
margin: 40px auto 20px;
|
||||
max-width: 600px;
|
||||
font-style: italic;
|
||||
color: var(--text-color);
|
||||
padding: 20px;
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quote-container:before,
|
||||
.quote-container:after {
|
||||
content: """;
|
||||
font-size: 60px;
|
||||
font-family: Georgia, serif;
|
||||
position: absolute;
|
||||
color: var(--primary-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.quote-container:before {
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.quote-container:after {
|
||||
content: """;
|
||||
bottom: -30px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.quote-container p {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.quote-author {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
/* 书籍列表标题 */
|
||||
.book-list-title {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
color: var(--accent-color);
|
||||
font-family: 'Ma Shan Zheng', cursive, Arial, sans-serif;
|
||||
font-size: 1.6em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.book-icon {
|
||||
font-size: 0.9em;
|
||||
margin: 0 8px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.column-icon {
|
||||
font-size: 0.9em;
|
||||
margin-right: 5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.book-list-title:before,
|
||||
.book-list-title:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--primary-color), transparent);
|
||||
width: 80px;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.book-list-title:before {
|
||||
right: 100%;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.book-list-title:after {
|
||||
left: 100%;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
/* 表格中的图标样式 */
|
||||
.data-table .borrow-count:after {
|
||||
content: '📚';
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transform: translateY(5px);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.data-table tr:hover .borrow-count:after {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 前三名特殊样式 */
|
||||
.data-table tr:nth-child(1) .rank:before {
|
||||
content: '🏆';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(2) .rank:before {
|
||||
content: '🥈';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(3) .rank:before {
|
||||
content: '🥉';
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 书名悬停效果 */
|
||||
.book-title {
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.book-title:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
background-color: var(--accent-color);
|
||||
transform: scaleX(0);
|
||||
transform-origin: bottom right;
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
tr:hover .book-title:after {
|
||||
transform: scaleX(1);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
/* 数据表格相关样式 */
|
||||
.data-table img {
|
||||
width: 55px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.data-table tr:hover img {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.data-table .rank {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.data-table .book-title {
|
||||
font-weight: 500;
|
||||
color: var(--accent-color);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.data-table tr:hover .book-title {
|
||||
color: #d06b9c;
|
||||
}
|
||||
|
||||
.data-table .author {
|
||||
font-style: italic;
|
||||
color: var(--light-text);
|
||||
}
|
||||
|
||||
.data-table .borrow-count {
|
||||
font-weight: 600;
|
||||
color: var(--accent-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--light-text);
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 12px;
|
||||
font-style: italic;
|
||||
border: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
/* 书籍行动画 */
|
||||
#ranking-table-body tr {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#ranking-table-body tr:hover {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* 四宫格统计页面样式 */
|
||||
.quote-banner {
|
||||
background-color: var(--secondary-color);
|
||||
border-radius: 10px;
|
||||
padding: 15px 25px;
|
||||
margin: 0 auto 30px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
box-shadow: 0 3px 15px var(--shadow-color);
|
||||
border-left: 4px solid var(--accent-color);
|
||||
border-right: 4px solid var(--accent-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quote-banner p {
|
||||
font-style: italic;
|
||||
color: var(--text-color);
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.quote-banner:before,
|
||||
.quote-banner:after {
|
||||
content: '"';
|
||||
font-family: Georgia, serif;
|
||||
font-size: 50px;
|
||||
color: var(--primary-color);
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
}
|
||||
|
||||
.quote-banner:before {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.quote-banner:after {
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 25px;
|
||||
margin: 20px auto;
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
/* 四宫格卡片样式 */
|
||||
.stats-grid .stats-card {
|
||||
position: relative;
|
||||
background-color: white;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 5px 20px var(--shadow-color);
|
||||
transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
height: 250px;
|
||||
padding: 0; /* 重置内边距 */
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stats-grid .stats-card:hover {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.stats-grid .stats-card:hover .card-inner {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.stats-grid .card-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--accent-color);
|
||||
background-color: var(--secondary-color);
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 10px var(--shadow-color);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stats-grid .stats-card:hover .card-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
.stats-grid .card-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: var(--accent-color);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.stats-grid .card-title:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: var(--primary-color);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stats-grid .stats-card:hover .card-title:after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* 卡片装饰 */
|
||||
.card-decoration {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
right: -30px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.1;
|
||||
transition: all 0.5s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-decoration.active {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* 特定卡片的独特装饰 */
|
||||
.book-decoration:before {
|
||||
content: '📚';
|
||||
position: absolute;
|
||||
font-size: 30px;
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.trend-decoration:before {
|
||||
content: '📈';
|
||||
position: absolute;
|
||||
font-size: 30px;
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.user-decoration:before {
|
||||
content: '👥';
|
||||
position: absolute;
|
||||
font-size: 30px;
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.overdue-decoration:before {
|
||||
content: '⏰';
|
||||
position: absolute;
|
||||
font-size: 30px;
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 页面装饰 */
|
||||
.page-decoration {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||
opacity: 0.3;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.page-decoration.left {
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
animation: floatLeft 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.page-decoration.right {
|
||||
bottom: -100px;
|
||||
right: -100px;
|
||||
animation: floatRight 17s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes floatLeft {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
25% { transform: translate(20px, 20px) rotate(5deg); }
|
||||
50% { transform: translate(10px, 30px) rotate(10deg); }
|
||||
75% { transform: translate(30px, 10px) rotate(5deg); }
|
||||
}
|
||||
|
||||
@keyframes floatRight {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
25% { transform: translate(-20px, -10px) rotate(-5deg); }
|
||||
50% { transform: translate(-15px, -25px) rotate(-10deg); }
|
||||
75% { transform: translate(-25px, -15px) rotate(-5deg); }
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.chart-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.half {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ml-20 {
|
||||
margin-left: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid .stats-card {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.quote-banner {
|
||||
max-width: 95%;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.quote-banner:before,
|
||||
.quote-banner:after {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
10
app/static/css/user_activity.css
Normal file
10
app/static/css/user_activity.css
Normal file
@ -0,0 +1,10 @@
|
||||
/* app/static/css/user_activity.css */
|
||||
.data-table .rank {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.data-table .borrow-count {
|
||||
font-weight: 600;
|
||||
color: #007bff;
|
||||
}
|
||||
165
app/static/js/book-import.js
Normal file
165
app/static/js/book-import.js
Normal file
@ -0,0 +1,165 @@
|
||||
// 图书批量导入页面的JavaScript功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 显示选择的文件名
|
||||
const fileInput = document.getElementById('file');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', function() {
|
||||
const fileName = this.value.split('\\').pop();
|
||||
const label = document.querySelector('.custom-file-label');
|
||||
if (label) {
|
||||
label.textContent = fileName || '点击这里选择文件...';
|
||||
|
||||
// 添加有文件的类
|
||||
if (fileName) {
|
||||
this.parentElement.classList.add('has-file');
|
||||
|
||||
// 显示文件类型检查和预览信息
|
||||
checkFileAndPreview(this.files[0]);
|
||||
} else {
|
||||
this.parentElement.classList.remove('has-file');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 美化表单提交按钮的点击效果
|
||||
const importBtn = document.querySelector('.import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.addEventListener('click', function(e) {
|
||||
if (!fileInput || !fileInput.files || !fileInput.files.length) {
|
||||
e.preventDefault();
|
||||
showMessage('请先选择要导入的Excel文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在导入...';
|
||||
this.disabled = true;
|
||||
|
||||
// 添加花朵飘落动画效果
|
||||
addFallingElements(10);
|
||||
});
|
||||
}
|
||||
|
||||
// 为浮动元素添加动画
|
||||
initFloatingElements();
|
||||
});
|
||||
|
||||
// 检查文件类型并尝试预览
|
||||
function checkFileAndPreview(file) {
|
||||
if (!file) return;
|
||||
|
||||
// 检查文件类型
|
||||
const validTypes = ['.xlsx', '.xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'];
|
||||
let isValid = false;
|
||||
|
||||
validTypes.forEach(type => {
|
||||
if (file.name.toLowerCase().endsWith(type) || file.type === type) {
|
||||
isValid = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
showMessage('请选择有效的Excel文件 (.xlsx 或 .xls)', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示文件准备就绪的消息
|
||||
showMessage(`文件 "${file.name}" 已准备就绪,点击"开始导入"按钮继续。`, 'success');
|
||||
}
|
||||
|
||||
// 显示提示消息
|
||||
function showMessage(message, type = 'info') {
|
||||
// 检查是否已有消息容器
|
||||
let messageContainer = document.querySelector('.import-message');
|
||||
|
||||
if (!messageContainer) {
|
||||
// 创建新的消息容器
|
||||
messageContainer = document.createElement('div');
|
||||
messageContainer.className = 'import-message animate__animated animate__fadeIn';
|
||||
|
||||
// 插入到按钮之后
|
||||
const importBtn = document.querySelector('.import-btn');
|
||||
if (importBtn && importBtn.parentNode) {
|
||||
importBtn.parentNode.insertBefore(messageContainer, importBtn.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置消息内容和样式
|
||||
messageContainer.innerHTML = `
|
||||
<div class="alert alert-${type} mt-3">
|
||||
<i class="fas ${getIconForMessageType(type)}"></i> ${message}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 如果是临时消息,设置自动消失
|
||||
if (type !== 'danger') {
|
||||
setTimeout(() => {
|
||||
messageContainer.classList.add('animate__fadeOut');
|
||||
setTimeout(() => {
|
||||
if (messageContainer.parentNode) {
|
||||
messageContainer.parentNode.removeChild(messageContainer);
|
||||
}
|
||||
}, 600);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据消息类型获取图标
|
||||
function getIconForMessageType(type) {
|
||||
switch (type) {
|
||||
case 'success': return 'fa-check-circle';
|
||||
case 'warning': return 'fa-exclamation-triangle';
|
||||
case 'danger': return 'fa-times-circle';
|
||||
default: return 'fa-info-circle';
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化浮动元素
|
||||
function initFloatingElements() {
|
||||
const floatingElements = document.querySelectorAll('.snowflake, .flower');
|
||||
|
||||
floatingElements.forEach(element => {
|
||||
const randomDuration = 15 + Math.random() * 20;
|
||||
const randomDelay = Math.random() * 10;
|
||||
|
||||
element.style.animationDuration = `${randomDuration}s`;
|
||||
element.style.animationDelay = `${randomDelay}s`;
|
||||
});
|
||||
}
|
||||
|
||||
// 添加花朵飘落效果
|
||||
function addFallingElements(count) {
|
||||
const container = document.querySelector('.import-container');
|
||||
if (!container) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const element = document.createElement('div');
|
||||
element.className = 'falling-element animate__animated animate__fadeInDown';
|
||||
|
||||
// 随机选择花朵或雪花
|
||||
const isFlower = Math.random() > 0.5;
|
||||
element.classList.add(isFlower ? 'falling-flower' : 'falling-snowflake');
|
||||
|
||||
// 随机位置
|
||||
const left = Math.random() * 100;
|
||||
element.style.left = `${left}%`;
|
||||
|
||||
// 随机延迟
|
||||
const delay = Math.random() * 2;
|
||||
element.style.animationDelay = `${delay}s`;
|
||||
|
||||
// 随机大小
|
||||
const size = 10 + Math.random() * 20;
|
||||
element.style.width = `${size}px`;
|
||||
element.style.height = `${size}px`;
|
||||
|
||||
container.appendChild(element);
|
||||
|
||||
// 动画结束后移除元素
|
||||
setTimeout(() => {
|
||||
if (element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
133
app/static/js/book_ranking.js
Normal file
133
app/static/js/book_ranking.js
Normal file
@ -0,0 +1,133 @@
|
||||
// app/static/js/book_ranking.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const timeRangeSelect = document.getElementById('time-range');
|
||||
const limitSelect = document.getElementById('limit-count');
|
||||
let rankingChart = null;
|
||||
|
||||
// 初始加载
|
||||
loadRankingData();
|
||||
|
||||
// 添加事件监听器
|
||||
timeRangeSelect.addEventListener('change', loadRankingData);
|
||||
limitSelect.addEventListener('change', loadRankingData);
|
||||
|
||||
function loadRankingData() {
|
||||
const timeRange = timeRangeSelect.value;
|
||||
const limit = limitSelect.value;
|
||||
|
||||
// 显示加载状态
|
||||
document.getElementById('ranking-table-body').innerHTML = `
|
||||
<tr class="loading-row">
|
||||
<td colspan="5">加载中...</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 调用API获取数据
|
||||
fetch(`/statistics/api/book-ranking?time_range=${timeRange}&limit=${limit}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// 更新表格
|
||||
updateRankingTable(data);
|
||||
// 更新图表
|
||||
updateRankingChart(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载排行数据失败:', error);
|
||||
document.getElementById('ranking-table-body').innerHTML = `
|
||||
<tr class="error-row">
|
||||
<td colspan="5">加载数据失败,请稍后重试</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateRankingTable(data) {
|
||||
const tableBody = document.getElementById('ranking-table-body');
|
||||
|
||||
if (data.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr class="no-data-row">
|
||||
<td colspan="5">暂无数据</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHtml = '';
|
||||
|
||||
data.forEach((book, index) => {
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="rank">${index + 1}</td>
|
||||
<td>
|
||||
<img src="${book.cover_url || '/static/images/book-placeholder.jpg'}" alt="${book.title}">
|
||||
</td>
|
||||
<td>${book.title}</td>
|
||||
<td>${book.author}</td>
|
||||
<td class="borrow-count">${book.borrow_count}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableBody.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
function updateRankingChart(data) {
|
||||
// 销毁旧图表
|
||||
if (rankingChart) {
|
||||
rankingChart.destroy();
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备图表数据
|
||||
const labels = data.map(book => book.title);
|
||||
const borrowCounts = data.map(book => book.borrow_count);
|
||||
|
||||
// 创建图表
|
||||
const ctx = document.getElementById('ranking-chart').getContext('2d');
|
||||
rankingChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '借阅次数',
|
||||
data: borrowCounts,
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '借阅次数'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '图书'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '热门图书借阅排行'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
206
app/static/js/borrow_statistics.js
Normal file
206
app/static/js/borrow_statistics.js
Normal file
@ -0,0 +1,206 @@
|
||||
// app/static/js/borrow_statistics.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const timeRangeSelect = document.getElementById('time-range');
|
||||
let trendChart = null;
|
||||
let categoryChart = null;
|
||||
|
||||
// 初始加载
|
||||
loadBorrowTrend();
|
||||
loadCategoryDistribution();
|
||||
|
||||
// 添加事件监听器
|
||||
timeRangeSelect.addEventListener('change', loadBorrowTrend);
|
||||
|
||||
function loadBorrowTrend() {
|
||||
const timeRange = timeRangeSelect.value;
|
||||
|
||||
// 调用API获取数据
|
||||
fetch(`/statistics/api/borrow-trend?time_range=${timeRange}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateTrendChart(data);
|
||||
updateBorrowSummary(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载借阅趋势数据失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCategoryDistribution() {
|
||||
// 调用API获取数据
|
||||
fetch('/statistics/api/category-distribution')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateCategoryChart(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载分类分布数据失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTrendChart(data) {
|
||||
// 销毁旧图表
|
||||
if (trendChart) {
|
||||
trendChart.destroy();
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备图表数据
|
||||
const labels = data.map(item => item.date);
|
||||
const borrowData = data.map(item => item.borrow);
|
||||
const returnData = data.map(item => item.return);
|
||||
const overdueData = data.map(item => item.overdue);
|
||||
|
||||
// 创建图表
|
||||
const ctx = document.getElementById('trend-chart').getContext('2d');
|
||||
trendChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '借阅数',
|
||||
data: borrowData,
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '归还数',
|
||||
data: returnData,
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: '逾期数',
|
||||
data: overdueData,
|
||||
borderColor: 'rgba(255, 99, 132, 1)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '数量'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '日期'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '借阅与归还趋势'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCategoryChart(data) {
|
||||
// 销毁旧图表
|
||||
if (categoryChart) {
|
||||
categoryChart.destroy();
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备图表数据
|
||||
const labels = data.map(item => item.category);
|
||||
const counts = data.map(item => item.count);
|
||||
|
||||
// 创建图表
|
||||
const ctx = document.getElementById('category-chart').getContext('2d');
|
||||
categoryChart = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: counts,
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.7)',
|
||||
'rgba(54, 162, 235, 0.7)',
|
||||
'rgba(255, 206, 86, 0.7)',
|
||||
'rgba(75, 192, 192, 0.7)',
|
||||
'rgba(153, 102, 255, 0.7)',
|
||||
'rgba(255, 159, 64, 0.7)',
|
||||
'rgba(199, 199, 199, 0.7)',
|
||||
'rgba(83, 102, 255, 0.7)',
|
||||
'rgba(40, 159, 64, 0.7)',
|
||||
'rgba(210, 199, 199, 0.7)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '分类借阅分布'
|
||||
},
|
||||
legend: {
|
||||
position: 'right'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateBorrowSummary(data) {
|
||||
if (!data || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总数和平均数
|
||||
let totalBorrows = 0;
|
||||
let totalReturns = 0;
|
||||
let totalOverdue = data[data.length - 1].overdue || 0;
|
||||
|
||||
data.forEach(item => {
|
||||
totalBorrows += item.borrow;
|
||||
totalReturns += item.return;
|
||||
});
|
||||
|
||||
const summary = document.getElementById('borrow-summary');
|
||||
summary.innerHTML = `
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">${totalBorrows}</div>
|
||||
<div class="stats-title">总借阅</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">${totalReturns}</div>
|
||||
<div class="stats-title">总归还</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">${totalOverdue}</div>
|
||||
<div class="stats-title">当前逾期</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">${Math.round((totalBorrows - totalReturns) - totalOverdue)}</div>
|
||||
<div class="stats-title">正常借出</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
240
app/static/js/log-list.js
Normal file
240
app/static/js/log-list.js
Normal file
@ -0,0 +1,240 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 日期范围选择器逻辑
|
||||
const dateRangeSelect = document.getElementById('date_range');
|
||||
const dateRangeInputs = document.querySelector('.date-range-inputs');
|
||||
|
||||
if (dateRangeSelect && dateRangeInputs) {
|
||||
dateRangeSelect.addEventListener('change', function() {
|
||||
if (this.value === 'custom') {
|
||||
dateRangeInputs.style.display = 'flex';
|
||||
} else {
|
||||
dateRangeInputs.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 导出日志功能
|
||||
const btnExport = document.getElementById('btnExport');
|
||||
const exportModal = new bootstrap.Modal(document.getElementById('exportLogModal'));
|
||||
const confirmExport = document.getElementById('confirmExport');
|
||||
|
||||
if (btnExport) {
|
||||
btnExport.addEventListener('click', function() {
|
||||
exportModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmExport) {
|
||||
confirmExport.addEventListener('click', function() {
|
||||
// 获取导出格式
|
||||
const exportFormat = document.getElementById('exportFormat').value;
|
||||
|
||||
// 获取当前筛选条件
|
||||
const userId = document.getElementById('user_id').value;
|
||||
const action = document.getElementById('action').value;
|
||||
const targetType = document.getElementById('target_type').value;
|
||||
let startDate = '';
|
||||
let endDate = '';
|
||||
|
||||
const dateRange = document.getElementById('date_range').value;
|
||||
if (dateRange === 'custom') {
|
||||
startDate = document.getElementById('start_date').value;
|
||||
endDate = document.getElementById('end_date').value;
|
||||
} else {
|
||||
// 根据选择的日期范围计算日期
|
||||
const today = new Date();
|
||||
endDate = formatDate(today);
|
||||
|
||||
if (dateRange === '1') {
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
startDate = formatDate(yesterday);
|
||||
} else if (dateRange === '7') {
|
||||
const lastWeek = new Date(today);
|
||||
lastWeek.setDate(lastWeek.getDate() - 7);
|
||||
startDate = formatDate(lastWeek);
|
||||
} else if (dateRange === '30') {
|
||||
const lastMonth = new Date(today);
|
||||
lastMonth.setDate(lastMonth.getDate() - 30);
|
||||
startDate = formatDate(lastMonth);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示加载提示
|
||||
showAlert('info', '正在生成导出文件,请稍候...');
|
||||
|
||||
// 发送导出请求
|
||||
fetch('/log/api/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: userId || null,
|
||||
action: action || null,
|
||||
target_type: targetType || null,
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
format: exportFormat
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
exportModal.hide();
|
||||
|
||||
if (data.success) {
|
||||
showAlert('success', data.message);
|
||||
|
||||
// 处理文件下载
|
||||
if (data.filedata && data.filename) {
|
||||
// 解码Base64数据
|
||||
const binaryData = atob(data.filedata);
|
||||
|
||||
// 转换为Blob
|
||||
const blob = new Blob([new Uint8Array([...binaryData].map(char => char.charCodeAt(0)))],
|
||||
{ type: data.filetype });
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
a.download = data.filename;
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// 清理
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} else {
|
||||
showAlert('danger', data.message || '导出失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
exportModal.hide();
|
||||
showAlert('danger', '导出失败: ' + error.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 清除日志功能
|
||||
const btnClear = document.getElementById('btnClear');
|
||||
const clearModal = new bootstrap.Modal(document.getElementById('clearLogModal'));
|
||||
const confirmClear = document.getElementById('confirmClear');
|
||||
|
||||
if (btnClear) {
|
||||
btnClear.addEventListener('click', function() {
|
||||
clearModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (confirmClear) {
|
||||
confirmClear.addEventListener('click', function() {
|
||||
const days = parseInt(document.getElementById('clearDays').value);
|
||||
|
||||
fetch('/log/api/clear', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ days: days })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
clearModal.hide();
|
||||
|
||||
if (data.success) {
|
||||
showAlert('success', data.message);
|
||||
// 2秒后刷新页面
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
showAlert('danger', data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
clearModal.hide();
|
||||
showAlert('danger', '操作失败: ' + error.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 辅助函数 - 格式化日期为 YYYY-MM-DD
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 辅助函数 - 显示提示框
|
||||
function showAlert(type, message) {
|
||||
// 移除之前的所有alert
|
||||
const existingAlerts = document.querySelectorAll('.alert-floating');
|
||||
existingAlerts.forEach(alert => alert.remove());
|
||||
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show alert-floating`;
|
||||
alertDiv.innerHTML = `
|
||||
<i class="fas fa-${type === 'success' ? 'check-circle' :
|
||||
type === 'danger' ? 'exclamation-circle' :
|
||||
type === 'info' ? 'info-circle' : 'bell'}"></i>
|
||||
${message}
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(alertDiv);
|
||||
|
||||
// 添加CSS,如果还没有添加
|
||||
if (!document.getElementById('alert-floating-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'alert-floating-style';
|
||||
style.textContent = `
|
||||
.alert-floating {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
min-width: 300px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
border-left: 4px solid;
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.alert-floating i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.alert-floating .close {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// 5秒后自动关闭
|
||||
setTimeout(() => {
|
||||
if (alertDiv.parentNode) {
|
||||
alertDiv.classList.add('fade');
|
||||
setTimeout(() => alertDiv.remove(), 300);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// 点击关闭按钮关闭
|
||||
const closeButton = alertDiv.querySelector('.close');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', function() {
|
||||
alertDiv.classList.add('fade');
|
||||
setTimeout(() => alertDiv.remove(), 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
126
app/static/js/overdue_analysis.js
Normal file
126
app/static/js/overdue_analysis.js
Normal file
@ -0,0 +1,126 @@
|
||||
// app/static/js/overdue_analysis.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let overdueRangeChart = null;
|
||||
let overdueStatusChart = null;
|
||||
|
||||
// 初始加载
|
||||
loadOverdueStatistics();
|
||||
|
||||
function loadOverdueStatistics() {
|
||||
// 调用API获取数据
|
||||
fetch('/statistics/api/overdue-statistics')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateOverdueCards(data);
|
||||
updateOverdueRangeChart(data.overdue_ranges);
|
||||
updateOverdueStatusChart(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载逾期统计数据失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function updateOverdueCards(data) {
|
||||
document.getElementById('total-borrows').querySelector('.card-value').textContent = data.total_borrows;
|
||||
document.getElementById('current-overdue').querySelector('.card-value').textContent = data.current_overdue;
|
||||
document.getElementById('returned-overdue').querySelector('.card-value').textContent = data.returned_overdue;
|
||||
document.getElementById('overdue-rate').querySelector('.card-value').textContent = data.overdue_rate + '%';
|
||||
}
|
||||
|
||||
function updateOverdueRangeChart(rangeData) {
|
||||
// 销毁旧图表
|
||||
if (overdueRangeChart) {
|
||||
overdueRangeChart.destroy();
|
||||
}
|
||||
|
||||
if (!rangeData || rangeData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备图表数据
|
||||
const labels = rangeData.map(item => item.range);
|
||||
const counts = rangeData.map(item => item.count);
|
||||
|
||||
// 创建图表
|
||||
const ctx = document.getElementById('overdue-range-chart').getContext('2d');
|
||||
overdueRangeChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '逾期数量',
|
||||
data: counts,
|
||||
backgroundColor: [
|
||||
'rgba(255, 206, 86, 0.7)',
|
||||
'rgba(255, 159, 64, 0.7)',
|
||||
'rgba(255, 99, 132, 0.7)',
|
||||
'rgba(255, 0, 0, 0.7)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '数量'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '逾期时长分布'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateOverdueStatusChart(data) {
|
||||
// 销毁旧图表
|
||||
if (overdueStatusChart) {
|
||||
overdueStatusChart.destroy();
|
||||
}
|
||||
|
||||
// 准备图表数据
|
||||
const statusLabels = ['当前逾期', '历史逾期', '正常'];
|
||||
const statusData = [
|
||||
data.current_overdue,
|
||||
data.returned_overdue,
|
||||
data.total_borrows - data.current_overdue - data.returned_overdue
|
||||
];
|
||||
|
||||
// 创建图表
|
||||
const ctx = document.getElementById('overdue-status-chart').getContext('2d');
|
||||
overdueStatusChart = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: statusLabels,
|
||||
datasets: [{
|
||||
data: statusData,
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.7)',
|
||||
'rgba(255, 206, 86, 0.7)',
|
||||
'rgba(75, 192, 192, 0.7)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '借阅状态分布'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
5
app/static/js/statistics.js
Normal file
5
app/static/js/statistics.js
Normal file
@ -0,0 +1,5 @@
|
||||
// app/static/js/statistics.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 统计模块主页初始化
|
||||
console.log('统计分析模块加载完成');
|
||||
});
|
||||
119
app/static/js/user_activity.js
Normal file
119
app/static/js/user_activity.js
Normal file
@ -0,0 +1,119 @@
|
||||
// app/static/js/user_activity.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let activityChart = null;
|
||||
|
||||
// 初始加载
|
||||
loadUserActivity();
|
||||
|
||||
function loadUserActivity() {
|
||||
// 显示加载状态
|
||||
document.getElementById('user-table-body').innerHTML = `
|
||||
<tr class="loading-row">
|
||||
<td colspan="4">加载中...</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
// 调用API获取数据
|
||||
fetch('/statistics/api/user-activity')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateUserTable(data);
|
||||
updateUserChart(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载用户活跃度数据失败:', error);
|
||||
document.getElementById('user-table-body').innerHTML = `
|
||||
<tr class="error-row">
|
||||
<td colspan="4">加载数据失败,请稍后重试</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateUserTable(data) {
|
||||
const tableBody = document.getElementById('user-table-body');
|
||||
|
||||
if (data.length === 0) {
|
||||
tableBody.innerHTML = `
|
||||
<tr class="no-data-row">
|
||||
<td colspan="4">暂无数据</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHtml = '';
|
||||
|
||||
data.forEach((user, index) => {
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="rank">${index + 1}</td>
|
||||
<td>${user.username}</td>
|
||||
<td>${user.nickname}</td>
|
||||
<td class="borrow-count">${user.borrow_count}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableBody.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
function updateUserChart(data) {
|
||||
// 销毁旧图表
|
||||
if (activityChart) {
|
||||
activityChart.destroy();
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 准备图表数据
|
||||
const labels = data.map(user => user.nickname || user.username);
|
||||
const borrowCounts = data.map(user => user.borrow_count);
|
||||
|
||||
// 创建图表
|
||||
const ctx = document.getElementById('user-activity-chart').getContext('2d');
|
||||
activityChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: '借阅次数',
|
||||
data: borrowCounts,
|
||||
backgroundColor: 'rgba(153, 102, 255, 0.6)',
|
||||
borderColor: 'rgba(153, 102, 255, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '借阅次数'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '用户'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '最活跃用户排行'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -52,10 +52,10 @@
|
||||
<a href="{{ url_for('inventory.inventory_list') }}"><i class="fas fa-warehouse"></i> 库存管理</a>
|
||||
</li>
|
||||
<li class="{% if '/statistics' in request.path %}active{% endif %}">
|
||||
<a href="#"><i class="fas fa-chart-bar"></i> 统计分析</a>
|
||||
<a href="{{ url_for('statistics.index') }}"><i class="fas fa-chart-bar"></i> 统计分析</a>
|
||||
</li>
|
||||
<li class="{% if '/log' in request.path %}active{% endif %}">
|
||||
<a href="#"><i class="fas fa-history"></i> 日志管理</a>
|
||||
<a href="{{ url_for('log.log_list') }}"><i class="fas fa-history"></i> 日志管理</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@ -1,64 +1,70 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}批量导入图书 - 图书管理系统{% endblock %}
|
||||
{% block title %}图书花园 - 批量导入{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/book-import.css') }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script&family=Playfair+Display&display=swap" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="import-container">
|
||||
<div class="page-header">
|
||||
<h1>批量导入图书</h1>
|
||||
<a href="{{ url_for('book.book_list') }}" class="btn btn-secondary">
|
||||
<div class="page-header animate__animated animate__fadeIn">
|
||||
<h1 class="fancy-title">图书花园 <span class="subtitle">批量导入</span></h1>
|
||||
<a href="{{ url_for('book.book_list') }}" class="btn btn-return">
|
||||
<i class="fas fa-arrow-left"></i> 返回图书列表
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card animate__animated animate__fadeInUp">
|
||||
<div class="card-header">
|
||||
<h4>Excel文件导入</h4>
|
||||
<h4><i class="fas fa-magic sparkle"></i> 添加您的图书收藏</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="form-group">
|
||||
<label for="file">选择Excel文件</label>
|
||||
<div class="form-group file-upload-wrapper">
|
||||
<label for="file" class="elegant-label">选择您的Excel文件</label>
|
||||
<div class="custom-file">
|
||||
<input type="file" class="custom-file-input" id="file" name="file" accept=".xlsx, .xls" required>
|
||||
<label class="custom-file-label" for="file">选择文件...</label>
|
||||
<label class="custom-file-label" for="file">点击这里选择文件...</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">支持的文件格式: .xlsx, .xls</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block import-btn">
|
||||
<i class="fas fa-upload"></i> 开始导入
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
<div class="divider">
|
||||
<span class="divider-content"><i class="fas fa-book-open"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="import-instructions">
|
||||
<h5>导入说明:</h5>
|
||||
<ul>
|
||||
<li>Excel文件须包含以下列 (标题行必须与下列完全一致):</li>
|
||||
<li class="required-field">title - 图书标题 (必填)</li>
|
||||
<li class="required-field">author - 作者名称 (必填)</li>
|
||||
<li>publisher - 出版社</li>
|
||||
<li>category_id - 分类ID (对应系统中的分类ID)</li>
|
||||
<li>tags - 标签 (多个标签用逗号分隔)</li>
|
||||
<li>isbn - ISBN编号 (建议唯一)</li>
|
||||
<li>publish_year - 出版年份</li>
|
||||
<li>description - 图书简介</li>
|
||||
<li>cover_url - 封面图片URL</li>
|
||||
<li>stock - 库存数量</li>
|
||||
<li>price - 价格</li>
|
||||
</ul>
|
||||
<div class="import-instructions animate__animated animate__fadeIn">
|
||||
<h5 class="instruction-title"><i class="fas fa-leaf"></i> 导入指南</h5>
|
||||
<div class="instruction-content">
|
||||
<p>Excel文件须包含以下字段 (标题行必须与下列完全一致):</p>
|
||||
<ul class="elegant-list">
|
||||
<li class="required-field"><span class="field-name">title</span> - 图书标题 <span class="required-badge">必填</span></li>
|
||||
<li class="required-field"><span class="field-name">author</span> - 作者名称 <span class="required-badge">必填</span></li>
|
||||
<li><span class="field-name">publisher</span> - 出版社</li>
|
||||
<li><span class="field-name">category_id</span> - 分类ID</li>
|
||||
<li><span class="field-name">tags</span> - 标签 (多个标签用逗号分隔)</li>
|
||||
<li><span class="field-name">isbn</span> - ISBN编号</li>
|
||||
<li><span class="field-name">publish_year</span> - 出版年份</li>
|
||||
<li><span class="field-name">description</span> - 图书简介</li>
|
||||
<li><span class="field-name">cover_url</span> - 封面图片URL</li>
|
||||
<li><span class="field-name">stock</span> - 库存数量</li>
|
||||
<li><span class="field-name">price</span> - 价格</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="template-download">
|
||||
<p>下载导入模板:</p>
|
||||
<a href="{{ url_for('static', filename='templates/book_import_template.xlsx') }}" class="btn btn-outline-primary">
|
||||
<div class="template-download animate__animated animate__pulse animate__infinite animate__slower">
|
||||
<p>不确定如何开始? 下载我们精心准备的模板:</p>
|
||||
<a href="{{ url_for('book.download_template') }}" class="btn download-btn">
|
||||
<i class="fas fa-download"></i> 下载Excel模板
|
||||
</a>
|
||||
</div>
|
||||
@ -67,17 +73,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="floating-elements">
|
||||
<div class="snowflake snowflake-1"></div>
|
||||
<div class="snowflake snowflake-2"></div>
|
||||
<div class="snowflake snowflake-3"></div>
|
||||
<div class="snowflake snowflake-4"></div>
|
||||
<div class="flower flower-1"></div>
|
||||
<div class="flower flower-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// 显示选择的文件名
|
||||
$('.custom-file-input').on('change', function() {
|
||||
const fileName = $(this).val().split('\\').pop();
|
||||
$(this).next('.custom-file-label').html(fileName);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/book-import.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
65
app/templates/log/detail.html
Normal file
65
app/templates/log/detail.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}日志详情{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/log-detail.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-header">
|
||||
<h1><i class="fas fa-file-alt"></i> 日志详情 #{{ log.id }}</h1>
|
||||
<div class="actions">
|
||||
<a href="{{ url_for('log.log_list') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> 返回列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="log-info">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="info-item">
|
||||
<div class="label">操作时间:</div>
|
||||
<div class="value">{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">操作用户:</div>
|
||||
<div class="value">
|
||||
{% if log.user %}
|
||||
{{ log.user.username }} (ID: {{ log.user_id }})
|
||||
{% else %}
|
||||
<span class="text-muted">未登录用户</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">操作类型:</div>
|
||||
<div class="value">{{ log.action }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="info-item">
|
||||
<div class="label">目标类型:</div>
|
||||
<div class="value">{{ log.target_type or '无' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">目标ID:</div>
|
||||
<div class="value">{{ log.target_id or '无' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="label">IP地址:</div>
|
||||
<div class="value">{{ log.ip_address or '未记录' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item description">
|
||||
<div class="label">详细描述:</div>
|
||||
<div class="value">{{ log.description or '无描述' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
253
app/templates/log/list.html
Normal file
253
app/templates/log/list.html
Normal file
@ -0,0 +1,253 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}系统日志管理{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/log-list.css') }}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Dancing+Script&family=Montserrat:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content-container">
|
||||
<div class="content-header">
|
||||
<h1><i class="fas fa-history sparkle"></i> 系统日志管理</h1>
|
||||
<div class="actions">
|
||||
<button id="btnExport" class="btn btn-blossom">
|
||||
<i class="fas fa-file-export"></i> 导出日志
|
||||
</button>
|
||||
<button id="btnClear" class="btn btn-danger-soft">
|
||||
<i class="fas fa-trash"></i> 清除日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel">
|
||||
<div class="filter-panel-header">
|
||||
<span class="filter-title">日志筛选</span>
|
||||
<div class="snowflake-divider">
|
||||
<span>❄</span><span>❄</span><span>❄</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="get" action="{{ url_for('log.log_list') }}">
|
||||
<div class="filter-row">
|
||||
<div class="filter-item">
|
||||
<label for="user_id">用户</label>
|
||||
<select name="user_id" id="user_id" class="elegant-select">
|
||||
<option value="">全部用户</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if filters.user_id == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="action">操作类型</label>
|
||||
<select name="action" id="action" class="elegant-select">
|
||||
<option value="">全部操作</option>
|
||||
{% for action_type, count in action_types %}
|
||||
<option value="{{ action_type }}" {% if filters.action == action_type %}selected{% endif %}>
|
||||
{{ action_type }} ({{ count }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="target_type">目标类型</label>
|
||||
<select name="target_type" id="target_type" class="elegant-select">
|
||||
<option value="">全部类型</option>
|
||||
{% for target_type, count in target_types %}
|
||||
<option value="{{ target_type }}" {% if filters.target_type == target_type %}selected{% endif %}>
|
||||
{{ target_type }} ({{ count }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label for="date_range">时间范围</label>
|
||||
<select name="date_range" id="date_range" class="elegant-select">
|
||||
<option value="1" {% if filters.date_range == '1' %}selected{% endif %}>最近1天</option>
|
||||
<option value="7" {% if filters.date_range == '7' %}selected{% endif %}>最近7天</option>
|
||||
<option value="30" {% if filters.date_range == '30' %}selected{% endif %}>最近30天</option>
|
||||
<option value="custom" {% if filters.date_range == 'custom' %}selected{% endif %}>自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row date-range-inputs" {% if filters.date_range != 'custom' %}style="display:none"{% endif %}>
|
||||
<div class="filter-item">
|
||||
<label for="start_date">开始日期</label>
|
||||
<input type="date" name="start_date" id="start_date" value="{{ filters.start_date }}" class="elegant-input">
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label for="end_date">结束日期</label>
|
||||
<input type="date" name="end_date" id="end_date" value="{{ filters.end_date }}" class="elegant-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button type="submit" class="btn btn-primary-soft">应用筛选</button>
|
||||
<a href="{{ url_for('log.log_list') }}" class="btn btn-secondary-soft">重置</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card glass-card">
|
||||
<div class="card-body">
|
||||
<div class="table-container">
|
||||
<table class="table elegant-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>用户</th>
|
||||
<th>操作</th>
|
||||
<th>目标类型</th>
|
||||
<th>目标ID</th>
|
||||
<th>IP地址</th>
|
||||
<th>描述</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in pagination.items %}
|
||||
<tr class="fade-in-row">
|
||||
<td>{{ log.id }}</td>
|
||||
<td>
|
||||
{% if log.user %}
|
||||
<span class="user-badge">{{ log.user.username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">未登录</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ log.action }}</td>
|
||||
<td>{{ log.target_type }}</td>
|
||||
<td>{{ log.target_id }}</td>
|
||||
<td>{{ log.ip_address }}</td>
|
||||
<td>{{ log.description }}</td>
|
||||
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center empty-message">
|
||||
<div class="empty-container">
|
||||
<i class="fas fa-search"></i>
|
||||
<p>没有找到符合条件的日志记录</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页部分 -->
|
||||
<div class="pagination-wrapper">
|
||||
<div class="pagination-container">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="{{ url_for('log.log_list', page=pagination.prev_num, user_id=filters.user_id, action=filters.action, target_type=filters.target_type, date_range=filters.date_range, start_date=filters.start_date, end_date=filters.end_date) }}" class="btn btn-sm btn-outline-primary page-btn"><i class="fas fa-chevron-left"></i> 上一页</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="page-info">
|
||||
第 {{ pagination.page }} 页,共 {{ pagination.pages }} 页,总计 {{ pagination.total }} 条记录
|
||||
</span>
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="{{ url_for('log.log_list', page=pagination.next_num, user_id=filters.user_id, action=filters.action, target_type=filters.target_type, date_range=filters.date_range, start_date=filters.start_date, end_date=filters.end_date) }}" class="btn btn-sm btn-outline-primary page-btn">下一页 <i class="fas fa-chevron-right"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导出日志模态框 -->
|
||||
<div class="modal fade" id="exportLogModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-elegant" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-file-export"></i> 导出日志</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-message">请选择导出格式:</p>
|
||||
<div class="form-group">
|
||||
<select id="exportFormat" class="form-control elegant-select">
|
||||
<option value="csv">CSV 格式</option>
|
||||
<option value="excel">Excel 格式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-info elegant-alert">
|
||||
<i class="fas fa-info-circle"></i> 将导出当前筛选条件下的所有日志记录。
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary-soft" data-dismiss="modal">取消</button>
|
||||
<button type="button" id="confirmExport" class="btn btn-primary-soft">确认导出</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清除日志确认框 -->
|
||||
<div class="modal fade" id="clearLogModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-elegant" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-trash-alt"></i> 清除日志确认</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-message">请选择要清除的日志范围:</p>
|
||||
<div class="form-group">
|
||||
<select id="clearDays" class="form-control elegant-select">
|
||||
<option value="0">清除全部日志</option>
|
||||
<option value="30">清除30天前的日志</option>
|
||||
<option value="90">清除90天前的日志</option>
|
||||
<option value="180">清除半年前的日志</option>
|
||||
<option value="365">清除一年前的日志</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="alert alert-warning elegant-alert">
|
||||
<i class="fas fa-exclamation-triangle"></i> 警告:此操作不可恢复!
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary-soft" data-dismiss="modal">取消</button>
|
||||
<button type="button" id="confirmClear" class="btn btn-danger-soft">确认清除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/log-list.js') }}"></script>
|
||||
<script>
|
||||
// 添加雪花动画效果
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 为表格行添加渐入效果
|
||||
const rows = document.querySelectorAll('.fade-in-row');
|
||||
rows.forEach((row, index) => {
|
||||
row.style.animationDelay = `${index * 0.05}s`;
|
||||
});
|
||||
|
||||
// 为按钮添加悬停效果
|
||||
const buttons = document.querySelectorAll('.btn');
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('mouseover', function() {
|
||||
this.classList.add('btn-glow');
|
||||
});
|
||||
btn.addEventListener('mouseout', function() {
|
||||
this.classList.remove('btn-glow');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
106
app/templates/statistics/book_ranking.html
Normal file
106
app/templates/statistics/book_ranking.html
Normal file
@ -0,0 +1,106 @@
|
||||
<!-- app/templates/statistics/book_ranking.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}热门图书排行 - 统计分析{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/book_ranking.css') }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&display=swap">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="statistics-container">
|
||||
<div class="breadcrumb">
|
||||
<a href="{{ url_for('statistics.index') }}">统计分析</a> / <span class="current-page">热门图书排行</span>
|
||||
</div>
|
||||
|
||||
<h1 class="page-title animate__animated animate__fadeIn">✨ 热门图书排行 ✨</h1>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-label">时间范围:</div>
|
||||
<select id="time-range" class="filter-select">
|
||||
<option value="week">最近7天</option>
|
||||
<option value="month" selected>最近30天</option>
|
||||
<option value="year">最近一年</option>
|
||||
<option value="all">全部时间</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-label ml-20">显示数量:</div>
|
||||
<select id="limit-count" class="filter-select">
|
||||
<option value="5">5本</option>
|
||||
<option value="10" selected>10本</option>
|
||||
<option value="20">20本</option>
|
||||
<option value="50">50本</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="chart-container animate__animated animate__fadeInUp">
|
||||
<div class="chart-decoration left"></div>
|
||||
<div class="chart-decoration right"></div>
|
||||
<canvas id="ranking-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="table-container animate__animated animate__fadeInUp">
|
||||
<h3 class="book-list-title"><span class="book-icon">📚</span> 热门图书榜单 <span class="book-icon">📖</span></h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><span class="column-icon">🏆</span> 排名</th>
|
||||
<th><span class="column-icon">🖼️</span> 封面</th>
|
||||
<th><span class="column-icon">📕</span> 书名</th>
|
||||
<th><span class="column-icon">✒️</span> 作者</th>
|
||||
<th><span class="column-icon">📊</span> 借阅次数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ranking-table-body">
|
||||
<!-- 数据将通过JS动态填充 -->
|
||||
<tr class="loading-row">
|
||||
<td colspan="5"><div class="loading-animation"><span>正在打开书页</span><span class="dot-animation">...</span></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="quote-container animate__animated animate__fadeIn">
|
||||
<p>"一本好书就像一艘船,带领我们从狭隘的地方,驶向生活的无限广阔的海洋。"</p>
|
||||
<span class="quote-author">—— 海伦·凯勒</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/book_ranking.js') }}"></script>
|
||||
<script>
|
||||
// 原有的 Chart.js 初始化代码可能会在 book_ranking.js 中
|
||||
// 这里我们添加额外的动画效果
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 添加表格行的动画效果
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
let delay = 0;
|
||||
entry.target.querySelectorAll('tr').forEach(row => {
|
||||
setTimeout(() => {
|
||||
row.classList.add('fade-in');
|
||||
}, delay);
|
||||
delay += 100;
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const tableBody = document.getElementById('ranking-table-body');
|
||||
if (tableBody) observer.observe(tableBody);
|
||||
|
||||
// 装饰元素动画
|
||||
const decorations = document.querySelectorAll('.chart-decoration');
|
||||
decorations.forEach(decor => {
|
||||
decor.classList.add('floating');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
120
app/templates/statistics/borrow_statistics.html
Normal file
120
app/templates/statistics/borrow_statistics.html
Normal file
@ -0,0 +1,120 @@
|
||||
<!-- app/templates/statistics/borrow_statistics.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}借阅趋势分析 - 统计分析{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/borrow_statistics.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="statistics-container">
|
||||
<!-- 页面装饰元素 -->
|
||||
<div class="page-decoration left"></div>
|
||||
<div class="page-decoration right"></div>
|
||||
|
||||
<div class="breadcrumb">
|
||||
<a href="{{ url_for('statistics.index') }}">统计分析</a> / <span class="current-page">借阅趋势分析</span>
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">借阅趋势分析</h1>
|
||||
|
||||
<div class="intro-text">
|
||||
<p>探索读者的阅读习惯与喜好,发现图书流通的奥秘</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-label">时间范围:</div>
|
||||
<select id="time-range" class="filter-select">
|
||||
<option value="week">最近7天</option>
|
||||
<option value="month" selected>最近30天</option>
|
||||
<option value="year">最近一年</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="chart-container trend-chart">
|
||||
<h3>借阅与归还趋势</h3>
|
||||
<div class="chart-decoration left floating"></div>
|
||||
<div class="chart-decoration right floating"></div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="trend-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-row">
|
||||
<div class="chart-container half">
|
||||
<h3>分类借阅分布</h3>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="category-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-summary half">
|
||||
<h3>借阅概况</h3>
|
||||
<div class="stats-grid" id="borrow-summary">
|
||||
<!-- 数据将通过JS动态填充 -->
|
||||
<div class="loading">
|
||||
<div class="loader"></div>
|
||||
<span>数据加载中<span class="dot-animation">...</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="insights-container">
|
||||
<h3>阅读洞察</h3>
|
||||
<div class="insights-content">
|
||||
<p>根据当前数据分析,发现读者更喜欢在周末借阅图书,女性读者偏爱文学和艺术类书籍,而男性读者则更关注科技和历史类图书。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/borrow_statistics.js') }}"></script>
|
||||
<script>
|
||||
// 添加动画效果
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 为统计项添加hover效果
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('animate-fadeInUp');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
// 监听所有主要元素
|
||||
document.querySelectorAll('.chart-container, .stats-summary, .insights-container').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
// 统计卡片的交互效果
|
||||
document.addEventListener('mouseover', function(e) {
|
||||
if (e.target.closest('.stats-item')) {
|
||||
const item = e.target.closest('.stats-item');
|
||||
const items = document.querySelectorAll('.stats-item');
|
||||
|
||||
items.forEach(i => {
|
||||
if (i !== item) {
|
||||
i.style.opacity = '0.7';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseout', function(e) {
|
||||
if (e.target.closest('.stats-item')) {
|
||||
document.querySelectorAll('.stats-item').forEach(i => {
|
||||
i.style.opacity = '1';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
83
app/templates/statistics/index.html
Normal file
83
app/templates/statistics/index.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!-- app/templates/statistics/index.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}统计分析 - 图书管理系统{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&display=swap">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="statistics-container animate__animated animate__fadeIn">
|
||||
<h1 class="page-title">统计分析</h1>
|
||||
|
||||
<div class="quote-banner">
|
||||
<p>"统计数字是文学世界的星辰,照亮知识的海洋"</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<a href="{{ url_for('statistics.book_ranking') }}" class="stats-card animate__animated animate__fadeInUp">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fas fa-chart-line"></i></div>
|
||||
<div class="card-title">热门图书排行</div>
|
||||
<div class="card-description">查看最受欢迎的图书,按借阅次数排名</div>
|
||||
<div class="card-decoration book-decoration"></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('statistics.borrow_statistics') }}" class="stats-card animate__animated animate__fadeInUp" style="animation-delay: 0.1s;">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fas fa-exchange-alt"></i></div>
|
||||
<div class="card-title">借阅趋势分析</div>
|
||||
<div class="card-description">查看借阅和归还的历史趋势和分布</div>
|
||||
<div class="card-decoration trend-decoration"></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('statistics.user_activity') }}" class="stats-card animate__animated animate__fadeInUp" style="animation-delay: 0.2s;">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fas fa-users"></i></div>
|
||||
<div class="card-title">用户活跃度分析</div>
|
||||
<div class="card-description">查看最活跃的用户和用户行为分析</div>
|
||||
<div class="card-decoration user-decoration"></div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('statistics.overdue_analysis') }}" class="stats-card animate__animated animate__fadeInUp" style="animation-delay: 0.3s;">
|
||||
<div class="card-inner">
|
||||
<div class="card-icon"><i class="fas fa-exclamation-circle"></i></div>
|
||||
<div class="card-title">逾期分析</div>
|
||||
<div class="card-description">分析图书逾期情况和趋势</div>
|
||||
<div class="card-decoration overdue-decoration"></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="page-decoration left"></div>
|
||||
<div class="page-decoration right"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/statistics.js') }}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 添加卡片悬停动画效果
|
||||
const cards = document.querySelectorAll('.stats-card');
|
||||
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
const decoration = this.querySelector('.card-decoration');
|
||||
decoration.classList.add('active');
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function() {
|
||||
const decoration = this.querySelector('.card-decoration');
|
||||
decoration.classList.remove('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
58
app/templates/statistics/overdue_analysis.html
Normal file
58
app/templates/statistics/overdue_analysis.html
Normal file
@ -0,0 +1,58 @@
|
||||
<!-- app/templates/statistics/overdue_analysis.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}逾期分析 - 统计分析{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/overdue_analysis.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="statistics-container">
|
||||
<div class="breadcrumb">
|
||||
<a href="{{ url_for('statistics.index') }}">统计分析</a> / 逾期分析
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">逾期分析</h1>
|
||||
|
||||
<div class="stats-cards">
|
||||
<div class="stats-card" id="total-borrows">
|
||||
<div class="card-value">0</div>
|
||||
<div class="card-title">总借阅数</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-card" id="current-overdue">
|
||||
<div class="card-value">0</div>
|
||||
<div class="card-title">当前逾期数</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-card" id="returned-overdue">
|
||||
<div class="card-value">0</div>
|
||||
<div class="card-title">历史逾期数</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-card" id="overdue-rate">
|
||||
<div class="card-value">0%</div>
|
||||
<div class="card-title">总逾期率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-row">
|
||||
<div class="chart-container half">
|
||||
<h3>逾期时长分布</h3>
|
||||
<canvas id="overdue-range-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="chart-container half">
|
||||
<h3>逾期状态分布</h3>
|
||||
<canvas id="overdue-status-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/overdue_analysis.js') }}"></script>
|
||||
{% endblock %}
|
||||
49
app/templates/statistics/user_activity.html
Normal file
49
app/templates/statistics/user_activity.html
Normal file
@ -0,0 +1,49 @@
|
||||
<!-- app/templates/statistics/user_activity.html -->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}用户活跃度分析 - 统计分析{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/user_activity.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="statistics-container">
|
||||
<div class="breadcrumb">
|
||||
<a href="{{ url_for('statistics.index') }}">统计分析</a> / 用户活跃度分析
|
||||
</div>
|
||||
|
||||
<h1 class="page-title">用户活跃度分析</h1>
|
||||
|
||||
<div class="chart-container">
|
||||
<h3>最活跃用户排行</h3>
|
||||
<canvas id="user-activity-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<h3>活跃用户列表</h3>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>借阅次数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-table-body">
|
||||
<!-- 数据将通过JS动态填充 -->
|
||||
<tr class="loading-row">
|
||||
<td colspan="4">加载中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/user_activity.js') }}"></script>
|
||||
{% endblock %}
|
||||
40
app/utils/logger.py
Normal file
40
app/utils/logger.py
Normal file
@ -0,0 +1,40 @@
|
||||
from flask import request, current_app
|
||||
from flask_login import current_user
|
||||
from app.models.log import Log
|
||||
|
||||
|
||||
def record_activity(action, target_type=None, target_id=None, description=None):
|
||||
"""
|
||||
记录用户活动
|
||||
|
||||
参数:
|
||||
- action: 操作类型,如 'login', 'logout', 'create', 'update', 'delete', 'borrow', 'return' 等
|
||||
- target_type: 操作对象类型,如 'book', 'user', 'borrow' 等
|
||||
- target_id: 操作对象ID
|
||||
- description: 操作详细描述
|
||||
"""
|
||||
try:
|
||||
# 获取当前用户ID
|
||||
user_id = current_user.id if current_user.is_authenticated else None
|
||||
|
||||
# 获取客户端IP地址
|
||||
ip_address = request.remote_addr
|
||||
if 'X-Forwarded-For' in request.headers:
|
||||
ip_address = request.headers.getlist("X-Forwarded-For")[0].rpartition(' ')[-1]
|
||||
|
||||
# 记录日志
|
||||
Log.add_log(
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
ip_address=ip_address,
|
||||
description=description
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
# 记录错误,但不影响主要功能
|
||||
if current_app:
|
||||
current_app.logger.error(f"Error recording activity log: {str(e)}")
|
||||
return False
|
||||
6085
code_collection.txt
6085
code_collection.txt
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user