diff --git a/app/__init__.py b/app/__init__.py index 0ea45e4..dea4982 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,6 +6,8 @@ from app.controllers.book import book_bp from app.controllers.borrow import borrow_bp from app.controllers.inventory import inventory_bp from flask_login import LoginManager, current_user +from app.controllers.statistics import statistics_bp +from app.controllers.log import log_bp import os login_manager = LoginManager() @@ -49,7 +51,9 @@ def create_app(config=None): app.register_blueprint(user_bp, url_prefix='/user') app.register_blueprint(book_bp, url_prefix='/book') app.register_blueprint(borrow_bp, url_prefix='/borrow') + app.register_blueprint(statistics_bp) app.register_blueprint(inventory_bp) + app.register_blueprint(log_bp) # 创建数据库表 with app.app_context(): @@ -63,6 +67,7 @@ def create_app(config=None): # 再导入依赖模型 - 但不在这里定义关系 from app.models.borrow import BorrowRecord from app.models.inventory import InventoryLog + from app.models.log import Log # 移除这些重复的关系定义 # Book.borrow_records = db.relationship('BorrowRecord', backref='book', lazy='dynamic') diff --git a/app/controllers/book.py b/app/controllers/book.py index 0c75f7d..ffc4b6d 100644 --- a/app/controllers/book.py +++ b/app/controllers/book.py @@ -8,9 +8,11 @@ from werkzeug.utils import secure_filename import datetime import pandas as pd import uuid +from app.models.log import Log # 导入日志模型 book_bp = Blueprint('book', __name__) + @book_bp.route('/admin/list') @login_required @admin_required @@ -43,16 +45,26 @@ def admin_book_list(): books = pagination.items # 获取所有分类供筛选使用 categories = Category.query.all() + + # 记录访问日志 + Log.add_log( + action='访问管理图书列表', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"查询条件: 搜索={search}, 分类={category_id}, 排序={sort} {order}" + ) + return render_template('book/list.html', - books=books, - pagination=pagination, - search=search, - categories=categories, - category_id=category_id, - sort=sort, - order=order, - current_user=g.user, - is_admin_view=True) # 指明这是管理视图 + books=books, + pagination=pagination, + search=search, + categories=categories, + category_id=category_id, + sort=sort, + order=order, + current_user=g.user, + is_admin_view=True) # 指明这是管理视图 + # 图书列表页面 @book_bp.route('/list') @@ -94,6 +106,14 @@ def book_list(): # 获取所有分类供筛选使用 categories = Category.query.all() + # 记录访问日志 + Log.add_log( + action='访问图书列表', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"查询条件: 搜索={search}, 分类={category_id}, 排序={sort} {order}" + ) + return render_template('book/list.html', books=books, pagination=pagination, @@ -122,6 +142,16 @@ def book_detail(book_id): borrow_records = BorrowRecord.query.filter_by(book_id=book_id).order_by(BorrowRecord.borrow_date.desc()).limit( 10).all() + # 记录访问日志 + Log.add_log( + action='查看图书详情', + user_id=current_user.id, + target_type='book', + target_id=book_id, + ip_address=request.remote_addr, + description=f"查看图书: {book.title}" + ) + return render_template( 'book/detail.html', book=book, @@ -131,7 +161,6 @@ def book_detail(book_id): ) - # 添加图书页面 @book_bp.route('/add', methods=['GET', 'POST']) @login_required @@ -196,7 +225,7 @@ def add_book(): ext = '.jpg' # 默认扩展名 filename = f"{uuid.uuid4()}{ext}" - upload_folder = os.path.join(current_app.static_folder, 'covers') + upload_folder = os.path.join(current_app.static_folder, 'covers') # 确保上传目录存在 if not os.path.exists(upload_folder): @@ -247,6 +276,16 @@ def add_book(): db.session.add(inventory_log) db.session.commit() + # 记录操作日志 + Log.add_log( + action='添加图书', + user_id=current_user.id, + target_type='book', + target_id=book.id, + ip_address=request.remote_addr, + description=f"添加图书: {title}, ISBN: {isbn}, 初始库存: {stock}" + ) + flash(f'《{title}》添加成功', 'success') return redirect(url_for('book.book_list')) @@ -255,6 +294,15 @@ def add_book(): error_msg = str(e) # 记录详细错误日志 current_app.logger.error(f"添加图书失败: {error_msg}") + + # 记录操作失败日志 + Log.add_log( + action='添加图书失败', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"添加图书失败: {title}, 错误: {error_msg}" + ) + flash(f'添加图书失败: {error_msg}', 'danger') categories = Category.query.all() @@ -277,6 +325,7 @@ def add_book(): categories = Category.query.all() return render_template('book/add.html', categories=categories, current_user=g.user) + # 编辑图书 @book_bp.route('/edit/', methods=['GET', 'POST']) @login_required @@ -395,6 +444,9 @@ def edit_book(book_id): cover_file.save(file_path) book.cover_url = f'/static/covers/{filename}' + # 记录更新前的图书信息 + old_info = f"原信息: 书名={book.title}, 作者={book.author}, ISBN={book.isbn}, 库存={book.stock}" + # 更新图书信息 book.title = title book.author = author @@ -410,10 +462,32 @@ def edit_book(book_id): try: db.session.commit() + + # 记录操作日志 + Log.add_log( + action='编辑图书', + user_id=current_user.id, + target_type='book', + target_id=book.id, + ip_address=request.remote_addr, + description=f"编辑图书: {title}, ISBN: {isbn}, 新库存: {new_stock}\n{old_info}" + ) + flash('图书信息更新成功', 'success') return redirect(url_for('book.book_list')) except Exception as e: db.session.rollback() + + # 记录操作失败日志 + Log.add_log( + action='编辑图书失败', + user_id=current_user.id, + target_type='book', + target_id=book.id, + ip_address=request.remote_addr, + description=f"编辑图书失败: {title}, 错误: {str(e)}" + ) + flash(f'保存失败: {str(e)}', 'danger') categories = Category.query.all() return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) @@ -435,6 +509,15 @@ def delete_book(book_id): active_borrows = BorrowRecord.query.filter_by(book_id=book_id, status=1).count() if active_borrows > 0: + # 记录操作失败日志 + Log.add_log( + action='删除图书失败', + user_id=current_user.id, + target_type='book', + target_id=book_id, + ip_address=request.remote_addr, + description=f"删除图书失败: {book.title}, 原因: 该图书有未归还的借阅记录" + ) return jsonify({'success': False, 'message': '该图书有未归还的借阅记录,无法删除'}) # 考虑软删除而不是物理删除 @@ -442,6 +525,16 @@ def delete_book(book_id): book.updated_at = datetime.datetime.now() db.session.commit() + # 记录操作日志 + Log.add_log( + action='下架图书', + user_id=current_user.id, + target_type='book', + target_id=book_id, + ip_address=request.remote_addr, + description=f"下架图书: {book.title}, ISBN: {book.isbn}" + ) + return jsonify({'success': True, 'message': '图书已成功下架'}) @@ -451,6 +544,15 @@ def delete_book(book_id): @admin_required def category_list(): categories = Category.query.all() + + # 记录访问日志 + Log.add_log( + action='访问分类管理', + user_id=current_user.id, + ip_address=request.remote_addr, + description="访问图书分类管理页面" + ) + return render_template('book/categories.html', categories=categories, current_user=g.user) @@ -470,6 +572,14 @@ def add_category(): db.session.add(category) db.session.commit() + # 记录操作日志 + Log.add_log( + action='添加图书分类', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"添加图书分类: {name}, 上级分类ID: {parent_id}, 排序: {sort}" + ) + return jsonify({'success': True, 'message': '分类添加成功', 'id': category.id, 'name': category.name}) @@ -480,6 +590,10 @@ def add_category(): def edit_category(category_id): category = Category.query.get_or_404(category_id) + old_name = category.name + old_parent_id = category.parent_id + old_sort = category.sort + name = request.form.get('name') parent_id = request.form.get('parent_id') or None sort = request.form.get('sort', 0, type=int) @@ -492,6 +606,16 @@ def edit_category(category_id): category.sort = sort db.session.commit() + # 记录操作日志 + Log.add_log( + action='编辑图书分类', + user_id=current_user.id, + target_type='category', + target_id=category_id, + ip_address=request.remote_addr, + description=f"编辑图书分类: 从 [名称={old_name}, 上级={old_parent_id}, 排序={old_sort}] 修改为 [名称={name}, 上级={parent_id}, 排序={sort}]" + ) + return jsonify({'success': True, 'message': '分类更新成功'}) @@ -505,16 +629,46 @@ def delete_category(category_id): # 检查是否有书籍使用此分类 books_count = Book.query.filter_by(category_id=category_id).count() if books_count > 0: + # 记录操作失败日志 + Log.add_log( + action='删除图书分类失败', + user_id=current_user.id, + target_type='category', + target_id=category_id, + ip_address=request.remote_addr, + description=f"删除图书分类失败: {category.name}, 原因: 该分类下有{books_count}本图书" + ) return jsonify({'success': False, 'message': f'该分类下有{books_count}本图书,无法删除'}) # 检查是否有子分类 children_count = Category.query.filter_by(parent_id=category_id).count() if children_count > 0: + # 记录操作失败日志 + Log.add_log( + action='删除图书分类失败', + user_id=current_user.id, + target_type='category', + target_id=category_id, + ip_address=request.remote_addr, + description=f"删除图书分类失败: {category.name}, 原因: 该分类下有{children_count}个子分类" + ) return jsonify({'success': False, 'message': f'该分类下有{children_count}个子分类,无法删除'}) + category_name = category.name # 保存分类名称以便记录日志 + db.session.delete(category) db.session.commit() + # 记录操作日志 + Log.add_log( + action='删除图书分类', + user_id=current_user.id, + target_type='category', + target_id=category_id, + ip_address=request.remote_addr, + description=f"删除图书分类: {category_name}" + ) + return jsonify({'success': True, 'message': '分类删除成功'}) @@ -599,6 +753,15 @@ def import_books(): error_count += 1 db.session.commit() + + # 记录操作日志 + Log.add_log( + action='批量导入图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"批量导入图书: 成功{success_count}条,失败{error_count}条,文件名:{file.filename}" + ) + flash(f'导入完成: 成功{success_count}条,失败{error_count}条', 'info') if errors: flash('
'.join(errors[:10]) + (f'
...等共{len(errors)}个错误' if len(errors) > 10 else ''), @@ -607,6 +770,14 @@ def import_books(): return redirect(url_for('book.book_list')) except Exception as e: + # 记录操作失败日志 + Log.add_log( + action='批量导入图书失败', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"批量导入图书失败: {str(e)}, 文件名:{file.filename}" + ) + flash(f'导入失败: {str(e)}', 'danger') return redirect(request.url) else: @@ -673,6 +844,14 @@ def export_books(): # 写入Excel df.to_excel(filepath, index=False) + # 记录操作日志 + Log.add_log( + action='导出图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"导出图书数据: {len(data)}条记录, 查询条件: 搜索={search}, 分类ID={category_id}" + ) + # 提供下载链接 return redirect(url_for('static', filename=f'temp/{filename}')) @@ -691,6 +870,7 @@ def test_permissions():

