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.borrow import borrow_bp
from app.controllers.inventory import inventory_bp from app.controllers.inventory import inventory_bp
from flask_login import LoginManager, current_user from flask_login import LoginManager, current_user
from app.controllers.statistics import statistics_bp
from app.controllers.log import log_bp
import os import os
login_manager = LoginManager() login_manager = LoginManager()
@ -49,7 +51,9 @@ def create_app(config=None):
app.register_blueprint(user_bp, url_prefix='/user') app.register_blueprint(user_bp, url_prefix='/user')
app.register_blueprint(book_bp, url_prefix='/book') app.register_blueprint(book_bp, url_prefix='/book')
app.register_blueprint(borrow_bp, url_prefix='/borrow') app.register_blueprint(borrow_bp, url_prefix='/borrow')
app.register_blueprint(statistics_bp)
app.register_blueprint(inventory_bp) app.register_blueprint(inventory_bp)
app.register_blueprint(log_bp)
# 创建数据库表 # 创建数据库表
with app.app_context(): with app.app_context():
@ -63,6 +67,7 @@ def create_app(config=None):
# 再导入依赖模型 - 但不在这里定义关系 # 再导入依赖模型 - 但不在这里定义关系
from app.models.borrow import BorrowRecord from app.models.borrow import BorrowRecord
from app.models.inventory import InventoryLog from app.models.inventory import InventoryLog
from app.models.log import Log
# 移除这些重复的关系定义 # 移除这些重复的关系定义
# Book.borrow_records = db.relationship('BorrowRecord', backref='book', lazy='dynamic') # 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 datetime
import pandas as pd import pandas as pd
import uuid import uuid
from app.models.log import Log # 导入日志模型
book_bp = Blueprint('book', __name__) book_bp = Blueprint('book', __name__)
@book_bp.route('/admin/list') @book_bp.route('/admin/list')
@login_required @login_required
@admin_required @admin_required
@ -43,6 +45,15 @@ def admin_book_list():
books = pagination.items books = pagination.items
# 获取所有分类供筛选使用 # 获取所有分类供筛选使用
categories = Category.query.all() 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', return render_template('book/list.html',
books=books, books=books,
pagination=pagination, pagination=pagination,
@ -54,6 +65,7 @@ def admin_book_list():
current_user=g.user, current_user=g.user,
is_admin_view=True) # 指明这是管理视图 is_admin_view=True) # 指明这是管理视图
# 图书列表页面 # 图书列表页面
@book_bp.route('/list') @book_bp.route('/list')
@login_required @login_required
@ -94,6 +106,14 @@ def book_list():
# 获取所有分类供筛选使用 # 获取所有分类供筛选使用
categories = Category.query.all() 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', return render_template('book/list.html',
books=books, books=books,
pagination=pagination, 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( borrow_records = BorrowRecord.query.filter_by(book_id=book_id).order_by(BorrowRecord.borrow_date.desc()).limit(
10).all() 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( return render_template(
'book/detail.html', 'book/detail.html',
book=book, book=book,
@ -131,7 +161,6 @@ def book_detail(book_id):
) )
# 添加图书页面 # 添加图书页面
@book_bp.route('/add', methods=['GET', 'POST']) @book_bp.route('/add', methods=['GET', 'POST'])
@login_required @login_required
@ -247,6 +276,16 @@ def add_book():
db.session.add(inventory_log) db.session.add(inventory_log)
db.session.commit() 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') flash(f'{title}》添加成功', 'success')
return redirect(url_for('book.book_list')) return redirect(url_for('book.book_list'))
@ -255,6 +294,15 @@ def add_book():
error_msg = str(e) error_msg = str(e)
# 记录详细错误日志 # 记录详细错误日志
current_app.logger.error(f"添加图书失败: {error_msg}") 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') flash(f'添加图书失败: {error_msg}', 'danger')
categories = Category.query.all() categories = Category.query.all()
@ -277,6 +325,7 @@ def add_book():
categories = Category.query.all() categories = Category.query.all()
return render_template('book/add.html', categories=categories, current_user=g.user) return render_template('book/add.html', categories=categories, current_user=g.user)
# 编辑图书 # 编辑图书
@book_bp.route('/edit/<int:book_id>', methods=['GET', 'POST']) @book_bp.route('/edit/<int:book_id>', methods=['GET', 'POST'])
@login_required @login_required
@ -395,6 +444,9 @@ def edit_book(book_id):
cover_file.save(file_path) cover_file.save(file_path)
book.cover_url = f'/static/covers/{filename}' book.cover_url = f'/static/covers/{filename}'
# 记录更新前的图书信息
old_info = f"原信息: 书名={book.title}, 作者={book.author}, ISBN={book.isbn}, 库存={book.stock}"
# 更新图书信息 # 更新图书信息
book.title = title book.title = title
book.author = author book.author = author
@ -410,10 +462,32 @@ def edit_book(book_id):
try: try:
db.session.commit() 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') flash('图书信息更新成功', 'success')
return redirect(url_for('book.book_list')) return redirect(url_for('book.book_list'))
except Exception as e: except Exception as e:
db.session.rollback() 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') flash(f'保存失败: {str(e)}', 'danger')
categories = Category.query.all() categories = Category.query.all()
return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) 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() active_borrows = BorrowRecord.query.filter_by(book_id=book_id, status=1).count()
if active_borrows > 0: 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': '该图书有未归还的借阅记录,无法删除'}) return jsonify({'success': False, 'message': '该图书有未归还的借阅记录,无法删除'})
# 考虑软删除而不是物理删除 # 考虑软删除而不是物理删除
@ -442,6 +525,16 @@ def delete_book(book_id):
book.updated_at = datetime.datetime.now() book.updated_at = datetime.datetime.now()
db.session.commit() 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': '图书已成功下架'}) return jsonify({'success': True, 'message': '图书已成功下架'})
@ -451,6 +544,15 @@ def delete_book(book_id):
@admin_required @admin_required
def category_list(): def category_list():
categories = Category.query.all() 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) 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.add(category)
db.session.commit() 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}) return jsonify({'success': True, 'message': '分类添加成功', 'id': category.id, 'name': category.name})
@ -480,6 +590,10 @@ def add_category():
def edit_category(category_id): def edit_category(category_id):
category = Category.query.get_or_404(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') name = request.form.get('name')
parent_id = request.form.get('parent_id') or None parent_id = request.form.get('parent_id') or None
sort = request.form.get('sort', 0, type=int) sort = request.form.get('sort', 0, type=int)
@ -492,6 +606,16 @@ def edit_category(category_id):
category.sort = sort category.sort = sort
db.session.commit() 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': '分类更新成功'}) 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() books_count = Book.query.filter_by(category_id=category_id).count()
if books_count > 0: 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}本图书,无法删除'}) return jsonify({'success': False, 'message': f'该分类下有{books_count}本图书,无法删除'})
# 检查是否有子分类 # 检查是否有子分类
children_count = Category.query.filter_by(parent_id=category_id).count() children_count = Category.query.filter_by(parent_id=category_id).count()
if children_count > 0: 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}个子分类,无法删除'}) return jsonify({'success': False, 'message': f'该分类下有{children_count}个子分类,无法删除'})
category_name = category.name # 保存分类名称以便记录日志
db.session.delete(category) db.session.delete(category)
db.session.commit() 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': '分类删除成功'}) return jsonify({'success': True, 'message': '分类删除成功'})
@ -599,6 +753,15 @@ def import_books():
error_count += 1 error_count += 1
db.session.commit() 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') flash(f'导入完成: 成功{success_count}条,失败{error_count}', 'info')
if errors: if errors:
flash('<br>'.join(errors[:10]) + (f'<br>...等共{len(errors)}个错误' if len(errors) > 10 else ''), 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')) return redirect(url_for('book.book_list'))
except Exception as e: 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') flash(f'导入失败: {str(e)}', 'danger')
return redirect(request.url) return redirect(request.url)
else: else:
@ -673,6 +844,14 @@ def export_books():
# 写入Excel # 写入Excel
df.to_excel(filepath, index=False) 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}')) return redirect(url_for('static', filename=f'temp/{filename}'))
@ -691,6 +870,7 @@ def test_permissions():
<p><a href="/book/admin/list">尝试访问管理页面</a></p> <p><a href="/book/admin/list">尝试访问管理页面</a></p>
""" """
# 添加到app/controllers/book.py文件中 # 添加到app/controllers/book.py文件中
@book_bp.route('/browse') @book_bp.route('/browse')
@ -732,6 +912,14 @@ def browse_books():
# 获取所有分类供筛选使用 # 获取所有分类供筛选使用
categories = Category.query.all() 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', return render_template('book/browse.html',
books=books, books=books,
pagination=pagination, pagination=pagination,
@ -740,3 +928,92 @@ def browse_books():
category_id=category_id, category_id=category_id,
sort=sort, 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.borrow import BorrowRecord
from app.models.inventory import InventoryLog from app.models.inventory import InventoryLog
from app.models.user import db, User from app.models.user import db, User
from app.models.log import Log # 导入日志模型
import datetime import datetime
from app.utils.auth import admin_required from app.utils.auth import admin_required
@ -72,6 +73,17 @@ def borrow_book():
changed_at=now changed_at=now
) )
db.session.add(inventory_log) 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() db.session.commit()
flash(f'成功借阅《{book.title}》,请在 {due_date.strftime("%Y-%m-%d")} 前归还', 'success') flash(f'成功借阅《{book.title}》,请在 {due_date.strftime("%Y-%m-%d")} 前归还', 'success')
@ -145,6 +157,17 @@ def add_borrow(book_id):
changed_at=now changed_at=now
) )
db.session.add(inventory_log) 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() db.session.commit()
return jsonify({ return jsonify({
@ -207,6 +230,21 @@ def return_book(borrow_id):
changed_at=now changed_at=now
) )
db.session.add(inventory_log) 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() db.session.commit()
return jsonify({ return jsonify({
@ -252,6 +290,7 @@ def renew_book(borrow_id):
try: try:
now = datetime.datetime.now() now = datetime.datetime.now()
book = Book.query.get(borrow_record.book_id)
# 检查是否已逾期 # 检查是否已逾期
if now > borrow_record.due_date: if now > borrow_record.due_date:
@ -268,6 +307,16 @@ def renew_book(borrow_id):
borrow_record.renew_count += 1 borrow_record.renew_count += 1
borrow_record.updated_at = now 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() db.session.commit()
return jsonify({ return jsonify({
@ -307,6 +356,14 @@ def my_borrows():
current_borrows_count = BorrowRecord.query.filter_by(user_id=current_user.id, status=1).count() 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() 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( return render_template(
'borrow/my_borrows.html', 'borrow/my_borrows.html',
pagination=pagination, pagination=pagination,
@ -317,7 +374,6 @@ def my_borrows():
) )
@borrow_bp.route('/manage') @borrow_bp.route('/manage')
@login_required @login_required
@admin_required @admin_required
@ -364,6 +420,14 @@ def manage_borrows():
# 获取所有用户(用于筛选) # 获取所有用户(用于筛选)
users = User.query.all() users = User.query.all()
# 记录日志 - 管理员查看借阅记录
Log.add_log(
action='管理借阅记录',
user_id=current_user.id,
ip_address=request.remote_addr,
description='管理员查看借阅管理页面'
)
return render_template( return render_template(
'borrow/borrow_management.html', 'borrow/borrow_management.html',
pagination=pagination, pagination=pagination,
@ -378,7 +442,6 @@ def manage_borrows():
) )
@borrow_bp.route('/admin/add', methods=['POST']) @borrow_bp.route('/admin/add', methods=['POST'])
@login_required @login_required
@admin_required @admin_required
@ -445,6 +508,17 @@ def admin_add_borrow():
changed_at=now changed_at=now
) )
db.session.add(inventory_log) 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() db.session.commit()
flash(f'成功为用户 {user.username} 借阅《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}', 'success') flash(f'成功为用户 {user.username} 借阅《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}', 'success')
@ -475,6 +549,14 @@ def overdue_borrows():
# 计算逾期总数 # 计算逾期总数
overdue_count = query.count() overdue_count = query.count()
# 记录日志 - 管理员查看逾期记录
Log.add_log(
action='查看逾期记录',
user_id=current_user.id,
ip_address=request.remote_addr,
description='管理员查看逾期借阅记录'
)
return render_template( return render_template(
'borrow/overdue.html', 'borrow/overdue.html',
pagination=pagination, pagination=pagination,
@ -508,6 +590,9 @@ def notify_overdue(borrow_id):
}) })
try: try:
book = Book.query.get(borrow_record.book_id)
user = User.query.get(borrow_record.user_id)
# 创建通知 # 创建通知
notification = Notification( notification = Notification(
user_id=borrow_record.user_id, user_id=borrow_record.user_id,
@ -519,11 +604,21 @@ def notify_overdue(borrow_id):
) )
db.session.add(notification) db.session.add(notification)
db.session.commit()
# 更新借阅记录备注 # 更新借阅记录备注
borrow_record.remark = f'{borrow_record.remark or ""}[{now.strftime("%Y-%m-%d")} 已发送逾期通知]' borrow_record.remark = f'{borrow_record.remark or ""}[{now.strftime("%Y-%m-%d")} 已发送逾期通知]'
borrow_record.updated_at = now 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() db.session.commit()
return jsonify({ 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 flask_login import login_required, current_user
from app.models.book import Book from app.models.book import Book
from app.models.inventory import InventoryLog from app.models.inventory import InventoryLog
from app.models.log import Log # 导入日志模型
from app.models.user import db from app.models.user import db
from app.utils.auth import admin_required from app.utils.auth import admin_required
from datetime import datetime from datetime import datetime
@ -40,6 +41,15 @@ def inventory_list():
pagination = query.paginate(page=page, per_page=per_page) pagination = query.paginate(page=page, per_page=per_page)
books = pagination.items 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', return render_template('inventory/list.html',
books=books, books=books,
pagination=pagination, pagination=pagination,
@ -55,6 +65,17 @@ def adjust_inventory(book_id):
"""调整图书库存""" """调整图书库存"""
book = Book.query.get_or_404(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': if request.method == 'POST':
change_type = request.form.get('change_type') change_type = request.form.get('change_type')
change_amount = int(request.form.get('change_amount', 0)) change_amount = int(request.form.get('change_amount', 0))
@ -69,12 +90,14 @@ def adjust_inventory(book_id):
if change_type == 'in': if change_type == 'in':
book.stock += change_amount book.stock += change_amount
after_stock = book.stock after_stock = book.stock
operation_desc = "入库"
elif change_type == 'out': elif change_type == 'out':
if book.stock < change_amount: if book.stock < change_amount:
flash('出库数量不能大于当前库存', 'danger') flash('出库数量不能大于当前库存', 'danger')
return redirect(url_for('inventory.adjust_inventory', book_id=book_id)) return redirect(url_for('inventory.adjust_inventory', book_id=book_id))
book.stock -= change_amount book.stock -= change_amount
after_stock = book.stock after_stock = book.stock
operation_desc = "出库"
else: else:
flash('无效的操作类型', 'danger') flash('无效的操作类型', 'danger')
return redirect(url_for('inventory.adjust_inventory', book_id=book_id)) return redirect(url_for('inventory.adjust_inventory', book_id=book_id))
@ -92,6 +115,18 @@ def adjust_inventory(book_id):
try: try:
db.session.add(log) 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() db.session.commit()
flash(f'图书《{book.title}》库存调整成功!原库存:{original_stock},现库存:{after_stock}', 'success') flash(f'图书《{book.title}》库存调整成功!原库存:{original_stock},现库存:{after_stock}', 'success')
return redirect(url_for('inventory.inventory_list')) return redirect(url_for('inventory.inventory_list'))
@ -116,6 +151,7 @@ def inventory_logs():
date_from = request.args.get('date_from', '') date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '') date_to = request.args.get('date_to', '')
query = InventoryLog.query query = InventoryLog.query
if book_id: if book_id:
query = query.filter_by(book_id=book_id) query = query.filter_by(book_id=book_id)
if change_type: if change_type:
@ -124,24 +160,49 @@ def inventory_logs():
query = query.filter(InventoryLog.changed_at >= datetime.strptime(date_from, '%Y-%m-%d')) query = query.filter(InventoryLog.changed_at >= datetime.strptime(date_from, '%Y-%m-%d'))
if date_to: if date_to:
query = query.filter(InventoryLog.changed_at <= datetime.strptime(date_to + ' 23:59:59', '%Y-%m-%d %H:%M:%S')) 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()) query = query.order_by(InventoryLog.changed_at.desc())
pagination = query.paginate(page=page, per_page=per_page) pagination = query.paginate(page=page, per_page=per_page)
logs = pagination.items logs = pagination.items
# 获取所有图书用于筛选 # 获取所有图书用于筛选
books = Book.query.all() books = Book.query.all()
# 如果特定 book_id 被指定,也获取该书的详细信息 # 如果特定 book_id 被指定,也获取该书的详细信息
book = Book.query.get(book_id) if book_id else None 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', return render_template('inventory/logs.html',
logs=logs, logs=logs,
pagination=pagination, pagination=pagination,
books=books, books=books,
book=book, # 添加这个变量 book=book,
book_id=book_id, book_id=book_id,
change_type=change_type, change_type=change_type,
date_from=date_from, date_from=date_from,
date_to=date_to) date_to=date_to)
@inventory_bp.route('/book/<int:book_id>/logs') @inventory_bp.route('/book/<int:book_id>/logs')
@login_required @login_required
@admin_required @admin_required
@ -155,6 +216,16 @@ def book_inventory_logs(book_id):
.order_by(InventoryLog.changed_at.desc()) \ .order_by(InventoryLog.changed_at.desc()) \
.paginate(page=page, per_page=per_page) .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', return render_template('inventory/book_logs.html',
book=book, book=book,
logs=logs.items, 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 flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from app.models.user import User, db from app.models.user import User, db
from app.models.log import Log # 导入日志模型
from app.utils.email import send_verification_email, generate_verification_code from app.utils.email import send_verification_email, generate_verification_code
import logging import logging
from functools import wraps from functools import wraps
@ -102,14 +103,35 @@ def login():
user = User.query.filter((User.username == username) | (User.email == username)).first() user = User.query.filter((User.username == username) | (User.email == username)).first()
if not user or not user.check_password(password): 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='用户名或密码错误') return render_template('login.html', error='用户名或密码错误')
if user.status == 0: 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='账号已被禁用,请联系管理员') return render_template('login.html', error='账号已被禁用,请联系管理员')
# 使用 Flask-Login 的 login_user 函数 # 使用 Flask-Login 的 login_user 函数
login_user(user, remember=remember_me) 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信息仍然可以保留但不再用于认证
session['username'] = user.username session['username'] = user.username
session['role_id'] = user.role_id session['role_id'] = user.role_id
@ -168,6 +190,14 @@ def register():
db.session.add(new_user) db.session.add(new_user)
db.session.commit() db.session.commit()
# 记录用户注册日志
Log.add_log(
action="用户注册",
user_id=new_user.id,
ip_address=request.remote_addr,
description=f"新用户 {username} 注册成功"
)
# 清除验证码 # 清除验证码
verification_codes.delete(email) verification_codes.delete(email)
@ -184,6 +214,17 @@ def register():
@user_bp.route('/logout') @user_bp.route('/logout')
@login_required @login_required
def logout(): 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() logout_user()
return redirect(url_for('user.login')) return redirect(url_for('user.login'))
@ -209,6 +250,12 @@ def send_verification_code():
# 发送验证码邮件 # 发送验证码邮件
if send_verification_email(email, code): if send_verification_email(email, code):
# 记录发送验证码日志
Log.add_log(
action="发送验证码",
ip_address=request.remote_addr,
description=f"向邮箱 {email} 发送验证码"
)
return jsonify({'success': True, 'message': '验证码已发送'}) return jsonify({'success': True, 'message': '验证码已发送'})
else: else:
return jsonify({'success': False, 'message': '邮件发送失败,请稍后重试'}) return jsonify({'success': False, 'message': '邮件发送失败,请稍后重试'})
@ -232,6 +279,14 @@ def user_list():
role_id=role_id 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() roles = UserService.get_all_roles()
return render_template( return render_template(
@ -271,11 +326,31 @@ def user_edit(user_id):
success, message = UserService.update_user(user_id, data) success, message = UserService.update_user(user_id, data)
if success: 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') flash(message, 'success')
return redirect(url_for('user.user_list')) return redirect(url_for('user.user_list'))
else: else:
flash(message, 'error') 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) 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: if user_id == current_user.id:
return jsonify({'success': False, 'message': '不能修改自己的状态'}) 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) 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}) return jsonify({'success': success, 'message': message})
@ -307,7 +400,25 @@ def user_delete(user_id):
if user_id == current_user.id: if user_id == current_user.id:
return jsonify({'success': False, 'message': '不能删除自己的账号'}) 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) 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}) return jsonify({'success': success, 'message': message})
@ -339,9 +450,23 @@ def user_profile():
return render_template('user/profile.html', user=user) return render_template('user/profile.html', user=user)
data['password'] = new_password data['password'] = new_password
password_changed = True
else:
password_changed = False
success, message = UserService.update_user(user.id, data) success, message = UserService.update_user(user.id, data)
if success: 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') flash(message, 'success')
else: else:
flash(message, 'error') flash(message, 'error')
@ -355,6 +480,15 @@ def user_profile():
@admin_required @admin_required
def role_list(): def role_list():
roles = UserService.get_all_roles() 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) return render_template('user/roles.html', roles=roles)
@ -373,20 +507,35 @@ def role_save():
if role_id: # 更新 if role_id: # 更新
success, message = UserService.update_role(role_id, role_name, description) 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: # 创建 else: # 创建
success, message = UserService.create_role(role_name, description) 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}) 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']) @user_bp.route('/user/role/<int:role_id>/count', methods=['GET'])
@login_required @login_required
@ -460,6 +609,16 @@ def add_user():
db.session.add(new_user) db.session.add(new_user)
db.session.commit() 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) 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> <a href="{{ url_for('inventory.inventory_list') }}"><i class="fas fa-warehouse"></i> 库存管理</a>
</li> </li>
<li class="{% if '/statistics' in request.path %}active{% endif %}"> <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>
<li class="{% if '/log' in request.path %}active{% endif %}"> <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> </li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -1,64 +1,70 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}批量导入图书 - 图书管理系统{% endblock %} {% block title %}图书花园 - 批量导入{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/book-import.css') }}"> <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 %} {% endblock %}
{% block content %} {% block content %}
<div class="import-container"> <div class="import-container">
<div class="page-header"> <div class="page-header animate__animated animate__fadeIn">
<h1>批量导入图书</h1> <h1 class="fancy-title">图书花园 <span class="subtitle">批量导入</span></h1>
<a href="{{ url_for('book.book_list') }}" class="btn btn-secondary"> <a href="{{ url_for('book.book_list') }}" class="btn btn-return">
<i class="fas fa-arrow-left"></i> 返回图书列表 <i class="fas fa-arrow-left"></i> 返回图书列表
</a> </a>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-8 offset-md-2"> <div class="col-md-8 offset-md-2">
<div class="card"> <div class="card animate__animated animate__fadeInUp">
<div class="card-header"> <div class="card-header">
<h4>Excel文件导入</h4> <h4><i class="fas fa-magic sparkle"></i> 添加您的图书收藏</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<div class="form-group"> <div class="form-group file-upload-wrapper">
<label for="file">选择Excel文件</label> <label for="file" class="elegant-label">选择您的Excel文件</label>
<div class="custom-file"> <div class="custom-file">
<input type="file" class="custom-file-input" id="file" name="file" accept=".xlsx, .xls" required> <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> </div>
<small class="form-text text-muted">支持的文件格式: .xlsx, .xls</small> <small class="form-text text-muted">支持的文件格式: .xlsx, .xls</small>
</div> </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> 开始导入 <i class="fas fa-upload"></i> 开始导入
</button> </button>
</form> </form>
<hr> <div class="divider">
<span class="divider-content"><i class="fas fa-book-open"></i></span>
</div>
<div class="import-instructions"> <div class="import-instructions animate__animated animate__fadeIn">
<h5>导入说明:</h5> <h5 class="instruction-title"><i class="fas fa-leaf"></i> 导入指南</h5>
<ul> <div class="instruction-content">
<li>Excel文件须包含以下列 (标题行必须与下列完全一致):</li> <p>Excel文件须包含以下字段 (标题行必须与下列完全一致):</p>
<li class="required-field">title - 图书标题 (必填)</li> <ul class="elegant-list">
<li class="required-field">author - 作者名称 (必填)</li> <li class="required-field"><span class="field-name">title</span> - 图书标题 <span class="required-badge">必填</span></li>
<li>publisher - 出版社</li> <li class="required-field"><span class="field-name">author</span> - 作者名称 <span class="required-badge">必填</span></li>
<li>category_id - 分类ID (对应系统中的分类ID)</li> <li><span class="field-name">publisher</span> - 出版社</li>
<li>tags - 标签 (多个标签用逗号分隔)</li> <li><span class="field-name">category_id</span> - 分类ID</li>
<li>isbn - ISBN编号 (建议唯一)</li> <li><span class="field-name">tags</span> - 标签 (多个标签用逗号分隔)</li>
<li>publish_year - 出版年份</li> <li><span class="field-name">isbn</span> - ISBN编号</li>
<li>description - 图书简介</li> <li><span class="field-name">publish_year</span> - 出版年份</li>
<li>cover_url - 封面图片URL</li> <li><span class="field-name">description</span> - 图书简介</li>
<li>stock - 库存数量</li> <li><span class="field-name">cover_url</span> - 封面图片URL</li>
<li>price - 价格</li> <li><span class="field-name">stock</span> - 库存数量</li>
<li><span class="field-name">price</span> - 价格</li>
</ul> </ul>
</div>
<div class="template-download"> <div class="template-download animate__animated animate__pulse animate__infinite animate__slower">
<p>下载导入模板:</p> <p>不确定如何开始? 下载我们精心准备的模板:</p>
<a href="{{ url_for('static', filename='templates/book_import_template.xlsx') }}" class="btn btn-outline-primary"> <a href="{{ url_for('book.download_template') }}" class="btn download-btn">
<i class="fas fa-download"></i> 下载Excel模板 <i class="fas fa-download"></i> 下载Excel模板
</a> </a>
</div> </div>
@ -67,17 +73,18 @@
</div> </div>
</div> </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> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script src="{{ url_for('static', filename='js/book-import.js') }}"></script>
$(document).ready(function() {
// 显示选择的文件名
$('.custom-file-input').on('change', function() {
const fileName = $(this).val().split('\\').pop();
$(this).next('.custom-file-label').html(fileName);
});
});
</script>
{% endblock %} {% 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