From 5933289d6e62cf655992e349b5fecea752692d16 Mon Sep 17 00:00:00 2001 From: superlishunqin <852326703@qq.com> Date: Mon, 12 May 2025 19:44:22 +0800 Subject: [PATCH] index__new_feature --- app/__init__.py | 109 +- app/controllers/announcement.py | 324 +++++ app/controllers/book.py | 51 +- app/models/announcement.py | 123 ++ app/models/notification.py | 115 ++ app/static/css/announcement-detail.css | 101 ++ app/static/css/announcement-form.css | 58 + app/static/css/announcement-list.css | 130 +++ app/static/css/announcement-manage.css | 83 ++ app/static/css/book_ranking.css | 35 +- app/static/css/index.css | 28 +- app/static/css/notifications.css | 188 +++ app/static/css/statistics.css | 1039 ++++++++++------- app/static/js/announcement-form.js | 37 + app/static/js/announcement-list.js | 19 + app/static/js/announcement-manage.js | 69 ++ app/static/js/book_ranking.js | 55 +- app/static/js/notifications.js | 66 ++ app/static/js/overdue_analysis.js | 150 ++- app/static/js/user_activity.js | 50 +- app/templates/announcement/add.html | 91 ++ app/templates/announcement/detail.html | 36 + app/templates/announcement/edit.html | 94 ++ app/templates/announcement/list.html | 104 ++ app/templates/announcement/manage.html | 163 +++ app/templates/announcement/notifications.html | 113 ++ app/templates/base.html | 54 +- app/templates/index.html | 179 ++- app/templates/statistics/index.html | 15 +- app/utils/auth.py | 17 +- 30 files changed, 3121 insertions(+), 575 deletions(-) create mode 100644 app/static/css/announcement-detail.css create mode 100644 app/static/css/announcement-form.css create mode 100644 app/static/css/announcement-list.css create mode 100644 app/static/css/announcement-manage.css create mode 100644 app/static/css/notifications.css create mode 100644 app/static/js/announcement-form.js create mode 100644 app/static/js/announcement-list.js create mode 100644 app/static/js/announcement-manage.js create mode 100644 app/static/js/notifications.js create mode 100644 app/templates/announcement/add.html create mode 100644 app/templates/announcement/detail.html create mode 100644 app/templates/announcement/edit.html create mode 100644 app/templates/announcement/list.html create mode 100644 app/templates/announcement/manage.html create mode 100644 app/templates/announcement/notifications.html diff --git a/app/__init__.py b/app/__init__.py index dea4982..3b71b3e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,9 +7,11 @@ 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.announcement import announcement_bp +from app.models.notification import Notification from app.controllers.log import log_bp import os - +from datetime import datetime login_manager = LoginManager() @@ -54,6 +56,7 @@ def create_app(config=None): app.register_blueprint(statistics_bp) app.register_blueprint(inventory_bp) app.register_blueprint(log_bp) + app.register_blueprint(announcement_bp, url_prefix='/announcement') # 创建数据库表 with app.app_context(): @@ -124,9 +127,83 @@ def create_app(config=None): @app.route('/') def index(): - if not current_user.is_authenticated: - return redirect(url_for('user.login')) - return render_template('index.html') # 无需传递current_user,Flask-Login自动提供 + from app.models.book import Book + from app.models.user import User + from app.models.borrow import BorrowRecord + from app.models.announcement import Announcement + from app.models.notification import Notification + from sqlalchemy import func, desc + from flask_login import current_user + + # 获取统计数据 + stats = { + 'total_books': Book.query.count(), + 'total_users': User.query.count(), + 'active_borrows': BorrowRecord.query.filter(BorrowRecord.return_date.is_(None)).count(), + 'user_borrows': 0 + } + + # 如果用户已登录,获取其待还图书数量 + if current_user.is_authenticated: + stats['user_borrows'] = BorrowRecord.query.filter( + BorrowRecord.user_id == current_user.id, + BorrowRecord.return_date.is_(None) + ).count() + + # 获取最新图书 + latest_books = Book.query.filter_by(status=1).order_by(Book.created_at.desc()).limit(4).all() + + # 获取热门图书(根据借阅次数) + try: + # 这里假设你的数据库中有表记录借阅次数 + popular_books_query = db.session.query( + Book, func.count(BorrowRecord.id).label('borrow_count') + ).join( + BorrowRecord, Book.id == BorrowRecord.book_id, isouter=True + ).filter( + Book.status == 1 + ).group_by( + Book.id + ).order_by( + desc('borrow_count') + ).limit(5) + + # 提取图书对象并添加借阅计数 + popular_books = [] + for book, count in popular_books_query: + book.borrow_count = count + popular_books.append(book) + except Exception as e: + # 如果查询有问题,使用最新的书作为备选 + popular_books = latest_books.copy() if latest_books else [] + print(f"获取热门图书失败: {str(e)}") + + # 获取最新公告 + announcements = Announcement.query.filter_by(status=1).order_by( + Announcement.is_top.desc(), + Announcement.created_at.desc() + ).limit(3).all() + + now = datetime.now() + + # 获取用户的未读通知 + user_notifications = [] + if current_user.is_authenticated: + user_notifications = Notification.query.filter_by( + user_id=current_user.id, + status=0 + ).order_by( + Notification.created_at.desc() + ).limit(5).all() + + return render_template('index.html', + stats=stats, + latest_books=latest_books, + popular_books=popular_books, + announcements=announcements, + user_notifications=user_notifications, + now=now + ) @app.errorhandler(404) def page_not_found(e): @@ -138,8 +215,32 @@ def create_app(config=None): return Markup(s.replace('\n', '
')) return s + @app.context_processor + def utility_processor(): + def get_unread_notifications_count(user_id): + if user_id: + return Notification.get_unread_count(user_id) + return 0 + + def get_recent_notifications(user_id, limit=5): + if user_id: + # 按时间倒序获取最近的几条通知 + notifications = Notification.query.filter_by(user_id=user_id) \ + .order_by(Notification.created_at.desc()) \ + .limit(limit) \ + .all() + return notifications + return [] + + return dict( + get_unread_notifications_count=get_unread_notifications_count, + get_recent_notifications=get_recent_notifications + ) + return app @app.context_processor def inject_now(): return {'now': datetime.datetime.now()} + + diff --git a/app/controllers/announcement.py b/app/controllers/announcement.py index e69de29..55dedb3 100644 --- a/app/controllers/announcement.py +++ b/app/controllers/announcement.py @@ -0,0 +1,324 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify +from app.models.announcement import Announcement +from app.models.log import Log +from app.utils.auth import admin_required +from flask_login import login_required, current_user +from datetime import datetime +from app.models.notification import Notification + +# 创建蓝图 +announcement_bp = Blueprint('announcement', __name__) + + +@announcement_bp.route('/list', methods=['GET']) +def announcement_list(): + """公告列表页面 - 所有用户可见""" + page = request.args.get('page', 1, type=int) + per_page = 10 + + # 查询活跃的公告 + query = Announcement.query.filter_by(status=1).order_by( + Announcement.is_top.desc(), + Announcement.created_at.desc() + ) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + + return render_template('announcement/list.html', pagination=pagination) + + +@announcement_bp.route('/detail/', methods=['GET']) +def announcement_detail(announcement_id): + """公告详情页面""" + announcement = Announcement.get_announcement_by_id(announcement_id) + + if not announcement or announcement.status == 0: + flash('公告不存在或已被删除', 'error') + return redirect(url_for('announcement.announcement_list')) + + return render_template('announcement/detail.html', announcement=announcement) + + +@announcement_bp.route('/manage', methods=['GET']) +@login_required +@admin_required +def manage_announcements(): + """管理员公告管理页面""" + page = request.args.get('page', 1, type=int) + per_page = 10 + search = request.args.get('search', '') + status = request.args.get('status', type=int) + + # 构建查询 + query = Announcement.query + + # 搜索过滤 + if search: + query = query.filter(Announcement.title.like(f'%{search}%')) + + # 状态过滤 + if status is not None: + query = query.filter(Announcement.status == status) + + # 排序 + query = query.order_by( + Announcement.is_top.desc(), + Announcement.created_at.desc() + ) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + + # 记录访问日志 + Log.add_log( + action="访问公告管理", + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 访问公告管理页面" + ) + + return render_template( + 'announcement/manage.html', + pagination=pagination, + search=search, + status=status + ) + + +@announcement_bp.route('/add', methods=['GET', 'POST']) +@login_required +@admin_required +def add_announcement(): + """添加公告""" + if request.method == 'POST': + title = request.form.get('title') + content = request.form.get('content') + is_top = request.form.get('is_top') == 'on' + + if not title or not content: + flash('标题和内容不能为空', 'error') + return render_template('announcement/add.html') + + success, result = Announcement.create_announcement( + title=title, + content=content, + publisher_id=current_user.id, + is_top=is_top + ) + + if success: + # 记录操作日志 + Log.add_log( + action="添加公告", + user_id=current_user.id, + target_type="公告", + target_id=result.id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 添加了新公告: {title}" + ) + + flash('公告发布成功', 'success') + return redirect(url_for('announcement.manage_announcements')) + else: + flash(f'公告发布失败: {result}', 'error') + return render_template('announcement/add.html') + + return render_template('announcement/add.html') + + +@announcement_bp.route('/edit/', methods=['GET', 'POST']) +@login_required +@admin_required +def edit_announcement(announcement_id): + """编辑公告""" + announcement = Announcement.get_announcement_by_id(announcement_id) + + if not announcement: + flash('公告不存在', 'error') + return redirect(url_for('announcement.manage_announcements')) + + if request.method == 'POST': + title = request.form.get('title') + content = request.form.get('content') + is_top = request.form.get('is_top') == 'on' + + if not title or not content: + flash('标题和内容不能为空', 'error') + return render_template('announcement/edit.html', announcement=announcement) + + success, result = Announcement.update_announcement( + announcement_id=announcement_id, + title=title, + content=content, + is_top=is_top + ) + + if success: + # 记录操作日志 + Log.add_log( + action="编辑公告", + user_id=current_user.id, + target_type="公告", + target_id=announcement_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} 编辑了公告: {title}" + ) + + flash('公告更新成功', 'success') + return redirect(url_for('announcement.manage_announcements')) + else: + flash(f'公告更新失败: {result}', 'error') + return render_template('announcement/edit.html', announcement=announcement) + + return render_template('announcement/edit.html', announcement=announcement) + + +@announcement_bp.route('/status/', methods=['POST']) +@login_required +@admin_required +def change_status(announcement_id): + """更改公告状态""" + data = request.get_json() + status = data.get('status') + + if status is None or status not in [0, 1]: + return jsonify({'success': False, 'message': '无效的状态值'}) + + # 查询公告获取标题(用于日志) + announcement = Announcement.get_announcement_by_id(announcement_id) + if not announcement: + return jsonify({'success': False, 'message': '公告不存在'}) + + success, message = Announcement.change_status(announcement_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=announcement_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} {status_text}公告: {announcement.title}" + ) + + return jsonify({'success': True, 'message': f'公告已{status_text}'}) + else: + return jsonify({'success': False, 'message': message}) + + +@announcement_bp.route('/top/', methods=['POST']) +@login_required +@admin_required +def change_top_status(announcement_id): + """更改公告置顶状态""" + data = request.get_json() + is_top = data.get('is_top') + + if is_top is None: + return jsonify({'success': False, 'message': '无效的置顶状态'}) + + # 查询公告获取标题(用于日志) + announcement = Announcement.get_announcement_by_id(announcement_id) + if not announcement: + return jsonify({'success': False, 'message': '公告不存在'}) + + success, message = Announcement.change_top_status(announcement_id, is_top) + + if success: + # 记录置顶状态变更日志 + action_text = "置顶" if is_top else "取消置顶" + Log.add_log( + action=f"公告{action_text}", + user_id=current_user.id, + target_type="公告", + target_id=announcement_id, + ip_address=request.remote_addr, + description=f"管理员 {current_user.username} {action_text}公告: {announcement.title}" + ) + + return jsonify({'success': True, 'message': f'公告已{action_text}'}) + else: + return jsonify({'success': False, 'message': message}) + + +@announcement_bp.route('/latest', methods=['GET']) +def get_latest_announcements(): + """获取最新公告列表,用于首页和API""" + limit = request.args.get('limit', 5, type=int) + announcements = Announcement.get_active_announcements(limit=limit) + + return jsonify({ + 'success': True, + 'announcements': [announcement.to_dict() for announcement in announcements] + }) + + +@announcement_bp.route('/notifications') +@login_required +def user_notifications(): + """用户个人通知列表页面""" + page = request.args.get('page', 1, type=int) + per_page = 10 + unread_only = request.args.get('unread_only') == '1' + + pagination = Notification.get_user_notifications( + user_id=current_user.id, + page=page, + per_page=per_page, + unread_only=unread_only + ) + + return render_template( + 'announcement/notifications.html', + pagination=pagination, + unread_only=unread_only + ) + + +@announcement_bp.route('/notification/') +@login_required +def view_notification(notification_id): + """查看单条通知""" + notification = Notification.query.get_or_404(notification_id) + + # 检查权限 - 只能查看自己的通知 + if notification.user_id != current_user.id: + flash('您无权查看此通知', 'error') + return redirect(url_for('announcement.user_notifications')) + + # 标记为已读 + if notification.status == 0: + Notification.mark_as_read(notification_id, current_user.id) + + # 如果是借阅类型的通知,可能需要跳转到相关页面 + if notification.type == 'borrow' and 'borrow_id' in notification.content: + # 这里可以解析content获取borrow_id然后重定向 + pass + + return render_template('announcement/notification_detail.html', notification=notification) + + +@announcement_bp.route('/notifications/mark-all-read') +@login_required +def mark_all_as_read(): + """标记所有通知为已读""" + try: + # 获取所有未读通知 + unread_notifications = Notification.query.filter_by( + user_id=current_user.id, + status=0 + ).all() + + # 标记为已读 + for notification in unread_notifications: + notification.status = 1 + notification.read_at = datetime.now() + + db.session.commit() + flash('所有通知已标记为已读', 'success') + except Exception as e: + db.session.rollback() + flash(f'操作失败: {str(e)}', 'error') + + return redirect(url_for('announcement.user_notifications')) diff --git a/app/controllers/book.py b/app/controllers/book.py index ffc4b6d..88d1bf1 100644 --- a/app/controllers/book.py +++ b/app/controllers/book.py @@ -1,8 +1,8 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, g, jsonify +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify from app.models.book import Book, Category from app.models.user import db from app.utils.auth import login_required, admin_required -from flask_login import current_user, login_required +from flask_login import current_user # 移除重复的 login_required 导入 import os from werkzeug.utils import secure_filename import datetime @@ -62,7 +62,7 @@ def admin_book_list(): category_id=category_id, sort=sort, order=order, - current_user=g.user, + current_user=current_user, # 使用current_user替代g.user is_admin_view=True) # 指明这是管理视图 @@ -122,7 +122,7 @@ def book_list(): category_id=category_id, sort=sort, order=order, - current_user=g.user) + current_user=current_user) # 使用current_user替代g.user # 图书详情页面 @@ -136,8 +136,8 @@ def book_detail(book_id): # 如果用户是管理员,预先查询并排序借阅记录 borrow_records = [] - # 防御性编程:确保 g.user 存在且有 role_id 属性 - if hasattr(g, 'user') and g.user is not None and hasattr(g.user, 'role_id') and g.user.role_id == 1: + # 使用current_user代替g.user + if current_user.is_authenticated and current_user.role_id == 1: from app.models.borrow import BorrowRecord borrow_records = BorrowRecord.query.filter_by(book_id=book_id).order_by(BorrowRecord.borrow_date.desc()).limit( 10).all() @@ -155,7 +155,7 @@ def book_detail(book_id): return render_template( 'book/detail.html', book=book, - current_user=current_user, # 使用 flask_login 的 current_user 而不是 g.user + current_user=current_user, borrow_records=borrow_records, now=now ) @@ -209,7 +209,7 @@ def add_book(): 'price': price } return render_template('book/add.html', categories=categories, - current_user=g.user, book=book_data) + current_user=current_user, book=book_data) # 处理封面图片上传 cover_url = None @@ -269,7 +269,7 @@ def add_book(): change_type='入库', change_amount=stock, after_stock=stock, - operator_id=g.user.id, + operator_id=current_user.id, # 使用current_user.id替代g.user.id remark='新书入库', changed_at=datetime.datetime.now() ) @@ -320,10 +320,10 @@ def add_book(): 'price': price } return render_template('book/add.html', categories=categories, - current_user=g.user, book=book_data) + current_user=current_user, book=book_data) categories = Category.query.all() - return render_template('book/add.html', categories=categories, current_user=g.user) + return render_template('book/add.html', categories=categories, current_user=current_user) # 编辑图书 @@ -350,7 +350,7 @@ def edit_book(book_id): if not title or not author: flash('书名和作者不能为空', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # ISBN验证 if isbn and isbn.strip(): # 确保ISBN不是空字符串 @@ -361,7 +361,7 @@ def edit_book(book_id): if len(clean_isbn) != 10 and len(clean_isbn) != 13: flash('ISBN必须是10位或13位', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # ISBN-10验证 if len(clean_isbn) == 10: @@ -369,13 +369,13 @@ def edit_book(book_id): if not clean_isbn[:9].isdigit(): flash('ISBN-10的前9位必须是数字', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # 检查最后一位是否为数字或'X' if not (clean_isbn[9].isdigit() or clean_isbn[9].upper() == 'X'): flash('ISBN-10的最后一位必须是数字或X', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # 校验和验证 sum = 0 @@ -388,7 +388,7 @@ def edit_book(book_id): if sum % 11 != 0: flash('ISBN-10校验和无效', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # ISBN-13验证 if len(clean_isbn) == 13: @@ -396,7 +396,7 @@ def edit_book(book_id): if not clean_isbn.isdigit(): flash('ISBN-13必须全是数字', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # 校验和验证 sum = 0 @@ -408,7 +408,7 @@ def edit_book(book_id): if check_digit != int(clean_isbn[12]): flash('ISBN-13校验和无效', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # 处理库存变更 new_stock = request.form.get('stock', type=int) or 0 # 默认为0而非None @@ -422,7 +422,7 @@ def edit_book(book_id): change_type=change_type, change_amount=abs(change_amount), after_stock=new_stock, - operator_id=g.user.id, + operator_id=current_user.id, # 使用current_user.id替代g.user.id remark=f'管理员编辑图书库存 - {book.title}', changed_at=datetime.datetime.now() ) @@ -490,11 +490,11 @@ def edit_book(book_id): flash(f'保存失败: {str(e)}', 'danger') categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # GET 请求 categories = Category.query.all() - return render_template('book/edit.html', book=book, categories=categories, current_user=g.user) + return render_template('book/edit.html', book=book, categories=categories, current_user=current_user) # 删除图书 @@ -553,7 +553,7 @@ def category_list(): description="访问图书分类管理页面" ) - return render_template('book/categories.html', categories=categories, current_user=g.user) + return render_template('book/categories.html', categories=categories, current_user=current_user) # 添加分类 @@ -741,7 +741,7 @@ def import_books(): change_type='入库', change_amount=book.stock, after_stock=book.stock, - operator_id=g.user.id, + operator_id=current_user.id, # 使用current_user.id remark='批量导入图书', changed_at=datetime.datetime.now() ) @@ -784,7 +784,7 @@ def import_books(): flash('只支持Excel文件(.xlsx, .xls)', 'danger') return redirect(request.url) - return render_template('book/import.html', current_user=g.user) + return render_template('book/import.html', current_user=current_user) # 使用current_user # 导出图书 @@ -927,7 +927,7 @@ def browse_books(): categories=categories, category_id=category_id, sort=sort, - order=order, ) + order=order) # current_user自动传递到模板 @book_bp.route('/template/download') @@ -1016,4 +1016,3 @@ def download_template(): as_attachment=True, download_name=filename ) - diff --git a/app/models/announcement.py b/app/models/announcement.py index e69de29..4b96264 100644 --- a/app/models/announcement.py +++ b/app/models/announcement.py @@ -0,0 +1,123 @@ +from datetime import datetime +from app.models.user import db, User # 从user模块导入db,而不是从app.models导入 + + +class Announcement(db.Model): + __tablename__ = 'announcements' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(128), nullable=False) + content = db.Column(db.Text, nullable=False) + publisher_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + is_top = db.Column(db.Boolean, default=False) + status = db.Column(db.Integer, default=1) # 1-正常, 0-已下架 + created_at = db.Column(db.DateTime, nullable=False, default=datetime.now) + updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + # 关联关系 + publisher = db.relationship('User', backref='announcements') + + def to_dict(self): + """将公告转换为字典""" + return { + 'id': self.id, + 'title': self.title, + 'content': self.content, + 'publisher_id': self.publisher_id, + 'publisher_name': self.publisher.username if self.publisher else '', + 'is_top': self.is_top, + 'status': self.status, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') + } + + @staticmethod + def get_active_announcements(limit=None): + """获取活跃的公告""" + query = Announcement.query.filter_by(status=1).order_by( + Announcement.is_top.desc(), + Announcement.created_at.desc() + ) + + if limit: + query = query.limit(limit) + + return query.all() + + @staticmethod + def get_announcement_by_id(announcement_id): + """根据ID获取公告""" + return Announcement.query.get(announcement_id) + + @staticmethod + def create_announcement(title, content, publisher_id, is_top=False): + """创建新公告""" + announcement = Announcement( + title=title, + content=content, + publisher_id=publisher_id, + is_top=is_top + ) + + try: + db.session.add(announcement) + db.session.commit() + return True, announcement + except Exception as e: + db.session.rollback() + return False, str(e) + + @staticmethod + def update_announcement(announcement_id, title, content, is_top=None): + """更新公告内容""" + announcement = Announcement.query.get(announcement_id) + + if not announcement: + return False, "公告不存在" + + announcement.title = title + announcement.content = content + + if is_top is not None: + announcement.is_top = is_top + + try: + db.session.commit() + return True, announcement + except Exception as e: + db.session.rollback() + return False, str(e) + + @staticmethod + def change_status(announcement_id, status): + """更改公告状态""" + announcement = Announcement.query.get(announcement_id) + + if not announcement: + return False, "公告不存在" + + announcement.status = status + + try: + db.session.commit() + return True, "状态已更新" + except Exception as e: + db.session.rollback() + return False, str(e) + + @staticmethod + def change_top_status(announcement_id, is_top): + """更改置顶状态""" + announcement = Announcement.query.get(announcement_id) + + if not announcement: + return False, "公告不存在" + + announcement.is_top = is_top + + try: + db.session.commit() + return True, "置顶状态已更新" + except Exception as e: + db.session.rollback() + return False, str(e) diff --git a/app/models/notification.py b/app/models/notification.py index e69de29..0ca1abb 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -0,0 +1,115 @@ +from datetime import datetime +from app.models.user import db, User # 从user模块导入db,而不是从app.models导入 + + +class Notification(db.Model): + __tablename__ = 'notifications' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + title = db.Column(db.String(128), nullable=False) + content = db.Column(db.Text, nullable=False) + type = db.Column(db.String(32), nullable=False) # 通知类型:system, borrow, return, overdue, etc. + status = db.Column(db.Integer, default=0) # 0-未读, 1-已读 + sender_id = db.Column(db.Integer, db.ForeignKey('users.id')) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.now) + read_at = db.Column(db.DateTime) + + # 关联关系 + user = db.relationship('User', foreign_keys=[user_id], backref='notifications') + sender = db.relationship('User', foreign_keys=[sender_id], backref='sent_notifications') + + def to_dict(self): + """将通知转换为字典""" + return { + 'id': self.id, + 'user_id': self.user_id, + 'title': self.title, + 'content': self.content, + 'type': self.type, + 'status': self.status, + 'sender_id': self.sender_id, + 'sender_name': self.sender.username if self.sender else 'System', + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'read_at': self.read_at.strftime('%Y-%m-%d %H:%M:%S') if self.read_at else None + } + + @staticmethod + def get_user_notifications(user_id, page=1, per_page=10, unread_only=False): + """获取用户通知""" + query = Notification.query.filter_by(user_id=user_id) + + if unread_only: + query = query.filter_by(status=0) + + return query.order_by(Notification.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + @staticmethod + def get_unread_count(user_id): + """获取用户未读通知数量""" + return Notification.query.filter_by(user_id=user_id, status=0).count() + + @staticmethod + def mark_as_read(notification_id, user_id=None): + """将通知标记为已读""" + notification = Notification.query.get(notification_id) + + if not notification: + return False, "通知不存在" + + # 验证用户权限 + if user_id and notification.user_id != user_id: + return False, "无权操作此通知" + + notification.status = 1 + notification.read_at = datetime.now() + + try: + db.session.commit() + return True, "已标记为已读" + except Exception as e: + db.session.rollback() + return False, str(e) + + @staticmethod + def create_notification(user_id, title, content, notification_type, sender_id=None): + """创建新通知""" + notification = Notification( + user_id=user_id, + title=title, + content=content, + type=notification_type, + sender_id=sender_id + ) + + try: + db.session.add(notification) + db.session.commit() + return True, notification + except Exception as e: + db.session.rollback() + return False, str(e) + + @staticmethod + def create_system_notification(user_ids, title, content, notification_type, sender_id=None): + """创建系统通知,发送给多个用户""" + success_count = 0 + fail_count = 0 + + for user_id in user_ids: + success, _ = Notification.create_notification( + user_id=user_id, + title=title, + content=content, + notification_type=notification_type, + sender_id=sender_id + ) + + if success: + success_count += 1 + else: + fail_count += 1 + + return success_count, fail_count diff --git a/app/static/css/announcement-detail.css b/app/static/css/announcement-detail.css new file mode 100644 index 0000000..68363a1 --- /dev/null +++ b/app/static/css/announcement-detail.css @@ -0,0 +1,101 @@ +.announcement-detail-container { + padding: 20px; + max-width: 900px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 25px; + position: relative; +} + +.back-link { + display: inline-block; + margin-bottom: 15px; + color: #6c757d; + text-decoration: none; + transition: color 0.2s; +} + +.back-link:hover { + color: #007bff; +} + +.page-header h1 { + margin-top: 0; + margin-bottom: 20px; + font-size: 2rem; + line-height: 1.3; +} + +.announcement-meta { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 25px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 8px; +} + +.meta-item { + display: flex; + align-items: center; + font-size: 0.95rem; + color: #6c757d; +} + +.meta-item i { + margin-right: 8px; +} + +.meta-item.pinned { + color: #dc3545; + font-weight: 500; +} + +.announcement-content { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + padding: 25px; + line-height: 1.7; + color: #333; +} + +/* 内容中的富文本样式 */ +.announcement-content h1, +.announcement-content h2, +.announcement-content h3 { + margin-top: 1.5em; + margin-bottom: 0.8em; +} + +.announcement-content p { + margin-bottom: 1em; +} + +.announcement-content img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 15px 0; +} + +.announcement-content ul, +.announcement-content ol { + margin-bottom: 1em; + padding-left: 2em; +} + +.announcement-content a { + color: #007bff; +} + +.announcement-content blockquote { + border-left: 4px solid #e3e3e3; + padding-left: 15px; + color: #6c757d; + margin-left: 0; + margin-right: 0; +} diff --git a/app/static/css/announcement-form.css b/app/static/css/announcement-form.css new file mode 100644 index 0000000..1d58cd9 --- /dev/null +++ b/app/static/css/announcement-form.css @@ -0,0 +1,58 @@ +.announcement-form-container { + padding: 20px; + max-width: 900px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 25px; + border-bottom: 1px solid #e3e3e3; + padding-bottom: 10px; +} + +.card { + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + margin-bottom: 30px; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + font-weight: 500; + margin-bottom: 0.5rem; + display: block; +} + +.ql-container { + min-height: 200px; + font-size: 16px; +} + +.form-check { + margin-top: 20px; + margin-bottom: 20px; +} + +.form-buttons { + display: flex; + justify-content: flex-end; + gap: 15px; + margin-top: 30px; +} + +.form-buttons .btn { + min-width: 100px; +} + +/* Quill编辑器样式重写 */ +.ql-toolbar.ql-snow { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.ql-container.ql-snow { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} diff --git a/app/static/css/announcement-list.css b/app/static/css/announcement-list.css new file mode 100644 index 0000000..b3d588f --- /dev/null +++ b/app/static/css/announcement-list.css @@ -0,0 +1,130 @@ +.announcement-container { + padding: 20px; +} + +.page-header { + margin-bottom: 25px; + border-bottom: 1px solid #e3e3e3; + padding-bottom: 10px; +} + +.announcement-list { + margin-top: 20px; +} + +.announcement-item { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0,0,0,0.05); + padding: 20px; + margin-bottom: 20px; + position: relative; + transition: transform 0.2s, box-shadow 0.2s; +} + +.announcement-item:hover { + transform: translateY(-3px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); +} + +.announcement-item.pinned { + border-left: 4px solid #dc3545; + background-color: #fff9f9; +} + +.pin-badge { + position: absolute; + top: 0; + right: 0; + background-color: #dc3545; + color: white; + padding: 5px 10px; + border-radius: 0 8px 0 8px; + font-size: 0.8rem; +} + +.announcement-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.announcement-header h3 { + margin: 0; + font-size: 1.3rem; +} + +.announcement-header h3 a { + color: #333; + text-decoration: none; +} + +.announcement-header h3 a:hover { + color: #007bff; +} + +.date { + color: #6c757d; + font-size: 0.9rem; +} + +.announcement-preview { + margin: 15px 0; + color: #495057; + line-height: 1.6; +} + +.announcement-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #f1f1f1; +} + +.publisher { + color: #6c757d; + font-style: italic; + font-size: 0.9rem; +} + +.read-more { + color: #007bff; + text-decoration: none; + font-weight: 500; + display: flex; + align-items: center; +} + +.read-more i { + margin-left: 5px; + transition: transform 0.2s; +} + +.read-more:hover i { + transform: translateX(3px); +} + +.pagination-container { + margin-top: 30px; + display: flex; + justify-content: center; +} + +.no-records { + text-align: center; + padding: 50px 20px; + background-color: #f8f9fa; + border-radius: 8px; + color: #6c757d; +} + +.no-records i { + font-size: 3rem; + margin-bottom: 15px; +} + +.no-records p { + font-size: 1.2rem; +} diff --git a/app/static/css/announcement-manage.css b/app/static/css/announcement-manage.css new file mode 100644 index 0000000..2e78bcb --- /dev/null +++ b/app/static/css/announcement-manage.css @@ -0,0 +1,83 @@ +.announcement-manage-container { + padding: 20px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + border-bottom: 1px solid #e3e3e3; + padding-bottom: 15px; +} + +.filter-container { + margin-bottom: 25px; +} + +.filter-form { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; +} + +.filter-form .form-group { + margin-bottom: 0; + min-width: 200px; +} + +.announcement-table { + background-color: #fff; + box-shadow: 0 2px 10px rgba(0,0,0,0.05); + border-radius: 8px; +} + +.announcement-table th { + background-color: #f8f9fa; + white-space: nowrap; +} + +.announcement-title { + font-weight: 500; + color: #333; + text-decoration: none; + display: block; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.announcement-title:hover { + color: #007bff; + text-decoration: underline; +} + +.btn-group { + display: flex; + gap: 5px; +} + +.pagination-container { + margin-top: 30px; + display: flex; + justify-content: center; +} + +.no-records { + text-align: center; + padding: 50px 20px; + background-color: #f8f9fa; + border-radius: 8px; + color: #6c757d; +} + +.no-records i { + font-size: 3rem; + margin-bottom: 15px; +} + +.no-records p { + font-size: 1.2rem; +} diff --git a/app/static/css/book_ranking.css b/app/static/css/book_ranking.css index 6ba849a..8992c99 100644 --- a/app/static/css/book_ranking.css +++ b/app/static/css/book_ranking.css @@ -104,7 +104,8 @@ font-weight: 600; color: var(--accent-color); position: relative; - display: inline-block; + display: block; /* 修改为block以占据整个单元格 */ + text-align: center; /* 确保文本居中 */ } .data-table .borrow-count:after { @@ -247,34 +248,40 @@ tr:hover .book-title:after { display: inline-block; } -/* 前三名特殊样式 */ +/* 前三名特殊样式 - 替换这部分代码 */ +.data-table tr:nth-child(1) .rank:before, +.data-table tr:nth-child(2) .rank:before, +.data-table tr:nth-child(3) .rank:before { + position: absolute; + left: 10px; /* 调整到数字左侧 */ + top: 50%; /* 垂直居中 */ + transform: translateY(-50%); /* 保持垂直居中 */ + opacity: 0.9; +} + +/* 分别设置每个奖牌的内容 */ .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 .rank { + padding-left: 35px; /* 增加左内边距为图标腾出空间 */ + text-align: left; /* 使数字左对齐 */ +} + + /* 加载动画美化 */ .loading-animation { display: flex; diff --git a/app/static/css/index.css b/app/static/css/index.css index f0896b9..953b87f 100644 --- a/app/static/css/index.css +++ b/app/static/css/index.css @@ -533,25 +533,35 @@ ul { display: flex; background-color: #f8fafc; border-radius: 8px; - padding: 15px; + padding: 20px 15px 15px; /* 增加顶部内边距,为角标留出空间 */ min-width: 280px; position: relative; + margin-top: 10px; /* 在顶部添加一些外边距 */ + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + transition: transform 0.2s; +} + +.popular-book-item:hover { + transform: translateY(-3px); + box-shadow: 0 4px 6px rgba(0,0,0,0.1); } .rank-badge { position: absolute; - top: -10px; + top: -8px; /* 略微调高一点 */ left: 10px; background-color: #4a89dc; color: white; - width: 24px; - height: 24px; + width: 28px; /* 增加尺寸 */ + height: 28px; /* 增加尺寸 */ display: flex; align-items: center; justify-content: center; border-radius: 50%; - font-size: 0.8rem; + font-size: 0.85rem; font-weight: bold; + z-index: 10; /* 确保它位于其他元素之上 */ + box-shadow: 0 2px 4px rgba(0,0,0,0.2); /* 添加阴影使其更突出 */ } .book-cover.small { @@ -559,6 +569,14 @@ ul { height: 90px; min-width: 60px; margin-right: 15px; + border-radius: 4px; + overflow: hidden; /* 确保图片不会溢出容器 */ +} + +.book-cover.small img { + width: 100%; + height: 100%; + object-fit: cover; /* 确保图片正确填充容器 */ } .book-details { diff --git a/app/static/css/notifications.css b/app/static/css/notifications.css new file mode 100644 index 0000000..e384379 --- /dev/null +++ b/app/static/css/notifications.css @@ -0,0 +1,188 @@ +.notifications-container { + padding: 20px; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + border-bottom: 1px solid #e3e3e3; + padding-bottom: 15px; +} + +.notification-actions { + display: flex; + gap: 10px; +} + +.filter-tabs { + display: flex; + margin-bottom: 20px; + border-bottom: 1px solid #dee2e6; +} + +.filter-tab { + padding: 10px 20px; + color: #495057; + text-decoration: none; + border-bottom: 2px solid transparent; + font-weight: 500; +} + +.filter-tab:hover { + color: #007bff; + text-decoration: none; +} + +.filter-tab.active { + color: #007bff; + border-bottom-color: #007bff; +} + +.notifications-list { + margin-top: 20px; +} + +.notification-card { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0,0,0,0.05); + padding: 20px; + margin-bottom: 15px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.notification-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); +} + +.notification-card.unread { + border-left: 4px solid #007bff; + background-color: #f8fbff; +} + +.notification-title { + display: flex; + align-items: center; + margin-top: 0; + margin-bottom: 10px; + font-size: 1.2rem; +} + +.notification-title a { + color: #333; + text-decoration: none; +} + +.notification-title a:hover { + color: #007bff; +} + +.unread-badge { + background-color: #007bff; + color: white; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 10px; + margin-left: 10px; +} + +.notification-text { + color: #495057; + margin-bottom: 15px; + line-height: 1.5; +} + +.notification-meta { + display: flex; + justify-content: space-between; + color: #6c757d; + font-size: 0.85rem; +} + +.notification-type { + background-color: #f1f1f1; + padding: 2px 8px; + border-radius: 4px; +} + +.no-records { + text-align: center; + padding: 50px 20px; + background-color: #f8f9fa; + border-radius: 8px; + color: #6c757d; +} + +.no-records i { + font-size: 3rem; + margin-bottom: 15px; +} + +.no-records p { + font-size: 1.2rem; +} + +/* 通知下拉菜单样式 */ +.notification-dropdown { + width: 320px; + padding: 0; + max-height: 400px; + overflow-y: auto; +} + +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 1rem; +} + +.mark-all-read { + font-size: 0.8rem; + color: #007bff; +} + +.notification-items { + max-height: 300px; + overflow-y: auto; +} + +.notification-item { + padding: 10px 15px; + border-bottom: 1px solid #f1f1f1; +} + +.notification-item.unread { + background-color: #f8fbff; +} + +.notification-content h6 { + margin-bottom: 5px; + font-size: 0.9rem; +} + +.notification-text { + font-size: 0.8rem; + color: #6c757d; + margin-bottom: 5px; +} + +.notification-time { + font-size: 0.75rem; + color: #999; +} + +.view-all { + text-align: center; + font-weight: 500; +} + +.no-notifications { + padding: 20px; + text-align: center; + color: #6c757d; +} + diff --git a/app/static/css/statistics.css b/app/static/css/statistics.css index 6543d75..38ae0bb 100644 --- a/app/static/css/statistics.css +++ b/app/static/css/statistics.css @@ -1,84 +1,342 @@ /* 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; + /* Soft & Elegant Palette */ + --color-primary-light-pink: #FCE4EC; /* 淡粉色 */ + --color-primary-milk-white: #FFF8F0; /* 奶白色 */ + --color-primary-apricot: #FFDAB9; /* 浅杏色 */ + + --color-aux-rose-gold: #B76E79; /* 玫瑰金 */ + --color-aux-light-purple: #E6E6FA; /* 淡紫色 */ + --color-aux-soft-gray: #D3D3D3; /* 柔和的灰色 */ + + --color-accent-berry-red: #8C2D5A; /* 深一点的浆果红 */ + + --font-serif-elegant: 'Playfair Display', serif; + --font-serif-lora: 'Lora', serif; + --font-sans-clean: 'Open Sans', sans-serif; + --font-script-delicate: 'Sacramento', cursive; + --font-serif-garamond: 'EB Garamond', serif; + + /* Derived/General Usage */ + --background-main: var(--color-primary-milk-white); + --background-container: #FFFFFF; + --text-main: #5D5053; /* A darker, softer, slightly desaturated rose-brown */ + --text-soft: #8A797C; + --text-heading: var(--color-aux-rose-gold); + --text-accent: var(--color-accent-berry-red); + --border-soft: var(--color-aux-soft-gray); + --border-decorative: var(--color-primary-light-pink); + --shadow-soft: rgba(183, 110, 121, 0.1); /* Soft shadow based on rose gold */ + --shadow-subtle: rgba(0, 0, 0, 0.05); + + /* Fallback for old variables - some might still be used by unchanged CSS */ + --primary-color: var(--color-primary-light-pink); + --secondary-color: var(--color-primary-apricot); /* Or #FFF8F0 for a lighter secondary */ + --accent-color: var(--color-aux-rose-gold); + --text-color: var(--text-main); + --light-text: var(--text-soft); + --border-color: var(--border-soft); + --shadow-color: var(--shadow-soft); + --hover-color: #F8E0E6; /* Lighter pink for hover */ } body { - background-color: #fff9fb; - color: var(--text-color); - font-family: 'Arial', sans-serif; + background-color: var(--background-main); + color: var(--text-main); + font-family: var(--font-sans-clean); + font-weight: 300; /* Lighter default font weight */ + line-height: 1.7; /* Increased line height */ } .statistics-container { - padding: 25px; - max-width: 1200px; - margin: 0 auto; - background-color: white; - border-radius: 15px; - box-shadow: 0 5px 20px var(--shadow-color); + padding: 40px 30px; /* Increased padding */ + max-width: 1100px; /* Slightly adjusted max-width */ + margin: 40px auto; /* More margin for breathing room */ + background-color: var(--background-container); + border-radius: 16px; /* Softer, larger border-radius */ + box-shadow: 0 8px 25px var(--shadow-soft); /* Softer shadow */ position: relative; overflow: hidden; } .page-title { - color: var(--accent-color); - margin-bottom: 30px; + color: var(--text-heading); + margin-bottom: 35px; padding-bottom: 15px; - border-bottom: 2px dotted var(--border-color); + border-bottom: 1px solid var(--border-decorative); /* Thinner, delicate line */ text-align: center; - font-family: 'Ma Shan Zheng', cursive, Arial, sans-serif; - font-size: 2.2em; - letter-spacing: 1px; + font-family: var(--font-serif-elegant); + font-size: 2.8em; /* Larger, more prominent */ + font-weight: 700; + letter-spacing: 0.5px; } -/* 波浪下划线动画 */ +/* Simplified page title decoration */ .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; + width: 80px; /* Shorter line */ + height: 2px; /* Thinner line */ + margin: 12px auto 0; + background: var(--color-aux-rose-gold); /* Solid accent color */ + border-radius: 2px; + /* animation: wave 3s infinite linear; Removed wave animation for elegance */ } -@keyframes wave { +/* @keyframes wave { 0%, 100% { background-position-x: 0%; } 50% { background-position-x: 100%; } +} */ + +/* Quote Banner - Styled for elegance */ +.quote-banner { + background-color: var(--color-primary-light-pink); /* Soft pink background */ + border-radius: 12px; /* Softer radius */ + padding: 25px 35px; /* Ample padding */ + margin: 0 auto 40px; /* Increased bottom margin */ + max-width: 75%; + text-align: center; + box-shadow: 0 4px 15px rgba(183, 110, 121, 0.08); /* Very subtle shadow */ + border-left: 3px solid var(--color-aux-rose-gold); + border-right: 3px solid var(--color-aux-rose-gold); + position: relative; } +.quote-banner p { + font-family: var(--font-serif-garamond), serif; /* Elegant serif for quote */ + font-style: italic; + color: var(--color-accent-berry-red); /* Berry red for emphasis */ + font-size: 1.1em; /* Slightly larger */ + margin: 0; + letter-spacing: 0.2px; + line-height: 1.6; +} + +.quote-banner:before, +.quote-banner:after { + content: """; /* Using """ for opening */ + font-family: var(--font-serif-elegant), serif; /* Consistent elegant font */ + font-size: 50px; /* Adjusted size */ + color: var(--color-aux-rose-gold); /* Rose gold for quotes */ + opacity: 0.4; /* Softer opacity */ + position: absolute; + top: 0px; +} + +.quote-banner:before { + left: 15px; +} + +.quote-banner:after { + content: """; /* Using """ for closing */ + right: 15px; + top: auto; /* Adjust position for closing quote mark */ + bottom: -20px; +} + +/* Stats Grid - main navigation cards container */ +.stats-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 30px; /* Increased gap for more whitespace */ + margin: 40px auto; /* Adjusted margin */ + max-width: 900px; /* Adjusted max-width */ +} + +.stats-grid .stats-card { + position: relative; + background-color: var(--background-container); + border-radius: 12px; /* Softer radius */ + overflow: hidden; + box-shadow: 0 6px 18px var(--shadow-subtle); /* More subtle shadow */ + transition: transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1), box-shadow 0.35s cubic-bezier(0.25, 0.8, 0.25, 1); + text-decoration: none; + color: var(--text-main); + border: 1px solid #F0E8E9; /* Very light, almost invisible border */ + min-height: 260px; /* Ensure cards have enough height */ + padding: 0; +} + +.card-inner { /* This class is directly inside stats-card links */ + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 25px; /* Ample padding */ + height: 100%; + position: relative; + z-index: 2; + background: transparent; /* Make it transparent to show card background */ + transition: background-color 0.3s ease; +} + +.stats-grid .stats-card:hover { + transform: translateY(-6px); /* Slightly less aggressive transform */ + box-shadow: 0 10px 25px var(--shadow-soft); /* Enhanced shadow on hover */ + border-color: var(--color-primary-light-pink); +} +.stats-grid .stats-card:hover .card-inner { + /* background: rgba(255, 248, 240, 0.5); */ /* Optional: very subtle hover background on inner part */ +} + + +.stats-grid .card-icon { + font-size: 36px; /* Slightly smaller icon */ + margin-bottom: 18px; + color: var(--color-aux-rose-gold); + background-color: var(--color-primary-milk-white); /* Milk white for icon background */ + width: 70px; /* Adjusted size */ + height: 70px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + box-shadow: 0 3px 8px rgba(183, 110, 121, 0.15); /* Subtle shadow for icon */ + transition: transform 0.3s ease, color 0.3s ease; +} + +.stats-grid .stats-card:hover .card-icon { + transform: scale(1.08) rotate(3deg); + color: var(--color-accent-berry-red); /* Icon color change on hover */ +} + +.stats-grid .card-title { + font-family: var(--font-serif-lora); + font-size: 1.45em; /* Adjusted size */ + font-weight: 600; + margin-bottom: 12px; + color: var(--text-heading); + position: relative; + display: inline-block; +} + +.stats-grid .card-title:after { /* Decorative line under card title */ + content: ''; + position: absolute; + bottom: -6px; /* Positioned slightly below */ + left: 50%; + transform: translateX(-50%) scaleX(0); /* Start scaled to 0 */ + width: 60%; /* Line width relative to title */ + height: 1.5px; + background-color: var(--color-primary-light-pink); /* Light pink line */ + transition: transform 0.35s ease-out; + transform-origin: center; +} + +.stats-grid .stats-card:hover .card-title:after { + transform: translateX(-50%) scaleX(1); /* Scale to full on hover */ +} + +.stats-grid .card-description { + font-family: var(--font-sans-clean); + font-size: 0.9em; + color: var(--text-soft); + line-height: 1.5; + max-width: 90%; /* Prevent text from touching edges */ +} + + +/* Card Decoration - Subtle background elements */ +.card-decoration { + position: absolute; + bottom: -40px; /* Adjusted position */ + right: -40px; + width: 120px; /* Smaller decoration */ + height: 120px; + border-radius: 50%; + background-color: var(--color-primary-light-pink); /* Light pink base */ + opacity: 0.15; /* More subtle opacity */ + transition: all 0.5s ease; + z-index: 1; +} + +.stats-card:hover .card-decoration { /* Use stats-card hover for decoration */ + transform: scale(1.4); + opacity: 0.25; +} + +/* Specific card decorations with more subtle emoji styling */ +.card-decoration:before { /* General style for emoji if used */ + position: absolute; + font-size: 24px; /* Smaller emoji */ + top: 50%; /* Centered better */ + left: 50%; + transform: translate(-50%, -50%); + opacity: 0.3; /* Very subtle */ + color: var(--color-aux-rose-gold); /* Themed color */ +} + +.book-decoration:before { content: '📚'; } +.trend-decoration:before { content: '📈'; } +.user-decoration:before { content: '👥'; } +.overdue-decoration:before { content: '⏰'; } + + +/* Page Decoration - Floating elements */ +.page-decoration { + position: absolute; + width: 180px; /* Slightly smaller */ + height: 180px; + border-radius: 50%; + background: linear-gradient(45deg, var(--color-primary-apricot), var(--color-aux-light-purple), var(--color-primary-light-pink)); /* New gradient */ + opacity: 0.15; /* More subtle */ + z-index: -1; /* Ensure it's behind content */ +} + +.page-decoration.left { + top: -80px; /* Adjusted position */ + left: -80px; + animation: floatLeft 18s ease-in-out infinite; +} + +.page-decoration.right { + bottom: -80px; + right: -80px; + animation: floatRight 20s ease-in-out infinite; +} + +@keyframes floatLeft { + 0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); } + 25% { transform: translate(15px, 20px) rotate(8deg) scale(1.05); } + 50% { transform: translate(5px, 35px) rotate(15deg) scale(1); } + 75% { transform: translate(25px, 10px) rotate(5deg) scale(1.05); } +} + +@keyframes floatRight { + 0%, 100% { transform: translate(0, 0) rotate(0deg) scale(1); } + 25% { transform: translate(-15px, -18px) rotate(-7deg) scale(1.05); } + 50% { transform: translate(-10px, -30px) rotate(-12deg) scale(1); } + 75% { transform: translate(-22px, -12px) rotate(-6deg) scale(1.05); } +} + + +/* --- Unchanged CSS from this point onwards as per request for elements not in index.html --- */ +/* --- (Or elements whose styling should largely be preserved unless overridden by above general styles) --- */ + .breadcrumb { margin-bottom: 20px; font-size: 14px; - color: var(--light-text); + color: var(--light-text); /* Will use new --light-text */ } .breadcrumb a { - color: var(--accent-color); + color: var(--accent-color); /* Will use new --accent-color */ text-decoration: none; transition: all 0.3s ease; } .breadcrumb a:hover { text-decoration: underline; - color: #d06b9c; + color: var(--color-accent-berry-red); /* More specific hover */ } .breadcrumb .current-page { - color: var(--text-color); + color: var(--text-color); /* Will use new --text-color */ font-weight: 500; } -/* 原始卡片菜单 */ +/* 原始卡片菜单 - Unchanged as it's not used in the provided HTML */ .stats-menu { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); @@ -86,8 +344,10 @@ body { margin-top: 30px; } -/* 原始卡片样式 */ -.stats-card { +/* 原始卡片样式 - This .stats-card is different from .stats-grid .stats-card. Keeping for other pages. */ +/* However, some properties might be inherited if not specific enough. */ +/* Adding a more specific selector to avoid conflict if this old style is needed elsewhere */ +.stats-menu > .stats-card { background-color: var(--secondary-color); border-radius: 12px; padding: 25px; @@ -102,55 +362,70 @@ body { border: 1px solid var(--border-color); } -.stats-card:hover { +.stats-menu > .stats-card:hover { transform: translateY(-8px) scale(1.02); box-shadow: 0 8px 20px var(--shadow-color); border-color: var(--primary-color); } -.card-icon { +/* Card icon/title/description for .stats-menu > .stats-card */ +.stats-menu > .stats-card .card-icon { font-size: 40px; margin-bottom: 15px; color: var(--accent-color); + /* Resetting some properties from .stats-grid .card-icon if they conflict */ + background-color: transparent; + width: auto; + height: auto; + box-shadow: none; } -.card-title { +.stats-menu > .stats-card .card-title { font-size: 18px; font-weight: 600; margin-bottom: 10px; + font-family: var(--font-sans-clean); /* Keep it simple for this version */ + color: var(--text-color); /* Default text color for these */ +} +.stats-menu > .stats-card .card-title:after { + display: none; /* No line for this version */ } -.card-description { + +.stats-menu > .stats-card .card-description { font-size: 14px; color: var(--light-text); + font-family: var(--font-sans-clean); } + .filter-section { margin-bottom: 25px; display: flex; align-items: center; - background-color: var(--secondary-color); + background-color: var(--color-primary-milk-white); /* Updated bg */ padding: 12px 18px; border-radius: 10px; - border: 1px dashed var(--border-color); + border: 1px dashed var(--border-decorative); /* Updated border */ } .filter-label { font-weight: 500; margin-right: 10px; - color: var(--text-color); + color: var(--text-main); /* Updated text */ } .filter-select { padding: 8px 15px; - border: 1px solid var(--border-color); - border-radius: 8px; + border: 1px solid var(--border-soft); /* Updated border */ + border-radius: 8px; /* Softer radius */ background-color: white; - color: var(--text-color); + color: var(--text-main); font-size: 0.95em; + font-family: var(--font-sans-clean); 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-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='%23B76E79' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); /* Updated arrow color */ background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; @@ -158,8 +433,8 @@ body { .filter-select:focus { outline: none; - border-color: var(--accent-color); - box-shadow: 0 0 0 3px rgba(230, 132, 174, 0.25); + border-color: var(--color-aux-rose-gold); /* Updated focus color */ + box-shadow: 0 0 0 3px rgba(183, 110, 121, 0.2); /* Updated focus shadow */ } .ml-20 { @@ -168,13 +443,13 @@ body { .chart-container { background-color: white; - border-radius: 12px; + border-radius: 12px; /* Softer radius */ padding: 25px; - box-shadow: 0 4px 15px var(--shadow-color); + box-shadow: 0 4px 15px var(--shadow-soft); /* Updated shadow */ margin-bottom: 35px; position: relative; - height: 400px; /* 添加固定高度 */ - border: 1px solid var(--border-color); + height: 400px; + border: 1px solid var(--border-decorative); /* Updated border */ overflow: hidden; } @@ -184,14 +459,13 @@ body { position: relative; } -/* 图表装饰元素 */ -.chart-decoration { +.chart-decoration { /* These are for charts, distinct from page/card decorations */ position: absolute; width: 60px; height: 60px; border-radius: 50%; - background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); - opacity: 0.6; + background: linear-gradient(45deg, var(--color-primary-light-pink), var(--color-primary-apricot)); /* Updated gradient */ + opacity: 0.4; /* Softer opacity */ z-index: 0; } @@ -211,38 +485,33 @@ body { @keyframes floating { 0% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(10px, 10px) scale(1.1); } + 50% { transform: translate(8px, 8px) scale(1.05); } /* Softer float */ 100% { transform: translate(0, 0) scale(1); } } -/* 适配小图表 */ .chart-container.half { height: auto; min-height: 400px; - padding-bottom: 40px; /* 增加底部空间 */ + padding-bottom: 40px; } -/* 特别针对分类图表的调整 */ .chart-container.half .chart-wrapper { - height: 340px; /* 增加图表容器高度 */ - padding-bottom: 20px; /* 增加底部填充 */ + 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; + opacity: 0.2; /* Softer opacity */ } -/* 调整图例位置,确保其正确显示 */ .chart-container.half .chart-wrapper { position: relative; } @@ -260,25 +529,43 @@ canvas#category-chart { min-width: 300px; } +/* 表格容器样式 */ +.table-container { + margin-bottom: 30px; + position: relative; + overflow: hidden; + border-radius: 12px; + box-shadow: 0 4px 20px var(--shadow-subtle); +} .data-table { width: 100%; - border-collapse: separate; + border-collapse: collapse; /* 修改为collapse以解决边框问题 */ border-spacing: 0; - border-radius: 10px; + border-radius: 10px; /* 保持圆角 */ overflow: hidden; - box-shadow: 0 2px 10px var(--shadow-color); + box-shadow: 0 2px 10px var(--shadow-subtle); /* 保持阴影 */ + font-family: var(--font-sans-clean); /* 确保一致字体 */ } .data-table th, .data-table td { padding: 14px 18px; text-align: left; + border-bottom: 1px solid var(--border-decorative); /* 保持底部边框 */ + vertical-align: middle; /* 确保所有内容垂直居中 */ + box-sizing: border-box; /* 确保边框计算在单元格尺寸内 */ +} + +.data-table td { + font-size: 0.95em; } .data-table th { - background-color: var(--primary-color); - font-weight: 600; - color: var(--text-color); + background-color: var(--color-primary-light-pink); /* Lighter pink for header */ + font-weight: 600; /* Was 600, can be 400 for softer look */ + color: var(--text-heading); /* Rose gold text for header */ letter-spacing: 0.5px; + font-size: 1em; + border-bottom: 2px solid var(--color-aux-rose-gold); } .data-table tr { @@ -286,21 +573,41 @@ canvas#category-chart { } .data-table tr:nth-child(even) { - background-color: var(--secondary-color); + background-color: var(--color-primary-milk-white); /* Milk white for even rows */ } .data-table tr:nth-child(odd) { background-color: white; } +.data-table tr:last-child td { + border-bottom: none; +} .data-table tr:hover { - background-color: #fceef3; + background-color: #FEF6F8; /* Very light pink on hover */ +} + +/* 表格特定列的样式 */ +.data-table th:first-child, +.data-table td:first-child { + text-align: center; /* 排名居中 */ + position: relative; /* 确保相对定位 */ +} + +.data-table th:nth-child(2), +.data-table td:nth-child(2) { + text-align: center; /* 封面图片居中 */ +} + +.data-table th:last-child, +.data-table td:last-child { + text-align: center; /* 借阅次数居中显示 */ } .loading-row td { text-align: center; padding: 30px; - color: var(--light-text); + color: var(--text-soft); /* Updated text color */ } .loading-animation { @@ -314,6 +621,7 @@ canvas#category-chart { margin-right: 10px; animation: bookFlip 2s infinite; display: inline-block; + color: var(--color-aux-rose-gold); /* Themed color */ } @keyframes bookFlip { @@ -333,51 +641,76 @@ canvas#category-chart { 100% { opacity: 0.3; } } -.stats-cards { +.stats-cards { /* This is for the small summary cards, different from .stats-grid */ 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); +/* Style for .stats-cards > .stats-card if they exist */ +.stats-cards > .stats-card { + background-color: var(--background-container); + border: 1px solid var(--border-decorative); + padding: 20px; + border-radius: 10px; + box-shadow: 0 3px 10px var(--shadow-subtle); + text-align: center; } -/* 引用容器 */ +.stats-cards .stats-card .card-value { /* Assuming .card-value is inside these cards */ + font-size: 2em; /* Adjusted size */ + font-weight: 700; + margin-bottom: 8px; + color: var(--color-accent-berry-red); /* Berry red for value */ + font-family: var(--font-serif-elegant); +} +.stats-cards .stats-card .card-title { /* Title within these small cards */ + font-family: var(--font-sans-clean); + font-size: 0.95em; + color: var(--text-soft); + font-weight: 400; +} +.stats-cards .stats-card .card-icon { /* Icon within these small cards */ + font-size: 1.8em; + color: var(--color-aux-rose-gold); + margin-bottom: 10px; +} + + +/* Quote Container - Appears distinct from .quote-banner, kept for other pages */ .quote-container { text-align: center; margin: 40px auto 20px; max-width: 600px; font-style: italic; - color: var(--text-color); + color: var(--text-main); /* Updated text */ padding: 20px; - background-color: var(--secondary-color); - border-radius: 12px; + background-color: var(--color-primary-apricot); /* Apricot background */ + border-radius: 12px; /* Softer radius */ position: relative; + font-family: var(--font-serif-garamond); + box-shadow: 0 3px 10px var(--shadow-subtle); } .quote-container:before, .quote-container:after { content: """; - font-size: 60px; - font-family: Georgia, serif; + font-size: 50px; + font-family: var(--font-serif-elegant); position: absolute; - color: var(--primary-color); - opacity: 0.5; + color: var(--color-aux-rose-gold); /* Rose gold quotes */ + opacity: 0.3; /* Softer */ } .quote-container:before { - top: -10px; + top: -5px; left: 10px; } .quote-container:after { content: """; - bottom: -30px; + bottom: -25px; right: 10px; } @@ -385,50 +718,54 @@ canvas#category-chart { position: relative; z-index: 1; margin-bottom: 10px; - font-size: 16px; + font-size: 1.05em; /* Adjusted */ + line-height: 1.6; } .quote-author { display: block; - font-size: 14px; + font-size: 0.9em; font-style: normal; text-align: right; - color: var(--light-text); + color: var(--text-soft); /* Updated text */ + font-family: var(--font-sans-clean); } -/* 书籍列表标题 */ +/* Book list title - for table pages */ .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; + color: var(--text-heading); /* Rose gold */ + font-family: var(--font-serif-lora); /* Lora for this title */ + font-size: 1.8em; /* Adjusted */ position: relative; display: inline-block; left: 50%; transform: translateX(-50%); - padding: 0 15px; + padding: 0 20px; } -.book-icon { +.book-icon { /* General book icon if used with this title */ font-size: 0.9em; margin: 0 8px; opacity: 0.85; + color: var(--color-aux-rose-gold); } .column-icon { font-size: 0.9em; margin-right: 5px; opacity: 0.8; + color: var(--color-aux-rose-gold); } .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; + height: 1.5px; /* Thinner line */ + background: linear-gradient(to right, transparent, var(--color-primary-light-pink), transparent); /* Softer gradient */ + width: 70px; top: 50%; } @@ -443,6 +780,15 @@ canvas#category-chart { } /* 表格中的图标样式 */ +.data-table .borrow-count { + font-weight: 600; + color: var(--text-heading); + position: relative; + display: block; /* 修改为block以占据整个单元格 */ + text-align: center; /* 确保文本居中 */ + font-size: 1em; +} + .data-table .borrow-count:after { content: '📚'; font-size: 12px; @@ -451,56 +797,76 @@ canvas#category-chart { transition: opacity 0.3s ease, transform 0.3s ease; transform: translateY(5px); display: inline-block; + color: var(--color-aux-rose-gold); } .data-table tr:hover .borrow-count:after { - opacity: 1; + opacity: 0.7; /* Softer opacity */ transform: translateY(0); } -/* 前三名特殊样式 */ +/* 排名列样式 */ +.data-table .rank { + font-weight: 700; + text-align: center; + position: relative; + font-size: 1.1em; + color: var(--text-heading); + font-family: var(--font-serif-lora); + padding: 5px 15px; /* 基本内边距 */ +} + +/* 前三名奖牌样式 */ +.data-table tr:nth-child(1) .rank:before, +.data-table tr:nth-child(2) .rank:before, +.data-table tr:nth-child(3) .rank:before { + position: absolute; + font-size: 1.2em; + left: 5px; /* 左侧位置 */ + top: 50%; + transform: translateY(-50%); + opacity: 0.85; +} + +/* 分别设置每个奖牌的内容 */ .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 { +/* 确保所有排名单元格的对齐一致 */ +.data-table td:first-child { + text-align: center; +} + + +.book-title { /* In data tables */ position: relative; text-decoration: none; display: inline-block; + font-weight: 600; /* Bolder for emphasis */ + color: var(--text-accent); /* Berry red for book titles */ + transition: color 0.3s; +} +.data-table tr:hover .book-title { + color: var(--color-aux-rose-gold); /* Rose gold on hover */ } -.book-title:after { +.book-title:after { /* Underline effect for book titles in tables */ content: ''; position: absolute; width: 100%; - height: 2px; - bottom: -2px; + height: 1.5px; + bottom: -3px; left: 0; - background-color: var(--accent-color); + background-color: var(--color-primary-light-pink); /* Light pink underline */ transform: scaleX(0); transform-origin: bottom right; transition: transform 0.3s ease-out; @@ -511,324 +877,137 @@ tr:hover .book-title:after { transform-origin: bottom left; } -/* 数据表格相关样式 */ +/* Data table image styling */ .data-table img { - width: 55px; - height: 80px; + width: 50px; /* Slightly smaller */ + height: 75px; object-fit: cover; - border-radius: 8px; - box-shadow: 0 3px 10px rgba(0,0,0,0.1); + border-radius: 6px; /* Softer radius */ + box-shadow: 0 2px 6px rgba(0,0,0,0.08); /* Softer shadow */ 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; + transform: scale(1.1); /* Slightly more pop */ + box-shadow: 0 4px 10px rgba(0,0,0,0.12); + border-color: var(--color-primary-light-pink); /* Pink border on hover */ } .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; + color: var(--text-soft); /* Softer text for author */ + font-size: 0.9em; } .no-data { text-align: center; padding: 40px; - color: var(--light-text); - background-color: var(--secondary-color); + color: var(--text-soft); + background-color: var(--color-primary-milk-white); /* Milk white background */ border-radius: 12px; font-style: italic; - border: 1px dashed var(--border-color); + border: 1px dashed var(--border-decorative); /* Decorative dashed border */ + font-family: var(--font-serif-garamond); } /* 书籍行动画 */ #ranking-table-body tr { - transition: transform 0.3s ease, opacity 0.3s ease; + transition: transform 0.3s ease, opacity 0.3s ease, background-color 0.3s ease; /* Added background-color */ } #ranking-table-body tr:hover { - transform: translateX(5px); + transform: translateX(3px); /* Subtle shift */ } -/* 四宫格统计页面样式 */ -.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; +/* Animation shared */ +.fade-in { /* This is a custom class, not from animate.css */ + animation: customFadeIn 0.6s ease forwards; /* Renamed to avoid conflict */ opacity: 0; - transform: translateY(10px); + transform: translateY(15px); /* Slightly more travel */ } -@keyframes fadeIn { +@keyframes customFadeIn { /* Renamed */ to { opacity: 1; transform: translateY(0); } } -/* 响应式调整 */ +/* Responsive adjustments */ +@media (max-width: 992px) { /* Adjusted breakpoint */ + .stats-grid { + max-width: 95%; + gap: 20px; /* Smaller gap on medium screens */ + } + .stats-grid .stats-card { + min-height: 240px; + } + .page-title { + font-size: 2.4em; + } + .quote-banner { + max-width: 85%; + } +} + + @media (max-width: 768px) { + .statistics-container { + padding: 30px 20px; + margin: 20px auto; + } + .page-title { + font-size: 2em; + } + .quote-banner { + max-width: 90%; + padding: 20px; + } + .quote-banner:before, + .quote-banner:after { + font-size: 35px; + } + .quote-banner:after { + bottom: -15px; + } + + .stats-grid { + grid-template-columns: 1fr; /* Single column for cards */ + gap: 25px; + max-width: 450px; /* Max width for single column */ + } + + .stats-grid .stats-card { + min-height: auto; /* Auto height for single column */ + height: auto; /* Ensure this is not fixed */ + padding-bottom: 20px; /* Ensure padding for content */ + } + .stats-grid .card-inner { + padding: 20px; + } + + .chart-row { flex-direction: column; } .half { width: 100%; + flex-basis: 100%; /* Ensure it takes full width */ } - .stats-cards { + .stats-cards { /* Small summary cards */ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .filter-section { flex-wrap: wrap; + padding: 10px 15px; + } + .filter-select { + width: 100%; } .ml-20 { @@ -836,21 +1015,61 @@ tr:hover .book-title:after { margin-top: 10px; } - .stats-grid { - grid-template-columns: 1fr; + .page-decoration { /* Make page decorations smaller or hide on mobile */ + width: 120px; + height: 120px; + opacity: 0.1; } - - .stats-grid .stats-card { - height: 200px; + .page-decoration.left { + top: -60px; + left: -60px; } - - .quote-banner { - max-width: 95%; - padding: 15px; + .page-decoration.right { + bottom: -60px; + right: -60px; } - - .quote-banner:before, - .quote-banner:after { - font-size: 30px; + .data-table th, .data-table td { + padding: 10px 12px; + font-size: 0.9em; + } + .data-table img { + width: 40px; + height: 60px; + } +} + +@media (max-width: 480px) { + .page-title { + font-size: 1.8em; + } + .quote-banner p { + font-size: 1em; + } + .stats-grid .card-title { + font-size: 1.3em; + } + .stats-grid .card-description { + font-size: 0.85em; + } + .stats-grid .card-icon { + width: 60px; + height: 60px; + font-size: 30px; + } + .statistics-container { + margin: 15px auto; + padding: 20px 15px; + } + .page-decoration { + display: none; /* Hide complex decorations on very small screens */ + } + + /* 移动端表格调整 */ + .data-table .rank:before { + left: -5px; /* 小屏幕上减少偏移量 */ + font-size: 1.2em; + } + .data-table .rank { + padding: 5px 8px; /* 减少内边距 */ } } diff --git a/app/static/js/announcement-form.js b/app/static/js/announcement-form.js new file mode 100644 index 0000000..7dc5ed6 --- /dev/null +++ b/app/static/js/announcement-form.js @@ -0,0 +1,37 @@ +// 公告编辑表单的Javascript +document.addEventListener('DOMContentLoaded', function() { + // 表单提交前验证 + document.getElementById('announcementForm').addEventListener('submit', function(e) { + // 由于富文本内容在各页面单独处理,这里仅做一些通用表单验证 + const title = document.getElementById('title').value.trim(); + + if (!title) { + e.preventDefault(); + alert('请输入公告标题'); + return false; + } + }); + + // 返回按钮处理 + const cancelButton = document.querySelector('button[type="button"]'); + if (cancelButton) { + cancelButton.addEventListener('click', function() { + // 如果有未保存内容,给出提示 + if (formHasChanges()) { + if (!confirm('表单有未保存的内容,确定要离开吗?')) { + return; + } + } + history.back(); + }); + } + + // 检测表单是否有变化 + function formHasChanges() { + // 这里可以添加逻辑来检测表单内容是否有变化 + // 简单实现:检查标题是否不为空 + const title = document.getElementById('title').value.trim(); + return title !== ''; + } +}); + diff --git a/app/static/js/announcement-list.js b/app/static/js/announcement-list.js new file mode 100644 index 0000000..dba920a --- /dev/null +++ b/app/static/js/announcement-list.js @@ -0,0 +1,19 @@ +// 通知公告列表页面的Javascript +document.addEventListener('DOMContentLoaded', function() { + // 平滑滚动到页面锚点 + const scrollToAnchor = (anchorId) => { + const element = document.getElementById(anchorId); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }; + + // 如果URL中有锚点,执行滚动 + if (window.location.hash) { + const anchorId = window.location.hash.substring(1); + setTimeout(() => scrollToAnchor(anchorId), 300); + } +}); diff --git a/app/static/js/announcement-manage.js b/app/static/js/announcement-manage.js new file mode 100644 index 0000000..6c0e673 --- /dev/null +++ b/app/static/js/announcement-manage.js @@ -0,0 +1,69 @@ +// 公告管理页面的Javascript +document.addEventListener('DOMContentLoaded', function() { + // 提示框自动关闭 + setTimeout(function() { + const alerts = document.querySelectorAll('.alert-success, .alert-info'); + alerts.forEach(alert => { + const bsAlert = new bootstrap.Alert(alert); + bsAlert.close(); + }); + }, 5000); +}); + +// 更改公告状态(发布/撤销) +function changeStatus(announcementId, status) { + if (!confirm('确定要' + (status === 1 ? '发布' : '撤销') + '这条公告吗?')) { + return; + } + + fetch(`/announcement/status/${announcementId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ status: status }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(data.message); + location.reload(); + } else { + alert('操作失败: ' + data.message); + } + }) + .catch(error => { + console.error('Error:', error); + alert('发生错误,请重试'); + }); +} + +// 更改公告置顶状态 +function changeTopStatus(announcementId, isTop) { + if (!confirm('确定要' + (isTop ? '置顶' : '取消置顶') + '这条公告吗?')) { + return; + } + + fetch(`/announcement/top/${announcementId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ is_top: isTop }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(data.message); + location.reload(); + } else { + alert('操作失败: ' + data.message); + } + }) + .catch(error => { + console.error('Error:', error); + alert('发生错误,请重试'); + }); +} diff --git a/app/static/js/book_ranking.js b/app/static/js/book_ranking.js index 09ec9bb..f7a9222 100644 --- a/app/static/js/book_ranking.js +++ b/app/static/js/book_ranking.js @@ -18,7 +18,7 @@ document.addEventListener('DOMContentLoaded', function() { // 显示加载状态 document.getElementById('ranking-table-body').innerHTML = ` - 加载中... +
正在打开书页...
`; @@ -56,15 +56,16 @@ document.addEventListener('DOMContentLoaded', function() { let tableHtml = ''; data.forEach((book, index) => { + // 给每个单元格添加适当的类名以匹配CSS tableHtml += ` ${index + 1} - + ${book.title} - ${book.title} - ${book.author} - ${book.borrow_count} + ${book.title} + ${book.author} + ${book.borrow_count} `; }); @@ -95,8 +96,8 @@ document.addEventListener('DOMContentLoaded', function() { datasets: [{ label: '借阅次数', data: borrowCounts, - backgroundColor: 'rgba(54, 162, 235, 0.6)', - borderColor: 'rgba(54, 162, 235, 1)', + backgroundColor: 'rgba(183, 110, 121, 0.6)', // 玫瑰金色调 + borderColor: 'rgba(140, 45, 90, 1)', // 浆果红 borderWidth: 1 }] }, @@ -108,13 +109,41 @@ document.addEventListener('DOMContentLoaded', function() { beginAtZero: true, title: { display: true, - text: '借阅次数' + text: '借阅次数', + font: { + family: "'Open Sans', sans-serif", + size: 13 + }, + color: '#5D5053' + }, + ticks: { + color: '#8A797C', + font: { + family: "'Open Sans', sans-serif" + } + }, + grid: { + color: 'rgba(211, 211, 211, 0.3)' } }, x: { title: { display: true, - text: '图书' + text: '图书', + font: { + family: "'Open Sans', sans-serif", + size: 13 + }, + color: '#5D5053' + }, + ticks: { + color: '#8A797C', + font: { + family: "'Open Sans', sans-serif" + } + }, + grid: { + display: false } } }, @@ -124,7 +153,13 @@ document.addEventListener('DOMContentLoaded', function() { }, title: { display: true, - text: '热门图书借阅排行' + text: '热门图书借阅排行', + font: { + family: "'Playfair Display', serif", + size: 16, + weight: 'bold' + }, + color: '#B76E79' } } } diff --git a/app/static/js/notifications.js b/app/static/js/notifications.js new file mode 100644 index 0000000..7d092e3 --- /dev/null +++ b/app/static/js/notifications.js @@ -0,0 +1,66 @@ +document.addEventListener('DOMContentLoaded', function() { + // 标记通知为已读 + const markAsRead = (notificationId) => { + fetch(`/announcement/notification/${notificationId}/mark-read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // 更新UI,如移除未读标记 + const notificationCard = document.querySelector(`.notification-card[data-id="${notificationId}"]`); + if (notificationCard) { + notificationCard.classList.remove('unread'); + const unreadBadge = notificationCard.querySelector('.unread-badge'); + if (unreadBadge) { + unreadBadge.remove(); + } + } + + // 更新通知计数 + updateNotificationCount(); + } + }) + .catch(error => console.error('Error:', error)); + }; + + // 自动标记为已读 + const notificationCards = document.querySelectorAll('.notification-card.unread'); + notificationCards.forEach(card => { + const notificationId = card.dataset.id; + if (notificationId) { + // 当用户查看通知列表时自动标记为已读 + // 这可以是可选的功能,也可能需要用户点击后才标记 + // markAsRead(notificationId); + } + }); + + // 更新通知计数 + function updateNotificationCount() { + // 获取当前未读通知数 + fetch('/announcement/notifications/count') + .then(response => response.json()) + .then(data => { + const badge = document.querySelector('.notifications .badge'); + if (data.count > 0) { + if (badge) { + badge.textContent = data.count; + } else { + const newBadge = document.createElement('span'); + newBadge.className = 'badge'; + newBadge.textContent = data.count; + document.querySelector('.notifications').appendChild(newBadge); + } + } else { + if (badge) { + badge.remove(); + } + } + }) + .catch(error => console.error('Error:', error)); + } +}); diff --git a/app/static/js/overdue_analysis.js b/app/static/js/overdue_analysis.js index 176292a..a77a2a8 100644 --- a/app/static/js/overdue_analysis.js +++ b/app/static/js/overdue_analysis.js @@ -3,58 +3,98 @@ document.addEventListener('DOMContentLoaded', function() { let overdueRangeChart = null; let overdueStatusChart = null; - // 初始加载 + // Initial load loadOverdueStatistics(); function loadOverdueStatistics() { - // 调用API获取数据 + // Call API to get data fetch('/statistics/api/overdue-statistics') - .then(response => response.json()) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) .then(data => { + if (!data) { + console.error('加载逾期统计数据失败: API返回空数据'); + // Optionally update UI to show error for cards + return; + } updateOverdueCards(data); updateOverdueRangeChart(data.overdue_ranges); updateOverdueStatusChart(data); }) .catch(error => { console.error('加载逾期统计数据失败:', error); + // Optionally update UI to show error for cards and charts + // For charts, you might want to clear them or show an error message + clearChart('overdue-range-chart'); + clearChart('overdue-status-chart'); }); } 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 + '%'; + document.getElementById('total-borrows').querySelector('.card-value').textContent = data.total_borrows || 0; + document.getElementById('current-overdue').querySelector('.card-value').textContent = data.current_overdue || 0; + document.getElementById('returned-overdue').querySelector('.card-value').textContent = data.returned_overdue || 0; + const overdueRate = data.overdue_rate !== undefined ? data.overdue_rate : 0; + document.getElementById('overdue-rate').querySelector('.card-value').textContent = overdueRate + '%'; + } + + function clearChart(canvasId) { + const canvas = document.getElementById(canvasId); + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Optionally display a message like "数据加载失败" or "暂无数据" + // ctx.textAlign = 'center'; + // ctx.fillText('数据加载失败', canvas.width / 2, canvas.height / 2); + } } function updateOverdueRangeChart(rangeData) { - // 销毁旧图表 + // Destroy old chart if (overdueRangeChart) { overdueRangeChart.destroy(); + overdueRangeChart = null; } + const canvas = document.getElementById('overdue-range-chart'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!rangeData || rangeData.length === 0) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Optionally display "暂无数据" + // ctx.textAlign = 'center'; + // ctx.fillText('暂无逾期时长数据', canvas.width / 2, canvas.height / 2); return; } - // 准备图表数据 + // Prepare chart data const labels = rangeData.map(item => item.range); const counts = rangeData.map(item => item.count); - // 创建图表 - const ctx = document.getElementById('overdue-range-chart').getContext('2d'); + // Create chart overdueRangeChart = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [{ - label: '逾期数量', + label: '逾期数量', // This will appear in the legend 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)' + 'rgba(255, 206, 86, 0.7)', // 1-7天 + 'rgba(255, 159, 64, 0.7)', // 8-14天 + 'rgba(255, 99, 132, 0.7)', // 15-30天 + 'rgba(230, 0, 0, 0.7)' // 30天以上 (made it darker red) + ], + borderColor: [ // Optional: Add border colors if you want them distinct + 'rgba(255, 206, 86, 1)', + 'rgba(255, 159, 64, 1)', + 'rgba(255, 99, 132, 1)', + 'rgba(230, 0, 0, 1)' ], borderWidth: 1 }] @@ -69,12 +109,27 @@ document.addEventListener('DOMContentLoaded', function() { display: true, text: '数量' } + }, + x: { + // No title for X-axis needed here based on current config } }, plugins: { - title: { - display: true, - text: '逾期时长分布' + title: { // Chart.js internal title + display: false, // Set to false to use HTML

