add_statistic_and_log

This commit is contained in:
superlishunqin 2025-05-12 02:42:27 +08:00
parent cb191ec379
commit c75521becd
36 changed files with 11742 additions and 202 deletions

View File

@ -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')

View File

@ -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
)

View File

@ -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({

View File

@ -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,

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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
View 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;
}

View File

@ -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;
}
}

View 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); }
}

View 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;
}
}

View 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
View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View 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);
}
}

View 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: '热门图书借阅排行'
}
}
}
});
}
});

View 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
View 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">&times;</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);
});
}
}
});

View 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: '借阅状态分布'
}
}
}
});
}
});

View File

@ -0,0 +1,5 @@
// app/static/js/statistics.js
document.addEventListener('DOMContentLoaded', function() {
// 统计模块主页初始化
console.log('统计分析模块加载完成');
});

View 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: '最活跃用户排行'
}
}
}
});
}
});

View File

@ -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>

View File

@ -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 %}

View 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
View 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">&times;</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">&times;</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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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

File diff suppressed because it is too large Load Diff