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