title + // text: '逾期时长分布' // This would be the Chart.js title if display: true + }, + legend: { + display: true, // Show legend for '逾期数量' + position: 'top', + } + }, + layout: { + padding: { + left: 25, // Increased left padding for Y-axis title "数量" + bottom: 10, // Padding for X-axis labels + top: 10, // Padding for legend/top elements + right: 10 } } } @@ -82,21 +137,39 @@ document.addEventListener('DOMContentLoaded', function() { } function updateOverdueStatusChart(data) { - // 销毁旧图表 + // Destroy old chart if (overdueStatusChart) { overdueStatusChart.destroy(); + overdueStatusChart = null; } - // 准备图表数据 - const statusLabels = ['当前逾期', '历史逾期', '正常']; + const canvas = document.getElementById('overdue-status-chart'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + const totalBorrows = data.total_borrows || 0; + const currentOverdue = data.current_overdue || 0; + const returnedOverdue = data.returned_overdue || 0; + + if (totalBorrows === 0 && currentOverdue === 0 && returnedOverdue === 0) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Optionally display "暂无数据" + // ctx.textAlign = 'center'; + // ctx.fillText('暂无借阅状态数据', canvas.width / 2, canvas.height / 2); + return; + } + + + // Prepare chart data + const statusLabels = ['当前逾期', '历史逾期 (已归还)', '正常在借/已还']; // Clarified labels + const normalCount = totalBorrows - currentOverdue - returnedOverdue; const statusData = [ - data.current_overdue, - data.returned_overdue, - data.total_borrows - data.current_overdue - data.returned_overdue + currentOverdue, + returnedOverdue, + normalCount < 0 ? 0 : normalCount // Ensure not negative ]; - // 创建图表 - const ctx = document.getElementById('overdue-status-chart').getContext('2d'); + // Create chart overdueStatusChart = new Chart(ctx, { type: 'pie', data: { @@ -104,9 +177,14 @@ document.addEventListener('DOMContentLoaded', function() { datasets: [{ data: statusData, backgroundColor: [ - 'rgba(255, 99, 132, 0.7)', - 'rgba(255, 206, 86, 0.7)', - 'rgba(75, 192, 192, 0.7)' + 'rgba(255, 99, 132, 0.7)', // 当前逾期 + 'rgba(255, 206, 86, 0.7)', // 历史逾期 + 'rgba(75, 192, 192, 0.7)' // 正常 + ], + borderColor: [ // Optional: Add border colors + 'rgba(255, 99, 132, 1)', + 'rgba(255, 206, 86, 1)', + 'rgba(75, 192, 192, 1)' ], borderWidth: 1 }] @@ -115,10 +193,18 @@ document.addEventListener('DOMContentLoaded', function() { responsive: true, maintainAspectRatio: false, plugins: { - title: { + title: { // Chart.js internal title + display: false, // Set to false to use HTML

