-
-
-
+
+
-
-
+
+
+
{% if current_user.is_authenticated %}
{% set unread_count = get_unread_notifications_count(current_user.id) %}
@@ -21698,9 +22003,9 @@ File: ./app/templates/base.html
{% if current_user.is_authenticated %}
- /count', methods=['GET'])
+@user_bp.route('/role//count', methods=['GET'])
@login_required
@admin_required
def get_role_user_count(role_id):
@@ -28322,16 +28784,13 @@ def log_detail(log_id):
@permission_required('view_logs') # 替代 @admin_required
def export_logs():
"""导出日志API"""
- import csv
- from io import StringIO
- from flask import Response
-
data = request.get_json()
user_id = data.get('user_id')
action = data.get('action')
target_type = data.get('target_type')
start_date_str = data.get('start_date')
end_date_str = data.get('end_date')
+ export_format = data.get('format', 'csv') # 获取导出格式参数
# 处理日期范围
start_date = None
@@ -28358,9 +28817,35 @@ def export_logs():
logs = query.all()
- # 生成CSV文件
- si = StringIO()
- csv_writer = csv.writer(si)
+ try:
+ # 根据格式选择导出方法
+ if export_format == 'xlsx':
+ return export_as_xlsx(logs)
+ else:
+ return export_as_csv(logs)
+ except Exception as e:
+ # 记录错误以便调试
+ import traceback
+ error_details = traceback.format_exc()
+ current_app.logger.error(f"Export error: {str(e)}\n{error_details}")
+
+ return jsonify({
+ 'success': False,
+ 'message': f'导出失败: {str(e)}'
+ }), 500
+
+
+def export_as_csv(logs):
+ """导出为CSV格式"""
+ import csv
+ from io import StringIO
+ import base64
+
+ # 创建CSV文件
+ output = StringIO()
+ output.write('\ufeff') # 添加BOM标记,解决Excel中文乱码
+
+ csv_writer = csv.writer(output)
# 写入标题行
csv_writer.writerow(['ID', '用户', '操作类型', '目标类型', '目标ID', 'IP地址', '描述', '创建时间'])
@@ -28379,25 +28864,88 @@ def export_logs():
log.created_at.strftime('%Y-%m-%d %H:%M:%S')
])
- # 设置响应头,使浏览器将其识别为下载文件
+ # 获取CSV字符串并进行Base64编码
+ csv_string = output.getvalue()
+ csv_bytes = csv_string.encode('utf-8')
+ b64_encoded = base64.b64encode(csv_bytes).decode('utf-8')
+
+ # 设置文件名
filename = f"system_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
- output = si.getvalue()
-
- # 返回Base64编码的CSV数据
- import base64
- encoded_data = base64.b64encode(output.encode('utf-8')).decode('utf-8')
-
return jsonify({
'success': True,
'message': f'已生成 {len(logs)} 条日志记录',
'count': len(logs),
'filename': filename,
- 'filedata': encoded_data,
+ 'filedata': b64_encoded,
'filetype': 'text/csv'
})
+def export_as_xlsx(logs):
+ """导出为XLSX格式"""
+ import base64
+ from io import BytesIO
+
+ try:
+ # 动态导入openpyxl,如果不存在则抛出异常
+ import openpyxl
+ except ImportError:
+ raise Exception("未安装openpyxl库,无法导出Excel格式。请安装后重试: pip install openpyxl")
+
+ # 创建工作簿和工作表
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.title = "系统日志"
+
+ # 写入标题行
+ headers = ['ID', '用户', '操作类型', '目标类型', '目标ID', 'IP地址', '描述', '创建时间']
+ for col_idx, header in enumerate(headers, 1):
+ ws.cell(row=1, column=col_idx, value=header)
+
+ # 写入数据行
+ for row_idx, log in enumerate(logs, 2):
+ username = log.user.username if log.user else "未登录"
+
+ ws.cell(row=row_idx, column=1, value=log.id)
+ ws.cell(row=row_idx, column=2, value=username)
+ ws.cell(row=row_idx, column=3, value=log.action)
+ ws.cell(row=row_idx, column=4, value=log.target_type or '')
+ ws.cell(row=row_idx, column=5, value=log.target_id or '')
+ ws.cell(row=row_idx, column=6, value=log.ip_address or '')
+ ws.cell(row=row_idx, column=7, value=log.description or '')
+ ws.cell(row=row_idx, column=8, value=log.created_at.strftime('%Y-%m-%d %H:%M:%S'))
+
+ # 调整列宽
+ for col_idx, header in enumerate(headers, 1):
+ column_letter = openpyxl.utils.get_column_letter(col_idx)
+ if header == '描述':
+ ws.column_dimensions[column_letter].width = 40
+ else:
+ ws.column_dimensions[column_letter].width = 15
+
+ # 保存到内存
+ output = BytesIO()
+ wb.save(output)
+ output.seek(0)
+
+ # 编码为Base64
+ xlsx_data = output.getvalue()
+ b64_encoded = base64.b64encode(xlsx_data).decode('utf-8')
+
+ # 设置文件名
+ filename = f"system_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+
+ return jsonify({
+ 'success': True,
+ 'message': f'已生成 {len(logs)} 条日志记录',
+ 'count': len(logs),
+ 'filename': filename,
+ 'filedata': b64_encoded,
+ 'filetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
+ })
+
+
@log_bp.route('/api/clear', methods=['POST'])
@login_required
@permission_required('view_logs') # 替代 @admin_required
@@ -28437,7 +28985,7 @@ File: ./app/controllers/__init__.py
File: ./app/controllers/book.py
================================================================================
-from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
+from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, send_file
from app.models.book import Book, Category
from app.models.user import db
from app.utils.auth import login_required, permission_required # 修改导入,替换admin_required为permission_required
@@ -28448,7 +28996,9 @@ import datetime
import pandas as pd
import uuid
from app.models.log import Log
-
+from io import BytesIO
+import xlsxwriter
+from sqlalchemy import text
book_bp = Blueprint('book', __name__)
@@ -29114,7 +29664,7 @@ def delete_category(category_id):
# 批量导入图书
@book_bp.route('/import', methods=['GET', 'POST'])
@login_required
-@permission_required('import_export_books') # 替换 @admin_required
+@permission_required('import_export_books')
def import_books():
if request.method == 'POST':
if 'file' not in request.files:
@@ -29128,41 +29678,152 @@ def import_books():
if file and file.filename.endswith(('.xlsx', '.xls')):
try:
+ # 添加详细日志
+ current_app.logger.info(f"开始导入Excel文件: {file.filename}")
+
# 读取Excel文件
df = pd.read_excel(file)
+ current_app.logger.info(f"成功读取Excel文件,共有{len(df)}行数据")
+
+ # 打印DataFrame的列名和前几行数据,帮助诊断问题
+ current_app.logger.info(f"Excel文件列名: {df.columns.tolist()}")
+ current_app.logger.info(f"Excel文件前两行数据:\n{df.head(2)}")
+
success_count = 0
+ update_count = 0 # 新增:更新计数
error_count = 0
errors = []
+ # 检查必填列是否存在
+ required_columns = ['title', 'author']
+ missing_columns = [col for col in required_columns if col not in df.columns]
+ if missing_columns:
+ error_msg = f"Excel文件缺少必要的列: {', '.join(missing_columns)}"
+ current_app.logger.error(error_msg)
+ flash(error_msg, 'danger')
+ return redirect(request.url)
+
# 处理每一行数据
for index, row in df.iterrows():
try:
+ current_app.logger.debug(f"处理第{index + 2}行数据")
+
# 检查必填字段
if pd.isna(row.get('title')) or pd.isna(row.get('author')):
- errors.append(f'第{index + 2}行: 书名或作者为空')
+ error_msg = f'第{index + 2}行: 书名或作者为空'
+ current_app.logger.warning(error_msg)
+ errors.append(error_msg)
+ error_count += 1
+ continue
+
+ # 处理数据类型转换
+ try:
+ stock = int(row.get('stock')) if not pd.isna(row.get('stock')) else 0
+ except ValueError:
+ error_msg = f'第{index + 2}行: 库存数量必须是整数'
+ current_app.logger.warning(error_msg)
+ errors.append(error_msg)
+ error_count += 1
+ continue
+
+ try:
+ price = float(row.get('price')) if not pd.isna(row.get('price')) else None
+ except ValueError:
+ error_msg = f'第{index + 2}行: 价格必须是数字'
+ current_app.logger.warning(error_msg)
+ errors.append(error_msg)
+ error_count += 1
+ continue
+
+ try:
+ category_id = int(row.get('category_id')) if not pd.isna(row.get('category_id')) else None
+ if category_id and not Category.query.get(category_id):
+ error_msg = f'第{index + 2}行: 分类ID {category_id} 不存在'
+ current_app.logger.warning(error_msg)
+ errors.append(error_msg)
+ error_count += 1
+ continue
+ except ValueError:
+ error_msg = f'第{index + 2}行: 分类ID必须是整数'
+ current_app.logger.warning(error_msg)
+ errors.append(error_msg)
error_count += 1
continue
# 检查ISBN是否已存在
isbn = row.get('isbn')
- if isbn and not pd.isna(isbn) and Book.query.filter_by(isbn=str(isbn)).first():
- errors.append(f'第{index + 2}行: ISBN {isbn} 已存在')
- error_count += 1
- continue
+ existing_book = None
+
+ if isbn and not pd.isna(isbn):
+ isbn = str(isbn)
+ existing_book = Book.query.filter_by(isbn=isbn).first()
+
+ # 如果ISBN已存在,检查状态
+ if existing_book:
+ if existing_book.status == 1:
+ # 活跃的图书,不允许重复导入
+ error_msg = f'第{index + 2}行: ISBN {isbn} 已存在于活跃图书中'
+ current_app.logger.warning(error_msg)
+ errors.append(error_msg)
+ error_count += 1
+ continue
+ else:
+ # 已软删除的图书,更新它
+ current_app.logger.info(f"第{index + 2}行: 发现已删除的ISBN {isbn},将更新该记录")
+
+ # 更新图书信息
+ existing_book.title = row.get('title')
+ existing_book.author = row.get('author')
+ existing_book.publisher = row.get('publisher') if not pd.isna(
+ row.get('publisher')) else None
+ existing_book.category_id = category_id
+ existing_book.tags = row.get('tags') if not pd.isna(row.get('tags')) else None
+ existing_book.publish_year = str(row.get('publish_year')) if not pd.isna(
+ row.get('publish_year')) else None
+ existing_book.description = row.get('description') if not pd.isna(
+ row.get('description')) else None
+ existing_book.cover_url = row.get('cover_url') if not pd.isna(
+ row.get('cover_url')) else None
+ existing_book.price = price
+ existing_book.status = 1 # 重新激活图书
+ existing_book.updated_at = datetime.datetime.now()
+
+ # 处理库存变更
+ stock_change = stock - existing_book.stock
+ existing_book.stock = stock
+
+ # 创建库存日志
+ if stock_change != 0:
+ from app.models.inventory import InventoryLog
+ change_type = '入库' if stock_change > 0 else '出库'
+
+ inventory_log = InventoryLog(
+ book_id=existing_book.id,
+ change_type=change_type,
+ change_amount=abs(stock_change),
+ after_stock=stock,
+ operator_id=current_user.id,
+ remark='批量导入更新图书',
+ changed_at=datetime.datetime.now()
+ )
+ db.session.add(inventory_log)
+
+ update_count += 1
+ continue # 继续处理下一行
# 创建新书籍记录
book = Book(
title=row.get('title'),
author=row.get('author'),
publisher=row.get('publisher') if not pd.isna(row.get('publisher')) else None,
- category_id=row.get('category_id') if not pd.isna(row.get('category_id')) else None,
+ category_id=category_id,
tags=row.get('tags') if not pd.isna(row.get('tags')) else None,
- isbn=str(row.get('isbn')) if not pd.isna(row.get('isbn')) else None,
+ isbn=isbn,
publish_year=str(row.get('publish_year')) if not pd.isna(row.get('publish_year')) else None,
description=row.get('description') if not pd.isna(row.get('description')) else None,
cover_url=row.get('cover_url') if not pd.isna(row.get('cover_url')) else None,
- stock=int(row.get('stock')) if not pd.isna(row.get('stock')) else 0,
- price=float(row.get('price')) if not pd.isna(row.get('price')) else None,
+ stock=stock,
+ price=price,
status=1,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now()
@@ -29172,6 +29833,8 @@ def import_books():
# 提交以获取book的id
db.session.flush()
+ current_app.logger.debug(f"书籍添加成功: {book.title}, ID: {book.id}")
+
# 创建库存日志
if book.stock > 0:
from app.models.inventory import InventoryLog
@@ -29185,30 +29848,59 @@ def import_books():
changed_at=datetime.datetime.now()
)
db.session.add(inventory_log)
+ current_app.logger.debug(f"库存日志添加成功: 书籍ID {book.id}, 数量 {book.stock}")
success_count += 1
+
except Exception as e:
- errors.append(f'第{index + 2}行: {str(e)}')
+ error_msg = f'第{index + 2}行: {str(e)}'
+ current_app.logger.error(f"处理第{index + 2}行时出错: {str(e)}")
+ errors.append(error_msg)
error_count += 1
+ db.session.rollback() # 每行错误单独回滚
- db.session.commit()
+ try:
+ # 最终提交所有成功的记录
+ if success_count > 0 or update_count > 0:
+ db.session.commit()
- # 记录操作日志
- Log.add_log(
- action='批量导入图书',
- user_id=current_user.id,
- ip_address=request.remote_addr,
- description=f"批量导入图书: 成功{success_count}条,失败{error_count}条,文件名:{file.filename}"
- )
+ current_app.logger.info(f"导入完成: 新增{success_count}条,更新{update_count}条,失败{error_count}条")
- flash(f'导入完成: 成功{success_count}条,失败{error_count}条', 'info')
- if errors:
- flash('
'.join(errors[:10]) + (f'
...等共{len(errors)}个错误' if len(errors) > 10 else ''), - 'warning') + # 记录操作日志 + Log.add_log( + action='批量导入图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"批量导入图书: 新增{success_count}条,更新{update_count}条,失败{error_count}条,文件名:{file.filename}" + ) - return redirect(url_for('book.book_list')) + # 输出详细的错误信息 + if success_count > 0 or update_count > 0: + flash(f'导入完成: 新增{success_count}条,更新{update_count}条,失败{error_count}条', 'success') + else: + flash(f'导入完成: 新增{success_count}条,更新{update_count}条,失败{error_count}条', 'warning') + + if errors: + error_display = '
'.join(errors[:10]) + if len(errors) > 10: + error_display += f'
...等共{len(errors)}个错误' + flash(error_display, 'warning') + + return redirect(url_for('book.book_list')) + + except Exception as commit_error: + db.session.rollback() + error_msg = f"提交数据库事务失败: {str(commit_error)}" + current_app.logger.error(error_msg) + flash(error_msg, 'danger') + return redirect(request.url) except Exception as e: + db.session.rollback() + # 记录详细错误日志 + error_msg = f"批量导入图书失败: {str(e)}" + current_app.logger.error(error_msg) + # 记录操作失败日志 Log.add_log( action='批量导入图书失败', @@ -29227,72 +29919,87 @@ def import_books(): # 导出图书 -@book_bp.route('/export') +@book_bp.route('/export', methods=['GET']) @login_required -@permission_required('import_export_books') # 替换 @admin_required +@permission_required('import_export_books') def export_books(): - # 获取查询参数 - search = request.args.get('search', '') - category_id = request.args.get('category_id', type=int) + try: + # 获取所有活跃的图书 + books = Book.query.filter_by(status=1).all() - query = Book.query + # 创建工作簿和工作表 + output = BytesIO() + workbook = xlsxwriter.Workbook(output) + worksheet = workbook.add_worksheet() - if search: - query = query.filter( - (Book.title.contains(search)) | - (Book.author.contains(search)) | - (Book.isbn.contains(search)) + # 添加表头 + headers = ['ID', '书名', '作者', '出版社', '分类', '标签', 'ISBN', '出版年份', + '描述', '封面链接', '库存', '价格', '创建时间', '更新时间'] + for col_num, header in enumerate(headers): + worksheet.write(0, col_num, header) + + # 不使用status过滤条件,因为Category模型没有status字段 + category_dict = {} + try: + categories = db.session.execute( + text("SELECT id, name FROM categories") + ).fetchall() + for cat in categories: + category_dict[cat[0]] = cat[1] + except Exception as ex: + current_app.logger.warning(f"获取分类失败: {str(ex)}") + + # 添加数据行 + for row_num, book in enumerate(books, 1): + # 获取分类名称(如果有) + category_name = category_dict.get(book.category_id, "") if book.category_id else "" + + # 格式化日期 + created_at = book.created_at.strftime('%Y-%m-%d %H:%M:%S') if book.created_at else "" + updated_at = book.updated_at.strftime('%Y-%m-%d %H:%M:%S') if book.updated_at else "" + + # 写入图书数据 + worksheet.write(row_num, 0, book.id) + worksheet.write(row_num, 1, book.title) + worksheet.write(row_num, 2, book.author) + worksheet.write(row_num, 3, book.publisher or "") + worksheet.write(row_num, 4, category_name) + worksheet.write(row_num, 5, book.tags or "") + worksheet.write(row_num, 6, book.isbn or "") + worksheet.write(row_num, 7, book.publish_year or "") + worksheet.write(row_num, 8, book.description or "") + worksheet.write(row_num, 9, book.cover_url or "") + worksheet.write(row_num, 10, book.stock) + worksheet.write(row_num, 11, float(book.price) if book.price else 0) + worksheet.write(row_num, 12, created_at) + worksheet.write(row_num, 13, updated_at) + + # 关闭工作簿 + workbook.close() + + # 设置响应 + output.seek(0) + + # 记录操作日志 + Log.add_log( + action='导出图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"导出了{len(books)}本图书" ) - if category_id: - query = query.filter_by(category_id=category_id) + # 返回文件 + return send_file( + output, + as_attachment=True, + download_name=f"图书导出_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx", + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) - books = query.all() - - # 创建DataFrame - data = [] - for book in books: - category_name = book.category.name if book.category else "" - data.append({ - 'id': book.id, - 'title': book.title, - 'author': book.author, - 'publisher': book.publisher, - 'category': category_name, - 'tags': book.tags, - 'isbn': book.isbn, - 'publish_year': book.publish_year, - 'description': book.description, - 'stock': book.stock, - 'price': book.price, - 'status': '上架' if book.status == 1 else '下架', - 'created_at': book.created_at.strftime('%Y-%m-%d %H:%M:%S') if book.created_at else '', - 'updated_at': book.updated_at.strftime('%Y-%m-%d %H:%M:%S') if book.updated_at else '' - }) - - df = pd.DataFrame(data) - - # 创建临时文件 - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - filename = f'books_export_{timestamp}.xlsx' - filepath = os.path.join(current_app.static_folder, 'temp', filename) - - # 确保目录存在 - os.makedirs(os.path.dirname(filepath), exist_ok=True) - - # 写入Excel - df.to_excel(filepath, index=False) - - # 记录操作日志 - Log.add_log( - action='导出图书', - user_id=current_user.id, - ip_address=request.remote_addr, - description=f"导出图书数据: {len(data)}条记录, 查询条件: 搜索={search}, 分类ID={category_id}" - ) - - # 提供下载链接 - return redirect(url_for('static', filename=f'temp/{filename}')) + except Exception as e: + current_app.logger.error(f"导出图书失败: {str(e)}") + flash(f'导出失败: {str(e)}', 'danger') + return redirect(url_for('book.book_list')) @book_bp.route('/test-permissions') @@ -29370,90 +30077,222 @@ def browse_books(): @book_bp.route('/template/download') @login_required -@permission_required('import_export_books') # 替换 @admin_required +@permission_required('import_export_books') def download_template(): - """生成并下载Excel图书导入模板""" - # 创建一个简单的DataFrame作为模板 - data = { - 'title': ['三体', '解忧杂货店'], - 'author': ['刘慈欣', '东野圭吾'], - 'publisher': ['重庆出版社', '南海出版公司'], - 'category_id': [2, 1], # 对应于科幻小说和文学分类 - 'tags': ['科幻,宇宙', '治愈,悬疑'], - 'isbn': ['9787229100605', '9787544270878'], - 'publish_year': ['2008', '2014'], - 'description': ['中国著名科幻小说,三体世界的故事。', '通过信件为人们解忧的杂货店故事。'], - 'cover_url': ['', ''], # 示例中为空 - 'stock': [10, 5], - 'price': [45.00, 39.80] + """下载图书导入模板""" + import tempfile + import os + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + from flask import after_this_request, current_app as app, send_file + + # 创建工作簿和工作表 + wb = Workbook() + ws = wb.active + ws.title = "图书导入模板" + + # 定义样式 + header_font = Font(name='微软雅黑', size=11, bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + required_font = Font(name='微软雅黑', size=11, bold=True, color="FF0000") + optional_font = Font(name='微软雅黑', size=11) + center_alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + left_alignment = Alignment(vertical='center', wrap_text=True) + + # 定义边框样式 + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # 定义列宽 + column_widths = { + 'A': 30, # title + 'B': 20, # author + 'C': 20, # publisher + 'D': 15, # category_id + 'E': 25, # tags + 'F': 15, # isbn + 'G': 15, # publish_year + 'H': 40, # description + 'I': 30, # cover_url + 'J': 10, # stock + 'K': 10, # price } - # 创建一个分类ID和名称的映射 - categories = Category.query.all() - category_map = {cat.id: cat.name for cat in categories} + # 设置列宽 + for col, width in column_widths.items(): + ws.column_dimensions[col].width = width - # 创建一个pandas DataFrame - df = pd.DataFrame(data) + # 定义字段信息 + fields = [ + {'name': 'title', 'desc': '图书标题', 'required': True, 'example': '哈利·波特与魔法石'}, + {'name': 'author', 'desc': '作者名称', 'required': True, 'example': 'J.K.罗琳'}, + {'name': 'publisher', 'desc': '出版社', 'required': False, 'example': '人民文学出版社'}, + {'name': 'category_id', 'desc': '分类ID', 'required': False, 'example': '1'}, + {'name': 'tags', 'desc': '标签', 'required': False, 'example': '魔幻,冒险,青少年文学'}, + {'name': 'isbn', 'desc': 'ISBN编号', 'required': False, 'example': '9787020042494'}, + {'name': 'publish_year', 'desc': '出版年份', 'required': False, 'example': '2000'}, + {'name': 'description', 'desc': '图书简介', 'required': False, + 'example': '这是一本关于魔法的书籍,讲述了小男孩哈利...'}, + {'name': 'cover_url', 'desc': '封面图片URL', 'required': False, 'example': 'https://example.com/covers/hp.jpg'}, + {'name': 'stock', 'desc': '库存数量', 'required': False, 'example': '10'}, + {'name': 'price', 'desc': '价格', 'required': False, 'example': '39.5'}, + ] + + # 添加标题行 + ws.append([f"{field['name']}" for field in fields]) + + # 设置标题行样式 + for col_idx, field in enumerate(fields, start=1): + cell = ws.cell(row=1, column=col_idx) + cell.font = header_font + cell.fill = header_fill + cell.alignment = center_alignment + cell.border = thin_border + + # 添加示例数据行 + ws.append([field['example'] for field in fields]) + + # 设置示例行样式 + for col_idx, field in enumerate(fields, start=1): + cell = ws.cell(row=2, column=col_idx) + cell.alignment = left_alignment + cell.border = thin_border + if field['required']: + cell.font = required_font + else: + cell.font = optional_font + + # 冻结首行 + ws.freeze_panes = "A2" # 添加说明工作表 - with pd.ExcelWriter('book_import_template.xlsx', engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='图书数据示例', index=False) + instructions_ws = wb.create_sheet(title="填写说明") - # 创建说明工作表 - info_df = pd.DataFrame({ - '字段名': ['title', 'author', 'publisher', 'category_id', 'tags', - 'isbn', 'publish_year', 'description', 'cover_url', 'stock', 'price'], - '说明': ['图书标题 (必填)', '作者名称 (必填)', '出版社', '分类ID (对应系统分类)', - '标签 (多个标签用逗号分隔)', 'ISBN编号', '出版年份', '图书简介', - '封面图片URL', '库存数量', '价格'], - '示例': ['三体', '刘慈欣', '重庆出版社', '2 (科幻小说)', '科幻,宇宙', - '9787229100605', '2008', '中国著名科幻小说...', 'http://example.com/cover.jpg', '10', '45.00'], - '是否必填': ['是', '是', '否', '否', '否', '否', '否', '否', '否', '否', '否'] - }) - info_df.to_excel(writer, sheet_name='填写说明', index=False) + # 设置说明工作表的列宽 + instructions_ws.column_dimensions['A'].width = 15 + instructions_ws.column_dimensions['B'].width = 20 + instructions_ws.column_dimensions['C'].width = 60 - # 创建分类ID工作表 - category_df = pd.DataFrame({ - '分类ID': list(category_map.keys()), - '分类名称': list(category_map.values()) - }) - category_df.to_excel(writer, sheet_name='分类对照表', index=False) + # 添加说明标题 + instructions_ws.append(["字段名", "说明", "备注"]) - # 设置响应 - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - filename = f'book_import_template_{timestamp}.xlsx' + # 设置标题行样式 + for col_idx in range(1, 4): + cell = instructions_ws.cell(row=1, column=col_idx) + cell.font = header_font + cell.fill = header_fill + cell.alignment = center_alignment + cell.border = thin_border - # 记录操作日志 - Log.add_log( - action='下载图书导入模板', - user_id=current_user.id, - ip_address=request.remote_addr, - description="下载图书批量导入Excel模板" - ) + # 字段详细说明 + notes = { + 'title': "图书的完整名称,例如:'哈利·波特与魔法石'。必须填写且不能为空。", + 'author': "图书的作者名称,例如:'J.K.罗琳'。必须填写且不能为空。如有多个作者,请用逗号分隔。", + 'publisher': "出版社名称,例如:'人民文学出版社'。", + 'category_id': "图书分类的系统ID。请在系统的分类管理页面查看ID。如果不确定,可以留空,系统将使用默认分类。", + 'tags': "图书的标签,多个标签请用英文逗号分隔,例如:'魔幻,冒险,青少年文学'。", + 'isbn': "图书的ISBN号码,例如:'9787020042494'。请输入完整的13位或10位ISBN。", + 'publish_year': "图书的出版年份,例如:'2000'。请输入4位数字年份。", + 'description': "图书的简介或摘要。可以输入详细的描述信息,系统支持换行和基本格式。", + 'cover_url': "图书封面的在线URL地址。如果有图片网址,系统将自动下载并设置为封面。", + 'stock': "图书的库存数量,例如:'10'。请输入整数。", + 'price': "图书的价格,例如:'39.5'。可以输入小数。" + } - # 直接返回生成的文件 - from flask import send_file - from io import BytesIO + # 添加字段说明 + for idx, field in enumerate(fields, start=2): + required_text = "【必填】" if field['required'] else "【选填】" + instructions_ws.append([ + field['name'], + f"{field['desc']} {required_text}", + notes.get(field['name'], "") + ]) - # 将文件转换为二进制流 - output = BytesIO() - with open('book_import_template.xlsx', 'rb') as f: - output.write(f.read()) - output.seek(0) + # 设置单元格样式 + for col_idx in range(1, 4): + cell = instructions_ws.cell(row=idx, column=col_idx) + cell.alignment = left_alignment + cell.border = thin_border + if field['required'] and col_idx == 2: + cell.font = required_font + else: + cell.font = optional_font + + # 添加示例行 + instructions_ws.append(["", "", ""]) + instructions_ws.append(["示例", "", "以下是完整的示例数据:"]) + + example_data = [ + {"title": "哈利·波特与魔法石", "author": "J.K.罗琳", "publisher": "人民文学出版社", + "category_id": "1", "tags": "魔幻,冒险,青少年文学", "isbn": "9787020042494", + "publish_year": "2000", "description": "这是一本关于魔法的书籍,讲述了小男孩哈利...", + "cover_url": "https://example.com/covers/hp.jpg", "stock": "10", "price": "39.5"}, + + {"title": "三体", "author": "刘慈欣", "publisher": "重庆出版社", + "category_id": "2", "tags": "科幻,硬科幻,中国科幻", "isbn": "9787536692930", + "publish_year": "2008", "description": "文化大革命如火如荼进行的同时...", + "cover_url": "https://example.com/covers/threebody.jpg", "stock": "15", "price": "59.8"}, + + {"title": "平凡的世界", "author": "路遥", "publisher": "北京十月文艺出版社", + "category_id": "3", "tags": "文学,现实主义,农村", "isbn": "9787530216781", + "publish_year": "2017", "description": "这是一部全景式地表现中国当代城乡社会...", + "cover_url": "https://example.com/covers/world.jpg", "stock": "8", "price": "128.0"} + ] + + # 添加3个完整示例到主模板工作表 + for example in example_data: + row_data = [example.get(field['name'], '') for field in fields] + ws.append(row_data) + + # 设置示例数据样式 + for row_idx in range(3, 6): + for col_idx in range(1, len(fields) + 1): + cell = ws.cell(row=row_idx, column=col_idx) + cell.alignment = left_alignment + cell.border = thin_border + + # 使用临时文件保存工作簿 + fd, temp_path = tempfile.mkstemp(suffix='.xlsx') + os.close(fd) - # 删除临时文件 try: - os.remove('book_import_template.xlsx') - except: - pass + wb.save(temp_path) - # 通过send_file返回 - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) + # 注册一个函数在响应完成后删除临时文件 + @after_this_request + def remove_file(response): + try: + os.unlink(temp_path) + except Exception as error: + app.logger.error("删除临时文件出错: %s", error) + return response + + response = send_file( + temp_path, + as_attachment=True, + download_name='图书导入模板.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + # 添加防止缓存的头信息 + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response + except Exception as e: + # 确保在出错时也删除临时文件 + try: + os.unlink(temp_path) + except: + pass + app.logger.error("生成模板文件出错: %s", str(e)) + return {"error": "生成模板文件失败,请稍后再试"}, 500 ================================================================================ File: ./app/controllers/statistics.py @@ -29579,6 +30418,8 @@ def api_borrow_trend(): """获取借阅趋势数据API""" time_range = request.args.get('time_range', 'month') + now = datetime.now() + # 记录获取借阅趋势数据的日志 Log.add_log( action="获取数据", @@ -29611,8 +30452,8 @@ def api_borrow_trend(): # 当天逾期未还的数量 overdue_count = BorrowRecord.query.filter( - BorrowRecord.due_date < day_end, - BorrowRecord.return_date.is_(None) + BorrowRecord.return_date.is_(None), # 未归还 + BorrowRecord.due_date < now # 应还日期早于当前时间 ).count() results.append({ @@ -29647,9 +30488,10 @@ def api_borrow_trend(): ).count() # 当天逾期未还的数量 + now = datetime.now() overdue_count = BorrowRecord.query.filter( - BorrowRecord.due_date < day_end, - BorrowRecord.return_date.is_(None) + BorrowRecord.return_date.is_(None), + BorrowRecord.due_date < now ).count() results.append({ @@ -29692,9 +30534,10 @@ def api_borrow_trend(): ).count() # 当月逾期未还的数量 + now = datetime.now() overdue_count = BorrowRecord.query.filter( - BorrowRecord.due_date < month_end, - BorrowRecord.return_date.is_(None) + BorrowRecord.return_date.is_(None), + BorrowRecord.due_date < now ).count() results.append({ @@ -29934,6 +30777,16 @@ def borrow_book(): flash('请选择要借阅的图书', 'danger') return redirect(url_for('book.book_list')) + # 检查用户当前借阅数量是否达到上限(5本) + current_borrows_count = BorrowRecord.query.filter_by( + user_id=current_user.id, + status=1 # 1表示借阅中 + ).count() + + if current_borrows_count >= 5: + flash('您当前已借阅5本图书,达到借阅上限。请先归还后再借阅新书。', 'warning') + return redirect(url_for('book.book_detail', book_id=book_id)) + book = Book.query.get_or_404(book_id) # 检查库存 @@ -30013,6 +30866,18 @@ def add_borrow(book_id): # 验证图书存在 book = Book.query.get_or_404(book_id) + # 检查用户当前借阅数量是否达到上限(5本) + current_borrows_count = BorrowRecord.query.filter_by( + user_id=current_user.id, + status=1 # 1表示借阅中 + ).count() + + if current_borrows_count >= 5: + return jsonify({ + 'success': False, + 'message': '您当前已借阅5本图书,达到借阅上限。请先归还后再借阅新书。' + }) + # 默认借阅天数 borrow_days = 14 @@ -30941,7 +31806,7 @@ def inventory_list(): @inventory_bp.route('/adjust/', methods=['GET', 'POST'])
@login_required
-@permission_required('manage_inventory') # 替代 @admin_required
+@permission_required('manage_inventory')
def adjust_inventory(book_id):
"""调整图书库存"""
book = Book.query.get_or_404(book_id)
@@ -31141,7 +32006,7 @@ File: ./app/services/user_service.py
from app.models.user import User, Role, db
from sqlalchemy import or_
from datetime import datetime
-
+from sqlalchemy import text
class UserService:
@staticmethod
@@ -31243,16 +32108,31 @@ class UserService:
@staticmethod
def delete_user(user_id):
- """删除用户 (软删除,将状态设为-1)"""
+ """删除用户 (物理删除)"""
user = User.query.get(user_id)
if not user:
return False, "用户不存在"
try:
- user.status = -1 # 软删除,设置状态为-1
- user.updated_at = datetime.now()
+ # 检查是否有未归还的图书 - 使用SQLAlchemy的text函数
+ active_borrows_count = db.session.execute(
+ text("SELECT COUNT(*) FROM borrow_records WHERE user_id = :user_id AND return_date IS NULL"),
+ {"user_id": user_id}
+ ).scalar()
+
+ if active_borrows_count > 0:
+ return False, f"无法删除:该用户还有 {active_borrows_count} 本未归还的图书"
+
+ # 删除用户相关的通知记录 - 使用SQLAlchemy的text函数
+ db.session.execute(
+ text("DELETE FROM notifications WHERE user_id = :user_id OR sender_id = :user_id"),
+ {"user_id": user_id}
+ )
+
+ # 物理删除用户
+ db.session.delete(user)
db.session.commit()
- return True, "用户已删除"
+ return True, "用户已永久删除"
except Exception as e:
db.session.rollback()
return False, f"删除失败: {str(e)}"
+
+
+
{% if current_user.is_authenticated %}
{% endblock %}
{% block scripts %}
+
{% endblock %}
================================================================================
@@ -28128,7 +28590,7 @@ def role_delete(role_id):
'message': f"删除角色失败: {str(e)}"
}), 500
-@user_bp.route('/user/role/
-
-
+
查看所有通知
{% endif %}
通知中心
+通知中心
{% if unread_count > 0 %} 全部标为已读 {% endif %} @@ -21710,7 +22015,7 @@ File: ./app/templates/base.html {% set recent_notifications = get_recent_notifications(current_user.id, 5) %} {% if recent_notifications %} {% for notification in recent_notifications %} -{{ notification.title }}
@@ -21727,22 +22032,26 @@ File: ./app/templates/base.html
-
+
+
+
- {{ current_user.username[0] }}
-
-
- {{ current_user.username }}
- {{ '管理员' if current_user.role_id == 1 else '普通用户' }}
-
-
+
+
@@ -25936,11 +26316,93 @@ File: ./app/templates/book/import.html
+ {{ current_user.username[0] }}
+
+
+ {{ current_user.username }}
+ {{ '管理员' if current_user.role_id == 1 else '普通用户' }}
+
+
+
-
+
{% block content %}
@@ -21771,22 +22080,91 @@ File: ./app/templates/base.html
@@ -25918,9 +26296,11 @@ File: ./app/templates/book/import.html
不确定如何开始? 下载我们精心准备的模板:
- + + + +'.join(errors[:10]) + (f'
...等共{len(errors)}个错误' if len(errors) > 10 else ''), - 'warning') + # 记录操作日志 + Log.add_log( + action='批量导入图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"批量导入图书: 新增{success_count}条,更新{update_count}条,失败{error_count}条,文件名:{file.filename}" + ) - return redirect(url_for('book.book_list')) + # 输出详细的错误信息 + if success_count > 0 or update_count > 0: + flash(f'导入完成: 新增{success_count}条,更新{update_count}条,失败{error_count}条', 'success') + else: + flash(f'导入完成: 新增{success_count}条,更新{update_count}条,失败{error_count}条', 'warning') + + if errors: + error_display = '
'.join(errors[:10]) + if len(errors) > 10: + error_display += f'
...等共{len(errors)}个错误' + flash(error_display, 'warning') + + return redirect(url_for('book.book_list')) + + except Exception as commit_error: + db.session.rollback() + error_msg = f"提交数据库事务失败: {str(commit_error)}" + current_app.logger.error(error_msg) + flash(error_msg, 'danger') + return redirect(request.url) except Exception as e: + db.session.rollback() + # 记录详细错误日志 + error_msg = f"批量导入图书失败: {str(e)}" + current_app.logger.error(error_msg) + # 记录操作失败日志 Log.add_log( action='批量导入图书失败', @@ -29227,72 +29919,87 @@ def import_books(): # 导出图书 -@book_bp.route('/export') +@book_bp.route('/export', methods=['GET']) @login_required -@permission_required('import_export_books') # 替换 @admin_required +@permission_required('import_export_books') def export_books(): - # 获取查询参数 - search = request.args.get('search', '') - category_id = request.args.get('category_id', type=int) + try: + # 获取所有活跃的图书 + books = Book.query.filter_by(status=1).all() - query = Book.query + # 创建工作簿和工作表 + output = BytesIO() + workbook = xlsxwriter.Workbook(output) + worksheet = workbook.add_worksheet() - if search: - query = query.filter( - (Book.title.contains(search)) | - (Book.author.contains(search)) | - (Book.isbn.contains(search)) + # 添加表头 + headers = ['ID', '书名', '作者', '出版社', '分类', '标签', 'ISBN', '出版年份', + '描述', '封面链接', '库存', '价格', '创建时间', '更新时间'] + for col_num, header in enumerate(headers): + worksheet.write(0, col_num, header) + + # 不使用status过滤条件,因为Category模型没有status字段 + category_dict = {} + try: + categories = db.session.execute( + text("SELECT id, name FROM categories") + ).fetchall() + for cat in categories: + category_dict[cat[0]] = cat[1] + except Exception as ex: + current_app.logger.warning(f"获取分类失败: {str(ex)}") + + # 添加数据行 + for row_num, book in enumerate(books, 1): + # 获取分类名称(如果有) + category_name = category_dict.get(book.category_id, "") if book.category_id else "" + + # 格式化日期 + created_at = book.created_at.strftime('%Y-%m-%d %H:%M:%S') if book.created_at else "" + updated_at = book.updated_at.strftime('%Y-%m-%d %H:%M:%S') if book.updated_at else "" + + # 写入图书数据 + worksheet.write(row_num, 0, book.id) + worksheet.write(row_num, 1, book.title) + worksheet.write(row_num, 2, book.author) + worksheet.write(row_num, 3, book.publisher or "") + worksheet.write(row_num, 4, category_name) + worksheet.write(row_num, 5, book.tags or "") + worksheet.write(row_num, 6, book.isbn or "") + worksheet.write(row_num, 7, book.publish_year or "") + worksheet.write(row_num, 8, book.description or "") + worksheet.write(row_num, 9, book.cover_url or "") + worksheet.write(row_num, 10, book.stock) + worksheet.write(row_num, 11, float(book.price) if book.price else 0) + worksheet.write(row_num, 12, created_at) + worksheet.write(row_num, 13, updated_at) + + # 关闭工作簿 + workbook.close() + + # 设置响应 + output.seek(0) + + # 记录操作日志 + Log.add_log( + action='导出图书', + user_id=current_user.id, + ip_address=request.remote_addr, + description=f"导出了{len(books)}本图书" ) - if category_id: - query = query.filter_by(category_id=category_id) + # 返回文件 + return send_file( + output, + as_attachment=True, + download_name=f"图书导出_{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx", + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) - books = query.all() - - # 创建DataFrame - data = [] - for book in books: - category_name = book.category.name if book.category else "" - data.append({ - 'id': book.id, - 'title': book.title, - 'author': book.author, - 'publisher': book.publisher, - 'category': category_name, - 'tags': book.tags, - 'isbn': book.isbn, - 'publish_year': book.publish_year, - 'description': book.description, - 'stock': book.stock, - 'price': book.price, - 'status': '上架' if book.status == 1 else '下架', - 'created_at': book.created_at.strftime('%Y-%m-%d %H:%M:%S') if book.created_at else '', - 'updated_at': book.updated_at.strftime('%Y-%m-%d %H:%M:%S') if book.updated_at else '' - }) - - df = pd.DataFrame(data) - - # 创建临时文件 - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - filename = f'books_export_{timestamp}.xlsx' - filepath = os.path.join(current_app.static_folder, 'temp', filename) - - # 确保目录存在 - os.makedirs(os.path.dirname(filepath), exist_ok=True) - - # 写入Excel - df.to_excel(filepath, index=False) - - # 记录操作日志 - Log.add_log( - action='导出图书', - user_id=current_user.id, - ip_address=request.remote_addr, - description=f"导出图书数据: {len(data)}条记录, 查询条件: 搜索={search}, 分类ID={category_id}" - ) - - # 提供下载链接 - return redirect(url_for('static', filename=f'temp/{filename}')) + except Exception as e: + current_app.logger.error(f"导出图书失败: {str(e)}") + flash(f'导出失败: {str(e)}', 'danger') + return redirect(url_for('book.book_list')) @book_bp.route('/test-permissions') @@ -29370,90 +30077,222 @@ def browse_books(): @book_bp.route('/template/download') @login_required -@permission_required('import_export_books') # 替换 @admin_required +@permission_required('import_export_books') def download_template(): - """生成并下载Excel图书导入模板""" - # 创建一个简单的DataFrame作为模板 - data = { - 'title': ['三体', '解忧杂货店'], - 'author': ['刘慈欣', '东野圭吾'], - 'publisher': ['重庆出版社', '南海出版公司'], - 'category_id': [2, 1], # 对应于科幻小说和文学分类 - 'tags': ['科幻,宇宙', '治愈,悬疑'], - 'isbn': ['9787229100605', '9787544270878'], - 'publish_year': ['2008', '2014'], - 'description': ['中国著名科幻小说,三体世界的故事。', '通过信件为人们解忧的杂货店故事。'], - 'cover_url': ['', ''], # 示例中为空 - 'stock': [10, 5], - 'price': [45.00, 39.80] + """下载图书导入模板""" + import tempfile + import os + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + from openpyxl.utils import get_column_letter + from flask import after_this_request, current_app as app, send_file + + # 创建工作簿和工作表 + wb = Workbook() + ws = wb.active + ws.title = "图书导入模板" + + # 定义样式 + header_font = Font(name='微软雅黑', size=11, bold=True, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + required_font = Font(name='微软雅黑', size=11, bold=True, color="FF0000") + optional_font = Font(name='微软雅黑', size=11) + center_alignment = Alignment(horizontal='center', vertical='center', wrap_text=True) + left_alignment = Alignment(vertical='center', wrap_text=True) + + # 定义边框样式 + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # 定义列宽 + column_widths = { + 'A': 30, # title + 'B': 20, # author + 'C': 20, # publisher + 'D': 15, # category_id + 'E': 25, # tags + 'F': 15, # isbn + 'G': 15, # publish_year + 'H': 40, # description + 'I': 30, # cover_url + 'J': 10, # stock + 'K': 10, # price } - # 创建一个分类ID和名称的映射 - categories = Category.query.all() - category_map = {cat.id: cat.name for cat in categories} + # 设置列宽 + for col, width in column_widths.items(): + ws.column_dimensions[col].width = width - # 创建一个pandas DataFrame - df = pd.DataFrame(data) + # 定义字段信息 + fields = [ + {'name': 'title', 'desc': '图书标题', 'required': True, 'example': '哈利·波特与魔法石'}, + {'name': 'author', 'desc': '作者名称', 'required': True, 'example': 'J.K.罗琳'}, + {'name': 'publisher', 'desc': '出版社', 'required': False, 'example': '人民文学出版社'}, + {'name': 'category_id', 'desc': '分类ID', 'required': False, 'example': '1'}, + {'name': 'tags', 'desc': '标签', 'required': False, 'example': '魔幻,冒险,青少年文学'}, + {'name': 'isbn', 'desc': 'ISBN编号', 'required': False, 'example': '9787020042494'}, + {'name': 'publish_year', 'desc': '出版年份', 'required': False, 'example': '2000'}, + {'name': 'description', 'desc': '图书简介', 'required': False, + 'example': '这是一本关于魔法的书籍,讲述了小男孩哈利...'}, + {'name': 'cover_url', 'desc': '封面图片URL', 'required': False, 'example': 'https://example.com/covers/hp.jpg'}, + {'name': 'stock', 'desc': '库存数量', 'required': False, 'example': '10'}, + {'name': 'price', 'desc': '价格', 'required': False, 'example': '39.5'}, + ] + + # 添加标题行 + ws.append([f"{field['name']}" for field in fields]) + + # 设置标题行样式 + for col_idx, field in enumerate(fields, start=1): + cell = ws.cell(row=1, column=col_idx) + cell.font = header_font + cell.fill = header_fill + cell.alignment = center_alignment + cell.border = thin_border + + # 添加示例数据行 + ws.append([field['example'] for field in fields]) + + # 设置示例行样式 + for col_idx, field in enumerate(fields, start=1): + cell = ws.cell(row=2, column=col_idx) + cell.alignment = left_alignment + cell.border = thin_border + if field['required']: + cell.font = required_font + else: + cell.font = optional_font + + # 冻结首行 + ws.freeze_panes = "A2" # 添加说明工作表 - with pd.ExcelWriter('book_import_template.xlsx', engine='openpyxl') as writer: - df.to_excel(writer, sheet_name='图书数据示例', index=False) + instructions_ws = wb.create_sheet(title="填写说明") - # 创建说明工作表 - info_df = pd.DataFrame({ - '字段名': ['title', 'author', 'publisher', 'category_id', 'tags', - 'isbn', 'publish_year', 'description', 'cover_url', 'stock', 'price'], - '说明': ['图书标题 (必填)', '作者名称 (必填)', '出版社', '分类ID (对应系统分类)', - '标签 (多个标签用逗号分隔)', 'ISBN编号', '出版年份', '图书简介', - '封面图片URL', '库存数量', '价格'], - '示例': ['三体', '刘慈欣', '重庆出版社', '2 (科幻小说)', '科幻,宇宙', - '9787229100605', '2008', '中国著名科幻小说...', 'http://example.com/cover.jpg', '10', '45.00'], - '是否必填': ['是', '是', '否', '否', '否', '否', '否', '否', '否', '否', '否'] - }) - info_df.to_excel(writer, sheet_name='填写说明', index=False) + # 设置说明工作表的列宽 + instructions_ws.column_dimensions['A'].width = 15 + instructions_ws.column_dimensions['B'].width = 20 + instructions_ws.column_dimensions['C'].width = 60 - # 创建分类ID工作表 - category_df = pd.DataFrame({ - '分类ID': list(category_map.keys()), - '分类名称': list(category_map.values()) - }) - category_df.to_excel(writer, sheet_name='分类对照表', index=False) + # 添加说明标题 + instructions_ws.append(["字段名", "说明", "备注"]) - # 设置响应 - timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S') - filename = f'book_import_template_{timestamp}.xlsx' + # 设置标题行样式 + for col_idx in range(1, 4): + cell = instructions_ws.cell(row=1, column=col_idx) + cell.font = header_font + cell.fill = header_fill + cell.alignment = center_alignment + cell.border = thin_border - # 记录操作日志 - Log.add_log( - action='下载图书导入模板', - user_id=current_user.id, - ip_address=request.remote_addr, - description="下载图书批量导入Excel模板" - ) + # 字段详细说明 + notes = { + 'title': "图书的完整名称,例如:'哈利·波特与魔法石'。必须填写且不能为空。", + 'author': "图书的作者名称,例如:'J.K.罗琳'。必须填写且不能为空。如有多个作者,请用逗号分隔。", + 'publisher': "出版社名称,例如:'人民文学出版社'。", + 'category_id': "图书分类的系统ID。请在系统的分类管理页面查看ID。如果不确定,可以留空,系统将使用默认分类。", + 'tags': "图书的标签,多个标签请用英文逗号分隔,例如:'魔幻,冒险,青少年文学'。", + 'isbn': "图书的ISBN号码,例如:'9787020042494'。请输入完整的13位或10位ISBN。", + 'publish_year': "图书的出版年份,例如:'2000'。请输入4位数字年份。", + 'description': "图书的简介或摘要。可以输入详细的描述信息,系统支持换行和基本格式。", + 'cover_url': "图书封面的在线URL地址。如果有图片网址,系统将自动下载并设置为封面。", + 'stock': "图书的库存数量,例如:'10'。请输入整数。", + 'price': "图书的价格,例如:'39.5'。可以输入小数。" + } - # 直接返回生成的文件 - from flask import send_file - from io import BytesIO + # 添加字段说明 + for idx, field in enumerate(fields, start=2): + required_text = "【必填】" if field['required'] else "【选填】" + instructions_ws.append([ + field['name'], + f"{field['desc']} {required_text}", + notes.get(field['name'], "") + ]) - # 将文件转换为二进制流 - output = BytesIO() - with open('book_import_template.xlsx', 'rb') as f: - output.write(f.read()) - output.seek(0) + # 设置单元格样式 + for col_idx in range(1, 4): + cell = instructions_ws.cell(row=idx, column=col_idx) + cell.alignment = left_alignment + cell.border = thin_border + if field['required'] and col_idx == 2: + cell.font = required_font + else: + cell.font = optional_font + + # 添加示例行 + instructions_ws.append(["", "", ""]) + instructions_ws.append(["示例", "", "以下是完整的示例数据:"]) + + example_data = [ + {"title": "哈利·波特与魔法石", "author": "J.K.罗琳", "publisher": "人民文学出版社", + "category_id": "1", "tags": "魔幻,冒险,青少年文学", "isbn": "9787020042494", + "publish_year": "2000", "description": "这是一本关于魔法的书籍,讲述了小男孩哈利...", + "cover_url": "https://example.com/covers/hp.jpg", "stock": "10", "price": "39.5"}, + + {"title": "三体", "author": "刘慈欣", "publisher": "重庆出版社", + "category_id": "2", "tags": "科幻,硬科幻,中国科幻", "isbn": "9787536692930", + "publish_year": "2008", "description": "文化大革命如火如荼进行的同时...", + "cover_url": "https://example.com/covers/threebody.jpg", "stock": "15", "price": "59.8"}, + + {"title": "平凡的世界", "author": "路遥", "publisher": "北京十月文艺出版社", + "category_id": "3", "tags": "文学,现实主义,农村", "isbn": "9787530216781", + "publish_year": "2017", "description": "这是一部全景式地表现中国当代城乡社会...", + "cover_url": "https://example.com/covers/world.jpg", "stock": "8", "price": "128.0"} + ] + + # 添加3个完整示例到主模板工作表 + for example in example_data: + row_data = [example.get(field['name'], '') for field in fields] + ws.append(row_data) + + # 设置示例数据样式 + for row_idx in range(3, 6): + for col_idx in range(1, len(fields) + 1): + cell = ws.cell(row=row_idx, column=col_idx) + cell.alignment = left_alignment + cell.border = thin_border + + # 使用临时文件保存工作簿 + fd, temp_path = tempfile.mkstemp(suffix='.xlsx') + os.close(fd) - # 删除临时文件 try: - os.remove('book_import_template.xlsx') - except: - pass + wb.save(temp_path) - # 通过send_file返回 - return send_file( - output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - as_attachment=True, - download_name=filename - ) + # 注册一个函数在响应完成后删除临时文件 + @after_this_request + def remove_file(response): + try: + os.unlink(temp_path) + except Exception as error: + app.logger.error("删除临时文件出错: %s", error) + return response + + response = send_file( + temp_path, + as_attachment=True, + download_name='图书导入模板.xlsx', + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + # 添加防止缓存的头信息 + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response + except Exception as e: + # 确保在出错时也删除临时文件 + try: + os.unlink(temp_path) + except: + pass + app.logger.error("生成模板文件出错: %s", str(e)) + return {"error": "生成模板文件失败,请稍后再试"}, 500 ================================================================================ File: ./app/controllers/statistics.py @@ -29579,6 +30418,8 @@ def api_borrow_trend(): """获取借阅趋势数据API""" time_range = request.args.get('time_range', 'month') + now = datetime.now() + # 记录获取借阅趋势数据的日志 Log.add_log( action="获取数据", @@ -29611,8 +30452,8 @@ def api_borrow_trend(): # 当天逾期未还的数量 overdue_count = BorrowRecord.query.filter( - BorrowRecord.due_date < day_end, - BorrowRecord.return_date.is_(None) + BorrowRecord.return_date.is_(None), # 未归还 + BorrowRecord.due_date < now # 应还日期早于当前时间 ).count() results.append({ @@ -29647,9 +30488,10 @@ def api_borrow_trend(): ).count() # 当天逾期未还的数量 + now = datetime.now() overdue_count = BorrowRecord.query.filter( - BorrowRecord.due_date < day_end, - BorrowRecord.return_date.is_(None) + BorrowRecord.return_date.is_(None), + BorrowRecord.due_date < now ).count() results.append({ @@ -29692,9 +30534,10 @@ def api_borrow_trend(): ).count() # 当月逾期未还的数量 + now = datetime.now() overdue_count = BorrowRecord.query.filter( - BorrowRecord.due_date < month_end, - BorrowRecord.return_date.is_(None) + BorrowRecord.return_date.is_(None), + BorrowRecord.due_date < now ).count() results.append({ @@ -29934,6 +30777,16 @@ def borrow_book(): flash('请选择要借阅的图书', 'danger') return redirect(url_for('book.book_list')) + # 检查用户当前借阅数量是否达到上限(5本) + current_borrows_count = BorrowRecord.query.filter_by( + user_id=current_user.id, + status=1 # 1表示借阅中 + ).count() + + if current_borrows_count >= 5: + flash('您当前已借阅5本图书,达到借阅上限。请先归还后再借阅新书。', 'warning') + return redirect(url_for('book.book_detail', book_id=book_id)) + book = Book.query.get_or_404(book_id) # 检查库存 @@ -30013,6 +30866,18 @@ def add_borrow(book_id): # 验证图书存在 book = Book.query.get_or_404(book_id) + # 检查用户当前借阅数量是否达到上限(5本) + current_borrows_count = BorrowRecord.query.filter_by( + user_id=current_user.id, + status=1 # 1表示借阅中 + ).count() + + if current_borrows_count >= 5: + return jsonify({ + 'success': False, + 'message': '您当前已借阅5本图书,达到借阅上限。请先归还后再借阅新书。' + }) + # 默认借阅天数 borrow_days = 14 @@ -30941,7 +31806,7 @@ def inventory_list(): @inventory_bp.route('/adjust/