尝试访问管理页面

""" + # 添加到app/controllers/book.py文件中 @book_bp.route('/browse') @@ -732,6 +912,14 @@ def browse_books(): # 获取所有分类供筛选使用 categories = Category.query.all() + # 记录访问日志 + Log.add_log( + action='浏览图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"浏览图书: 搜索={search}, 分类={category_id}, 排序={sort} {order}" + ) + return render_template('book/browse.html', books=books, pagination=pagination, @@ -739,4 +927,93 @@ def browse_books(): categories=categories, category_id=category_id, sort=sort, - order=order,) + order=order, ) + + +@book_bp.route('/template/download') +@login_required +@admin_required +def download_template(): + """生成并下载Excel图书导入模板""" + # 创建一个简单的DataFrame作为模板 + data = { + 'title': ['三体', '解忧杂货店'], + 'author': ['刘慈欣', '东野圭吾'], + 'publisher': ['重庆出版社', '南海出版公司'], + 'category_id': [2, 1], # 对应于科幻小说和文学分类 + 'tags': ['科幻,宇宙', '治愈,悬疑'], + 'isbn': ['9787229100605', '9787544270878'], + 'publish_year': ['2008', '2014'], + 'description': ['中国著名科幻小说,三体世界的故事。', '通过信件为人们解忧的杂货店故事。'], + 'cover_url': ['', ''], # 示例中为空 + 'stock': [10, 5], + 'price': [45.00, 39.80] + } + + # 创建一个分类ID和名称的映射 + categories = Category.query.all() + category_map = {cat.id: cat.name for cat in categories} + + # 创建一个pandas DataFrame + df = pd.DataFrame(data) + + # 添加说明工作表 + with pd.ExcelWriter('book_import_template.xlsx', engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='图书数据示例', index=False) + + # 创建说明工作表 + info_df = pd.DataFrame({ + '字段名': ['title', 'author', 'publisher', 'category_id', 'tags', + 'isbn', 'publish_year', 'description', 'cover_url', 'stock', 'price'], + '说明': ['图书标题 (必填)', '作者名称 (必填)', '出版社', '分类ID (对应系统分类)', + '标签 (多个标签用逗号分隔)', 'ISBN编号', '出版年份', '图书简介', + '封面图片URL', '库存数量', '价格'], + '示例': ['三体', '刘慈欣', '重庆出版社', '2 (科幻小说)', '科幻,宇宙', + '9787229100605', '2008', '中国著名科幻小说...', 'http://example.com/cover.jpg', '10', '45.00'], + '是否必填': ['是', '是', '否', '否', '否', '否', '否', '否', '否', '否', '否'] + }) + info_df.to_excel(writer, sheet_name='填写说明', index=False) + + # 创建分类ID工作表 + category_df = pd.DataFrame({ + '分类ID': list(category_map.keys()), + '分类名称': list(category_map.values()) + }) + category_df.to_excel(writer, sheet_name='分类对照表', index=False) + + # 设置响应 + timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + filename = f'book_import_template_{timestamp}.xlsx' + + # 记录操作日志 + Log.add_log( + action='下载图书导入模板', + user_id=current_user.id, + ip_address=request.remote_addr, + description="下载图书批量导入Excel模板" + ) + + # 直接返回生成的文件 + from flask import send_file + from io import BytesIO + + # 将文件转换为二进制流 + output = BytesIO() + with open('book_import_template.xlsx', 'rb') as f: + output.write(f.read()) + output.seek(0) + + # 删除临时文件 + try: + os.remove('book_import_template.xlsx') + except: + pass + + # 通过send_file返回 + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + diff --git a/app/controllers/borrow.py b/app/controllers/borrow.py index 52b2f6e..23433bb 100644 --- a/app/controllers/borrow.py +++ b/app/controllers/borrow.py @@ -4,6 +4,7 @@ from app.models.book import Book from app.models.borrow import BorrowRecord from app.models.inventory import InventoryLog from app.models.user import db, User +from app.models.log import Log # 导入日志模型 import datetime from app.utils.auth import admin_required @@ -72,6 +73,17 @@ def borrow_book(): changed_at=now ) db.session.add(inventory_log) + + # 添加系统操作日志 + Log.add_log( + action='借阅图书', + user_id=current_user.id, + target_type='book', + target_id=book_id, + ip_address=request.remote_addr, + description=f'用户借阅图书《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}' + ) + db.session.commit() flash(f'成功借阅《{book.title}》,请在 {due_date.strftime("%Y-%m-%d")} 前归还', 'success') @@ -145,6 +157,17 @@ def add_borrow(book_id): changed_at=now ) db.session.add(inventory_log) + + # 添加系统操作日志 + Log.add_log( + action='借阅图书', + user_id=current_user.id, + target_type='book', + target_id=book_id, + ip_address=request.remote_addr, + description=f'用户借阅图书《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}' + ) + db.session.commit() return jsonify({ @@ -207,6 +230,21 @@ def return_book(borrow_id): changed_at=now ) db.session.add(inventory_log) + + # 添加系统操作日志 + # 判断是否逾期归还 + is_overdue = now > borrow_record.due_date + overdue_msg = '(逾期归还)' if is_overdue else '' + + Log.add_log( + action='归还图书', + user_id=current_user.id, + target_type='book', + target_id=borrow_record.book_id, + ip_address=request.remote_addr, + description=f'用户归还图书《{book.title}》{overdue_msg}' + ) + db.session.commit() return jsonify({ @@ -252,6 +290,7 @@ def renew_book(borrow_id): try: now = datetime.datetime.now() + book = Book.query.get(borrow_record.book_id) # 检查是否已逾期 if now > borrow_record.due_date: @@ -268,6 +307,16 @@ def renew_book(borrow_id): borrow_record.renew_count += 1 borrow_record.updated_at = now + # 添加系统操作日志 + Log.add_log( + action='续借图书', + user_id=current_user.id, + target_type='book', + target_id=borrow_record.book_id, + ip_address=request.remote_addr, + description=f'用户续借图书《{book.title}》,新归还日期: {new_due_date.strftime("%Y-%m-%d")}' + ) + db.session.commit() return jsonify({ @@ -307,6 +356,14 @@ def my_borrows(): current_borrows_count = BorrowRecord.query.filter_by(user_id=current_user.id, status=1).count() history_borrows_count = BorrowRecord.query.filter_by(user_id=current_user.id, status=0).count() + # 记录日志 - 用户查看借阅记录 + Log.add_log( + action='查看借阅记录', + user_id=current_user.id, + ip_address=request.remote_addr, + description='用户查看个人借阅记录' + ) + return render_template( 'borrow/my_borrows.html', pagination=pagination, @@ -317,7 +374,6 @@ def my_borrows(): ) - @borrow_bp.route('/manage') @login_required @admin_required @@ -364,6 +420,14 @@ def manage_borrows(): # 获取所有用户(用于筛选) users = User.query.all() + # 记录日志 - 管理员查看借阅记录 + Log.add_log( + action='管理借阅记录', + user_id=current_user.id, + ip_address=request.remote_addr, + description='管理员查看借阅管理页面' + ) + return render_template( 'borrow/borrow_management.html', pagination=pagination, @@ -378,7 +442,6 @@ def manage_borrows(): ) - @borrow_bp.route('/admin/add', methods=['POST']) @login_required @admin_required @@ -445,6 +508,17 @@ def admin_add_borrow(): changed_at=now ) db.session.add(inventory_log) + + # 添加系统操作日志 + Log.add_log( + action='管理员借阅操作', + user_id=current_user.id, + target_type='book', + target_id=book_id, + ip_address=request.remote_addr, + description=f'管理员为用户 {user.username} 借阅图书《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}' + ) + db.session.commit() flash(f'成功为用户 {user.username} 借阅《{book.title}》,归还日期: {due_date.strftime("%Y-%m-%d")}', 'success') @@ -475,6 +549,14 @@ def overdue_borrows(): # 计算逾期总数 overdue_count = query.count() + # 记录日志 - 管理员查看逾期记录 + Log.add_log( + action='查看逾期记录', + user_id=current_user.id, + ip_address=request.remote_addr, + description='管理员查看逾期借阅记录' + ) + return render_template( 'borrow/overdue.html', pagination=pagination, @@ -508,6 +590,9 @@ def notify_overdue(borrow_id): }) try: + book = Book.query.get(borrow_record.book_id) + user = User.query.get(borrow_record.user_id) + # 创建通知 notification = Notification( user_id=borrow_record.user_id, @@ -519,11 +604,21 @@ def notify_overdue(borrow_id): ) db.session.add(notification) - db.session.commit() # 更新借阅记录备注 borrow_record.remark = f'{borrow_record.remark or ""}[{now.strftime("%Y-%m-%d")} 已发送逾期通知]' borrow_record.updated_at = now + + # 添加系统操作日志 + Log.add_log( + action='发送逾期通知', + user_id=current_user.id, + target_type='notification', + target_id=borrow_record.user_id, + ip_address=request.remote_addr, + description=f'管理员向用户 {user.username} 发送图书《{book.title}》逾期通知' + ) + db.session.commit() return jsonify({ diff --git a/app/controllers/inventory.py b/app/controllers/inventory.py index fb33411..66f8e19 100644 --- a/app/controllers/inventory.py +++ b/app/controllers/inventory.py @@ -3,6 +3,7 @@ from flask import Blueprint, render_template, request, jsonify, flash, redirect, from flask_login import login_required, current_user from app.models.book import Book from app.models.inventory import InventoryLog +from app.models.log import Log # 导入日志模型 from app.models.user import db from app.utils.auth import admin_required from datetime import datetime @@ -40,6 +41,15 @@ def inventory_list(): pagination = query.paginate(page=page, per_page=per_page) books = pagination.items + # 记录系统日志 - 访问库存管理页面 + Log.add_log( + action="访问库存管理", + user_id=current_user.id, + target_type="inventory", + ip_address=request.remote_addr, + description=f"管理员访问库存管理页面,搜索条件:{search if search else '无'}" + ) + return render_template('inventory/list.html', books=books, pagination=pagination, @@ -55,6 +65,17 @@ def adjust_inventory(book_id): """调整图书库存""" book = Book.query.get_or_404(book_id) + # GET请求记录日志 + if request.method == 'GET': + Log.add_log( + action="查看库存调整", + user_id=current_user.id, + target_type="book", + target_id=book.id, + ip_address=request.remote_addr, + description=f"管理员查看图书《{book.title}》的库存调整页面" + ) + if request.method == 'POST': change_type = request.form.get('change_type') change_amount = int(request.form.get('change_amount', 0)) @@ -69,12 +90,14 @@ def adjust_inventory(book_id): if change_type == 'in': book.stock += change_amount after_stock = book.stock + operation_desc = "入库" elif change_type == 'out': if book.stock < change_amount: flash('出库数量不能大于当前库存', 'danger') return redirect(url_for('inventory.adjust_inventory', book_id=book_id)) book.stock -= change_amount after_stock = book.stock + operation_desc = "出库" else: flash('无效的操作类型', 'danger') return redirect(url_for('inventory.adjust_inventory', book_id=book_id)) @@ -92,6 +115,18 @@ def adjust_inventory(book_id): try: db.session.add(log) + + # 记录系统日志 - 库存调整 + Log.add_log( + action=f"库存{operation_desc}", + user_id=current_user.id, + target_type="book", + target_id=book.id, + ip_address=request.remote_addr, + description=f"管理员对图书《{book.title}》进行{operation_desc}操作,数量:{change_amount}," + f"原库存:{original_stock},现库存:{after_stock},备注:{remark}" + ) + db.session.commit() flash(f'图书《{book.title}》库存调整成功!原库存:{original_stock},现库存:{after_stock}', 'success') return redirect(url_for('inventory.inventory_list')) @@ -116,6 +151,7 @@ def inventory_logs(): date_from = request.args.get('date_from', '') date_to = request.args.get('date_to', '') query = InventoryLog.query + if book_id: query = query.filter_by(book_id=book_id) if change_type: @@ -124,24 +160,49 @@ def inventory_logs(): query = query.filter(InventoryLog.changed_at >= datetime.strptime(date_from, '%Y-%m-%d')) if date_to: query = query.filter(InventoryLog.changed_at <= datetime.strptime(date_to + ' 23:59:59', '%Y-%m-%d %H:%M:%S')) + # 默认按时间倒序 query = query.order_by(InventoryLog.changed_at.desc()) pagination = query.paginate(page=page, per_page=per_page) logs = pagination.items + # 获取所有图书用于筛选 books = Book.query.all() + # 如果特定 book_id 被指定,也获取该书的详细信息 book = Book.query.get(book_id) if book_id else None + + # 记录系统日志 - 查看库存日志 + filter_desc = [] + if book_id: + book_title = book.title if book else f"ID:{book_id}" + filter_desc.append(f"图书:{book_title}") + if change_type: + change_type_text = "入库" if change_type == "in" else "出库" + filter_desc.append(f"操作类型:{change_type_text}") + if date_from or date_to: + date_range = f"{date_from or '无限制'} 至 {date_to or '无限制'}" + filter_desc.append(f"日期范围:{date_range}") + + Log.add_log( + action="查看库存日志", + user_id=current_user.id, + target_type="inventory_log", + ip_address=request.remote_addr, + description=f"管理员查看库存变动日志,筛选条件:{', '.join(filter_desc) if filter_desc else '无'}" + ) + return render_template('inventory/logs.html', logs=logs, pagination=pagination, books=books, - book=book, # 添加这个变量 + book=book, book_id=book_id, change_type=change_type, date_from=date_from, date_to=date_to) + @inventory_bp.route('/book//logs') @login_required @admin_required @@ -155,6 +216,16 @@ def book_inventory_logs(book_id): .order_by(InventoryLog.changed_at.desc()) \ .paginate(page=page, per_page=per_page) + # 记录系统日志 - 查看特定图书的库存日志 + Log.add_log( + action="查看图书库存日志", + user_id=current_user.id, + target_type="book", + target_id=book.id, + ip_address=request.remote_addr, + description=f"管理员查看图书《{book.title}》的库存变动日志" + ) + return render_template('inventory/book_logs.html', book=book, logs=logs.items, diff --git a/app/controllers/log.py b/app/controllers/log.py index e69de29..c530184 100644 --- a/app/controllers/log.py +++ b/app/controllers/log.py @@ -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/') +@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 diff --git a/app/controllers/statistics.py b/app/controllers/statistics.py index e69de29..6ba3ed6 100644 --- a/app/controllers/statistics.py +++ b/app/controllers/statistics.py @@ -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) diff --git a/app/controllers/user.py b/app/controllers/user.py index dd7289f..918feb7 100644 --- a/app/controllers/user.py +++ b/app/controllers/user.py @@ -1,6 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from werkzeug.security import generate_password_hash, check_password_hash from app.models.user import User, db +from app.models.log import Log # 导入日志模型 from app.utils.email import send_verification_email, generate_verification_code import logging from functools import wraps @@ -102,14 +103,35 @@ def login(): user = User.query.filter((User.username == username) | (User.email == username)).first() if not user or not user.check_password(password): + # 记录登录失败日志 + Log.add_log( + action="登录失败", + ip_address=request.remote_addr, + description=f"尝试使用用户名/邮箱 {username} 登录失败" + ) return render_template('login.html', error='用户名或密码错误') if user.status == 0: + # 记录禁用账号登录尝试 + Log.add_log( + action="登录失败", + user_id=user.id, + ip_address=request.remote_addr, + description=f"禁用账号 {username} 尝试登录" + ) return render_template('login.html', error='账号已被禁用,请联系管理员') # 使用 Flask-Login 的 login_user 函数 login_user(user, remember=remember_me) + # 记录登录成功日志 + Log.add_log( + action="用户登录", + user_id=user.id, + ip_address=request.remote_addr, + description=f"用户 {user.username} 登录成功" + ) + # 这些session信息仍然可以保留,但不再用于认证 session['username'] = user.username session['role_id'] = user.role_id @@ -168,6 +190,14 @@ def register(): db.session.add(new_user) db.session.commit() + # 记录用户注册日志 + Log.add_log( + action="用户注册", + user_id=new_user.id, + ip_address=request.remote_addr, + description=f"新用户 {username} 注册成功" + ) + # 清除验证码 verification_codes.delete(email) @@ -184,6 +214,17 @@ def register(): @user_bp.route('/logout') @login_required def logout(): + username = current_user.username + user_id = current_user.id + + # 先记录日志,再登出 + Log.add_log( + action="用户登出", + user_id=user_id, + ip_address=request.remote_addr, + description=f"用户 {username} 登出系统" + ) + logout_user() return redirect(url_for('user.login')) @@ -209,6 +250,12 @@ def send_verification_code(): # 发送验证码邮件 if send_verification_email(email, code): + # 记录发送验证码日志 + Log.add_log( + action="发送验证码", + ip_address=request.remote_addr, + description=f"向邮箱 {email} 发送验证码" + ) return jsonify({'success': True, 'message': '验证码已发送'}) else: return jsonify({'success': False, 'message': '邮件发送失败,请稍后重试'}) @@ -232,6 +279,14 @@ def user_list(): role_id=role_id ) + # 记录管理员访问用户列表日志 + Log.add_log( + action="访问用户管理", + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 访问用户管理列表" + ) + roles = UserService.get_all_roles() return render_template( @@ -271,11 +326,31 @@ def user_edit(user_id): success, message = UserService.update_user(user_id, data) if success: + # 记录管理员编辑用户信息日志 + Log.add_log( + action="编辑用户", + user_id=current_user.id, + target_type="用户", + target_id=user_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 编辑用户 {user.username} 的信息" + ) flash(message, 'success') return redirect(url_for('user.user_list')) else: flash(message, 'error') + # 记录访问用户编辑页面日志 + if request.method == 'GET': + Log.add_log( + action="访问用户编辑", + user_id=current_user.id, + target_type="用户", + target_id=user_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 访问用户 {user.username} 的编辑页面" + ) + return render_template('user/edit.html', user=user, roles=roles) @@ -294,7 +369,25 @@ def user_status(user_id): if user_id == current_user.id: return jsonify({'success': False, 'message': '不能修改自己的状态'}) + # 查询用户获取用户名(用于日志) + target_user = User.query.get(user_id) + if not target_user: + return jsonify({'success': False, 'message': '用户不存在'}) + success, message = UserService.change_user_status(user_id, status) + + if success: + # 记录修改用户状态日志 + status_text = "启用" if status == 1 else "禁用" + Log.add_log( + action=f"用户{status_text}", + user_id=current_user.id, + target_type="用户", + target_id=user_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} {status_text}用户 {target_user.username}" + ) + return jsonify({'success': success, 'message': message}) @@ -307,7 +400,25 @@ def user_delete(user_id): if user_id == current_user.id: return jsonify({'success': False, 'message': '不能删除自己的账号'}) + # 查询用户获取用户名(用于日志) + target_user = User.query.get(user_id) + if not target_user: + return jsonify({'success': False, 'message': '用户不存在'}) + + target_username = target_user.username # 保存用户名以便记录在日志中 + success, message = UserService.delete_user(user_id) + + if success: + # 记录删除用户日志 + Log.add_log( + action="删除用户", + user_id=current_user.id, + target_type="用户", + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 删除用户 {target_username}" + ) + return jsonify({'success': success, 'message': message}) @@ -339,9 +450,23 @@ def user_profile(): return render_template('user/profile.html', user=user) data['password'] = new_password + password_changed = True + else: + password_changed = False success, message = UserService.update_user(user.id, data) if success: + # 记录用户修改个人信息日志 + log_description = f"用户 {user.username} 修改了个人信息" + if password_changed: + log_description += ",包括密码修改" + + Log.add_log( + action="修改个人信息", + user_id=user.id, + ip_address=request.remote_addr, + description=log_description + ) flash(message, 'success') else: flash(message, 'error') @@ -355,6 +480,15 @@ def user_profile(): @admin_required def role_list(): roles = UserService.get_all_roles() + + # 记录访问角色管理页面日志 + Log.add_log( + action="访问角色管理", + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 访问角色管理页面" + ) + return render_template('user/roles.html', roles=roles) @@ -373,20 +507,35 @@ def role_save(): if role_id: # 更新 success, message = UserService.update_role(role_id, role_name, description) + if success: + # 记录编辑角色日志 + Log.add_log( + action="编辑角色", + user_id=current_user.id, + target_type="角色", + target_id=role_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 编辑角色 {role_name}" + ) else: # 创建 success, message = UserService.create_role(role_name, description) + if success: + # 获取新创建的角色ID + new_role = db.session.query(User.Role).filter_by(role_name=role_name).first() + role_id = new_role.id if new_role else None + + # 记录创建角色日志 + Log.add_log( + action="创建角色", + user_id=current_user.id, + target_type="角色", + target_id=role_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 创建新角色 {role_name}" + ) return jsonify({'success': success, 'message': message}) -""" -@user_bp.route('/api/role//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//count', methods=['GET']) @login_required @@ -460,6 +609,16 @@ def add_user(): db.session.add(new_user) db.session.commit() + # 记录管理员添加用户日志 + Log.add_log( + action="添加用户", + user_id=current_user.id, + target_type="用户", + target_id=new_user.id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 添加新用户 {username}" + ) + # 清除验证码 verification_codes.delete(email) diff --git a/app/models/log.py b/app/models/log.py index e69de29..89f46ed 100644 --- a/app/models/log.py +++ b/app/models/log.py @@ -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) diff --git a/app/static/css/book-import.css b/app/static/css/book-import.css new file mode 100644 index 0000000..a8c3ddc --- /dev/null +++ b/app/static/css/book-import.css @@ -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; +} diff --git a/app/static/css/book-import.css b/app/static/css/book-import.css deleted file mode 100644 index f9e9c3d..0000000 --- a/app/static/css/book-import.css +++ /dev/null @@ -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; - } -} diff --git a/app/static/css/book_ranking.css b/app/static/css/book_ranking.css new file mode 100644 index 0000000..6ba849a --- /dev/null +++ b/app/static/css/book_ranking.css @@ -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); } +} diff --git a/app/static/css/borrow_statistics.css b/app/static/css/borrow_statistics.css new file mode 100644 index 0000000..f5206eb --- /dev/null +++ b/app/static/css/borrow_statistics.css @@ -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; + } +} diff --git a/app/static/css/log-detail.css b/app/static/css/log-detail.css new file mode 100644 index 0000000..f3e4c33 --- /dev/null +++ b/app/static/css/log-detail.css @@ -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; +} diff --git a/app/static/css/log-list.css b/app/static/css/log-list.css new file mode 100644 index 0000000..77dc05a --- /dev/null +++ b/app/static/css/log-list.css @@ -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; + } +} + + diff --git a/app/static/css/overdue_analysis.css b/app/static/css/overdue_analysis.css new file mode 100644 index 0000000..bd06869 --- /dev/null +++ b/app/static/css/overdue_analysis.css @@ -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; + } +} diff --git a/app/static/css/statistics.css b/app/static/css/statistics.css new file mode 100644 index 0000000..6543d75 --- /dev/null +++ b/app/static/css/statistics.css @@ -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; + } +} diff --git a/app/static/css/user_activity.css b/app/static/css/user_activity.css new file mode 100644 index 0000000..7ff8b0f --- /dev/null +++ b/app/static/css/user_activity.css @@ -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; +} diff --git a/app/static/js/book-edit.js b/app/static/js/book-edit.js similarity index 100% rename from app/static/js/book-edit.js rename to app/static/js/book-edit.js diff --git a/app/static/js/book-import.js b/app/static/js/book-import.js new file mode 100644 index 0000000..8676cf5 --- /dev/null +++ b/app/static/js/book-import.js @@ -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 = ' 正在导入...'; + 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 = ` +
+ ${message} +
+ `; + + // 如果是临时消息,设置自动消失 + 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); + } +} diff --git a/app/static/js/book_ranking.js b/app/static/js/book_ranking.js new file mode 100644 index 0000000..09ec9bb --- /dev/null +++ b/app/static/js/book_ranking.js @@ -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 = ` + + 加载中... + + `; + + // 调用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 = ` + + 加载数据失败,请稍后重试 + + `; + }); + } + + function updateRankingTable(data) { + const tableBody = document.getElementById('ranking-table-body'); + + if (data.length === 0) { + tableBody.innerHTML = ` + + 暂无数据 + + `; + return; + } + + let tableHtml = ''; + + data.forEach((book, index) => { + tableHtml += ` + + ${index + 1} + + ${book.title} + + ${book.title} + ${book.author} + ${book.borrow_count} + + `; + }); + + 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: '热门图书借阅排行' + } + } + } + }); + } +}); diff --git a/app/static/js/borrow_statistics.js b/app/static/js/borrow_statistics.js new file mode 100644 index 0000000..77b7c26 --- /dev/null +++ b/app/static/js/borrow_statistics.js @@ -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 = ` +
+
${totalBorrows}
+
总借阅
+
+
+
${totalReturns}
+
总归还
+
+
+
${totalOverdue}
+
当前逾期
+
+
+
${Math.round((totalBorrows - totalReturns) - totalOverdue)}
+
正常借出
+
+ `; + } +}); diff --git a/app/static/js/log-list.js b/app/static/js/log-list.js new file mode 100644 index 0000000..400f319 --- /dev/null +++ b/app/static/js/log-list.js @@ -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 = ` + + ${message} + + `; + + 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); + }); + } + } +}); diff --git a/app/static/js/overdue_analysis.js b/app/static/js/overdue_analysis.js new file mode 100644 index 0000000..176292a --- /dev/null +++ b/app/static/js/overdue_analysis.js @@ -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: '借阅状态分布' + } + } + } + }); + } +}); diff --git a/app/static/js/statistics.js b/app/static/js/statistics.js new file mode 100644 index 0000000..c134adf --- /dev/null +++ b/app/static/js/statistics.js @@ -0,0 +1,5 @@ +// app/static/js/statistics.js +document.addEventListener('DOMContentLoaded', function() { + // 统计模块主页初始化 + console.log('统计分析模块加载完成'); +}); diff --git a/app/static/js/user_activity.js b/app/static/js/user_activity.js new file mode 100644 index 0000000..65ec3a9 --- /dev/null +++ b/app/static/js/user_activity.js @@ -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 = ` + + 加载中... + + `; + + // 调用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 = ` + + 加载数据失败,请稍后重试 + + `; + }); + } + + function updateUserTable(data) { + const tableBody = document.getElementById('user-table-body'); + + if (data.length === 0) { + tableBody.innerHTML = ` + + 暂无数据 + + `; + return; + } + + let tableHtml = ''; + + data.forEach((user, index) => { + tableHtml += ` + + ${index + 1} + ${user.username} + ${user.nickname} + ${user.borrow_count} + + `; + }); + + 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: '最活跃用户排行' + } + } + } + }); + } +}); diff --git a/app/templates/base.html b/app/templates/base.html index 2cf8eb4..7620a80 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -52,10 +52,10 @@ 库存管理
  • - 统计分析 + 统计分析
  • - 日志管理 + 日志管理
  • {% endif %} diff --git a/app/templates/book/import.html b/app/templates/book/import.html index 5ece977..a2c5471 100644 --- a/app/templates/book/import.html +++ b/app/templates/book/import.html @@ -1,64 +1,70 @@ {% extends 'base.html' %} -{% block title %}批量导入图书 - 图书管理系统{% endblock %} +{% block title %}图书花园 - 批量导入{% endblock %} {% block head %} + + {% endblock %} {% block content %}
    -