-
- - -
+ +
+ + +
-
- +
{% block content %} @@ -21771,22 +22080,91 @@ File: ./app/templates/base.html @@ -25918,9 +26296,11 @@ File: ./app/templates/book/import.html

不确定如何开始? 下载我们精心准备的模板:

- + + +
@@ -25936,11 +26316,93 @@ File: ./app/templates/book/import.html
+ + +
+ +
{% endblock %} {% block scripts %} + {% endblock %} ================================================================================ @@ -28128,7 +28590,7 @@ def role_delete(role_id): 'message': f"删除角色失败: {str(e)}" }), 500 -@user_bp.route('/user/role//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)}"