fix_api_export_and_import_and_homepage_search
This commit is contained in:
parent
04cc629988
commit
6246b9730f
@ -1,4 +1,4 @@
|
||||
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
|
||||
@ -9,7 +9,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__)
|
||||
|
||||
|
||||
@ -675,7 +677,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:
|
||||
@ -689,41 +691,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()
|
||||
@ -733,6 +846,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
|
||||
@ -746,30 +861,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('<br>'.join(errors[:10]) + (f'<br>...等共{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 = '<br>'.join(errors[:10])
|
||||
if len(errors) > 10:
|
||||
error_display += f'<br>...等共{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='批量导入图书失败',
|
||||
@ -788,72 +932,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')
|
||||
@ -931,87 +1090,219 @@ 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
|
||||
|
||||
@ -22,28 +22,72 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 美化表单提交按钮的点击效果
|
||||
const importBtn = document.querySelector('.import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.addEventListener('click', function(e) {
|
||||
// 监听表单提交
|
||||
const form = document.querySelector('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const fileInput = document.getElementById('file');
|
||||
if (!fileInput || !fileInput.files || !fileInput.files.length) {
|
||||
e.preventDefault();
|
||||
showMessage('请先选择要导入的Excel文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在导入...';
|
||||
this.disabled = true;
|
||||
const importBtn = document.querySelector('.import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 正在导入...';
|
||||
importBtn.disabled = true;
|
||||
}
|
||||
|
||||
// 添加花朵飘落动画效果
|
||||
addFallingElements(10);
|
||||
|
||||
// 设置超时处理,如果30秒后还没响应,提示用户
|
||||
window.importTimeout = setTimeout(function() {
|
||||
showMessage('导入处理时间较长,请耐心等待...', 'info');
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// 美化表单提交按钮的点击效果
|
||||
const importBtn = document.querySelector('.import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.addEventListener('click', function(e) {
|
||||
// 按钮的点击效果已由表单提交事件处理
|
||||
// 避免重复处理
|
||||
if (!form || form.reportValidity() === false) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 为浮动元素添加动画
|
||||
initFloatingElements();
|
||||
|
||||
// 检查页面中的flash消息
|
||||
checkFlashMessages();
|
||||
});
|
||||
|
||||
// 检查页面中的flash消息
|
||||
function checkFlashMessages() {
|
||||
// Flask的flash消息通常会渲染为带有特定类的元素
|
||||
const flashMessages = document.querySelectorAll('.alert');
|
||||
if (flashMessages && flashMessages.length > 0) {
|
||||
// 如果存在flash消息,说明页面是提交后重新加载的
|
||||
// 恢复按钮状态
|
||||
const importBtn = document.querySelector('.import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.innerHTML = '<i class="fas fa-upload"></i> 开始导入';
|
||||
importBtn.disabled = false;
|
||||
}
|
||||
|
||||
// 清除可能的超时
|
||||
if (window.importTimeout) {
|
||||
clearTimeout(window.importTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文件类型并尝试预览
|
||||
function checkFileAndPreview(file) {
|
||||
if (!file) return;
|
||||
|
||||
BIN
app/static/temp/book_import_template_20250516215908.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516215908.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516215913.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516215913.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220032.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220032.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220049.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220049.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220442.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220442.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220453.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220453.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220458.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220458.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220505.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220505.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220509.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220509.xlsx
Normal file
Binary file not shown.
BIN
app/static/temp/book_import_template_20250516220625.xlsx
Normal file
BIN
app/static/temp/book_import_template_20250516220625.xlsx
Normal file
Binary file not shown.
@ -68,10 +68,11 @@
|
||||
<main class="main-content">
|
||||
<!-- 顶部导航 -->
|
||||
<header class="top-bar">
|
||||
<div class="search-container">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" placeholder="搜索图书..." class="search-input">
|
||||
</div>
|
||||
<!-- 修改搜索容器为表单 -->
|
||||
<form action="{{ url_for('book.browse_books') }}" method="GET" class="search-container" id="global-search-form">
|
||||
<i class="fas fa-search search-icon" id="search-submit-icon"></i>
|
||||
<input type="text" name="search" placeholder="搜索图书..." class="search-input" id="global-search-input">
|
||||
</form>
|
||||
<div class="user-menu">
|
||||
<div class="notifications dropdown">
|
||||
<a href="#" class="notification-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@ -175,6 +176,52 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索功能
|
||||
const searchForm = document.getElementById('global-search-form');
|
||||
const searchInput = document.getElementById('global-search-input');
|
||||
const searchIcon = document.getElementById('search-submit-icon');
|
||||
|
||||
if (searchForm && searchInput && searchIcon) {
|
||||
// 智能判断搜索提交目标 - 根据当前页面设置合适的搜索结果页
|
||||
function updateSearchFormAction() {
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath.includes('/book/admin/list')) {
|
||||
searchForm.action = "{{ url_for('book.admin_book_list') }}";
|
||||
} else if (currentPath.includes('/book/list')) {
|
||||
searchForm.action = "{{ url_for('book.book_list') }}";
|
||||
} else if (currentPath.includes('/book/browse')) {
|
||||
searchForm.action = "{{ url_for('book.browse_books') }}";
|
||||
} else {
|
||||
searchForm.action = "{{ url_for('book.browse_books') }}";
|
||||
}
|
||||
}
|
||||
|
||||
updateSearchFormAction();
|
||||
|
||||
// 点击搜索图标时提交表单
|
||||
searchIcon.addEventListener('click', function() {
|
||||
if (searchInput.value.trim() !== '') {
|
||||
updateSearchFormAction();
|
||||
searchForm.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// 输入框按回车键时提交表单
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (searchInput.value.trim() === '') {
|
||||
e.preventDefault(); // 防止空搜索提交
|
||||
} else {
|
||||
updateSearchFormAction();
|
||||
// 表单自然会提交,不需要额外代码
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 添加视觉反馈,让用户知道搜索图标可点击
|
||||
searchIcon.style.cursor = 'pointer';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -64,9 +64,11 @@
|
||||
|
||||
<div class="template-download animate__animated animate__pulse animate__infinite animate__slower">
|
||||
<p>不确定如何开始? 下载我们精心准备的模板:</p>
|
||||
<a href="{{ url_for('book.download_template') }}" class="btn download-btn">
|
||||
<button type="button" id="downloadTemplateBtn" class="btn download-btn">
|
||||
<i class="fas fa-download"></i> 下载Excel模板
|
||||
</a>
|
||||
</button>
|
||||
<!-- 用于显示下载成功或失败的消息 -->
|
||||
<div id="downloadMessage" style="margin-top: 10px; display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -82,9 +84,91 @@
|
||||
<div class="flower flower-1"></div>
|
||||
<div class="flower flower-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- 基本模板隐藏在页面里,以便在无法请求服务器时使用 -->
|
||||
<div style="display:none;">
|
||||
<a id="directDownloadLink" href="#" download="book_import_template.xlsx"></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/book-import.js') }}"></script>
|
||||
<script>
|
||||
// 添加下载模板的JavaScript代码
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const downloadBtn = document.getElementById('downloadTemplateBtn');
|
||||
const messageDiv = document.getElementById('downloadMessage');
|
||||
const directLink = document.getElementById('directDownloadLink');
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// 显示下载中状态
|
||||
const originalText = downloadBtn.innerHTML;
|
||||
downloadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 准备下载中...';
|
||||
downloadBtn.disabled = true;
|
||||
|
||||
// 方法1: 使用fetch API请求文件并手动触发下载
|
||||
fetch('{{ url_for("book.download_template") }}', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('下载请求失败,状态码: ' + response.status);
|
||||
}
|
||||
return response.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
// 创建临时URL
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// 设置下载链接
|
||||
directLink.href = url;
|
||||
directLink.download = 'book_import_template.xlsx';
|
||||
|
||||
// 触发点击
|
||||
directLink.click();
|
||||
|
||||
// 释放URL对象
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
// 显示成功消息
|
||||
messageDiv.innerHTML = '<div class="alert alert-success">模板下载成功!</div>';
|
||||
messageDiv.style.display = 'block';
|
||||
|
||||
// 3秒后隐藏消息
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = 'none';
|
||||
}, 3000);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载失败:', error);
|
||||
|
||||
// 显示错误消息
|
||||
messageDiv.innerHTML = '<div class="alert alert-danger">下载失败,请稍后重试。</div>';
|
||||
messageDiv.style.display = 'block';
|
||||
|
||||
// 方法2: 如果fetch失败,尝试使用window.open在新标签页下载
|
||||
window.open('{{ url_for("book.download_template") }}', '_blank');
|
||||
})
|
||||
.finally(() => {
|
||||
// 恢复按钮状态
|
||||
downloadBtn.innerHTML = originalText;
|
||||
downloadBtn.disabled = false;
|
||||
});
|
||||
|
||||
// 方法3: 超时后,如果以上方法都失败,提供直接链接让用户手动点击
|
||||
setTimeout(function() {
|
||||
if (messageDiv.innerHTML.includes('下载失败') || messageDiv.style.display === 'none') {
|
||||
messageDiv.innerHTML = '<div class="alert alert-warning">下载似乎没有开始。<a href="{{ url_for("book.download_template") }}" target="_blank" download class="alert-link">点击这里</a>手动下载。</div>';
|
||||
messageDiv.style.display = 'block';
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -10,4 +10,7 @@ email-validator==2.0.0
|
||||
pillow==9.5.0
|
||||
numpy
|
||||
pandas
|
||||
flask-login
|
||||
flask-login
|
||||
openpyxl
|
||||
xlrd
|
||||
xlsxwriter
|
||||
Loading…
x
Reference in New Issue
Block a user