index__new_feature

This commit is contained in:
superlishunqin 2025-05-12 19:44:22 +08:00
parent c75521becd
commit 5933289d6e
30 changed files with 3121 additions and 575 deletions

View File

@ -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_userFlask-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', '<br>'))
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()}

View File

@ -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/<int:announcement_id>', 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/<int:announcement_id>', 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/<int:announcement_id>', 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/<int:announcement_id>', 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/<int:notification_id>')
@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'))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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 !== '';
}
});

View File

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

View File

@ -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('发生错误,请重试');
});
}

View File

@ -18,7 +18,7 @@ document.addEventListener('DOMContentLoaded', function() {
// 显示加载状态
document.getElementById('ranking-table-body').innerHTML = `
<tr class="loading-row">
<td colspan="5">加载中...</td>
<td colspan="5"><div class="loading-animation"><span>正在打开书页</span><span class="dot-animation">...</span></div></td>
</tr>
`;
@ -56,15 +56,16 @@ document.addEventListener('DOMContentLoaded', function() {
let tableHtml = '';
data.forEach((book, index) => {
// 给每个单元格添加适当的类名以匹配CSS
tableHtml += `
<tr>
<td class="rank">${index + 1}</td>
<td>
<td class="book-cover">
<img src="${book.cover_url || '/static/images/book-placeholder.jpg'}" alt="${book.title}">
</td>
<td>${book.title}</td>
<td>${book.author}</td>
<td class="borrow-count">${book.borrow_count}</td>
<td class="book-title-cell"><span class="book-title">${book.title}</span></td>
<td class="author">${book.author}</td>
<td><span class="borrow-count">${book.borrow_count}</span></td>
</tr>
`;
});
@ -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'
}
}
}

View File

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

View File

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

View File

@ -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() {
<td colspan="4">加载数据失败请稍后重试</td>
</tr>
`;
// 也可以考虑清除或提示图表加载失败
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 = `
<tr class="no-data-row">
<td colspan="4">暂无数据</td>
@ -48,8 +62,8 @@ document.addEventListener('DOMContentLoaded', function() {
tableHtml += `
<tr>
<td class="rank">${index + 1}</td>
<td>${user.username}</td>
<td>${user.nickname}</td>
<td>${user.username || 'N/A'}</td>
<td>${user.nickname || 'N/A'}</td>
<td class="borrow-count">${user.borrow_count}</td>
</tr>
`;
@ -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轴标题和标签留出足够的底部空间可以根据实际显示效果调整此数值
}
}
}

View File

@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block title %}添加公告 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/announcement-form.css') }}">
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="announcement-form-container">
<div class="page-header">
<h1>添加公告</h1>
</div>
<div class="card">
<div class="card-body">
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('announcement.add_announcement') }}" id="announcementForm">
<div class="form-group">
<label for="title">公告标题 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="title" name="title" required>
</div>
<div class="form-group">
<label for="editor">公告内容 <span class="text-danger">*</span></label>
<div id="editor" style="height: 250px;"></div>
<input type="hidden" name="content" id="content">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="is_top" name="is_top">
<label class="form-check-label" for="is_top">置顶公告</label>
</div>
<div class="form-buttons">
<button type="button" class="btn btn-secondary" onclick="history.back()">取消</button>
<button type="submit" class="btn btn-primary" id="submitBtn">发布公告</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
<script src="{{ url_for('static', filename='js/announcement-form.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 初始化富文本编辑器
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'align': [] }],
['link', 'image'],
['clean']
]
},
placeholder: '请输入公告内容...'
});
// 提交表单前获取富文本内容
document.getElementById('announcementForm').addEventListener('submit', function(e) {
const content = document.getElementById('content');
content.value = quill.root.innerHTML;
// 简单验证
if (!document.getElementById('title').value.trim()) {
e.preventDefault();
alert('请输入公告标题');
return false;
}
if (quill.getText().trim().length <= 1) {
e.preventDefault();
alert('请输入公告内容');
return false;
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block title %}{{ announcement.title }} - 通知公告{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/announcement-detail.css') }}">
{% endblock %}
{% block content %}
<div class="announcement-detail-container">
<div class="page-header">
<a href="{{ url_for('announcement.announcement_list') }}" class="back-link">
<i class="fas fa-arrow-left"></i> 返回公告列表
</a>
<h1>{{ announcement.title }}</h1>
</div>
<div class="announcement-meta">
<div class="meta-item">
<i class="fas fa-user"></i> 发布者: {{ announcement.publisher.username if announcement.publisher else '系统' }}
</div>
<div class="meta-item">
<i class="fas fa-calendar-alt"></i> 发布时间: {{ announcement.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
{% if announcement.is_top %}
<div class="meta-item pinned">
<i class="fas fa-thumbtack"></i> 置顶公告
</div>
{% endif %}
</div>
<div class="announcement-content">
{{ announcement.content|safe }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% block title %}编辑公告 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/announcement-form.css') }}">
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="announcement-form-container">
<div class="page-header">
<h1>编辑公告</h1>
</div>
<div class="card">
<div class="card-body">
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('announcement.edit_announcement', announcement_id=announcement.id) }}" id="announcementForm">
<div class="form-group">
<label for="title">公告标题 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="title" name="title" value="{{ announcement.title }}" required>
</div>
<div class="form-group">
<label for="editor">公告内容 <span class="text-danger">*</span></label>
<div id="editor" style="height: 250px;"></div>
<input type="hidden" name="content" id="content">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="is_top" name="is_top" {% if announcement.is_top %}checked{% endif %}>
<label class="form-check-label" for="is_top">置顶公告</label>
</div>
<div class="form-buttons">
<button type="button" class="btn btn-secondary" onclick="history.back()">取消</button>
<button type="submit" class="btn btn-primary" id="submitBtn">更新公告</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
<script src="{{ url_for('static', filename='js/announcement-form.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 初始化富文本编辑器
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'align': [] }],
['link', 'image'],
['clean']
]
},
placeholder: '请输入公告内容...'
});
// 加载现有内容
quill.root.innerHTML = `{{ announcement.content|safe }}`;
// 提交表单前获取富文本内容
document.getElementById('announcementForm').addEventListener('submit', function(e) {
const content = document.getElementById('content');
content.value = quill.root.innerHTML;
// 简单验证
if (!document.getElementById('title').value.trim()) {
e.preventDefault();
alert('请输入公告标题');
return false;
}
if (quill.getText().trim().length <= 1) {
e.preventDefault();
alert('请输入公告内容');
return false;
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,104 @@
{% extends 'base.html' %}
{% block title %}通知公告 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/announcement-list.css') }}">
{% endblock %}
{% block content %}
<div class="announcement-container">
<div class="page-header">
<h1>通知公告</h1>
</div>
<div class="announcement-list">
{% if pagination.items %}
{% for announcement in pagination.items %}
<div class="announcement-item {% if announcement.is_top %}pinned{% endif %}">
{% if announcement.is_top %}
<div class="pin-badge">
<i class="fas fa-thumbtack"></i> 置顶
</div>
{% endif %}
<div class="announcement-header">
<h3><a href="{{ url_for('announcement.announcement_detail', announcement_id=announcement.id) }}">{{ announcement.title }}</a></h3>
<span class="date">{{ announcement.created_at }}</span>
</div>
<div class="announcement-preview">
{{ announcement.content|striptags|truncate(150) }}
</div>
<div class="announcement-footer">
<span class="publisher">发布者: {{ announcement.publisher.username if announcement.publisher else '系统' }}</span>
<a href="{{ url_for('announcement.announcement_detail', announcement_id=announcement.id) }}" class="read-more">阅读更多 <i class="fas fa-arrow-right"></i></a>
</div>
</div>
{% endfor %}
<!-- 分页 -->
<div class="pagination-container">
{% if pagination.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.announcement_list', page=pagination.prev_num) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<li class="page-item active">
<a class="page-link" href="{{ url_for('announcement.announcement_list', page=page_num) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.announcement_list', page=page_num) }}">{{ page_num }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#">...</a>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.announcement_list', page=pagination.next_num) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% else %}
<div class="no-records">
<i class="fas fa-info-circle"></i>
<p>目前没有通知公告</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/announcement-list.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,163 @@
{% extends 'base.html' %}
{% block title %}公告管理 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/announcement-manage.css') }}">
{% endblock %}
{% block content %}
<div class="announcement-manage-container">
<div class="page-header">
<h1>公告管理</h1>
<a href="{{ url_for('announcement.add_announcement') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加公告
</a>
</div>
<!-- 搜索和筛选 -->
<div class="filter-container">
<form action="{{ url_for('announcement.manage_announcements') }}" method="get" class="filter-form">
<div class="form-group">
<input type="text" name="search" class="form-control" placeholder="搜索公告标题..." value="{{ search }}">
</div>
<div class="form-group">
<select name="status" class="form-control">
<option value="">全部状态</option>
<option value="1" {% if status == 1 %}selected{% endif %}>已发布</option>
<option value="0" {% if status == 0 %}selected{% endif %}>已撤销</option>
</select>
</div>
<button type="submit" class="btn btn-secondary">
<i class="fas fa-search"></i> 搜索
</button>
</form>
</div>
<!-- 公告列表 -->
<div class="announcement-list">
{% if pagination.items %}
<table class="table announcement-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>发布者</th>
<th>发布时间</th>
<th>最后更新</th>
<th>状态</th>
<th>置顶</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for announcement in pagination.items %}
<tr>
<td>{{ announcement.id }}</td>
<td>
<a href="{{ url_for('announcement.announcement_detail', announcement_id=announcement.id) }}" target="_blank" class="announcement-title">
{{ announcement.title }}
</a>
</td>
<td>{{ announcement.publisher.username if announcement.publisher else '系统' }}</td>
<td>{{ announcement.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ announcement.updated_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<span class="badge badge-{{ 'success' if announcement.status == 1 else 'secondary' }}">
{{ '已发布' if announcement.status == 1 else '已撤销' }}
</span>
</td>
<td>
<span class="badge badge-{{ 'primary' if announcement.is_top else 'light' }}">
{{ '已置顶' if announcement.is_top else '未置顶' }}
</span>
</td>
<td>
<div class="btn-group">
<a href="{{ url_for('announcement.edit_announcement', announcement_id=announcement.id) }}" class="btn btn-sm btn-info">
<i class="fas fa-edit"></i> 编辑
</a>
<button class="btn btn-sm btn-{{ 'warning' if announcement.status == 1 else 'success' }}"
onclick="changeStatus({{ announcement.id }}, {{ 0 if announcement.status == 1 else 1 }})">
<i class="fas fa-{{ 'times' if announcement.status == 1 else 'check' }}"></i>
{{ '撤销' if announcement.status == 1 else '发布' }}
</button>
<button class="btn btn-sm btn-{{ 'secondary' if announcement.is_top else 'primary' }}"
onclick="changeTopStatus({{ announcement.id }}, {{ 'false' if announcement.is_top else 'true' }})">
<i class="fas fa-thumbtack"></i>
{{ '取消置顶' if announcement.is_top else '置顶' }}
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- 分页 -->
<div class="pagination-container">
{% if pagination.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.manage_announcements', page=pagination.prev_num, search=search, status=status) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<li class="page-item active">
<a class="page-link" href="{{ url_for('announcement.manage_announcements', page=page_num, search=search, status=status) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.manage_announcements', page=page_num, search=search, status=status) }}">{{ page_num }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#">...</a>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.manage_announcements', page=pagination.next_num, search=search, status=status) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% else %}
<div class="no-records">
<i class="fas fa-info-circle"></i>
<p>没有找到符合条件的公告</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/announcement-manage.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,113 @@
{% extends 'base.html' %}
{% block title %}我的通知 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/notifications.css') }}">
{% endblock %}
{% block content %}
<div class="notifications-container">
<div class="page-header">
<h1>我的通知</h1>
<div class="notification-actions">
{% if pagination.items and not (unread_only and pagination.total == 0) %}
<a href="{{ url_for('announcement.mark_all_as_read') }}" class="btn btn-outline-primary">
<i class="fas fa-check-double"></i> 全部标为已读
</a>
{% endif %}
</div>
</div>
<div class="filter-tabs">
<a href="{{ url_for('announcement.user_notifications') }}" class="filter-tab {% if not unread_only %}active{% endif %}">所有通知</a>
<a href="{{ url_for('announcement.user_notifications', unread_only=1) }}" class="filter-tab {% if unread_only %}active{% endif %}">未读通知</a>
</div>
<div class="notifications-list">
{% if pagination.items %}
{% for notification in pagination.items %}
<div class="notification-card {% if notification.status == 0 %}unread{% endif %}">
<div class="notification-content">
<h3 class="notification-title">
<a href="{{ url_for('announcement.view_notification', notification_id=notification.id) }}">{{ notification.title }}</a>
{% if notification.status == 0 %}
<span class="unread-badge">未读</span>
{% endif %}
</h3>
<div class="notification-text">{{ notification.content|striptags|truncate(150) }}</div>
<div class="notification-meta">
<span class="notification-type">{{ notification.type }}</span>
<span class="notification-time">{{ notification.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
</div>
</div>
{% endfor %}
<!-- 分页 -->
<div class="pagination-container">
{% if pagination.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.user_notifications', page=pagination.prev_num, unread_only=1 if unread_only else 0) }}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == pagination.page %}
<li class="page-item active">
<a class="page-link" href="{{ url_for('announcement.user_notifications', page=page_num, unread_only=1 if unread_only else 0) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.user_notifications', page=page_num, unread_only=1 if unread_only else 0) }}">{{ page_num }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#">...</a>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('announcement.user_notifications', page=pagination.next_num, unread_only=1 if unread_only else 0) }}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% else %}
<div class="no-records">
<i class="fas fa-bell-slash"></i>
<p>{{ '暂无未读通知' if unread_only else '暂无通知' }}</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
{% endblock %}

View File

@ -30,7 +30,7 @@
<a href="{{ url_for('borrow.my_borrows') }}"><i class="fas fa-bookmark"></i> 我的借阅</a>
</li>
<li class="{% if '/announcement' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-bell"></i> 通知公告</a>
<a href="{{ url_for('announcement.announcement_list') }}"><i class="fas fa-bell"></i> 通知公告</a>
</li>
{% if current_user.is_authenticated and current_user.role_id == 1 %}
<li class="nav-category">管理功能</li>
@ -57,6 +57,9 @@
<li class="{% if '/log' in request.path %}active{% endif %}">
<a href="{{ url_for('log.log_list') }}"><i class="fas fa-history"></i> 日志管理</a>
</li>
<li class="{% if '/announcement/manage' in request.path %}active{% endif %}">
<a href="{{ url_for('announcement.manage_announcements') }}"><i class="fas fa-bullhorn"></i> 公告管理</a>
</li>
{% endif %}
</ul>
</nav>
@ -70,9 +73,52 @@
<input type="text" placeholder="搜索图书..." class="search-input">
</div>
<div class="user-menu">
<div class="notifications">
<div class="notifications dropdown">
<a href="#" class="notification-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-bell"></i>
<span class="badge">3</span>
{% if current_user.is_authenticated %}
{% set unread_count = get_unread_notifications_count(current_user.id) %}
{% if unread_count > 0 %}
<span class="badge">{{ unread_count }}</span>
{% endif %}
{% endif %}
</a>
{% if current_user.is_authenticated %}
<div class="dropdown-menu notification-dropdown">
<div class="notification-header">
<h6 class="dropdown-header">通知中心</h6>
{% if unread_count > 0 %}
<a href="{{ url_for('announcement.mark_all_as_read') }}" class="mark-all-read">全部标为已读</a>
{% endif %}
</div>
<div class="notification-items">
{% set recent_notifications = get_recent_notifications(current_user.id, 5) %}
{% if recent_notifications %}
{% for notification in recent_notifications %}
<a class="dropdown-item notification-item {% if notification.status == 0 %}unread{% endif %}"
href="{{ url_for('announcement.view_notification', notification_id=notification.id) }}">
<div class="notification-content">
<h6 class="notification-title">{{ notification.title }}</h6>
<p class="notification-text">{{ notification.content|striptags|truncate(50) }}</p>
<span class="notification-time">{{ notification.created_at }}</span>
</div>
</a>
{% endfor %}
{% else %}
<div class="no-notifications">
<p>暂无通知</p>
</div>
{% endif %}
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-item view-all" href="{{ url_for('announcement.user_notifications') }}">
查看所有通知
</a>
</div>
{% endif %}
</div>
{% if current_user.is_authenticated %}
<div class="user-info">

View File

@ -20,28 +20,28 @@
<i class="fas fa-book stat-icon"></i>
<div class="stat-info">
<h3>馆藏总量</h3>
<p class="stat-number">8,567</p>
<p class="stat-number">{{ stats.total_books }}</p>
</div>
</div>
<div class="stat-card">
<i class="fas fa-users stat-icon"></i>
<div class="stat-info">
<h3>注册用户</h3>
<p class="stat-number">1,245</p>
<p class="stat-number">{{ stats.total_users }}</p>
</div>
</div>
<div class="stat-card">
<i class="fas fa-exchange-alt stat-icon"></i>
<div class="stat-info">
<h3>当前借阅</h3>
<p class="stat-number">352</p>
<p class="stat-number">{{ stats.active_borrows }}</p>
</div>
</div>
<div class="stat-card">
<i class="fas fa-clock stat-icon"></i>
<div class="stat-info">
<h3>待还图书</h3>
<p class="stat-number">{{ 5 }}</p>
<p class="stat-number">{{ stats.user_borrows if current_user.is_authenticated else 0 }}</p>
</div>
</div>
</div>
@ -52,9 +52,35 @@
<div class="content-section book-section">
<div class="section-header">
<h2>最新图书</h2>
<a href="#" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
<a href="{{ url_for('book.browse_books', sort='newest') }}" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
</div>
<div class="book-grid">
{% if latest_books %}
{% for book in latest_books %}
<div class="book-card">
<div class="book-cover">
{% if book.cover_url %}
<img src="{{ book.cover_url }}" alt="{{ book.title }} 封面">
{% else %}
<img src="{{ url_for('static', filename='images/book-placeholder.jpg') }}" alt="无封面">
{% endif %}
</div>
<div class="book-info">
<h3 class="book-title">{{ book.title }}</h3>
<p class="book-author">{{ book.author }}</p>
<div class="book-meta">
<span class="book-category">{{ book.category.name if book.category else '未分类' }}</span>
<span class="book-status {{ 'available' if book.stock > 0 else 'unavailable' }}">
{{ '可借阅' if book.stock > 0 else '已借完' }}
</span>
</div>
<a href="{{ url_for('book.book_detail', book_id=book.id) }}" class="borrow-btn">
{{ '借阅' if book.stock > 0 else '详情' }}
</a>
</div>
</div>
{% endfor %}
{% else %}
{% for i in range(4) %}
<div class="book-card">
<div class="book-cover">
@ -71,6 +97,7 @@
</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
@ -78,30 +105,55 @@
<div class="content-section notice-section">
<div class="section-header">
<h2>通知公告</h2>
<a href="#" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
<a href="{{ url_for('announcement.announcement_list') }}" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
</div>
<div class="notice-list">
{% if announcements %}
{% for announcement in announcements %}
<div class="notice-item {% if announcement.is_top %}pinned{% endif %}">
<div class="notice-icon"><i class="fas fa-bullhorn"></i></div>
<div class="notice-content">
<h3><a href="{{ url_for('announcement.announcement_detail', announcement_id=announcement.id) }}">{{ announcement.title }}</a></h3>
<p>{{ announcement.content|striptags|truncate(100) }}</p>
<div class="notice-meta">
<span class="notice-time">{{ announcement.created_at.strftime('%Y-%m-%d') }}</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="notice-item">
<div class="notice-icon"><i class="fas fa-bullhorn"></i></div>
<div class="notice-content">
<h3>关于五一假期图书馆开放时间调整的通知</h3>
<p>五一期间(5月1日-5日)图书馆开放时间调整为上午9:00-下午5:00。</p>
<h3>暂无公告</h3>
<p>目前没有任何通知公告</p>
<div class="notice-meta">
<span class="notice-time">2023-04-28</span>
<span class="notice-time">{{ now.strftime('%Y-%m-%d') }}</span>
</div>
</div>
</div>
{% endif %}
{% if current_user.is_authenticated and user_notifications %}
<div class="notice-item">
<div class="notice-icon"><i class="fas fa-bell"></i></div>
<div class="notice-content">
<h3>您有2本图书即将到期</h3>
<p>《Python编程》《算法导论》将于3天后到期请及时归还或办理续借。</p>
<h3>您有{{ user_notifications|length }}条未读通知</h3>
<p>
{% 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 %}
</p>
<div class="notice-meta">
<span class="notice-time">2023-04-27</span>
<button class="renew-btn">一键续借</button>
<span class="notice-time">{{ now.strftime('%Y-%m-%d') }}</span>
<a href="{{ url_for('announcement.user_notifications') }}" class="renew-btn">查看详情</a>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@ -110,9 +162,31 @@
<div class="content-section popular-section">
<div class="section-header">
<h2>热门图书</h2>
<a href="#" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
<a href="{{ url_for('book.browse_books', sort='popular') }}" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
</div>
<div class="popular-books">
{% if popular_books %}
{% for book in popular_books %}
<div class="popular-book-item">
<div class="rank-badge">{{ loop.index }}</div>
<div class="book-cover small">
{% if book.cover_url %}
<img src="{{ book.cover_url }}" alt="{{ book.title }} 封面">
{% else %}
<img src="{{ url_for('static', filename='images/book-placeholder.jpg') }}" alt="无封面">
{% endif %}
</div>
<div class="book-details">
<h3 class="book-title">{{ book.title }}</h3>
<p class="book-author">{{ book.author }}</p>
<div class="book-stats">
<span><i class="fas fa-eye"></i> {{ book.view_count if book.view_count else 0 }} 次浏览</span>
<span><i class="fas fa-bookmark"></i> {{ book.borrow_count if book.borrow_count else 0 }} 次借阅</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
{% for i in range(5) %}
<div class="popular-book-item">
<div class="rank-badge">{{ i+1 }}</div>
@ -129,6 +203,7 @@
</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -6,7 +6,10 @@
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/statistics.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&display=swap">
<!-- Updated Google Fonts Link -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Lora:wght@400;600&family=Open+Sans:wght@300;400&family=Sacramento&family=EB+Garamond:ital@0;1&display=swap" rel="stylesheet">
{% endblock %}
{% block content %}
@ -70,12 +73,18 @@ document.addEventListener('DOMContentLoaded', function() {
cards.forEach(card => {
card.addEventListener('mouseenter', function() {
const decoration = this.querySelector('.card-decoration');
if (decoration) { // Check if decoration exists
decoration.classList.add('active');
}
this.classList.add('hovered'); // Add class for general hover style
});
card.addEventListener('mouseleave', function() {
const decoration = this.querySelector('.card-decoration');
if (decoration) { // Check if decoration exists
decoration.classList.remove('active');
}
this.classList.remove('hovered'); // Remove class
});
});
});

View File

@ -1,23 +1,32 @@
from functools import wraps
from flask import g, redirect, url_for, flash, request
from flask import redirect, url_for, flash, request
from flask_login import current_user
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
print(f"DEBUG: login_required 检查 - current_user.is_authenticated = {current_user.is_authenticated}")
if not current_user.is_authenticated:
flash('请先登录', 'warning')
return redirect(url_for('user.login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
print(f"DEBUG: admin_required 检查 - current_user.is_authenticated = {current_user.is_authenticated}")
if not current_user.is_authenticated:
flash('请先登录', 'warning')
return redirect(url_for('user.login', next=request.url))
if g.user.role_id != 1: # 假设role_id=1是管理员
print(f"DEBUG: admin_required 检查 - current_user.role_id = {getattr(current_user, 'role_id', None)}")
if getattr(current_user, 'role_id', None) != 1: # 安全地获取role_id属性
flash('权限不足', 'danger')
return redirect(url_for('index'))
return f(*args, **kwargs)
return decorated_function