title + // text: '借阅状态分布' // This would be the Chart.js title if display: true + }, + legend: { display: true, - text: '借阅状态分布' + position: 'top', // Pie chart legends are often at top or side } + }, + layout: { + padding: 20 // General padding for pie chart (can be object: {top: val, ...}) + // e.g., { top: 20, bottom: 20, left: 10, right: 10 } } } }); diff --git a/app/static/js/user_activity.js b/app/static/js/user_activity.js index 65ec3a9..9deca32 100644 --- a/app/static/js/user_activity.js +++ b/app/static/js/user_activity.js @@ -15,7 +15,12 @@ document.addEventListener('DOMContentLoaded', function() { // 调用API获取数据 fetch('/statistics/api/user-activity') - .then(response => response.json()) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) .then(data => { updateUserTable(data); updateUserChart(data); @@ -27,13 +32,22 @@ document.addEventListener('DOMContentLoaded', function() { 加载数据失败,请稍后重试 `; + // 也可以考虑清除或提示图表加载失败 + if (activityChart) { + activityChart.destroy(); + activityChart = null; + } + const canvas = document.getElementById('user-activity-chart'); + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除画布 + // 可以在画布上显示错误信息,但这比较复杂,通常表格的错误提示已足够 }); } function updateUserTable(data) { const tableBody = document.getElementById('user-table-body'); - if (data.length === 0) { + if (!data || data.length === 0) { // 增加了对 data 本身的检查 tableBody.innerHTML = ` 暂无数据 @@ -48,8 +62,8 @@ document.addEventListener('DOMContentLoaded', function() { tableHtml += ` ${index + 1} - ${user.username} - ${user.nickname} + ${user.username || 'N/A'} + ${user.nickname || 'N/A'} ${user.borrow_count} `; @@ -62,18 +76,27 @@ document.addEventListener('DOMContentLoaded', function() { // 销毁旧图表 if (activityChart) { activityChart.destroy(); + activityChart = null; // 确保旧实例被完全清除 } - if (data.length === 0) { + const canvas = document.getElementById('user-activity-chart'); + const ctx = canvas.getContext('2d'); + + if (!data || data.length === 0) { + // 如果没有数据,清除画布,避免显示旧图表或空白图表框架 + ctx.clearRect(0, 0, canvas.width, canvas.height); + // 也可以在这里显示 "暂无数据" 的文本到 canvas 上,如果需要 + // 例如: + // ctx.textAlign = 'center'; + // ctx.fillText('暂无图表数据', canvas.width / 2, canvas.height / 2); return; } // 准备图表数据 - const labels = data.map(user => user.nickname || user.username); + 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: { @@ -100,17 +123,22 @@ document.addEventListener('DOMContentLoaded', function() { x: { title: { display: true, - text: '用户' + text: '用户' // 这个标题现在应该有空间显示了 } } }, plugins: { legend: { - display: false + display: false // 保持不显示图例 }, title: { - display: true, - text: '最活跃用户排行' + display: true, // 如果HTML中已有h3标题,这里可以设为 false + text: '最活跃用户排行' // 这个是图表内部的标题 + } + }, + layout: { // <--- 这是添加的部分 + padding: { + bottom: 30 // 为X轴标题和标签留出足够的底部空间,可以根据实际显示效果调整此数值 } } } diff --git a/app/templates/announcement/add.html b/app/templates/announcement/add.html new file mode 100644 index 0000000..c8ea68e --- /dev/null +++ b/app/templates/announcement/add.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} + +{% block title %}添加公告 - 图书管理系统{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+ + +
+
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/app/templates/announcement/detail.html b/app/templates/announcement/detail.html new file mode 100644 index 0000000..eee92c4 --- /dev/null +++ b/app/templates/announcement/detail.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} + +{% block title %}{{ announcement.title }} - 通知公告{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + +
+
+ 发布者: {{ announcement.publisher.username if announcement.publisher else '系统' }} +
+
+ 发布时间: {{ announcement.created_at.strftime('%Y-%m-%d %H:%M:%S') }} +
+ {% if announcement.is_top %} +
+ 置顶公告 +
+ {% endif %} +
+ +
+ {{ announcement.content|safe }} +
+
+{% endblock %} diff --git a/app/templates/announcement/edit.html b/app/templates/announcement/edit.html new file mode 100644 index 0000000..3e995e9 --- /dev/null +++ b/app/templates/announcement/edit.html @@ -0,0 +1,94 @@ +{% extends 'base.html' %} + +{% block title %}编辑公告 - 图书管理系统{% endblock %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+ + +
+
+ {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/app/templates/announcement/list.html b/app/templates/announcement/list.html new file mode 100644 index 0000000..c87a8e8 --- /dev/null +++ b/app/templates/announcement/list.html @@ -0,0 +1,104 @@ +{% extends 'base.html' %} + +{% block title %}通知公告 - 图书管理系统{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + +
+ {% if pagination.items %} + {% for announcement in pagination.items %} +
+ {% if announcement.is_top %} +
+ 置顶 +
+ {% endif %} +
+

{{ announcement.title }}

+ {{ announcement.created_at }} +
+
+ {{ announcement.content|striptags|truncate(150) }} +
+ +
+ {% endfor %} + + +
+ {% if pagination.pages > 1 %} + + {% endif %} +
+ {% else %} +
+ +

目前没有通知公告

+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/announcement/manage.html b/app/templates/announcement/manage.html new file mode 100644 index 0000000..4e1528c --- /dev/null +++ b/app/templates/announcement/manage.html @@ -0,0 +1,163 @@ +{% extends 'base.html' %} + +{% block title %}公告管理 - 图书管理系统{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + + +
+
+
+ +
+
+ +
+ +
+
+ + +
+ {% if pagination.items %} + + + + + + + + + + + + + + + {% for announcement in pagination.items %} + + + + + + + + + + + {% endfor %} + +
ID标题发布者发布时间最后更新状态置顶操作
{{ announcement.id }} + + {{ announcement.title }} + + {{ announcement.publisher.username if announcement.publisher else '系统' }}{{ announcement.created_at.strftime('%Y-%m-%d %H:%M') }}{{ announcement.updated_at.strftime('%Y-%m-%d %H:%M') }} + + {{ '已发布' if announcement.status == 1 else '已撤销' }} + + + + {{ '已置顶' if announcement.is_top else '未置顶' }} + + +
+ + 编辑 + + + +
+
+ + +
+ {% if pagination.pages > 1 %} + + {% endif %} +
+ {% else %} +
+ +

没有找到符合条件的公告

+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/announcement/notifications.html b/app/templates/announcement/notifications.html new file mode 100644 index 0000000..935001b --- /dev/null +++ b/app/templates/announcement/notifications.html @@ -0,0 +1,113 @@ +{% extends 'base.html' %} + +{% block title %}我的通知 - 图书管理系统{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ + + + +
+ {% if pagination.items %} + {% for notification in pagination.items %} +
+
+

+ {{ notification.title }} + {% if notification.status == 0 %} + 未读 + {% endif %} +

+
{{ notification.content|striptags|truncate(150) }}
+
+ {{ notification.type }} + {{ notification.created_at.strftime('%Y-%m-%d %H:%M') }} +
+
+
+ {% endfor %} + + +
+ {% if pagination.pages > 1 %} + + {% endif %} +
+ {% else %} +
+ +

{{ '暂无未读通知' if unread_only else '暂无通知' }}

+
+ {% endif %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 7620a80..649bcc6 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -30,7 +30,7 @@ 我的借阅
  • - 通知公告 + 通知公告
  • {% if current_user.is_authenticated and current_user.role_id == 1 %} @@ -57,6 +57,9 @@
  • 日志管理
  • +
  • + 公告管理 +
  • {% endif %} @@ -70,9 +73,52 @@
    -
    - - 3 + {% if current_user.is_authenticated %}

    注册用户

    -

    1,245

    +

    {{ stats.total_users }}

    当前借阅

    -

    352

    +

    {{ stats.active_borrows }}

    待还图书

    -

    {{ 5 }}

    +

    {{ stats.user_borrows if current_user.is_authenticated else 0 }}

    @@ -52,25 +52,52 @@

    最新图书

    - 查看全部 + 查看全部
    - {% for i in range(4) %} -
    -
    - Book Cover -
    -
    -

    示例图书标题

    -

    作者名

    -
    - 计算机 - 可借阅 + {% if latest_books %} + {% for book in latest_books %} +
    +
    + {% if book.cover_url %} + {{ book.title }} 封面 + {% else %} + 无封面 + {% endif %} +
    +
    +

    {{ book.title }}

    +

    {{ book.author }}

    +
    + {{ book.category.name if book.category else '未分类' }} + + {{ '可借阅' if book.stock > 0 else '已借完' }} + +
    + + {{ '借阅' if book.stock > 0 else '详情' }} +
    -
    -
    - {% endfor %} + {% endfor %} + {% else %} + {% for i in range(4) %} +
    +
    + Book Cover +
    +
    +

    示例图书标题

    +

    作者名

    +
    + 计算机 + 可借阅 +
    + +
    +
    + {% endfor %} + {% endif %}
    @@ -78,30 +105,55 @@

    通知公告

    - 查看全部 + 查看全部
    -
    -
    -
    -

    关于五一假期图书馆开放时间调整的通知

    -

    五一期间(5月1日-5日),图书馆开放时间调整为上午9:00-下午5:00。

    -
    - 2023-04-28 + {% if announcements %} + {% for announcement in announcements %} +
    +
    +
    +

    {{ announcement.title }}

    +

    {{ announcement.content|striptags|truncate(100) }}

    +
    + {{ announcement.created_at.strftime('%Y-%m-%d') }} +
    -
    -
    -
    -
    -

    您有2本图书即将到期

    -

    《Python编程》《算法导论》将于3天后到期,请及时归还或办理续借。

    -
    - 2023-04-27 - + {% endfor %} + {% else %} +
    +
    +
    +

    暂无公告

    +

    目前没有任何通知公告

    +
    + {{ now.strftime('%Y-%m-%d') }} +
    -
    + {% endif %} + + {% if current_user.is_authenticated and user_notifications %} +
    +
    +
    +

    您有{{ user_notifications|length }}条未读通知

    +

    + {% for notification in user_notifications %} + {% if loop.index <= 2 %} + {{ notification.title }}{% if not loop.last and loop.index < 2 %}、{% endif %} + {% endif %} + {% endfor %} + {% if user_notifications|length > 2 %}等{% endif %} +

    +
    + {{ now.strftime('%Y-%m-%d') }} + 查看详情 +
    +
    +
    + {% endif %}
    @@ -110,25 +162,48 @@