Book_system/code_collection.txt
superlishunqin 29009ef7de user
2025-05-01 04:52:53 +08:00

11640 lines
344 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

================================================================================
File: ./config.py
================================================================================
import os
# 数据库配置
DB_HOST = os.environ.get('DB_HOST', '27.124.22.104')
DB_PORT = os.environ.get('DB_PORT', '3306')
DB_USER = os.environ.get('DB_USER', 'book20250428')
DB_PASSWORD = os.environ.get('DB_PASSWORD', 'booksystem')
DB_NAME = os.environ.get('DB_NAME', 'book_system')
# 数据库连接字符串
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 应用密钥
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev_key_replace_in_production')
# 邮件配置
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.qq.com')
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
EMAIL_ENCRYPTION = os.environ.get('EMAIL_ENCRYPTION', 'starttls')
EMAIL_USERNAME = os.environ.get('EMAIL_USERNAME', '3399560459@qq.com')
EMAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD', 'fzwhyirhbqdzcjgf')
EMAIL_FROM = os.environ.get('EMAIL_FROM', '3399560459@qq.com')
EMAIL_FROM_NAME = os.environ.get('EMAIL_FROM_NAME', 'BOOKSYSTEM_OFFICIAL')
# 会话配置
PERMANENT_SESSION_LIFETIME = 86400 * 7
================================================================================
File: ./all_file_output.py
================================================================================
import os
import sys
def collect_code_files(output_file="code_collection.txt"):
# 定义代码文件扩展名
code_extensions = [
'.py', '.java', '.cpp', '.c', '.h', '.hpp', '.cs',
'.js', '.html', '.css', '.php', '.go', '.rb',
'.swift', '.kt', '.ts', '.sh', '.pl', '.r'
]
# 定义要排除的目录
excluded_dirs = [
'venv', 'env', '.venv', '.env', 'virtualenv',
'__pycache__', 'node_modules', '.git', '.idea',
'dist', 'build', 'target', 'bin'
]
# 计数器
file_count = 0
# 打开输出文件
with open(output_file, 'w', encoding='utf-8') as out_file:
# 遍历当前目录及所有子目录
for root, dirs, files in os.walk('.'):
# 从dirs中移除排除的目录这会阻止os.walk进入这些目录
dirs[:] = [d for d in dirs if d not in excluded_dirs]
for file in files:
# 获取文件扩展名
_, ext = os.path.splitext(file)
# 检查是否为代码文件
if ext.lower() in code_extensions:
file_path = os.path.join(root, file)
file_count += 1
# 写入文件路径作为分隔
out_file.write(f"\n{'=' * 80}\n")
out_file.write(f"File: {file_path}\n")
out_file.write(f"{'=' * 80}\n\n")
# 尝试读取文件内容并写入
try:
with open(file_path, 'r', encoding='utf-8') as code_file:
out_file.write(code_file.read())
except UnicodeDecodeError:
# 尝试用不同的编码
try:
with open(file_path, 'r', encoding='latin-1') as code_file:
out_file.write(code_file.read())
except Exception as e:
out_file.write(f"无法读取文件内容: {str(e)}\n")
except Exception as e:
out_file.write(f"读取文件时出错: {str(e)}\n")
print(f"已成功收集 {file_count} 个代码文件到 {output_file}")
if __name__ == "__main__":
# 如果提供了命令行参数,则使用它作为输出文件名
output_file = sys.argv[1] if len(sys.argv) > 1 else "code_collection.txt"
collect_code_files(output_file)
================================================================================
File: ./app.py
================================================================================
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=49666)
================================================================================
File: ./main.py
================================================================================
# 这是一个示例 Python 脚本。
# 按 ⌃R 执行或将其替换为您的代码。
# 按 双击 ⇧ 在所有地方搜索类、文件、工具窗口、操作和设置。
def print_hi(name):
# 在下面的代码行中使用断点来调试脚本。
print(f'Hi, {name}') # 按 ⌘F8 切换断点。
# 按间距中的绿色按钮以运行脚本。
if __name__ == '__main__':
print_hi('PyCharm')
# 访问 https://www.jetbrains.com/help/pycharm/ 获取 PyCharm 帮助
================================================================================
File: ./app/__init__.py
================================================================================
from flask import Flask, render_template, session, g, Markup
from flask_login import LoginManager
from app.models.user import db, User
from app.controllers.user import user_bp
from app.controllers.book import book_bp
from app.controllers.borrow import borrow_bp
import os
login_manager = LoginManager()
def create_app(config=None):
app = Flask(__name__)
# 配置应用
app.config.from_mapping(
SECRET_KEY=os.environ.get('SECRET_KEY', 'dev_key_replace_in_production'),
SQLALCHEMY_DATABASE_URI='mysql+pymysql://book20250428:booksystem@27.124.22.104/book_system',
SQLALCHEMY_TRACK_MODIFICATIONS=False,
PERMANENT_SESSION_LIFETIME=86400 * 7, # 7天
# 邮件配置
EMAIL_HOST='smtp.qq.com',
EMAIL_PORT=587,
EMAIL_ENCRYPTION='starttls',
EMAIL_USERNAME='3399560459@qq.com',
EMAIL_PASSWORD='fzwhyirhbqdzcjgf',
EMAIL_FROM='3399560459@qq.com',
EMAIL_FROM_NAME='BOOKSYSTEM_OFFICIAL'
)
# 实例配置,如果存在
app.config.from_pyfile('config.py', silent=True)
# 初始化数据库
db.init_app(app)
# 初始化 Flask-Login
login_manager.init_app(app)
login_manager.login_view = 'user.login'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# 注册蓝图
app.register_blueprint(user_bp, url_prefix='/user')
app.register_blueprint(book_bp, url_prefix='/book')
app.register_blueprint(borrow_bp, url_prefix='/borrow')
# 创建数据库表
with app.app_context():
# 先导入基础模型
from app.models.user import User, Role
from app.models.book import Book, Category
# 创建表
db.create_all()
# 再导入依赖模型 - 但不在这里定义关系
from app.models.borrow import BorrowRecord
from app.models.inventory import InventoryLog
# 移除这些重复的关系定义
# Book.borrow_records = db.relationship('BorrowRecord', backref='book', lazy='dynamic')
# Book.inventory_logs = db.relationship('InventoryLog', backref='book', lazy='dynamic')
# Category.books = db.relationship('Book', backref='category', lazy='dynamic')
# 创建默认角色
from app.models.user import Role
if not Role.query.filter_by(id=1).first():
admin_role = Role(id=1, role_name='管理员', description='系统管理员')
db.session.add(admin_role)
if not Role.query.filter_by(id=2).first():
user_role = Role(id=2, role_name='普通用户', description='普通用户')
db.session.add(user_role)
# 创建管理员账号
if not User.query.filter_by(username='admin').first():
admin = User(
username='admin',
password='admin123',
email='admin@example.com',
role_id=1,
nickname='系统管理员'
)
db.session.add(admin)
# 创建基础分类
from app.models.book import Category
if not Category.query.first():
categories = [
Category(name='文学', sort=1),
Category(name='计算机', sort=2),
Category(name='历史', sort=3),
Category(name='科学', sort=4),
Category(name='艺术', sort=5),
Category(name='经济', sort=6),
Category(name='哲学', sort=7),
Category(name='教育', sort=8)
]
db.session.add_all(categories)
db.session.commit()
# 其余代码保持不变...
@app.before_request
def load_logged_in_user():
user_id = session.get('user_id')
if user_id is None:
g.user = None
else:
g.user = User.query.get(user_id)
@app.route('/')
def index():
if not g.user:
return render_template('login.html')
return render_template('index.html', current_user=g.user)
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
@app.template_filter('nl2br')
def nl2br_filter(s):
if s:
return Markup(s.replace('\n', '<br>'))
return s
return app
================================================================================
File: ./app/utils/auth.py
================================================================================
from functools import wraps
from flask import g, redirect, url_for, flash, request
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
flash('请先登录', 'warning')
return redirect(url_for('user.login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if g.user is None:
flash('请先登录', 'warning')
return redirect(url_for('user.login', next=request.url))
if g.user.role_id != 1: # 假设role_id=1是管理员
flash('权限不足', 'danger')
return redirect(url_for('index'))
return f(*args, **kwargs)
return decorated_function
================================================================================
File: ./app/utils/db.py
================================================================================
================================================================================
File: ./app/utils/__init__.py
================================================================================
================================================================================
File: ./app/utils/email.py
================================================================================
import smtplib
import random
import string
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from flask import current_app
import logging
# 配置日志
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
# 配置邮件发送功能
def send_verification_email(to_email, verification_code):
"""
发送验证码邮件
"""
try:
# 从应用配置获取邮件设置
email_host = current_app.config['EMAIL_HOST']
email_port = current_app.config['EMAIL_PORT']
email_username = current_app.config['EMAIL_USERNAME']
email_password = current_app.config['EMAIL_PASSWORD']
email_from = current_app.config['EMAIL_FROM']
email_from_name = current_app.config['EMAIL_FROM_NAME']
logger.info(f"准备发送邮件到: {to_email}, 验证码: {verification_code}")
logger.debug(f"邮件配置: 主机={email_host}, 端口={email_port}")
# 邮件内容
msg = MIMEMultipart()
msg['From'] = f"{email_from_name} <{email_from}>"
msg['To'] = to_email
msg['Subject'] = "图书管理系统 - 验证码"
# 邮件正文
body = f"""
<html>
<body>
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e1e1e1; border-radius: 5px;">
<h2 style="color: #4a89dc;">图书管理系统 - 邮箱验证</h2>
<p>您好,</p>
<p>感谢您注册图书管理系统,您的验证码是:</p>
<div style="background-color: #f5f5f5; padding: 10px; border-radius: 5px; text-align: center; font-size: 24px; letter-spacing: 5px; font-weight: bold; margin: 20px 0;">
{verification_code}
</div>
<p>该验证码将在10分钟内有效请勿将验证码分享给他人。</p>
<p>如果您没有请求此验证码,请忽略此邮件。</p>
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e1e1e1; font-size: 12px; color: #888;">
<p>此邮件为系统自动发送,请勿回复。</p>
<p>&copy; 2025 图书管理系统</p>
</div>
</div>
</body>
</html>
"""
msg.attach(MIMEText(body, 'html'))
logger.debug("尝试连接到SMTP服务器...")
# 连接服务器发送邮件
server = smtplib.SMTP(email_host, email_port)
server.set_debuglevel(1) # 启用详细的SMTP调试输出
logger.debug("检查是否需要STARTTLS加密...")
if current_app.config.get('EMAIL_ENCRYPTION') == 'starttls':
logger.debug("启用STARTTLS...")
server.starttls()
logger.debug(f"尝试登录邮箱: {email_username}")
server.login(email_username, email_password)
logger.debug("发送邮件...")
server.send_message(msg)
logger.debug("关闭连接...")
server.quit()
logger.info(f"邮件发送成功: {to_email}")
return True
except Exception as e:
logger.error(f"邮件发送失败: {str(e)}", exc_info=True)
return False
def generate_verification_code(length=6):
"""
生成数字验证码
"""
return ''.join(random.choice(string.digits) for _ in range(length))
================================================================================
File: ./app/utils/helpers.py
================================================================================
================================================================================
File: ./app/models/user.py
================================================================================
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from flask_login import UserMixin
db = SQLAlchemy()
class User(db.Model, UserMixin):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
username = db.Column(db.String(64), unique=True, nullable=False)
password = db.Column(db.String(255), nullable=False)
email = db.Column(db.String(128), unique=True, nullable=True)
phone = db.Column(db.String(20), unique=True, nullable=True)
nickname = db.Column(db.String(64), nullable=True)
status = db.Column(db.Integer, default=1) # 1: active, 0: disabled
role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), default=2) # 2: 普通用户, 1: 管理员
created_at = db.Column(db.DateTime, default=datetime.now)
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
def __init__(self, username, password, email=None, phone=None, nickname=None, role_id=2):
self.username = username
self.set_password(password)
self.email = email
self.phone = phone
self.nickname = nickname
self.role_id = role_id
def is_active(self):
return self.status == 1
def set_password(self, password):
"""设置密码,使用哈希加密"""
self.password = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password, password)
def to_dict(self):
"""转换为字典格式"""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'phone': self.phone,
'nickname': self.nickname,
'status': self.status,
'role_id': self.role_id,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S')
}
@classmethod
def create_user(cls, username, password, email=None, phone=None, nickname=None, role_id=2):
"""创建新用户"""
user = User(
username=username,
password=password,
email=email,
phone=phone,
nickname=nickname,
role_id=role_id
)
db.session.add(user)
db.session.commit()
return user
class Role(db.Model):
__tablename__ = 'roles'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
role_name = db.Column(db.String(32), unique=True, nullable=False)
description = db.Column(db.String(128))
users = db.relationship('User', backref='role')
================================================================================
File: ./app/models/log.py
================================================================================
================================================================================
File: ./app/models/notification.py
================================================================================
================================================================================
File: ./app/models/__init__.py
================================================================================
def create_app():
app = Flask(__name__)
# ... 配置代码 ...
# 初始化数据库
db.init_app(app)
# 导入模型,确保所有模型在创建表之前被加载
from app.models.user import User, Role
from app.models.book import Book, Category
from app.models.borrow import BorrowRecord
from app.models.inventory import InventoryLog
# 创建数据库表
with app.app_context():
db.create_all()
# ... 其余代码 ...
================================================================================
File: ./app/models/book.py
================================================================================
from app.models.user import db
from datetime import datetime
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), nullable=False)
parent_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
sort = db.Column(db.Integer, default=0)
# 关系 - 只保留与自身的关系
parent = db.relationship('Category', remote_side=[id], backref='children')
def __repr__(self):
return f'<Category {self.name}>'
class Book(db.Model):
__tablename__ = 'books'
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(255), nullable=False)
author = db.Column(db.String(128), nullable=False)
publisher = db.Column(db.String(128), nullable=True)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
tags = db.Column(db.String(255), nullable=True)
isbn = db.Column(db.String(32), unique=True, nullable=True)
publish_year = db.Column(db.String(16), nullable=True)
description = db.Column(db.Text, nullable=True)
cover_url = db.Column(db.String(255), nullable=True)
stock = db.Column(db.Integer, default=0)
price = db.Column(db.Numeric(10, 2), nullable=True)
status = db.Column(db.Integer, default=1) # 1:可用, 0:不可用
created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
# 移除所有关系引用
def __repr__(self):
return f'<Book {self.title}>'
================================================================================
File: ./app/models/borrow.py
================================================================================
from app.models.user import db
from datetime import datetime
class BorrowRecord(db.Model):
__tablename__ = 'borrow_records'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
book_id = db.Column(db.Integer, db.ForeignKey('books.id'), nullable=False)
borrow_date = db.Column(db.DateTime, nullable=False, default=datetime.now)
due_date = db.Column(db.DateTime, nullable=False)
return_date = db.Column(db.DateTime, nullable=True)
renew_count = db.Column(db.Integer, default=0)
status = db.Column(db.Integer, default=1) # 1: 借出, 0: 已归还
remark = db.Column(db.String(255), nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
# 添加反向关系引用
user = db.relationship('User', backref=db.backref('borrow_records', lazy='dynamic'))
book = db.relationship('Book', backref=db.backref('borrow_records', lazy='dynamic'))
# book 关系会在后面步骤添加
def __repr__(self):
return f'<BorrowRecord {self.id}>'
================================================================================
File: ./app/models/announcement.py
================================================================================
================================================================================
File: ./app/models/inventory.py
================================================================================
from app.models.user import db
from datetime import datetime
class InventoryLog(db.Model):
__tablename__ = 'inventory_logs'
id = db.Column(db.Integer, primary_key=True)
book_id = db.Column(db.Integer, db.ForeignKey('books.id'), nullable=False)
change_type = db.Column(db.String(32), nullable=False) # 'in' 入库, 'out' 出库
change_amount = db.Column(db.Integer, nullable=False)
after_stock = db.Column(db.Integer, nullable=False)
operator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
remark = db.Column(db.String(255), nullable=True)
changed_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
# 添加反向关系引用
operator = db.relationship('User', backref=db.backref('inventory_logs', lazy='dynamic'))
# book 关系会在后面步骤添加
def __repr__(self):
return f'<InventoryLog {self.id}>'
================================================================================
File: ./app/static/css/register.css
================================================================================
/* register.css - 注册页面专用样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
:root {
--primary-color: #4a89dc;
--primary-hover: #3b78c4;
--secondary-color: #5cb85c;
--text-color: #333;
--light-text: #666;
--bg-color: #f5f7fa;
--card-bg: #ffffff;
--border-color: #ddd;
--error-color: #e74c3c;
--success-color: #2ecc71;
}
body.dark-mode {
--primary-color: #5a9aed;
--primary-hover: #4a89dc;
--secondary-color: #6bc76b;
--text-color: #f1f1f1;
--light-text: #aaa;
--bg-color: #1a1a1a;
--card-bg: #2c2c2c;
--border-color: #444;
}
body {
background-color: var(--bg-color);
background-image: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
min-height: 100vh;
color: var(--text-color);
transition: all 0.3s ease;
}
.theme-toggle {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
cursor: pointer;
padding: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.overlay {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(5px);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
}
.main-container {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 20px;
}
.login-container {
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
width: 450px;
padding: 35px;
position: relative;
overflow: hidden;
animation: fadeIn 0.5s ease;
}
.register-container {
width: 500px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.logo {
text-align: center;
margin-bottom: 25px;
position: relative;
}
.logo img {
width: 90px;
height: 90px;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 5px;
background-color: #fff;
transition: transform 0.3s ease;
}
h1 {
text-align: center;
color: var(--text-color);
margin-bottom: 10px;
font-weight: 600;
font-size: 28px;
}
.subtitle {
text-align: center;
color: var(--light-text);
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 22px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-weight: 500;
font-size: 14px;
}
.input-with-icon {
position: relative;
}
.input-icon {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: var(--light-text);
}
.form-control {
width: 100%;
height: 48px;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0 15px 0 45px;
font-size: 15px;
transition: all 0.3s ease;
background-color: var(--card-bg);
color: var(--text-color);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(74, 137, 220, 0.2);
outline: none;
}
.password-toggle {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: var(--light-text);
}
.validation-message {
margin-top: 6px;
font-size: 12px;
color: var(--error-color);
display: none;
}
.validation-message.show {
display: block;
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.btn-login {
width: 100%;
height: 48px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-login:hover {
background-color: var(--primary-hover);
}
.btn-login:active {
transform: scale(0.98);
}
.btn-login .loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.btn-login.loading-state {
color: transparent;
}
.btn-login.loading-state .loading {
display: block;
}
.signup {
text-align: center;
margin-top: 25px;
font-size: 14px;
color: var(--light-text);
}
.signup a {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.signup a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.alert {
padding: 10px;
margin-bottom: 15px;
border-radius: 4px;
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
.verification-code-container {
display: flex;
gap: 10px;
}
.verification-input {
flex: 1;
height: 48px;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0 15px;
font-size: 15px;
transition: all 0.3s ease;
background-color: var(--card-bg);
color: var(--text-color);
}
.send-code-btn {
padding: 0 15px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
transition: all 0.3s ease;
}
.send-code-btn:hover {
background-color: var(--primary-hover);
}
.send-code-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
footer {
text-align: center;
padding: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
}
footer a {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
}
@media (max-width: 576px) {
.login-container, .register-container {
width: 100%;
padding: 25px;
border-radius: 0;
}
.theme-toggle {
top: 10px;
}
.logo img {
width: 70px;
height: 70px;
}
h1 {
font-size: 22px;
}
.main-container {
padding: 0;
}
.verification-code-container {
flex-direction: column;
}
}
================================================================================
File: ./app/static/css/user-list.css
================================================================================
/* 用户列表页面样式 */
.user-list-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 页面标题和操作按钮 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.page-header h1 {
font-size: 1.8rem;
color: #333;
margin: 0;
}
.page-header .actions {
display: flex;
gap: 10px;
}
/* 搜索和筛选区域 */
.search-filter-container {
margin-bottom: 20px;
padding: 20px;
background-color: #f9f9f9;
border-radius: 6px;
}
.search-filter-form .form-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 15px;
}
.search-box {
position: relative;
flex: 1;
min-width: 250px;
}
.search-box input {
padding-right: 40px;
border-radius: 4px;
border: 1px solid #ddd;
}
.btn-search {
position: absolute;
right: 5px;
top: 5px;
background: none;
border: none;
color: #666;
}
.filter-box {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-box select {
min-width: 120px;
border-radius: 4px;
border: 1px solid #ddd;
padding: 5px 10px;
}
.btn-filter, .btn-reset {
padding: 6px 15px;
border-radius: 4px;
}
.btn-filter {
background-color: #4c84ff;
color: white;
border: none;
}
.btn-reset {
background-color: #f8f9fa;
color: #333;
border: 1px solid #ddd;
}
/* 表格样式 */
.table {
width: 100%;
margin-bottom: 0;
color: #333;
border-collapse: collapse;
}
.table th {
background-color: #f8f9fa;
padding: 12px 15px;
font-weight: 600;
text-align: left;
border-top: 1px solid #dee2e6;
border-bottom: 1px solid #dee2e6;
}
.table td {
padding: 12px 15px;
vertical-align: middle;
border-bottom: 1px solid #f0f0f0;
}
.table tr:hover {
background-color: #f8f9fa;
}
/* 状态标签 */
.status-badge {
display: inline-block;
padding: 5px 10px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-badge.active {
background-color: #e8f5e9;
color: #43a047;
}
.status-badge.inactive {
background-color: #ffebee;
color: #e53935;
}
/* 操作按钮 */
.actions {
display: flex;
gap: 5px;
align-items: center;
}
.actions .btn {
padding: 5px 8px;
line-height: 1;
}
/* 分页控件 */
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
.pagination {
display: flex;
padding-left: 0;
list-style: none;
border-radius: 0.25rem;
}
.page-item {
margin: 0 2px;
}
.page-link {
position: relative;
display: block;
padding: 0.5rem 0.75rem;
margin-left: -1px;
color: #4c84ff;
background-color: #fff;
border: 1px solid #dee2e6;
text-decoration: none;
}
.page-item.active .page-link {
z-index: 3;
color: #fff;
background-color: #4c84ff;
border-color: #4c84ff;
}
.page-item.disabled .page-link {
color: #aaa;
pointer-events: none;
background-color: #f8f9fa;
border-color: #dee2e6;
}
/* 通知样式 */
.alert-box {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.alert-box .alert {
margin-bottom: 10px;
padding: 10px 15px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.alert-box .fade-in {
opacity: 1;
}
.alert-box .fade-out {
opacity: 0;
}
/* 响应式调整 */
@media (max-width: 992px) {
.search-filter-form .form-row {
flex-direction: column;
}
.search-box, .filter-box {
width: 100%;
}
}
@media (max-width: 768px) {
.table {
display: block;
overflow-x: auto;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
}
================================================================================
File: ./app/static/css/book-detail.css
================================================================================
/* 图书详情页样式 */
.book-detail-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.actions {
display: flex;
gap: 10px;
}
.book-content {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
overflow: hidden;
}
.book-header {
display: flex;
padding: 25px;
border-bottom: 1px solid #f0f0f0;
background-color: #f9f9f9;
}
.book-cover-large {
flex: 0 0 200px;
height: 300px;
background-color: #f0f0f0;
border-radius: 5px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
margin-right: 30px;
}
.book-cover-large img {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-cover-large {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #aaa;
}
.no-cover-large i {
font-size: 48px;
margin-bottom: 10px;
}
.book-main-info {
flex: 1;
}
.book-title {
font-size: 1.8rem;
font-weight: 600;
margin-bottom: 15px;
color: #333;
}
.book-author {
font-size: 1.1rem;
color: #555;
margin-bottom: 20px;
}
.book-meta-info {
margin-bottom: 25px;
}
.meta-item {
display: flex;
align-items: center;
margin-bottom: 12px;
color: #666;
}
.meta-item i {
width: 20px;
margin-right: 10px;
text-align: center;
color: #555;
}
.meta-value {
font-weight: 500;
color: #444;
}
.tag {
display: inline-block;
background-color: #e9ecef;
color: #495057;
padding: 2px 8px;
border-radius: 3px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 0.85rem;
}
.book-status-info {
display: flex;
align-items: center;
gap: 20px;
margin-top: 20px;
}
.status-badge {
display: inline-block;
padding: 8px 16px;
border-radius: 4px;
font-weight: 600;
font-size: 0.9rem;
}
.status-badge.available {
background-color: #d4edda;
color: #155724;
}
.status-badge.unavailable {
background-color: #f8d7da;
color: #721c24;
}
.stock-info {
font-size: 0.95rem;
color: #555;
}
.book-details-section {
padding: 25px;
}
.book-details-section h3 {
font-size: 1.3rem;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
color: #444;
}
.book-description {
color: #555;
line-height: 1.6;
}
.no-description {
color: #888;
font-style: italic;
}
.book-borrow-history {
padding: 0 25px 25px;
}
.book-borrow-history h3 {
font-size: 1.3rem;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
color: #444;
}
.borrow-table {
border: 1px solid #eee;
}
.no-records {
color: #888;
font-style: italic;
text-align: center;
padding: 20px;
background-color: #f9f9f9;
border-radius: 4px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.book-header {
flex-direction: column;
}
.book-cover-large {
margin-right: 0;
margin-bottom: 20px;
max-width: 200px;
align-self: center;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.actions {
width: 100%;
}
}
================================================================================
File: ./app/static/css/book.css
================================================================================
/* 图书列表页面样式 - 女性友好版 */
/* 背景和泡泡动画 */
.book-list-container {
padding: 24px;
background-color: #ffeef2; /* 淡粉色背景 */
min-height: calc(100vh - 60px);
position: relative;
overflow: hidden;
}
/* 泡泡动画 */
.book-list-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
@keyframes bubble {
0% {
transform: translateY(100%) scale(0);
opacity: 0;
}
50% {
opacity: 0.6;
}
100% {
transform: translateY(-100vh) scale(1);
opacity: 0;
}
}
.bubble {
position: absolute;
bottom: -50px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 50%;
z-index: 1;
animation: bubble 15s infinite ease-in;
}
/* 为页面添加15个泡泡 */
.bubble:nth-child(1) { left: 5%; width: 30px; height: 30px; animation-duration: 20s; animation-delay: 0s; }
.bubble:nth-child(2) { left: 15%; width: 20px; height: 20px; animation-duration: 18s; animation-delay: 1s; }
.bubble:nth-child(3) { left: 25%; width: 25px; height: 25px; animation-duration: 16s; animation-delay: 2s; }
.bubble:nth-child(4) { left: 35%; width: 15px; height: 15px; animation-duration: 15s; animation-delay: 0.5s; }
.bubble:nth-child(5) { left: 45%; width: 30px; height: 30px; animation-duration: 14s; animation-delay: 3s; }
.bubble:nth-child(6) { left: 55%; width: 20px; height: 20px; animation-duration: 13s; animation-delay: 2.5s; }
.bubble:nth-child(7) { left: 65%; width: 25px; height: 25px; animation-duration: 12s; animation-delay: 1.5s; }
.bubble:nth-child(8) { left: 75%; width: 15px; height: 15px; animation-duration: 11s; animation-delay: 4s; }
.bubble:nth-child(9) { left: 85%; width: 30px; height: 30px; animation-duration: 10s; animation-delay: 3.5s; }
.bubble:nth-child(10) { left: 10%; width: 18px; height: 18px; animation-duration: 19s; animation-delay: 0.5s; }
.bubble:nth-child(11) { left: 20%; width: 22px; height: 22px; animation-duration: 17s; animation-delay: 2.5s; }
.bubble:nth-child(12) { left: 30%; width: 28px; height: 28px; animation-duration: 16s; animation-delay: 1.2s; }
.bubble:nth-child(13) { left: 40%; width: 17px; height: 17px; animation-duration: 15s; animation-delay: 3.7s; }
.bubble:nth-child(14) { left: 60%; width: 23px; height: 23px; animation-duration: 13s; animation-delay: 2.1s; }
.bubble:nth-child(15) { left: 80%; width: 19px; height: 19px; animation-duration: 12s; animation-delay: 1.7s; }
/* 页面标题部分 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid rgba(233, 152, 174, 0.3);
position: relative;
z-index: 2;
}
.page-header h1 {
color: #d23f6e;
font-size: 1.9rem;
font-weight: 600;
margin: 0;
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.8);
}
/* 更漂亮的顶部按钮 */
.action-buttons {
display: flex;
gap: 12px;
position: relative;
z-index: 2;
}
.action-buttons .btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 50px;
font-weight: 500;
padding: 9px 18px;
transition: all 0.3s ease;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.06);
border: none;
font-size: 0.95rem;
position: relative;
overflow: hidden;
}
.action-buttons .btn::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.2), transparent);
pointer-events: none;
}
.action-buttons .btn:hover {
transform: translateY(-3px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.12), 0 3px 6px rgba(0, 0, 0, 0.08);
}
.action-buttons .btn:active {
transform: translateY(1px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
/* 按钮颜色 */
.btn-primary {
background: linear-gradient(135deg, #5c88da, #4a73c7);
color: white;
}
.btn-success {
background: linear-gradient(135deg, #56c596, #41b384);
color: white;
}
.btn-info {
background: linear-gradient(135deg, #5bc0de, #46b8da);
color: white;
}
.btn-secondary {
background: linear-gradient(135deg, #f0ad4e, #ec971f);
color: white;
}
.btn-danger {
background: linear-gradient(135deg, #ff7676, #ff5252);
color: white;
}
/* 过滤和搜索部分 */
.filter-section {
margin-bottom: 25px;
padding: 18px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 16px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
position: relative;
z-index: 2;
backdrop-filter: blur(5px);
}
.search-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.search-row {
margin-bottom: 5px;
width: 100%;
}
.search-group {
display: flex;
width: 100%;
max-width: 800px;
}
.search-group .form-control {
border: 1px solid #f9c0d0;
border-right: none;
border-radius: 25px 0 0 25px;
padding: 10px 20px;
height: 42px;
font-size: 0.95rem;
background-color: rgba(255, 255, 255, 0.9);
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
flex: 1;
}
.search-group .form-control:focus {
outline: none;
border-color: #e67e9f;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 0 0 3px rgba(230, 126, 159, 0.2);
}
.search-group .btn {
border-radius: 50%;
width: 42px;
height: 42px;
min-width: 42px;
padding: 0;
background: linear-gradient(135deg, #e67e9f 60%, #ffd3e1 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
margin-left: -1px; /* 防止和输入框间有缝隙 */
font-size: 1.1rem;
box-shadow: 0 2px 6px rgba(230, 126, 159, 0.10);
transition: background 0.2s, box-shadow 0.2s;
}
.search-group .btn:hover {
background: linear-gradient(135deg, #d23f6e 80%, #efb6c6 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(230, 126, 159, 0.14);
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
width: 100%;
}
.filter-group {
flex: 1;
min-width: 130px;
}
.filter-section .form-control {
border: 1px solid #f9c0d0;
border-radius: 25px;
height: 42px;
padding: 10px 20px;
background-color: rgba(255, 255, 255, 0.9);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23e67e9f' d='M6 8.825L1.175 4 2.238 2.938 6 6.7 9.763 2.937 10.825 4z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 15px center;
background-size: 12px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
width: 100%;
}
.filter-section .form-control:focus {
outline: none;
border-color: #e67e9f;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05), 0 0 0 3px rgba(230, 126, 159, 0.2);
}
/* 图书网格布局 */
.books-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-bottom: 30px;
position: relative;
z-index: 2;
}
/* 图书卡片样式 */
.book-card {
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
background-color: white;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
height: 100%;
position: relative;
border: 1px solid rgba(233, 152, 174, 0.2);
}
.book-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 25px rgba(0, 0, 0, 0.1);
}
.book-cover {
width: 100%;
height: 180px;
background-color: #faf3f5;
overflow: hidden;
position: relative;
}
.book-cover::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, transparent 60%, rgba(249, 219, 227, 0.4));
pointer-events: none;
}
.book-cover img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.book-card:hover .book-cover img {
transform: scale(1.05);
}
.no-cover {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #ffeef2 0%, #ffd9e2 100%);
color: #e67e9f;
position: absolute;
left: 0; right: 0; top: 0; bottom: 0;
z-index: 1;
pointer-events: none;
}
.no-cover i {
font-size: 36px;
margin-bottom: 10px;
}
.book-info {
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
}
.book-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 10px;
color: #d23f6e;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.book-author {
font-size: 0.95rem;
color: #888;
margin-bottom: 15px;
}
.book-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.book-category {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
background-color: #ffebf0;
color: #e67e9f;
font-weight: 500;
}
.book-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.book-status.available {
background-color: #dffff6;
color: #26a69a;
}
.book-status.unavailable {
background-color: #ffeeee;
color: #e57373;
}
.book-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
font-size: 0.9rem;
color: #777;
}
.book-details p {
margin: 0;
display: flex;
}
.book-details strong {
min-width: 65px;
color: #999;
font-weight: 600;
}
/* 按钮组样式 */
.book-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: auto;
}
.book-actions .btn {
padding: 8px 0;
font-size: 0.9rem;
text-align: center;
border-radius: 25px;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
font-weight: 500;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.book-actions .btn:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
.book-actions .btn i {
font-size: 0.85rem;
}
/* 具体按钮颜色 */
.book-actions .btn-primary {
background: linear-gradient(135deg, #5c88da, #4a73c7);
}
.book-actions .btn-info {
background: linear-gradient(135deg, #5bc0de, #46b8da);
}
.book-actions .btn-success {
background: linear-gradient(135deg, #56c596, #41b384);
}
.book-actions .btn-danger {
background: linear-gradient(135deg, #ff7676, #ff5252);
}
/* 无图书状态 */
.no-books {
grid-column: 1 / -1;
padding: 50px 30px;
text-align: center;
background-color: white;
border-radius: 16px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
position: relative;
z-index: 2;
}
.no-books i {
font-size: 60px;
color: #f9c0d0;
margin-bottom: 20px;
}
.no-books p {
font-size: 1.1rem;
color: #e67e9f;
font-weight: 500;
}
/* 分页容器 */
.pagination-container {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 30px;
position: relative;
z-index: 2;
}
.pagination {
display: flex;
list-style: none;
padding: 0;
margin: 0 0 15px 0;
background-color: white;
border-radius: 30px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.pagination .page-item {
margin: 0;
}
.pagination .page-link {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
padding: 0 15px;
border: none;
color: #777;
font-weight: 500;
transition: all 0.2s;
position: relative;
}
.pagination .page-link:hover {
color: #e67e9f;
background-color: #fff9fb;
}
.pagination .page-item.active .page-link {
background-color: #e67e9f;
color: white;
box-shadow: none;
}
.pagination .page-item.disabled .page-link {
color: #bbb;
background-color: #f9f9f9;
}
.pagination-info {
color: #999;
font-size: 0.9rem;
}
/* 优化模态框样式 */
.modal-content {
border-radius: 20px;
border: none;
box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07);
overflow: hidden;
}
.modal-header {
padding: 20px 25px;
background-color: #ffeef2;
border-bottom: 1px solid #ffe0e9;
}
.modal-title {
color: #d23f6e;
font-size: 1.2rem;
font-weight: 600;
}
.modal-body {
padding: 25px;
}
.modal-footer {
padding: 15px 25px;
border-top: 1px solid #ffe0e9;
background-color: #ffeef2;
}
.modal-body p {
color: #666;
font-size: 1rem;
line-height: 1.6;
}
.modal-body p.text-danger {
color: #ff5252 !important;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.modal-body p.text-danger::before {
content: "\f06a";
font-family: "Font Awesome 5 Free";
font-weight: 900;
}
.modal .close {
font-size: 1.5rem;
color: #e67e9f;
opacity: 0.8;
text-shadow: none;
transition: all 0.2s;
}
.modal .close:hover {
opacity: 1;
color: #d23f6e;
}
.modal .btn {
border-radius: 25px;
padding: 8px 20px;
font-weight: 500;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
border: none;
}
.modal .btn-secondary {
background: linear-gradient(135deg, #a0a0a0, #808080);
color: white;
}
.modal .btn-danger {
background: linear-gradient(135deg, #ff7676, #ff5252);
color: white;
}
/* 封面标题栏 */
.cover-title-bar {
position: absolute;
left: 0; right: 0; bottom: 0;
background: linear-gradient(0deg, rgba(233,152,174,0.92) 0%, rgba(255,255,255,0.08) 90%);
color: #fff;
font-size: 1rem;
font-weight: bold;
padding: 10px 14px 7px 14px;
text-shadow: 0 2px 6px rgba(180,0,80,0.14);
line-height: 1.3;
width: 100%;
box-sizing: border-box;
display: flex;
align-items: flex-end;
min-height: 38px;
z-index: 2;
}
.book-card:hover .cover-title-bar {
background: linear-gradient(0deg, #d23f6e 0%, rgba(255,255,255,0.1) 100%);
font-size: 1.07rem;
letter-spacing: .5px;
}
/* 响应式调整 */
@media (max-width: 992px) {
.filter-row {
flex-wrap: wrap;
}
.filter-group {
flex: 1 0 180px;
}
}
@media (max-width: 768px) {
.book-list-container {
padding: 16px;
}
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.action-buttons {
width: 100%;
overflow-x: auto;
padding-bottom: 8px;
flex-wrap: nowrap;
justify-content: flex-start;
}
.filter-section {
padding: 15px;
}
.search-form {
flex-direction: column;
gap: 12px;
}
.search-group {
max-width: 100%;
}
.filter-row {
gap: 12px;
}
.books-grid {
grid-template-columns: 1fr;
}
.book-actions {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.cover-title-bar {
font-size: 0.95rem;
min-height: 27px;
padding: 8px 8px 5px 10px;
}
.book-actions {
grid-template-columns: 1fr;
}
}
================================================================================
File: ./app/static/css/login.css
================================================================================
/* login.css - 登录页面专用样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
:root {
--primary-color: #4a89dc;
--primary-hover: #3b78c4;
--secondary-color: #5cb85c;
--text-color: #333;
--light-text: #666;
--bg-color: #f5f7fa;
--card-bg: #ffffff;
--border-color: #ddd;
--error-color: #e74c3c;
--success-color: #2ecc71;
}
body.dark-mode {
--primary-color: #5a9aed;
--primary-hover: #4a89dc;
--secondary-color: #6bc76b;
--text-color: #f1f1f1;
--light-text: #aaa;
--bg-color: #1a1a1a;
--card-bg: #2c2c2c;
--border-color: #444;
}
body {
background-color: var(--bg-color);
background-image: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
min-height: 100vh;
color: var(--text-color);
transition: all 0.3s ease;
}
.theme-toggle {
position: absolute;
top: 20px;
right: 20px;
z-index: 10;
cursor: pointer;
padding: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.overlay {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(5px);
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
}
.main-container {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
padding: 20px;
}
.login-container {
background-color: var(--card-bg);
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
width: 450px;
padding: 35px;
position: relative;
overflow: hidden;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.logo {
text-align: center;
margin-bottom: 25px;
position: relative;
}
.logo img {
width: 90px;
height: 90px;
border-radius: 12px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
padding: 5px;
background-color: #fff;
transition: transform 0.3s ease;
}
h1 {
text-align: center;
color: var(--text-color);
margin-bottom: 10px;
font-weight: 600;
font-size: 28px;
}
.subtitle {
text-align: center;
color: var(--light-text);
margin-bottom: 30px;
font-size: 14px;
}
.form-group {
margin-bottom: 22px;
position: relative;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-weight: 500;
font-size: 14px;
}
.input-with-icon {
position: relative;
}
.input-icon {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: var(--light-text);
}
.form-control {
width: 100%;
height: 48px;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0 15px 0 45px;
font-size: 15px;
transition: all 0.3s ease;
background-color: var(--card-bg);
color: var(--text-color);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(74, 137, 220, 0.2);
outline: none;
}
.password-toggle {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: var(--light-text);
}
.validation-message {
margin-top: 6px;
font-size: 12px;
color: var(--error-color);
display: none;
}
.validation-message.show {
display: block;
animation: shake 0.5s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.remember-forgot {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.custom-checkbox {
position: relative;
padding-left: 30px;
cursor: pointer;
font-size: 14px;
user-select: none;
color: var(--light-text);
}
.custom-checkbox input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkmark {
position: absolute;
top: 0;
left: 0;
height: 18px;
width: 18px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 3px;
transition: all 0.2s ease;
}
.custom-checkbox:hover input ~ .checkmark {
border-color: var(--primary-color);
}
.custom-checkbox input:checked ~ .checkmark {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.checkmark:after {
content: "";
position: absolute;
display: none;
}
.custom-checkbox input:checked ~ .checkmark:after {
display: block;
}
.custom-checkbox .checkmark:after {
left: 6px;
top: 2px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.forgot-password a {
color: var(--primary-color);
text-decoration: none;
font-size: 14px;
transition: color 0.3s ease;
}
.forgot-password a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.btn-login {
width: 100%;
height: 48px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn-login:hover {
background-color: var(--primary-hover);
}
.btn-login:active {
transform: scale(0.98);
}
.btn-login .loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.btn-login.loading-state {
color: transparent;
}
.btn-login.loading-state .loading {
display: block;
}
.signup {
text-align: center;
margin-top: 25px;
font-size: 14px;
color: var(--light-text);
}
.signup a {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.signup a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.features {
display: flex;
justify-content: center;
margin-top: 25px;
gap: 30px;
}
.feature-item {
text-align: center;
font-size: 12px;
color: var(--light-text);
display: flex;
flex-direction: column;
align-items: center;
}
.feature-icon {
margin-bottom: 5px;
font-size: 18px;
}
footer {
text-align: center;
padding: 20px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
}
footer a {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
}
.alert {
padding: 10px;
margin-bottom: 15px;
border-radius: 4px;
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
@media (max-width: 576px) {
.login-container {
width: 100%;
padding: 25px;
border-radius: 0;
}
.theme-toggle {
top: 10px;
}
.logo img {
width: 70px;
height: 70px;
}
h1 {
font-size: 22px;
}
.main-container {
padding: 0;
}
}
================================================================================
File: ./app/static/css/index.css
================================================================================
/* index.css - 仅用于图书管理系统首页/仪表板 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
body {
background-color: #f5f7fa;
color: #333;
font-size: 16px;
line-height: 1.6;
}
a {
text-decoration: none;
color: #4a89dc;
}
ul {
list-style: none;
}
/* 应用容器 */
.app-container {
display: flex;
min-height: 100vh;
}
/* 侧边导航栏 */
.sidebar {
width: 250px;
background-color: #2c3e50;
color: #ecf0f1;
padding: 20px 0;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: fixed;
height: 100vh;
overflow-y: auto;
}
.logo-container {
padding: 0 20px 20px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-bottom: 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo {
width: 60px;
height: auto;
margin-bottom: 10px;
}
.logo-container h2 {
font-size: 1.2rem;
margin: 10px 0;
color: #ecf0f1;
font-weight: 500;
}
.nav-links li {
margin-bottom: 5px;
}
.nav-links li a {
padding: 10px 20px;
display: flex;
align-items: center;
color: #bdc3c7;
transition: all 0.3s ease;
}
.nav-links li a i {
margin-right: 10px;
font-size: 1.1rem;
width: 20px;
text-align: center;
}
.nav-links li a:hover, .nav-links li.active a {
background-color: #34495e;
color: #ecf0f1;
border-left: 3px solid #4a89dc;
}
.nav-category {
padding: 10px 20px;
font-size: 0.85rem;
text-transform: uppercase;
color: #7f8c8d;
margin-top: 15px;
margin-bottom: 5px;
}
/* 主内容区 */
.main-content {
flex: 1;
margin-left: 250px;
padding: 20px;
}
/* 顶部导航栏 */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 30px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 20px;
}
.search-container {
position: relative;
width: 300px;
}
.search-input {
padding: 10px 15px 10px 40px;
width: 100%;
border: 1px solid #e1e4e8;
border-radius: 20px;
font-size: 14px;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: #4a89dc;
box-shadow: 0 0 0 3px rgba(74, 137, 220, 0.1);
outline: none;
}
.search-icon {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #8492a6;
}
.user-menu {
display: flex;
align-items: center;
}
.notifications {
margin-right: 20px;
position: relative;
cursor: pointer;
}
.notifications i {
font-size: 1.2rem;
color: #606266;
}
.badge {
position: absolute;
top: -8px;
right: -8px;
background-color: #f56c6c;
color: white;
font-size: 0.7rem;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-info {
display: flex;
align-items: center;
position: relative;
cursor: pointer;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #4a89dc;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 10px;
font-size: 1.2rem;
}
.user-details {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 500;
color: #333;
}
.user-role {
font-size: 0.8rem;
color: #8492a6;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
padding: 10px 0;
min-width: 150px;
display: none;
z-index: 10;
}
.user-info.active .dropdown-menu {
display: block;
}
.dropdown-menu a {
display: block;
padding: 8px 15px;
color: #606266;
transition: all 0.3s ease;
}
.dropdown-menu a:hover {
background-color: #f5f7fa;
}
.dropdown-menu a i {
margin-right: 8px;
width: 16px;
text-align: center;
}
/* 欢迎区域 */
.welcome-section {
background: linear-gradient(to right, #4a89dc, #5d9cec);
color: white;
padding: 30px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
.welcome-section h1 {
font-size: 1.8rem;
margin-bottom: 5px;
}
.welcome-section p {
font-size: 1rem;
opacity: 0.9;
}
/* 统计卡片样式 */
.stats-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
padding: 20px;
display: flex;
align-items: center;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.stat-icon {
font-size: 2rem;
color: #4a89dc;
margin-right: 15px;
width: 40px;
text-align: center;
}
.stat-info h3 {
font-size: 0.9rem;
color: #606266;
margin-bottom: 5px;
}
.stat-number {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
}
/* 主要内容区域 */
.main-sections {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.content-section {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
padding: 20px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #edf2f7;
}
.section-header h2 {
font-size: 1.2rem;
color: #2c3e50;
}
.view-all {
font-size: 0.85rem;
color: #4a89dc;
display: flex;
align-items: center;
}
.view-all i {
margin-left: 5px;
transition: transform 0.3s ease;
}
.view-all:hover i {
transform: translateX(3px);
}
/* 图书卡片样式 */
.book-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.book-card {
display: flex;
border: 1px solid #edf2f7;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.book-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.book-cover {
width: 100px;
height: 140px;
min-width: 100px;
background-color: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.book-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.book-info {
padding: 15px;
flex: 1;
display: flex;
flex-direction: column;
}
.book-title {
font-size: 1rem;
margin-bottom: 5px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-author {
font-size: 0.85rem;
color: #606266;
margin-bottom: 10px;
}
.book-meta {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
}
.book-category {
background-color: #e5f1ff;
color: #4a89dc;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.75rem;
}
.book-status {
font-size: 0.75rem;
font-weight: 500;
}
.book-status.available {
color: #67c23a;
}
.book-status.borrowed {
color: #e6a23c;
}
.borrow-btn {
background-color: #4a89dc;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
margin-top: auto;
transition: background-color 0.3s ease;
}
.borrow-btn:hover {
background-color: #357bc8;
}
/* 通知公告样式 */
.notice-item {
display: flex;
padding: 15px 0;
border-bottom: 1px solid #edf2f7;
}
.notice-item:last-child {
border-bottom: none;
}
.notice-icon {
font-size: 1.5rem;
color: #4a89dc;
margin-right: 15px;
display: flex;
align-items: flex-start;
padding-top: 5px;
}
.notice-content h3 {
font-size: 1rem;
color: #2c3e50;
margin-bottom: 5px;
}
.notice-content p {
font-size: 0.9rem;
color: #606266;
margin-bottom: 10px;
}
.notice-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.notice-time {
font-size: 0.8rem;
color: #8492a6;
}
.renew-btn {
background-color: #ecf5ff;
color: #4a89dc;
border: 1px solid #d9ecff;
padding: 5px 10px;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
}
.renew-btn:hover {
background-color: #4a89dc;
color: white;
border-color: #4a89dc;
}
/* 热门图书区域 */
.popular-section {
margin-top: 20px;
}
.popular-books {
display: flex;
overflow-x: auto;
gap: 15px;
padding-bottom: 10px;
}
.popular-book-item {
display: flex;
background-color: #f8fafc;
border-radius: 8px;
padding: 15px;
min-width: 280px;
position: relative;
}
.rank-badge {
position: absolute;
top: -10px;
left: 10px;
background-color: #4a89dc;
color: white;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.8rem;
font-weight: bold;
}
.book-cover.small {
width: 60px;
height: 90px;
min-width: 60px;
margin-right: 15px;
}
.book-details {
flex: 1;
}
.book-stats {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 10px;
}
.book-stats span {
font-size: 0.8rem;
color: #8492a6;
}
.book-stats i {
margin-right: 5px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.stats-container {
grid-template-columns: repeat(2, 1fr);
}
.main-sections {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.sidebar {
width: 70px;
overflow: hidden;
}
.logo-container {
padding: 10px;
}
.logo-container h2 {
display: none;
}
.nav-links li a span {
display: none;
}
.nav-links li a i {
margin-right: 0;
}
.nav-category {
display: none;
}
.main-content {
margin-left: 70px;
}
.search-container {
width: 180px;
}
.book-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 576px) {
.stats-container {
grid-template-columns: 1fr;
}
.top-bar {
flex-direction: column;
gap: 15px;
}
.search-container {
width: 100%;
}
.user-details {
display: none;
}
}
================================================================================
File: ./app/static/css/main.css
================================================================================
/* 基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f0f2f5;
color: #333;
}
.app-container {
display: flex;
min-height: 100vh;
}
/* 侧边栏样式 */
.sidebar {
width: 250px;
background-color: #2c3e50;
color: white;
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
position: fixed;
height: 100vh;
overflow-y: auto;
z-index: 1000;
}
.logo-container {
display: flex;
align-items: center;
padding: 20px 15px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.logo {
width: 40px;
height: 40px;
margin-right: 10px;
}
.logo-container h2 {
font-size: 1.2rem;
font-weight: 600;
}
.nav-links {
list-style: none;
padding: 15px 0;
}
.nav-category {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
padding: 15px 20px 5px;
color: #adb5bd;
}
.nav-links li {
position: relative;
}
.nav-links li.active {
background-color: rgba(255,255,255,0.1);
}
.nav-links li a {
display: flex;
align-items: center;
padding: 12px 20px;
color: #ecf0f1;
text-decoration: none;
transition: all 0.3s;
}
.nav-links li a:hover {
background-color: rgba(255,255,255,0.05);
}
.nav-links li a i {
margin-right: 10px;
width: 20px;
text-align: center;
}
/* 主内容区样式 */
.main-content {
flex: 1;
margin-left: 250px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 25px;
background-color: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 900;
}
.search-container {
position: relative;
width: 350px;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: #adb5bd;
}
.search-input {
width: 100%;
padding: 10px 10px 10px 35px;
border: 1px solid #dee2e6;
border-radius: 20px;
font-size: 0.9rem;
}
.search-input:focus {
outline: none;
border-color: #4a6cf7;
}
.user-menu {
display: flex;
align-items: center;
}
.notifications {
position: relative;
margin-right: 20px;
cursor: pointer;
}
.badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #e74c3c;
color: white;
font-size: 0.7rem;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
position: relative;
}
.user-avatar {
width: 40px;
height: 40px;
background-color: #4a6cf7;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 10px;
}
.user-details {
display: flex;
flex-direction: column;
}
.user-name {
font-weight: 600;
font-size: 0.9rem;
}
.user-role {
font-size: 0.8rem;
color: #6c757d;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background-color: white;
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
border-radius: 5px;
width: 200px;
padding: 10px 0;
display: none;
z-index: 1000;
}
.user-info.active .dropdown-menu {
display: block;
}
.dropdown-menu a {
display: block;
padding: 8px 15px;
color: #333;
text-decoration: none;
transition: background-color 0.3s;
}
.dropdown-menu a:hover {
background-color: #f8f9fa;
}
.dropdown-menu a i {
width: 20px;
margin-right: 10px;
text-align: center;
}
/* 内容区域 */
.content-wrapper {
flex: 1;
padding: 20px;
background-color: #f0f2f5;
}
/* 响应式适配 */
@media (max-width: 768px) {
.sidebar {
width: 70px;
overflow: visible;
}
.logo-container h2 {
display: none;
}
.nav-links li a span {
display: none;
}
.main-content {
margin-left: 70px;
}
.user-details {
display: none;
}
}
================================================================================
File: ./app/static/css/book-form.css
================================================================================
/* ========== 基础重置和变量 ========== */
:root {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--primary-light: #eff6ff;
--danger-color: #ef4444;
--success-color: #10b981;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--text-dark: #1e293b;
--text-medium: #475569;
--text-light: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--border-focus: #bfdbfe;
--bg-white: #ffffff;
--bg-light: #f8fafc;
--bg-lightest: #f1f5f9;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--transition-fast: 0.15s ease;
--transition-base: 0.3s ease;
--transition-slow: 0.5s ease;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* ========== 全局样式 ========== */
.book-form-container {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
font-family: var(--font-sans);
color: var(--text-dark);
}
/* ========== 页头样式 ========== */
.page-header-wrapper {
margin-bottom: 24px;
background-color: var(--bg-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
}
.header-title-section {
display: flex;
flex-direction: column;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-dark);
margin: 0;
}
.subtitle {
margin: 8px 0 0 0;
color: var(--text-medium);
font-size: 0.9rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.btn-back {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-medium);
background-color: var(--bg-lightest);
border-radius: var(--radius-md);
padding: 8px 16px;
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition-fast);
text-decoration: none;
box-shadow: var(--shadow-sm);
}
.btn-back:hover {
background-color: var(--border-color);
color: var(--text-dark);
text-decoration: none;
}
.btn-back i {
font-size: 14px;
}
/* 进度条样式 */
.form-progress {
min-width: 180px;
}
.progress-bar-container {
height: 6px;
background-color: var(--bg-lightest);
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: var(--primary-color);
border-radius: 3px;
transition: width var(--transition-base);
}
.progress-text {
font-size: 0.75rem;
color: var(--text-light);
text-align: right;
display: block;
margin-top: 4px;
}
/* ========== 表单布局 ========== */
.form-grid {
display: grid;
grid-template-columns: 1fr 360px;
gap: 24px;
}
.form-main-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
}
/* ========== 表单卡片样式 ========== */
.form-card {
background-color: var(--bg-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
transition: box-shadow var(--transition-base);
}
.form-card:hover {
box-shadow: var(--shadow-md);
}
.card-header {
padding: 16px 20px;
background-color: var(--bg-white);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
}
.card-title {
font-weight: 600;
color: var(--text-dark);
font-size: 0.9375rem;
}
.card-body {
padding: 20px;
}
.form-section {
padding: 0;
}
/* ========== 表单元素样式 ========== */
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-weight: 500;
color: var(--text-dark);
margin-bottom: 8px;
font-size: 0.9375rem;
}
.form-control {
display: block;
width: 100%;
padding: 10px 14px;
font-size: 0.9375rem;
line-height: 1.5;
color: var(--text-dark);
background-color: var(--bg-white);
background-clip: padding-box;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.form-control:focus {
border-color: var(--border-focus);
outline: 0;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.form-control::placeholder {
color: var(--text-muted);
}
.form-control:disabled, .form-control[readonly] {
background-color: var(--bg-lightest);
opacity: 0.6;
}
.form-help {
margin-top: 6px;
font-size: 0.8125rem;
color: var(--text-light);
}
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.char-counter {
font-size: 0.8125rem;
color: var(--text-muted);
}
/* 带按钮输入框 */
.input-with-button {
display: flex;
align-items: center;
}
.input-with-button .form-control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
flex-grow: 1;
}
.btn-append {
height: 42px;
padding: 0 14px;
background-color: var(--bg-lightest);
border: 1px solid var(--border-color);
border-left: none;
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
color: var(--text-medium);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.btn-append:hover {
background-color: var(--border-color);
color: var(--text-dark);
}
/* 文本域 */
textarea.form-control {
min-height: 150px;
resize: vertical;
}
/* 数字输入控件 */
.number-control {
display: flex;
align-items: center;
width: 100%;
border-radius: var(--radius-md);
overflow: hidden;
}
.number-btn {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-lightest);
border: 1px solid var(--border-color);
color: var(--text-medium);
cursor: pointer;
transition: all var(--transition-fast);
font-size: 1rem;
user-select: none;
}
.number-btn:hover {
background-color: var(--border-color);
color: var(--text-dark);
}
.decrement {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.increment {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}
.number-control .form-control {
flex: 1;
border-radius: 0;
border-left: none;
border-right: none;
text-align: center;
padding: 10px 0;
}
/* 价格输入 */
.price-input {
position: relative;
}
.currency-symbol {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-medium);
}
.price-input .form-control {
padding-left: 30px;
}
.price-slider {
margin-top: 16px;
}
.range-slider {
-webkit-appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background-color: var(--border-color);
outline: none;
margin: 14px 0;
}
.range-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: 2px solid var(--bg-white);
box-shadow: var(--shadow-sm);
}
.slider-marks {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-light);
}
/* ========== 按钮样式 ========== */
.btn-primary {
padding: 12px 16px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background-color: var(--primary-hover);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.btn-primary:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
.btn-primary:active {
transform: translateY(1px);
}
.btn-secondary {
padding: 10px 16px;
background-color: var(--bg-white);
color: var(--text-medium);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
cursor: pointer;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all var(--transition-fast);
}
.btn-secondary:hover {
background-color: var(--bg-lightest);
color: var(--text-dark);
}
.btn-secondary:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(226, 232, 240, 0.5);
}
/* ========== 标签输入样式 ========== */
.tag-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.tag-input-wrapper .form-control {
flex-grow: 1;
}
.btn-tag-add {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--primary-color);
border: none;
border-radius: var(--radius-md);
color: white;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-tag-add:hover {
background-color: var(--primary-hover);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
min-height: 32px;
}
.tag {
display: inline-flex;
align-items: center;
background-color: var(--primary-light);
border-radius: 50px;
padding: 6px 10px 6px 14px;
font-size: 0.8125rem;
color: var(--primary-color);
transition: all var(--transition-fast);
}
.tag:hover {
background-color: rgba(59, 130, 246, 0.2);
}
.tag-text {
margin-right: 6px;
}
.tag-remove {
background: none;
border: none;
color: var(--primary-color);
cursor: pointer;
padding: 0;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
transition: all var(--transition-fast);
}
.tag-remove:hover {
background-color: rgba(59, 130, 246, 0.3);
color: white;
}
/* ========== 封面上传区域 ========== */
.cover-preview-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.cover-preview {
width: 100%;
aspect-ratio: 5/7;
background-color: var(--bg-lightest);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: all var(--transition-fast);
}
.cover-preview:hover {
background-color: var(--bg-light);
}
.cover-preview.dragover {
background-color: var(--primary-light);
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.no-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--text-light);
padding: 24px;
text-align: center;
}
.no-cover-placeholder i {
font-size: 48px;
margin-bottom: 16px;
color: var(--text-muted);
}
.placeholder-tip {
font-size: 0.8125rem;
margin-top: 8px;
color: var(--text-muted);
}
.upload-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.upload-btn-group {
display: flex;
gap: 8px;
}
.btn-upload {
flex-grow: 1;
padding: 10px 16px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all var(--transition-fast);
}
.btn-upload:hover {
background-color: var(--primary-hover);
}
.btn-remove {
width: 42px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-white);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-medium);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-remove:hover {
background-color: #fee2e2;
border-color: #fca5a5;
color: #ef4444;
}
.upload-tips {
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ========== 表单提交区域 ========== */
.form-actions {
display: flex;
flex-direction: column;
gap: 16px;
}
.secondary-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.form-tip {
margin-top: 8px;
font-size: 0.8125rem;
color: var(--text-muted);
text-align: center;
}
.form-tip i {
color: var(--info-color);
margin-right: 4px;
}
/* 必填项标记 */
.required {
color: var(--danger-color);
margin-left: 4px;
}
/* 无效输入状态 */
.is-invalid {
border-color: var(--danger-color) !important;
}
.invalid-feedback {
display: block;
color: var(--danger-color);
font-size: 0.8125rem;
margin-top: 6px;
}
/* ========== Select2 定制 ========== */
.select2-container--classic .select2-selection--single {
height: 42px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background-color: var(--bg-white);
}
.select2-container--classic .select2-selection--single .select2-selection__rendered {
line-height: 40px;
color: var(--text-dark);
padding-left: 14px;
}
.select2-container--classic .select2-selection--single .select2-selection__arrow {
height: 40px;
border-left: 1px solid var(--border-color);
}
.select2-container--classic .select2-selection--single:focus {
border-color: var(--border-focus);
outline: 0;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
/* ========== 模态框样式 ========== */
.modal-content {
border: none;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.modal-header {
background-color: var(--bg-white);
border-bottom: 1px solid var(--border-color);
padding: 16px 20px;
}
.modal-title {
font-weight: 600;
color: var(--text-dark);
font-size: 1.125rem;
}
.modal-body {
padding: 20px;
}
.modal-footer {
border-top: 1px solid var(--border-color);
padding: 16px 20px;
}
.modal-btn {
min-width: 100px;
}
/* 裁剪模态框 */
.img-container {
max-height: 500px;
overflow: hidden;
margin-bottom: 20px;
}
#cropperImage {
display: block;
max-width: 100%;
}
.cropper-controls {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 16px;
}
.control-group {
display: flex;
gap: 8px;
}
.control-btn {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background-color: var(--bg-lightest);
border: 1px solid var(--border-color);
color: var(--text-medium);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
}
.control-btn:hover {
background-color: var(--border-color);
color: var(--text-dark);
}
/* 图书预览模态框 */
.preview-header {
background-color: var(--bg-white);
border-bottom: 1px solid var(--border-color);
}
.preview-body {
padding: 0;
background-color: var(--bg-lightest);
}
/* 添加到你的CSS文件中 */
.book-preview {
display: flex;
flex-direction: row;
gap: 20px;
}
.preview-cover-section {
flex: 0 0 200px;
}
.preview-details-section {
flex: 1;
}
.book-preview-cover {
height: 280px;
width: 200px;
overflow: hidden;
border-radius: 4px;
border: 1px solid #ddd;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.preview-cover-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-tag {
display: inline-block;
background: #e9ecef;
color: #495057;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
margin-right: 5px;
margin-bottom: 5px;
}
.book-tags-preview {
margin: 15px 0;
}
.book-description-preview {
margin-top: 20px;
}
.section-title {
font-size: 16px;
margin-bottom: 10px;
color: #495057;
border-bottom: 1px solid #dee2e6;
padding-bottom: 5px;
}
.book-meta {
margin-top: 10px;
text-align: center;
}
.book-price {
font-size: 18px;
font-weight: bold;
color: #dc3545;
}
.book-stock {
font-size: 14px;
color: #6c757d;
}
/* 响应式调整 */
@media (max-width: 768px) {
.book-preview {
flex-direction: column;
}
.preview-cover-section {
margin: 0 auto;
}
}
.preview-details-section {
padding: 24px;
}
.book-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-dark);
margin: 0 0 8px 0;
}
.book-author {
color: var(--text-medium);
font-size: 1rem;
margin-bottom: 24px;
}
.book-info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
background-color: var(--bg-white);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
margin-bottom: 24px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 0.75rem;
color: var(--text-light);
text-transform: uppercase;
}
.info-value {
font-weight: 500;
color: var(--text-dark);
}
.book-tags-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.preview-tag {
display: inline-block;
background-color: var(--primary-light);
color: var(--primary-color);
padding: 4px 12px;
border-radius: 50px;
font-size: 0.8125rem;
}
.no-tags {
font-size: 0.875rem;
color: var(--text-muted);
}
.book-description-preview {
background-color: var(--bg-white);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-sm);
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-dark);
margin: 0 0 12px 0;
}
.description-content {
font-size: 0.9375rem;
color: var(--text-medium);
line-height: 1.6;
}
.placeholder-text {
color: var(--text-muted);
font-style: italic;
}
.preview-footer {
background-color: var(--bg-white);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ========== 通知样式 ========== */
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 320px;
}
.notification {
background-color: var(--bg-white);
border-radius: var(--radius-md);
padding: 12px 16px;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
gap: 12px;
animation-duration: 0.5s;
}
.success-notification {
border-left: 4px solid var(--success-color);
}
.error-notification {
border-left: 4px solid var(--danger-color);
}
.warning-notification {
border-left: 4px solid var(--warning-color);
}
.info-notification {
border-left: 4px solid var(--info-color);
}
.notification-icon {
color: var(--text-light);
}
.success-notification .notification-icon {
color: var(--success-color);
}
.error-notification .notification-icon {
color: var(--danger-color);
}
.warning-notification .notification-icon {
color: var(--warning-color);
}
.info-notification .notification-icon {
color: var(--info-color);
}
.notification-content {
flex-grow: 1;
}
.notification-content p {
margin: 0;
font-size: 0.875rem;
color: var(--text-dark);
}
.notification-close {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 5px;
transition: color var(--transition-fast);
}
.notification-close:hover {
color: var(--text-medium);
}
/* ========== 动画效果 ========== */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
70% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
}
}
.pulse {
animation: pulse 2s infinite;
}
/* ========== 响应式样式 ========== */
@media (max-width: 1200px) {
.form-grid {
grid-template-columns: 1fr 320px;
gap: 20px;
}
}
@media (max-width: 992px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-actions {
width: 100%;
justify-content: space-between;
}
.form-grid {
grid-template-columns: 1fr;
}
.book-preview {
grid-template-columns: 1fr;
}
.preview-cover-section {
border-right: none;
border-bottom: 1px solid var(--border-color);
padding-bottom: 24px;
}
.book-preview-cover {
max-width: 240px;
margin: 0 auto;
}
}
@media (max-width: 768px) {
.book-form-container {
padding: 16px 12px;
}
.page-header {
padding: 20px;
}
.form-row {
grid-template-columns: 1fr;
gap: 12px;
}
.secondary-actions {
grid-template-columns: 1fr;
}
.card-body {
padding: 16px;
}
.book-info-grid {
grid-template-columns: 1fr;
}
}
.cover-preview {
min-height: 250px;
width: 100%;
border: 1px dashed #ccc;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.cover-preview img.cover-image {
max-width: 100%;
max-height: 300px;
object-fit: contain;
}
.img-container {
max-height: 500px;
overflow: auto;
}
#cropperImage {
max-width: 100%;
display: block;
}
================================================================================
File: ./app/static/css/categories.css
================================================================================
/* 分类管理页面样式 */
.categories-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.card {
margin-bottom: 20px;
border: 1px solid rgba(0,0,0,0.125);
border-radius: 0.25rem;
}
.card-header {
padding: 0.75rem 1.25rem;
background-color: rgba(0,0,0,0.03);
border-bottom: 1px solid rgba(0,0,0,0.125);
font-weight: 600;
}
.card-body {
padding: 1.25rem;
}
.category-table {
border: 1px solid #eee;
}
.category-table th {
background-color: #f8f9fa;
}
.no-categories {
text-align: center;
padding: 30px;
color: #888;
}
.no-categories i {
font-size: 48px;
color: #ddd;
margin-bottom: 10px;
}
/* 通知弹窗 */
.notification-alert {
position: fixed;
top: 20px;
right: 20px;
min-width: 300px;
z-index: 1050;
}
/* 响应式调整 */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
}
================================================================================
File: ./app/static/css/user-edit.css
================================================================================
/* 用户编辑页面样式 */
.user-edit-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 页面标题和操作按钮 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.page-header h1 {
font-size: 1.8rem;
color: #333;
margin: 0;
}
.page-header .actions {
display: flex;
gap: 10px;
}
/* 卡片样式 */
.card {
border: none;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
margin-bottom: 20px;
}
.card-body {
padding: 25px;
}
/* 表单样式 */
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
display: block;
}
.form-control {
height: auto;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus {
border-color: #4c84ff;
box-shadow: 0 0 0 0.2rem rgba(76, 132, 255, 0.25);
}
.form-control[readonly] {
background-color: #f8f9fa;
opacity: 0.7;
}
.form-text {
font-size: 0.85rem;
margin-top: 5px;
}
.form-row {
margin-right: -15px;
margin-left: -15px;
display: flex;
flex-wrap: wrap;
}
.col-md-6 {
flex: 0 0 50%;
max-width: 50%;
padding-right: 15px;
padding-left: 15px;
}
.col-md-12 {
flex: 0 0 100%;
max-width: 100%;
padding-right: 15px;
padding-left: 15px;
}
/* 用户信息框 */
.user-info-box {
margin-top: 20px;
margin-bottom: 20px;
padding: 20px;
background-color: #f8f9fa;
border-radius: 4px;
display: flex;
flex-wrap: wrap;
}
.info-item {
flex: 0 0 auto;
margin-right: 30px;
margin-bottom: 10px;
}
.info-label {
font-weight: 500;
color: #666;
margin-right: 5px;
}
.info-value {
color: #333;
}
/* 表单操作区域 */
.form-actions {
display: flex;
justify-content: flex-start;
gap: 10px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #4c84ff;
border-color: #4c84ff;
}
.btn-primary:hover {
background-color: #3a70e9;
border-color: #3a70e9;
}
.btn-secondary {
background-color: #f8f9fa;
border-color: #ddd;
color: #333;
}
.btn-secondary:hover {
background-color: #e9ecef;
border-color: #ccc;
}
.btn-outline-secondary {
color: #6c757d;
border-color: #6c757d;
}
.btn-outline-secondary:hover {
color: #fff;
background-color: #6c757d;
border-color: #6c757d;
}
/* 表单分隔线 */
.form-divider {
height: 1px;
background-color: #f0f0f0;
margin: 30px 0;
}
/* 警告和错误状态 */
.is-invalid {
border-color: #dc3545 !important;
}
.invalid-feedback {
display: block;
width: 100%;
margin-top: 5px;
font-size: 0.85rem;
color: #dc3545;
}
/* 成功消息样式 */
.alert-success {
color: #155724;
background-color: #d4edda;
border-color: #c3e6cb;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
/* 错误消息样式 */
.alert-error, .alert-danger {
color: #721c24;
background-color: #f8d7da;
border-color: #f5c6cb;
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.form-row {
flex-direction: column;
}
.col-md-6, .col-md-12 {
flex: 0 0 100%;
max-width: 100%;
}
.page-header {
flex-direction: column;
align-items: flex-start;
}
.page-header .actions {
margin-top: 15px;
}
.user-info-box {
flex-direction: column;
}
.info-item {
margin-right: 0;
}
}
================================================================================
File: ./app/static/css/user-profile.css
================================================================================
/* 用户个人中心页面样式 */
.profile-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 页面标题 */
.page-header {
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.page-header h1 {
font-size: 1.8rem;
color: #333;
margin: 0;
}
/* 个人中心内容布局 */
.profile-content {
display: flex;
gap: 30px;
}
/* 左侧边栏 */
.profile-sidebar {
flex: 0 0 300px;
background-color: #f8f9fa;
border-radius: 8px;
padding: 25px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 右侧主要内容 */
.profile-main {
flex: 1;
min-width: 0; /* 防止内容溢出 */
}
/* 用户头像容器 */
.user-avatar-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 25px;
padding-bottom: 25px;
border-bottom: 1px solid #e9ecef;
}
/* 大头像样式 */
.user-avatar.large {
width: 120px;
height: 120px;
border-radius: 50%;
background-color: #4c84ff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
margin-bottom: 15px;
box-shadow: 0 4px 8px rgba(76, 132, 255, 0.2);
}
.user-name {
font-size: 1.5rem;
margin: 10px 0 5px;
color: #333;
}
.user-role {
font-size: 0.9rem;
color: #6c757d;
margin: 0;
}
/* 用户统计信息 */
.user-stats {
display: flex;
justify-content: space-between;
margin-bottom: 25px;
padding-bottom: 25px;
border-bottom: 1px solid #e9ecef;
}
.stat-item {
text-align: center;
flex: 1;
}
.stat-value {
font-size: 1.8rem;
font-weight: 600;
color: #4c84ff;
line-height: 1;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.85rem;
color: #6c757d;
}
/* 账户信息样式 */
.account-info {
margin-bottom: 10px;
}
.account-info .info-row {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
font-size: 0.95rem;
}
.account-info .info-label {
color: #6c757d;
font-weight: 500;
}
.account-info .info-value {
color: #333;
text-align: right;
word-break: break-all;
}
/* 选项卡导航样式 */
.nav-tabs {
border-bottom: 1px solid #dee2e6;
margin-bottom: 25px;
}
.nav-tabs .nav-link {
border: none;
color: #6c757d;
padding: 12px 15px;
margin-right: 5px;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
}
.nav-tabs .nav-link:hover {
color: #4c84ff;
border-bottom-color: #4c84ff;
}
.nav-tabs .nav-link.active {
font-weight: 500;
color: #4c84ff;
border-bottom: 2px solid #4c84ff;
background-color: transparent;
}
.nav-tabs .nav-link i {
margin-right: 5px;
}
/* 表单区域 */
.form-section {
padding: 20px;
background-color: #f9f9fb;
border-radius: 8px;
margin-bottom: 20px;
}
.form-section h4 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
font-size: 1.2rem;
font-weight: 500;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
display: block;
}
.form-control {
height: auto;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus {
border-color: #4c84ff;
box-shadow: 0 0 0 0.2rem rgba(76, 132, 255, 0.25);
}
.form-text {
font-size: 0.85rem;
margin-top: 5px;
}
/* 表单操作区域 */
.form-actions {
margin-top: 25px;
display: flex;
justify-content: flex-start;
}
.btn {
padding: 10px 20px;
border-radius: 4px;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #4c84ff;
border-color: #4c84ff;
}
.btn-primary:hover {
background-color: #3a70e9;
border-color: #3a70e9;
}
/* 活动记录选项卡 */
.activity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.activity-filter {
display: flex;
align-items: center;
gap: 10px;
}
.activity-filter label {
margin-bottom: 0;
}
.activity-filter select {
width: auto;
}
/* 活动时间线 */
.activity-timeline {
padding: 20px;
background-color: #f9f9fb;
border-radius: 8px;
min-height: 300px;
position: relative;
}
.timeline-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 250px;
}
.timeline-loading p {
margin-top: 15px;
color: #6c757d;
}
.timeline-item {
position: relative;
padding-left: 30px;
padding-bottom: 25px;
border-left: 2px solid #dee2e6;
}
.timeline-item:last-child {
border-left: none;
}
.timeline-icon {
position: absolute;
left: -10px;
top: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #4c84ff;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
}
.timeline-content {
background-color: white;
border-radius: 6px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
padding: 15px;
}
.timeline-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.timeline-title {
font-weight: 500;
color: #333;
margin: 0;
}
.timeline-time {
font-size: 0.85rem;
color: #6c757d;
}
.timeline-details {
color: #555;
font-size: 0.95rem;
}
.timeline-type-login .timeline-icon {
background-color: #4caf50;
}
.timeline-type-borrow .timeline-icon {
background-color: #2196f3;
}
.timeline-type-return .timeline-icon {
background-color: #ff9800;
}
/* 通知样式 */
.alert {
padding: 15px;
margin-bottom: 20px;
border-radius: 4px;
}
.alert-success {
color: #155724;
background-color: #d4edda;
border: 1px solid #c3e6cb;
}
.alert-error, .alert-danger {
color: #721c24;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
}
/* 响应式调整 */
@media (max-width: 992px) {
.profile-content {
flex-direction: column;
}
.profile-sidebar {
flex: none;
width: 100%;
margin-bottom: 20px;
}
.user-stats {
justify-content: space-around;
}
}
================================================================================
File: ./app/static/css/user-roles.css
================================================================================
/* 角色管理页面样式 */
.roles-container {
padding: 20px;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
/* 页面标题 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
}
.page-header h1 {
font-size: 1.8rem;
color: #333;
margin: 0;
}
.page-header .actions {
display: flex;
gap: 10px;
}
/* 角色列表 */
.role-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
/* 角色卡片 */
.role-card {
background-color: #f9f9fb;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.role-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.role-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.role-name {
font-size: 1.3rem;
color: #333;
margin: 0;
font-weight: 600;
}
.role-actions {
display: flex;
gap: 5px;
}
.role-actions .btn {
padding: 5px 8px;
color: #6c757d;
background: none;
border: none;
}
.role-actions .btn:hover {
color: #4c84ff;
}
.role-actions .btn-delete-role:hover {
color: #dc3545;
}
.role-description {
color: #555;
margin-bottom: 20px;
min-height: 50px;
line-height: 1.5;
}
.role-stats {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: #6c757d;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
}
.stat-item i {
color: #4c84ff;
}
/* 角色标签 */
.role-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.role-badge.admin {
background-color: #e3f2fd;
color: #1976d2;
}
.role-badge.user {
background-color: #e8f5e9;
color: #43a047;
}
.role-badge.custom {
background-color: #fff3e0;
color: #ef6c00;
}
/* 无数据提示 */
.no-data-message {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px 0;
color: #6c757d;
}
.no-data-message i {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.5;
}
/* 权限信息部分 */
.permissions-info {
margin-top: 30px;
}
.permissions-info h3 {
font-size: 1.4rem;
margin-bottom: 15px;
color: #333;
}
.permission-table {
width: 100%;
margin-bottom: 0;
}
.permission-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.permission-table td, .permission-table th {
padding: 12px 15px;
text-align: center;
}
.permission-table td:first-child {
text-align: left;
font-weight: 500;
}
.text-success {
color: #28a745;
}
.text-danger {
color: #dc3545;
}
/* 模态框样式 */
.modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #f0f0f0;
}
.modal-title {
font-weight: 600;
color: #333;
}
.modal-footer {
border-top: 1px solid #f0f0f0;
padding: 15px;
}
/* 表单样式 */
.form-group {
margin-bottom: 20px;
}
.form-group label {
font-weight: 500;
margin-bottom: 8px;
color: #333;
display: block;
}
.form-control {
height: auto;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
.form-control:focus {
border-color: #4c84ff;
box-shadow: 0 0 0 0.2rem rgba(76, 132, 255, 0.25);
}
/* 响应式调整 */
@media (max-width: 992px) {
.role-list {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
@media (max-width: 576px) {
.role-list {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
align-items: flex-start;
}
.page-header .actions {
margin-top: 15px;
}
}
================================================================================
File: ./app/static/js/user-edit.js
================================================================================
// 用户编辑页面交互
document.addEventListener('DOMContentLoaded', function() {
const passwordField = document.getElementById('password');
const confirmPasswordGroup = document.getElementById('confirmPasswordGroup');
const confirmPasswordField = document.getElementById('confirm_password');
const userEditForm = document.getElementById('userEditForm');
// 如果输入密码,显示确认密码字段
passwordField.addEventListener('input', function() {
if (this.value.trim() !== '') {
confirmPasswordGroup.style.display = 'block';
} else {
confirmPasswordGroup.style.display = 'none';
confirmPasswordField.value = '';
}
});
// 表单提交验证
userEditForm.addEventListener('submit', function(event) {
let valid = true;
// 清除之前的错误提示
const invalidFields = document.querySelectorAll('.is-invalid');
const feedbackElements = document.querySelectorAll('.invalid-feedback');
invalidFields.forEach(field => {
field.classList.remove('is-invalid');
});
feedbackElements.forEach(element => {
element.parentNode.removeChild(element);
});
// 邮箱格式验证
const emailField = document.getElementById('email');
if (emailField.value.trim() !== '') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailField.value.trim())) {
showError(emailField, '请输入有效的邮箱地址');
valid = false;
}
}
// 手机号码格式验证
const phoneField = document.getElementById('phone');
if (phoneField.value.trim() !== '') {
const phonePattern = /^1[3456789]\d{9}$/;
if (!phonePattern.test(phoneField.value.trim())) {
showError(phoneField, '请输入有效的手机号码');
valid = false;
}
}
// 密码验证
if (passwordField.value.trim() !== '') {
if (passwordField.value.length < 6) {
showError(passwordField, '密码长度至少为6个字符');
valid = false;
}
if (passwordField.value !== confirmPasswordField.value) {
showError(confirmPasswordField, '两次输入的密码不一致');
valid = false;
}
}
if (!valid) {
event.preventDefault();
}
});
// 显示表单字段错误
function showError(field, message) {
field.classList.add('is-invalid');
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.innerText = message;
field.parentNode.appendChild(feedback);
}
// 处理表单提交后的成功反馈
const successAlert = document.querySelector('.alert-success');
if (successAlert) {
// 如果有成功消息,显示成功对话框
setTimeout(() => {
$('#successModal').modal('show');
}, 500);
}
});
================================================================================
File: ./app/static/js/user-roles.js
================================================================================
// 角色管理页面交互
document.addEventListener('DOMContentLoaded', function() {
// 获取DOM元素
const addRoleBtn = document.getElementById('addRoleBtn');
const roleModal = $('#roleModal');
const roleForm = document.getElementById('roleForm');
const roleIdInput = document.getElementById('roleId');
const roleNameInput = document.getElementById('roleName');
const roleDescriptionInput = document.getElementById('roleDescription');
const saveRoleBtn = document.getElementById('saveRoleBtn');
const deleteModal = $('#deleteModal');
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
let roleIdToDelete = null;
// 加载角色用户统计
fetchRoleUserCounts();
// 添加角色按钮点击事件
if (addRoleBtn) {
addRoleBtn.addEventListener('click', function() {
// 重置表单
roleIdInput.value = '';
roleNameInput.value = '';
roleDescriptionInput.value = '';
// 更新模态框标题
document.getElementById('roleModalLabel').textContent = '添加角色';
// 显示模态框
roleModal.modal('show');
});
}
// 编辑角色按钮点击事件
const editButtons = document.querySelectorAll('.btn-edit-role');
editButtons.forEach(button => {
button.addEventListener('click', function() {
const roleCard = this.closest('.role-card');
const roleId = roleCard.getAttribute('data-id');
const roleName = roleCard.querySelector('.role-name').textContent;
let roleDescription = roleCard.querySelector('.role-description').textContent;
// 移除"暂无描述"文本
if (roleDescription.trim() === '暂无描述') {
roleDescription = '';
}
// 填充表单
roleIdInput.value = roleId;
roleNameInput.value = roleName;
roleDescriptionInput.value = roleDescription.trim();
// 更新模态框标题
document.getElementById('roleModalLabel').textContent = '编辑角色';
// 显示模态框
roleModal.modal('show');
});
});
// 删除角色按钮点击事件
const deleteButtons = document.querySelectorAll('.btn-delete-role');
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
const roleCard = this.closest('.role-card');
roleIdToDelete = roleCard.getAttribute('data-id');
// 显示确认删除模态框
deleteModal.modal('show');
});
});
// 保存角色按钮点击事件
if (saveRoleBtn) {
saveRoleBtn.addEventListener('click', function() {
if (!roleNameInput.value.trim()) {
showAlert('角色名称不能为空', 'error');
return;
}
const roleData = {
id: roleIdInput.value || null,
role_name: roleNameInput.value.trim(),
description: roleDescriptionInput.value.trim() || null
};
saveRole(roleData);
});
}
// 确认删除按钮点击事件
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', function() {
if (roleIdToDelete) {
deleteRole(roleIdToDelete);
deleteModal.modal('hide');
}
});
}
// 保存角色
function saveRole(roleData) {
// 实际应用中应从后端保存
// fetch('/user/role/save', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// 'X-Requested-With': 'XMLHttpRequest'
// },
// body: JSON.stringify(roleData)
// })
// .then(response => response.json())
// .then(data => {
// if (data.success) {
// showAlert(data.message, 'success');
// setTimeout(() => {
// window.location.reload();
// }, 1500);
// } else {
// showAlert(data.message, 'error');
// }
// });
// 模拟成功响应
setTimeout(() => {
showAlert('角色保存成功!', 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
}, 500);
}
// 删除角色
function deleteRole(roleId) {
// 实际应用中应从后端删除
// fetch(`/user/role/delete/${roleId}`, {
// method: 'POST',
// headers: {
// 'X-Requested-With': 'XMLHttpRequest'
// }
// })
// .then(response => response.json())
// .then(data => {
// if (data.success) {
// showAlert(data.message, 'success');
// setTimeout(() => {
// window.location.reload();
// }, 1500);
// } else {
// showAlert(data.message, 'error');
// }
// });
// 模拟成功响应
setTimeout(() => {
showAlert('角色删除成功!', 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
}, 500);
}
// 获取角色用户数量
function fetchRoleUserCounts() {
const roleCards = document.querySelectorAll('.role-card');
roleCards.forEach(card => {
const roleId = card.getAttribute('data-id');
const countElement = document.getElementById(`userCount-${roleId}`);
// 实际应用中应从后端获取
// fetch(`/api/role/${roleId}/user-count`)
// .then(response => response.json())
// .then(data => {
// countElement.textContent = data.count;
// });
// 模拟数据
setTimeout(() => {
const count = roleId == 1 ? 1 : (roleId == 2 ? 42 : Math.floor(Math.random() * 10));
if (countElement) countElement.textContent = count;
}, 300);
});
}
// 显示通知
function showAlert(message, type) {
// 检查是否已有通知元素
let alertBox = document.querySelector('.alert-box');
if (!alertBox) {
alertBox = document.createElement('div');
alertBox.className = 'alert-box';
document.body.appendChild(alertBox);
}
// 创建新的通知
const alert = document.createElement('div');
alert.className = `alert alert-${type === 'success' ? 'success' : 'danger'} fade-in`;
alert.innerHTML = message;
// 添加到通知框中
alertBox.appendChild(alert);
// 自动关闭
setTimeout(() => {
alert.classList.add('fade-out');
setTimeout(() => {
alertBox.removeChild(alert);
}, 500);
}, 3000);
}
});
================================================================================
File: ./app/static/js/user-list.js
================================================================================
// 用户列表页面交互
document.addEventListener('DOMContentLoaded', function() {
// 处理状态切换按钮
const toggleStatusButtons = document.querySelectorAll('.toggle-status');
toggleStatusButtons.forEach(button => {
button.addEventListener('click', function() {
const userId = this.getAttribute('data-id');
const newStatus = parseInt(this.getAttribute('data-status'));
const statusText = newStatus === 1 ? '启用' : '禁用';
if (confirm(`确定要${statusText}该用户吗?`)) {
toggleUserStatus(userId, newStatus);
}
});
});
// 处理删除按钮
const deleteButtons = document.querySelectorAll('.delete-user');
const deleteModal = $('#deleteModal');
let userIdToDelete = null;
deleteButtons.forEach(button => {
button.addEventListener('click', function() {
userIdToDelete = this.getAttribute('data-id');
deleteModal.modal('show');
});
});
// 确认删除按钮
document.getElementById('confirmDelete').addEventListener('click', function() {
if (userIdToDelete) {
deleteUser(userIdToDelete);
deleteModal.modal('hide');
}
});
// 自动提交表单的下拉菜单
const autoSubmitSelects = document.querySelectorAll('select[name="status"], select[name="role_id"]');
autoSubmitSelects.forEach(select => {
select.addEventListener('change', function() {
this.closest('form').submit();
});
});
});
// 切换用户状态
function toggleUserStatus(userId, status) {
fetch(`/user/status/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ status: status })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showAlert('操作失败,请稍后重试', 'error');
});
}
// 删除用户
function deleteUser(userId) {
fetch(`/user/delete/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
console.error('Error:', error);
showAlert('操作失败,请稍后重试', 'error');
});
}
// 显示通知
function showAlert(message, type) {
// 检查是否已有通知元素
let alertBox = document.querySelector('.alert-box');
if (!alertBox) {
alertBox = document.createElement('div');
alertBox.className = 'alert-box';
document.body.appendChild(alertBox);
}
// 创建新的通知
const alert = document.createElement('div');
alert.className = `alert alert-${type === 'success' ? 'success' : 'danger'} fade-in`;
alert.innerHTML = message;
// 添加到通知框中
alertBox.appendChild(alert);
// 自动关闭
setTimeout(() => {
alert.classList.add('fade-out');
setTimeout(() => {
alertBox.removeChild(alert);
}, 500);
}, 3000);
}
================================================================================
File: ./app/static/js/main.js
================================================================================
// 主JS文件 - 包含登录和注册功能
document.addEventListener('DOMContentLoaded', function() {
// 主题切换
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
const body = document.body;
themeToggle.addEventListener('click', function() {
body.classList.toggle('dark-mode');
const isDarkMode = body.classList.contains('dark-mode');
localStorage.setItem('dark-mode', isDarkMode);
themeToggle.innerHTML = isDarkMode ? '🌙' : '☀️';
});
// 从本地存储中加载主题首选项
const savedDarkMode = localStorage.getItem('dark-mode') === 'true';
if (savedDarkMode) {
body.classList.add('dark-mode');
themeToggle.innerHTML = '🌙';
}
}
// 密码可见性切换
const passwordToggle = document.getElementById('password-toggle');
if (passwordToggle) {
const passwordInput = document.getElementById('password');
passwordToggle.addEventListener('click', function() {
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
passwordToggle.innerHTML = type === 'password' ? '👁️' : '👁️‍🗨️';
});
}
// 登录表单验证
const loginForm = document.getElementById('login-form');
if (loginForm) {
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const usernameError = document.getElementById('username-error');
const passwordError = document.getElementById('password-error');
const loginButton = document.getElementById('login-button');
if (usernameInput && usernameError) {
usernameInput.addEventListener('input', function() {
if (usernameInput.value.trim() === '') {
usernameError.textContent = '用户名不能为空';
usernameError.classList.add('show');
} else {
usernameError.classList.remove('show');
}
});
}
if (passwordInput && passwordError) {
passwordInput.addEventListener('input', function() {
if (passwordInput.value.trim() === '') {
passwordError.textContent = '密码不能为空';
passwordError.classList.add('show');
} else if (passwordInput.value.length < 6) {
passwordError.textContent = '密码长度至少6位';
passwordError.classList.add('show');
} else {
passwordError.classList.remove('show');
}
});
}
loginForm.addEventListener('submit', function(e) {
let isValid = true;
// 验证用户名
if (usernameInput.value.trim() === '') {
usernameError.textContent = '用户名不能为空';
usernameError.classList.add('show');
isValid = false;
}
// 验证密码
if (passwordInput.value.trim() === '') {
passwordError.textContent = '密码不能为空';
passwordError.classList.add('show');
isValid = false;
} else if (passwordInput.value.length < 6) {
passwordError.textContent = '密码长度至少6位';
passwordError.classList.add('show');
isValid = false;
}
if (!isValid) {
e.preventDefault();
} else if (loginButton) {
loginButton.classList.add('loading-state');
}
});
}
// 注册表单验证
const registerForm = document.getElementById('register-form');
if (registerForm) {
const usernameInput = document.getElementById('username');
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
const confirmPasswordInput = document.getElementById('confirm_password');
const verificationCodeInput = document.getElementById('verification_code');
const usernameError = document.getElementById('username-error');
const emailError = document.getElementById('email-error');
const passwordError = document.getElementById('password-error');
const confirmPasswordError = document.getElementById('confirm-password-error');
const verificationCodeError = document.getElementById('verification-code-error');
const registerButton = document.getElementById('register-button');
const sendCodeBtn = document.getElementById('send-code-btn');
// 用户名验证
if (usernameInput && usernameError) {
usernameInput.addEventListener('input', function() {
if (usernameInput.value.trim() === '') {
usernameError.textContent = '用户名不能为空';
usernameError.classList.add('show');
} else if (usernameInput.value.length < 3) {
usernameError.textContent = '用户名至少3个字符';
usernameError.classList.add('show');
} else {
usernameError.classList.remove('show');
}
});
}
// 邮箱验证
if (emailInput && emailError) {
emailInput.addEventListener('input', function() {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailInput.value.trim() === '') {
emailError.textContent = '邮箱不能为空';
emailError.classList.add('show');
} else if (!emailRegex.test(emailInput.value)) {
emailError.textContent = '请输入有效的邮箱地址';
emailError.classList.add('show');
} else {
emailError.classList.remove('show');
}
});
}
// 密码验证
if (passwordInput && passwordError) {
passwordInput.addEventListener('input', function() {
if (passwordInput.value.trim() === '') {
passwordError.textContent = '密码不能为空';
passwordError.classList.add('show');
} else if (passwordInput.value.length < 6) {
passwordError.textContent = '密码长度至少6位';
passwordError.classList.add('show');
} else {
passwordError.classList.remove('show');
}
// 检查确认密码是否匹配
if (confirmPasswordInput && confirmPasswordInput.value) {
if (confirmPasswordInput.value !== passwordInput.value) {
confirmPasswordError.textContent = '两次输入的密码不匹配';
confirmPasswordError.classList.add('show');
} else {
confirmPasswordError.classList.remove('show');
}
}
});
}
// 确认密码验证
if (confirmPasswordInput && confirmPasswordError) {
confirmPasswordInput.addEventListener('input', function() {
if (confirmPasswordInput.value.trim() === '') {
confirmPasswordError.textContent = '请确认密码';
confirmPasswordError.classList.add('show');
} else if (confirmPasswordInput.value !== passwordInput.value) {
confirmPasswordError.textContent = '两次输入的密码不匹配';
confirmPasswordError.classList.add('show');
} else {
confirmPasswordError.classList.remove('show');
}
});
}
// 发送验证码按钮
if (sendCodeBtn) {
sendCodeBtn.addEventListener('click', function() {
const email = emailInput.value.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
emailError.textContent = '请输入邮箱地址';
emailError.classList.add('show');
return;
} else if (!emailRegex.test(email)) {
emailError.textContent = '请输入有效的邮箱地址';
emailError.classList.add('show');
return;
}
// 禁用按钮并显示倒计时
let countdown = 60;
sendCodeBtn.disabled = true;
const originalText = sendCodeBtn.textContent;
sendCodeBtn.textContent = `${countdown}秒后重试`;
const timer = setInterval(() => {
countdown--;
sendCodeBtn.textContent = `${countdown}秒后重试`;
if (countdown <= 0) {
clearInterval(timer);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = originalText;
}
}, 1000);
// 发送请求获取验证码
fetch('/user/send_verification_code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: email }),
})
.then(response => response.json())
.then(data => {
console.log("验证码发送响应:", data); // 添加调试日志
if (data.success) {
showMessage('验证码已发送', '请检查您的邮箱', 'success');
} else {
showMessage('发送失败', data.message || '请稍后重试', 'error');
clearInterval(timer);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = originalText;
}
})
.catch(error => {
console.error('Error:', error);
showMessage('发送失败', '网络错误,请稍后重试', 'error');
clearInterval(timer);
sendCodeBtn.disabled = false;
sendCodeBtn.textContent = originalText;
});
});
}
// 表单提交验证
registerForm.addEventListener('submit', function(e) {
let isValid = true;
// 验证用户名
if (usernameInput.value.trim() === '') {
usernameError.textContent = '用户名不能为空';
usernameError.classList.add('show');
isValid = false;
} else if (usernameInput.value.length < 3) {
usernameError.textContent = '用户名至少3个字符';
usernameError.classList.add('show');
isValid = false;
}
// 验证邮箱
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (emailInput.value.trim() === '') {
emailError.textContent = '邮箱不能为空';
emailError.classList.add('show');
isValid = false;
} else if (!emailRegex.test(emailInput.value)) {
emailError.textContent = '请输入有效的邮箱地址';
emailError.classList.add('show');
isValid = false;
}
// 验证密码
if (passwordInput.value.trim() === '') {
passwordError.textContent = '密码不能为空';
passwordError.classList.add('show');
isValid = false;
} else if (passwordInput.value.length < 6) {
passwordError.textContent = '密码长度至少6位';
passwordError.classList.add('show');
isValid = false;
}
// 验证确认密码
if (confirmPasswordInput.value.trim() === '') {
confirmPasswordError.textContent = '请确认密码';
confirmPasswordError.classList.add('show');
isValid = false;
} else if (confirmPasswordInput.value !== passwordInput.value) {
confirmPasswordError.textContent = '两次输入的密码不匹配';
confirmPasswordError.classList.add('show');
isValid = false;
}
// 验证验证码
if (verificationCodeInput.value.trim() === '') {
verificationCodeError.textContent = '请输入验证码';
verificationCodeError.classList.add('show');
isValid = false;
}
if (!isValid) {
e.preventDefault();
} else if (registerButton) {
registerButton.classList.add('loading-state');
}
});
}
// 通知消息显示函数
function showMessage(title, message, type) {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
const icon = type === 'success' ? '✓' : '✗';
notification.innerHTML = `
<div class="notification-icon">${icon}</div>
<div class="notification-content">
<h3>${title}</h3>
<p>${message}</p>
</div>
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 10);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
});
================================================================================
File: ./app/static/js/book-add.js
================================================================================
/**
* 图书添加页面脚本
* 处理图书表单的交互、验证和预览功能
*/
let isSubmitting = false;
$(document).ready(function() {
// 全局变量
let cropper;
let coverBlob;
let tags = [];
const coverPreview = $('#coverPreview');
const coverInput = $('#cover');
const tagInput = $('#tagInput');
const tagsContainer = $('#tagsContainer');
const tagsHiddenInput = $('#tags');
// 初始化函数
function initialize() {
initSelect2();
initFormProgress();
initTagsFromInput();
initCoverHandlers();
initNumberControls();
initPriceSlider();
initCharCounter();
initFormValidation();
attachEventListeners();
}
// ========== 组件初始化 ==========
// 初始化Select2
function initSelect2() {
$('.select2').select2({
placeholder: "选择分类...",
allowClear: true,
theme: "classic",
width: '100%'
});
}
// 初始化表单进度条
function initFormProgress() {
updateFormProgress();
$('input, textarea, select').on('change keyup', function() {
updateFormProgress();
});
}
// 初始化标签(从隐藏输入字段)
function initTagsFromInput() {
const tagsValue = $('#tags').val();
if (tagsValue) {
tags = tagsValue.split(',');
renderTags();
}
}
// 初始化封面处理
function initCoverHandlers() {
// 拖放上传功能
coverPreview.on('dragover', function(e) {
e.preventDefault();
$(this).addClass('dragover');
}).on('dragleave drop', function(e) {
e.preventDefault();
$(this).removeClass('dragover');
}).on('drop', function(e) {
e.preventDefault();
const file = e.originalEvent.dataTransfer.files[0];
if (file && file.type.match('image.*')) {
coverInput[0].files = e.originalEvent.dataTransfer.files;
coverInput.trigger('change');
}
}).on('click', function() {
if (!$(this).find('img').length) {
coverInput.click();
}
});
// 重置页面加载完后的字符计数
if ($('#description').val()) {
$('#charCount').text($('#description').val().length);
}
}
// 初始化数字控制
function initNumberControls() {
$('#stockDecrement').on('click', function() {
const input = $('#stock');
const value = parseInt(input.val());
if (value > parseInt(input.attr('min'))) {
input.val(value - 1).trigger('change');
}
});
$('#stockIncrement').on('click', function() {
const input = $('#stock');
const value = parseInt(input.val());
input.val(value + 1).trigger('change');
});
}
// 初始化价格滑块
function initPriceSlider() {
$('#priceRange').on('input', function() {
$('#price').val($(this).val());
});
$('#price').on('input', function() {
const value = parseFloat($(this).val()) || 0;
$('#priceRange').val(Math.min(value, 500));
});
}
// 初始化字符计数器
function initCharCounter() {
$('#description').on('input', function() {
const count = $(this).val().length;
$('#charCount').text(count);
if (count > 2000) {
$('#charCount').addClass('text-danger');
} else {
$('#charCount').removeClass('text-danger');
}
});
}
// 初始化表单验证
ffunction initFormValidation() {
$('#bookForm').on('submit', function(e) {
// 如果表单正在提交中,阻止重复提交
if (isSubmitting) {
e.preventDefault();
showNotification('表单正在提交中,请勿重复点击', 'warning');
return false;
}
let isValid = true;
$('[required]').each(function() {
if (!$(this).val().trim()) {
isValid = false;
$(this).addClass('is-invalid');
// 添加错误提示
if (!$(this).next('.invalid-feedback').length) {
$(this).after(`<div class="invalid-feedback">此字段不能为空</div>`);
}
} else {
$(this).removeClass('is-invalid').next('.invalid-feedback').remove();
}
});
// 验证ISBN格式如果已填写
const isbn = $('#isbn').val().trim();
if (isbn) {
// 移除所有非数字、X和x字符后检查
const cleanIsbn = isbn.replace(/[^0-9Xx]/g, '');
const isbnRegex = /^(?:\d{10}|\d{13})$|^(?:\d{9}[Xx])$/;
if (!isbnRegex.test(cleanIsbn)) {
isValid = false;
$('#isbn').addClass('is-invalid');
if (!$('#isbn').next('.invalid-feedback').length) {
$('#isbn').after(`<div class="invalid-feedback">ISBN格式不正确应为10位或13位</div>`);
}
}
}
if (!isValid) {
e.preventDefault();
// 滚动到第一个错误字段
$('html, body').animate({
scrollTop: $('.is-invalid:first').offset().top - 100
}, 500);
showNotification('请正确填写所有标记的字段', 'error');
} else {
// 设置表单锁定状态
isSubmitting = true;
// 修改提交按钮样式
const submitBtn = $(this).find('button[type="submit"]');
const originalHtml = submitBtn.html();
submitBtn.prop('disabled', true)
.html('<i class="fas fa-spinner fa-spin"></i> 保存中...');
// 显示提交中通知
showNotification('表单提交中...', 'info');
// 如果表单提交时间过长30秒后自动解锁
setTimeout(function() {
if (isSubmitting) {
isSubmitting = false;
submitBtn.prop('disabled', false).html(originalHtml);
showNotification('提交超时,请重试', 'warning');
}
}, 30000);
}
});
// 输入时移除错误样式
$('input, textarea, select').on('input change', function() {
$(this).removeClass('is-invalid').next('.invalid-feedback').remove();
});
}
// 还需要在服务端处理成功后重置状态
// 在页面加载完成时,添加监听服务器重定向事件
$(window).on('pageshow', function(event) {
if (event.originalEvent.persisted ||
(window.performance && window.performance.navigation.type === 2)) {
// 如果页面是从缓存加载的或通过后退按钮回到的
isSubmitting = false;
$('button[type="submit"]').prop('disabled', false)
.html('<i class="fas fa-save"></i> 保存图书');
}
});
// 绑定事件监听器
function attachEventListeners() {
// 文件选择处理
coverInput.on('change', handleCoverSelect);
// 裁剪控制
$('#rotateLeft').on('click', function() { cropper && cropper.rotate(-90); });
$('#rotateRight').on('click', function() { cropper && cropper.rotate(90); });
$('#zoomIn').on('click', function() { cropper && cropper.zoom(0.1); });
$('#zoomOut').on('click', function() { cropper && cropper.zoom(-0.1); });
$('#cropImage').on('click', applyCrop);
$('#removeCover').on('click', removeCover);
// 标签处理
tagInput.on('keydown', handleTagKeydown);
$('#addTagBtn').on('click', addTag);
$(document).on('click', '.tag-remove', removeTag);
// ISBN查询
$('#isbnLookup').on('click', lookupISBN);
// 预览按钮
$('#previewBtn').on('click', showPreview);
// 表单重置
$('#resetBtn').on('click', confirmReset);
}
// ========== 功能函数 ==========
// 更新表单进度条
function updateFormProgress() {
const requiredFields = $('[required]');
const filledFields = requiredFields.filter(function() {
return $(this).val() !== '';
});
const otherFields = $('input:not([required]), textarea:not([required]), select:not([required])').not('[type="file"]');
const filledOtherFields = otherFields.filter(function() {
return $(this).val() !== '';
});
let requiredWeight = 70; // 必填字段权重70%
let otherWeight = 30; // 非必填字段权重30%
let requiredProgress = requiredFields.length ? (filledFields.length / requiredFields.length) * requiredWeight : requiredWeight;
let otherProgress = otherFields.length ? (filledOtherFields.length / otherFields.length) * otherWeight : 0;
let totalProgress = Math.floor(requiredProgress + otherProgress);
$('#formProgress').css('width', totalProgress + '%').attr('aria-valuenow', totalProgress);
$('#progressText').text('完成 ' + totalProgress + '%');
if (totalProgress >= 100) {
$('.btn-primary').addClass('pulse');
} else {
$('.btn-primary').removeClass('pulse');
}
}
// 处理封面选择
function handleCoverSelect(e) {
const file = e.target.files[0];
if (!file) return;
// 验证文件类型
if (!file.type.match('image.*')) {
showNotification('请选择图片文件', 'warning');
return;
}
// 验证文件大小最大5MB
if (file.size > 5 * 1024 * 1024) {
showNotification('图片大小不能超过5MB', 'warning');
return;
}
const reader = new FileReader();
reader.onload = function(e) {
// 先显示在预览框中,确保用户能立即看到上传的图片
coverPreview.html(`<img src="${e.target.result}" class="cover-image" alt="图书封面预览">`);
// 准备裁剪图片
$('#cropperImage').attr('src', e.target.result);
// 确保图片加载完成后再显示模态框
$('#cropperImage').on('load', function() {
// 打开模态框
$('#cropperModal').modal('show');
// 在模态框完全显示后初始化裁剪器
$('#cropperModal').on('shown.bs.modal', function() {
if (cropper) {
cropper.destroy();
}
try {
cropper = new Cropper(document.getElementById('cropperImage'), {
aspectRatio: 5 / 7,
viewMode: 2,
responsive: true,
guides: true,
background: true,
ready: function() {
console.log('Cropper初始化成功');
}
});
} catch (err) {
console.error('Cropper初始化失败:', err);
showNotification('图片处理工具初始化失败,请重试', 'error');
}
});
});
};
// 处理读取错误
reader.onerror = function() {
showNotification('读取图片失败,请重试', 'error');
};
reader.readAsDataURL(file);
}
// 应用裁剪
function applyCrop() {
if (!cropper) {
showNotification('图片处理工具未就绪,请重新上传', 'error');
$('#cropperModal').modal('hide');
return;
}
try {
const canvas = cropper.getCroppedCanvas({
width: 500,
height: 700,
fillColor: '#fff',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
if (!canvas) {
throw new Error('无法生成裁剪后的图片');
}
canvas.toBlob(function(blob) {
if (!blob) {
showNotification('图片处理失败,请重试', 'error');
return;
}
const url = URL.createObjectURL(blob);
coverPreview.html(`<img src="${url}" class="cover-image" alt="图书封面">`);
coverBlob = blob;
// 模拟File对象
const fileList = new DataTransfer();
const file = new File([blob], "cover.jpg", {type: "image/jpeg"});
fileList.items.add(file);
document.getElementById('cover').files = fileList.files;
$('#cropperModal').modal('hide');
showNotification('封面图片已更新', 'success');
}, 'image/jpeg', 0.95);
} catch (err) {
console.error('裁剪失败:', err);
showNotification('图片裁剪失败,请重试', 'error');
$('#cropperModal').modal('hide');
}
}
// 移除封面
function removeCover() {
coverPreview.html(`
<div class="no-cover-placeholder">
<i class="fas fa-book-open"></i>
<span>暂无封面</span>
<p class="placeholder-tip">点击上传或拖放图片至此处</p>
</div>
`);
coverInput.val('');
coverBlob = null;
}
// 渲染标签
function renderTags() {
tagsContainer.empty();
tags.forEach(tag => {
tagsContainer.append(`
<div class="tag">
<span class="tag-text">${tag}</span>
<button type="button" class="tag-remove" data-tag="${tag}">
<i class="fas fa-times"></i>
</button>
</div>
`);
});
tagsHiddenInput.val(tags.join(','));
}
// 添加标签
function addTag() {
const tag = tagInput.val().trim();
if (tag && !tags.includes(tag)) {
tags.push(tag);
renderTags();
tagInput.val('').focus();
}
}
// 处理标签输入键盘事件
function handleTagKeydown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag();
}
}
// 移除标签
function removeTag() {
const tagToRemove = $(this).data('tag');
tags = tags.filter(t => t !== tagToRemove);
renderTags();
}
// ISBN查询
function lookupISBN() {
const isbn = $('#isbn').val().trim();
if (!isbn) {
showNotification('请先输入ISBN', 'warning');
return;
}
// 验证ISBN格式
const cleanIsbn = isbn.replace(/[^0-9Xx]/g, '');
const isbnRegex = /^(?:\d{10}|\d{13})$|^(?:\d{9}[Xx])$/;
if (!isbnRegex.test(cleanIsbn)) {
showNotification('ISBN格式不正确应为10位或13位', 'warning');
return;
}
$(this).html('<i class="fas fa-spinner fa-spin"></i>');
// 先检查ISBN是否已存在
$.get('/book/api/check-isbn', {isbn: isbn}, function(data) {
if (data.exists) {
$('#isbnLookup').html('<i class="fas fa-search"></i>');
showNotification(`ISBN "${isbn}" 已存在: 《${data.book_title}》`, 'warning');
$('#isbn').addClass('is-invalid');
if (!$('#isbn').next('.invalid-feedback').length) {
$('#isbn').after(`<div class="invalid-feedback">此ISBN已被图书《${data.book_title}》使用</div>`);
}
} else {
// 继续查询外部API模拟
simulateISBNLookup(isbn);
}
}).fail(function() {
$('#isbnLookup').html('<i class="fas fa-search"></i>');
showNotification('服务器查询失败,请稍后再试', 'error');
});
}
// 模拟ISBN查询
function simulateISBNLookup(isbn) {
// 模拟API查询延迟
setTimeout(() => {
// 模拟查到的数据
if (isbn === '9787020002207') {
$('#title').val('红楼梦').trigger('blur');
$('#author').val('曹雪芹').trigger('blur');
$('#publisher').val('人民文学出版社').trigger('blur');
$('#publish_year').val('1996').trigger('blur');
$('#category_id').val('1').trigger('change');
tags = ['中国文学', '古典', '名著'];
renderTags();
$('#description').val('《红楼梦》是中国古代章回体长篇小说中国古典四大名著之一通行本共120回一般认为前80回是清代作家曹雪芹所著后40回作者有争议。小说以贾、史、王、薛四大家族的兴衰为背景以贾府的家庭琐事、闺阁闲情为脉络以贾宝玉、林黛玉、薛宝钗的爱情婚姻悲剧为主线刻画了以贾宝玉和金陵十二钗为中心的正邪两赋有情人的人性美和悲剧美。').trigger('input');
$('#price').val('59.70').trigger('input');
$('#priceRange').val('59.70');
showNotification('ISBN查询成功', 'success');
} else if (isbn === '9787544270878') {
$('#title').val('挪威的森林').trigger('blur');
$('#author').val('村上春树').trigger('blur');
$('#publisher').val('南海出版社').trigger('blur');
$('#publish_year').val('2017').trigger('blur');
$('#category_id').val('2').trigger('change');
tags = ['外国文学', '日本', '小说'];
renderTags();
$('#description').val('《挪威的森林》是日本作家村上春树创作的长篇小说首次出版于1987年。小说讲述了一个悲伤的爱情故事背景设定在20世纪60年代末的日本。主人公渡边纠缠在与平静的直子和开朗的绿子两人的感情中最终选择了生活。').trigger('input');
$('#price').val('39.50').trigger('input');
$('#priceRange').val('39.50');
showNotification('ISBN查询成功', 'success');
} else {
showNotification('未找到相关图书信息', 'warning');
}
$('#isbnLookup').html('<i class="fas fa-search"></i>');
updateFormProgress();
}, 1500);
}
// 显示预览
function showPreview() {
// 检查必填字段
if (!$('#title').val().trim() || !$('#author').val().trim()) {
showNotification('请至少填写书名和作者后再预览', 'warning');
return;
}
try {
// 确保所有值都有默认值防止undefined错误
const title = $('#title').val() || '未填写标题';
const author = $('#author').val() || '未填写作者';
const publisher = $('#publisher').val() || '-';
const isbn = $('#isbn').val() || '-';
const publishYear = $('#publish_year').val() || '-';
const description = $('#description').val() || '';
const stock = $('#stock').val() || '0';
let price = parseFloat($('#price').val()) || 0;
// 填充预览内容
$('#previewTitle').text(title);
$('#previewAuthor').text(author ? '作者: ' + author : '未填写作者');
$('#previewPublisher').text(publisher);
$('#previewISBN').text(isbn);
$('#previewYear').text(publishYear);
// 获取分类文本
const categoryId = $('#category_id').val();
const categoryText = categoryId ? $('#category_id option:selected').text() : '-';
$('#previewCategory').text(categoryText);
// 价格和库存
$('#previewPrice').text(price ? '¥' + price.toFixed(2) : '¥0.00');
$('#previewStock').text('库存: ' + stock);
// 标签
const previewTags = $('#previewTags');
previewTags.empty();
if (tags && tags.length > 0) {
tags.forEach(tag => {
previewTags.append(`<span class="preview-tag">${tag}</span>`);
});
} else {
previewTags.append('<span class="no-tags">暂无标签</span>');
}
// 描述
if (description) {
$('#previewDescription').html(`<p>${description.replace(/\n/g, '<br>')}</p>`);
} else {
$('#previewDescription').html(`<p class="placeholder-text">暂无简介内容</p>`);
}
// 封面
const previewCover = $('#previewCover');
previewCover.empty(); // 清空现有内容
if ($('#coverPreview img').length) {
const coverSrc = $('#coverPreview img').attr('src');
previewCover.html(`<img src="${coverSrc}" class="preview-cover-img" alt="封面预览">`);
} else {
previewCover.html(`
<div class="no-cover-placeholder">
<i class="fas fa-book-open"></i>
<span>暂无封面</span>
</div>
`);
}
// 显示预览模态框
$('#previewModal').modal('show');
console.log('预览模态框已显示');
} catch (err) {
console.error('生成预览时发生错误:', err);
showNotification('生成预览时出错,请重试', 'error');
}
}
// 确认重置表单
function confirmReset() {
if (confirm('确定要重置表单吗?所有已填写的内容将被清空。')) {
$('#bookForm')[0].reset();
removeCover();
tags = [];
renderTags();
updateFormProgress();
$('.select2').val(null).trigger('change');
$('#charCount').text('0');
showNotification('表单已重置', 'info');
}
}
// 通知提示函数
function showNotification(message, type) {
// 创建通知元素
const notification = $(`
<div class="notification ${type}-notification animate__animated animate__fadeInRight">
<div class="notification-icon">
<i class="fas ${getIconForType(type)}"></i>
</div>
<div class="notification-content">
<p>${message}</p>
</div>
<button class="notification-close">
<i class="fas fa-times"></i>
</button>
</div>
`);
// 添加到页面
if ($('.notification-container').length === 0) {
$('body').append('<div class="notification-container"></div>');
}
$('.notification-container').append(notification);
// 自动关闭
setTimeout(() => {
notification.removeClass('animate__fadeInRight').addClass('animate__fadeOutRight');
setTimeout(() => {
notification.remove();
}, 500);
}, 5000);
// 点击关闭
notification.find('.notification-close').on('click', function() {
notification.removeClass('animate__fadeInRight').addClass('animate__fadeOutRight');
setTimeout(() => {
notification.remove();
}, 500);
});
}
function getIconForType(type) {
switch(type) {
case 'success': return 'fa-check-circle';
case 'warning': return 'fa-exclamation-triangle';
case 'error': return 'fa-times-circle';
case 'info':
default: return 'fa-info-circle';
}
}
// 初始化页面
initialize();
});
================================================================================
File: ./app/static/js/user-profile.js
================================================================================
// 用户个人中心页面交互
document.addEventListener('DOMContentLoaded', function() {
// 获取表单对象
const profileForm = document.getElementById('profileForm');
const passwordForm = document.getElementById('passwordForm');
// 表单验证逻辑
if (profileForm) {
profileForm.addEventListener('submit', function(event) {
let valid = true;
// 清除之前的错误提示
clearValidationErrors();
// 验证邮箱
const emailField = document.getElementById('email');
if (emailField.value.trim() !== '') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailField.value.trim())) {
showError(emailField, '请输入有效的邮箱地址');
valid = false;
}
}
// 验证手机号
const phoneField = document.getElementById('phone');
if (phoneField.value.trim() !== '') {
const phonePattern = /^1[3456789]\d{9}$/;
if (!phonePattern.test(phoneField.value.trim())) {
showError(phoneField, '请输入有效的手机号码');
valid = false;
}
}
if (!valid) {
event.preventDefault();
}
});
}
// 密码修改表单验证
if (passwordForm) {
passwordForm.addEventListener('submit', function(event) {
let valid = true;
// 清除之前的错误提示
clearValidationErrors();
// 验证当前密码
const currentPasswordField = document.getElementById('current_password');
if (currentPasswordField.value.trim() === '') {
showError(currentPasswordField, '请输入当前密码');
valid = false;
}
// 验证新密码
const newPasswordField = document.getElementById('new_password');
if (newPasswordField.value.trim() === '') {
showError(newPasswordField, '请输入新密码');
valid = false;
} else if (newPasswordField.value.length < 6) {
showError(newPasswordField, '密码长度至少为6个字符');
valid = false;
}
// 验证确认密码
const confirmPasswordField = document.getElementById('confirm_password');
if (confirmPasswordField.value.trim() === '') {
showError(confirmPasswordField, '请确认新密码');
valid = false;
} else if (confirmPasswordField.value !== newPasswordField.value) {
showError(confirmPasswordField, '两次输入的密码不一致');
valid = false;
}
if (!valid) {
event.preventDefault();
}
});
}
// 获取用户统计数据
fetchUserStats();
// 获取用户活动记录
const activityFilter = document.getElementById('activityFilter');
if (activityFilter) {
// 初始加载
fetchUserActivities('all');
// 监听过滤器变化
activityFilter.addEventListener('change', function() {
fetchUserActivities(this.value);
});
}
// 处理URL中的tab参数
const urlParams = new URLSearchParams(window.location.search);
const tabParam = urlParams.get('tab');
if (tabParam) {
const tabElement = document.getElementById(`${tabParam}-tab`);
if (tabElement) {
$('#profileTabs a[href="#' + tabParam + '"]').tab('show');
}
}
// 清除表单验证错误
function clearValidationErrors() {
const invalidFields = document.querySelectorAll('.is-invalid');
const feedbackElements = document.querySelectorAll('.invalid-feedback');
invalidFields.forEach(field => {
field.classList.remove('is-invalid');
});
feedbackElements.forEach(element => {
element.parentNode.removeChild(element);
});
}
// 显示错误消息
function showError(field, message) {
field.classList.add('is-invalid');
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.innerText = message;
field.parentNode.appendChild(feedback);
}
// 获取用户统计数据
function fetchUserStats() {
// 这里使用虚拟数据,实际应用中应当从后端获取
// fetch('/api/user/stats')
// .then(response => response.json())
// .then(data => {
// updateUserStats(data);
// });
// 模拟数据
setTimeout(() => {
const mockData = {
borrow: 2,
returned: 15,
overdue: 0
};
updateUserStats(mockData);
}, 500);
}
// 更新用户统计显示
function updateUserStats(data) {
const borrowCount = document.getElementById('borrowCount');
const returnedCount = document.getElementById('returnedCount');
const overdueCount = document.getElementById('overdueCount');
if (borrowCount) borrowCount.textContent = data.borrow;
if (returnedCount) returnedCount.textContent = data.returned;
if (overdueCount) overdueCount.textContent = data.overdue;
}
// 获取用户活动记录
function fetchUserActivities(type) {
const timelineContainer = document.getElementById('activityTimeline');
if (!timelineContainer) return;
// 显示加载中
timelineContainer.innerHTML = `
<div class="timeline-loading">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
<p>加载中...</p>
</div>
`;
// 实际应用中应当从后端获取
// fetch(`/api/user/activities?type=${type}`)
// .then(response => response.json())
// .then(data => {
// renderActivityTimeline(data, timelineContainer);
// });
// 模拟数据
setTimeout(() => {
const mockActivities = [
{
id: 1,
type: 'login',
title: '系统登录',
details: '成功登录系统',
time: '2023-04-28 15:30:22',
ip: '192.168.1.1'
},
{
id: 2,
type: 'borrow',
title: '借阅图书',
details: '借阅《JavaScript高级编程》',
time: '2023-04-27 11:45:10',
book_id: 101
},
{
id: 3,
type: 'return',
title: '归还图书',
details: '归还《Python数据分析》',
time: '2023-04-26 09:15:33',
book_id: 95
},
{
id: 4,
type: 'login',
title: '系统登录',
details: '成功登录系统',
time: '2023-04-25 08:22:15',
ip: '192.168.1.1'
}
];
// 根据筛选条件过滤活动
let filteredActivities = mockActivities;
if (type !== 'all') {
filteredActivities = mockActivities.filter(activity => activity.type === type);
}
renderActivityTimeline(filteredActivities, timelineContainer);
}, 800);
}
// 渲染活动时间线
function renderActivityTimeline(activities, container) {
if (!activities || activities.length === 0) {
container.innerHTML = '<div class="text-center p-4">暂无活动记录</div>';
return;
}
let timelineHTML = '';
activities.forEach((activity, index) => {
let iconClass = 'fas fa-info';
if (activity.type === 'login') {
iconClass = 'fas fa-sign-in-alt';
} else if (activity.type === 'borrow') {
iconClass = 'fas fa-book';
} else if (activity.type === 'return') {
iconClass = 'fas fa-undo';
}
const isLast = index === activities.length - 1;
timelineHTML += `
<div class="timeline-item ${isLast ? 'last' : ''} timeline-type-${activity.type}">
<div class="timeline-icon">
<i class="${iconClass}"></i>
</div>
<div class="timeline-content">
<div class="timeline-header">
<h5 class="timeline-title">${activity.title}</h5>
<div class="timeline-time">${activity.time}</div>
</div>
<div class="timeline-details">
${activity.details}
${activity.ip ? `<div class="text-muted small">IP: ${activity.ip}</div>` : ''}
</div>
</div>
</div>
`;
});
container.innerHTML = timelineHTML;
}
});
================================================================================
File: ./app/static/js/book-list.js
================================================================================
// 图书列表页面脚本
$(document).ready(function() {
// 处理分类筛选
function setFilter(button, categoryId) {
// 移除所有按钮的活跃状态
$('.filter-btn').removeClass('active');
// 为当前点击的按钮添加活跃状态
$(button).addClass('active');
// 设置隐藏的分类ID输入值
$('#category_id').val(categoryId);
// 提交表单
$(button).closest('form').submit();
}
// 处理排序方向切换
function toggleSortDirection(button) {
const $button = $(button);
const isAsc = $button.hasClass('asc');
// 切换方向类
$button.toggleClass('asc desc');
// 更新图标
if (isAsc) {
$button.find('i').removeClass('fa-sort-amount-up').addClass('fa-sort-amount-down');
$('#sort_order').val('desc');
} else {
$button.find('i').removeClass('fa-sort-amount-down').addClass('fa-sort-amount-up');
$('#sort_order').val('asc');
}
// 提交表单
$button.closest('form').submit();
}
// 将函数暴露到全局作用域
window.setFilter = setFilter;
window.toggleSortDirection = toggleSortDirection;
// 处理删除图书
let bookIdToDelete = null;
$('.delete-book').click(function(e) {
e.preventDefault();
bookIdToDelete = $(this).data('id');
const bookTitle = $(this).data('title');
$('#deleteBookTitle').text(bookTitle);
$('#deleteModal').modal('show');
});
$('#confirmDelete').click(function() {
if (!bookIdToDelete) return;
$.ajax({
url: `/book/delete/${bookIdToDelete}`,
type: 'POST',
success: function(response) {
if (response.success) {
$('#deleteModal').modal('hide');
// 显示成功消息
showNotification(response.message, 'success');
// 移除图书卡片
$(`.book-card[data-id="${bookIdToDelete}"]`).fadeOut(300, function() {
$(this).remove();
});
setTimeout(() => {
if ($('.book-card').length === 0) {
location.reload(); // 如果没有图书了,刷新页面显示"无图书"提示
}
}, 500);
} else {
$('#deleteModal').modal('hide');
showNotification(response.message, 'error');
}
},
error: function() {
$('#deleteModal').modal('hide');
showNotification('删除操作失败,请稍后重试', 'error');
}
});
});
// 处理借阅图书
$('.borrow-book').click(function(e) {
e.preventDefault();
const bookId = $(this).data('id');
$.ajax({
url: `/borrow/add/${bookId}`,
type: 'POST',
success: function(response) {
if (response.success) {
showNotification(response.message, 'success');
// 可以更新UI显示比如更新库存或禁用借阅按钮
setTimeout(() => {
location.reload();
}, 800);
} else {
showNotification(response.message, 'error');
}
},
error: function() {
showNotification('借阅操作失败,请稍后重试', 'error');
}
});
});
// 显示通知
function showNotification(message, type) {
// 移除可能存在的旧通知
$('.notification-alert').remove();
const alertClass = type === 'success' ? 'notification-success' : 'notification-error';
const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
const notification = `
<div class="notification-alert ${alertClass}">
<div class="notification-icon">
<i class="fas ${iconClass}"></i>
</div>
<div class="notification-message">${message}</div>
<button class="notification-close">
<i class="fas fa-times"></i>
</button>
</div>
`;
$('body').append(notification);
// 显示通知
setTimeout(() => {
$('.notification-alert').addClass('show');
}, 10);
// 通知自动关闭
setTimeout(() => {
$('.notification-alert').removeClass('show');
setTimeout(() => {
$('.notification-alert').remove();
}, 300);
}, 4000);
// 点击关闭按钮
$('.notification-close').click(function() {
$(this).closest('.notification-alert').removeClass('show');
setTimeout(() => {
$(this).closest('.notification-alert').remove();
}, 300);
});
}
// 添加通知样式
const notificationCSS = `
.notification-alert {
position: fixed;
top: 20px;
right: 20px;
min-width: 280px;
max-width: 350px;
background-color: white;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
padding: 15px;
transform: translateX(calc(100% + 20px));
transition: transform 0.3s ease;
z-index: 9999;
}
.notification-alert.show {
transform: translateX(0);
}
.notification-success {
border-left: 4px solid var(--success-color);
}
.notification-error {
border-left: 4px solid var(--danger-color);
}
.notification-icon {
margin-right: 15px;
font-size: 24px;
}
.notification-success .notification-icon {
color: var(--success-color);
}
.notification-error .notification-icon {
color: var(--danger-color);
}
.notification-message {
flex: 1;
font-size: 0.95rem;
color: var(--text-color);
}
.notification-close {
background: none;
border: none;
color: var(--text-lighter);
cursor: pointer;
padding: 5px;
margin-left: 10px;
font-size: 0.8rem;
}
.notification-close:hover {
color: var(--text-color);
}
@media (max-width: 576px) {
.notification-alert {
top: auto;
bottom: 20px;
left: 20px;
right: 20px;
min-width: auto;
max-width: none;
transform: translateY(calc(100% + 20px));
}
.notification-alert.show {
transform: translateY(0);
}
}
`;
// 将通知样式添加到头部
$('<style>').text(notificationCSS).appendTo('head');
// 修复图书卡片布局的高度问题
function adjustCardHeights() {
// 重置所有卡片高度
$('.book-card').css('height', 'auto');
// 在大屏幕上应用等高布局
if (window.innerWidth >= 768) {
// 分组按行
const rows = {};
$('.book-card').each(function() {
const offsetTop = $(this).offset().top;
if (!rows[offsetTop]) {
rows[offsetTop] = [];
}
rows[offsetTop].push($(this));
});
// 为每行设置相同高度
Object.keys(rows).forEach(offsetTop => {
const cards = rows[offsetTop];
let maxHeight = 0;
// 找出最大高度
cards.forEach(card => {
const height = card.outerHeight();
if (height > maxHeight) {
maxHeight = height;
}
});
// 应用最大高度
cards.forEach(card => {
card.css('height', maxHeight + 'px');
});
});
}
}
// 初始调整高度
$(window).on('load', adjustCardHeights);
// 窗口大小变化时调整高度
$(window).on('resize', adjustCardHeights);
// 为封面图片添加加载错误处理
$('.book-cover').on('error', function() {
const $this = $(this);
const title = $this.attr('alt') || '图书';
// 替换为默认封面
$this.replaceWith(`
<div class="default-cover">
<i class="fas fa-book"></i>
<span class="default-cover-text">${title.charAt(0)}</span>
</div>
`);
});
// 添加初始动画效果
$('.book-card').each(function(index) {
$(this).css({
'opacity': '0',
'transform': 'translateY(20px)'
});
setTimeout(() => {
$(this).css({
'opacity': '1',
'transform': 'translateY(0)',
'transition': 'opacity 0.5s ease, transform 0.5s ease'
});
}, 50 * index);
});
});
================================================================================
File: ./app/templates/index.html
================================================================================
{% extends 'base.html' %}
{% block title %}首页 - 图书管理系统{% endblock %}
{% block head %}
<!-- 只引用index页面的专用样式 -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
{% endblock %}
{% block content %}
<!-- 欢迎区域 -->
<div class="welcome-section">
<h1>欢迎回来,{{ current_user.username }}</h1>
<p>今天是 <span id="current-date"></span>,祝您使用愉快。</p>
</div>
<!-- 快速统计 -->
<div class="stats-container">
<div class="stat-card">
<i class="fas fa-book stat-icon"></i>
<div class="stat-info">
<h3>馆藏总量</h3>
<p class="stat-number">8,567</p>
</div>
</div>
<div class="stat-card">
<i class="fas fa-users stat-icon"></i>
<div class="stat-info">
<h3>注册用户</h3>
<p class="stat-number">1,245</p>
</div>
</div>
<div class="stat-card">
<i class="fas fa-exchange-alt stat-icon"></i>
<div class="stat-info">
<h3>当前借阅</h3>
<p class="stat-number">352</p>
</div>
</div>
<div class="stat-card">
<i class="fas fa-clock stat-icon"></i>
<div class="stat-info">
<h3>待还图书</h3>
<p class="stat-number">{{ 5 }}</p>
</div>
</div>
</div>
<!-- 主要内容区 -->
<div class="main-sections">
<!-- 最新图书 -->
<div class="content-section book-section">
<div class="section-header">
<h2>最新图书</h2>
<a href="#" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
</div>
<div class="book-grid">
{% for i in range(4) %}
<div class="book-card">
<div class="book-cover">
<img src="https://via.placeholder.com/150x210?text=No+Cover" alt="Book Cover">
</div>
<div class="book-info">
<h3 class="book-title">示例图书标题</h3>
<p class="book-author">作者名</p>
<div class="book-meta">
<span class="book-category">计算机</span>
<span class="book-status available">可借阅</span>
</div>
<button class="borrow-btn">借阅</button>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 通知公告 -->
<div class="content-section notice-section">
<div class="section-header">
<h2>通知公告</h2>
<a href="#" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
</div>
<div class="notice-list">
<div class="notice-item">
<div class="notice-icon"><i class="fas fa-bullhorn"></i></div>
<div class="notice-content">
<h3>关于五一假期图书馆开放时间调整的通知</h3>
<p>五一期间(5月1日-5日)图书馆开放时间调整为上午9:00-下午5:00。</p>
<div class="notice-meta">
<span class="notice-time">2023-04-28</span>
</div>
</div>
</div>
<div class="notice-item">
<div class="notice-icon"><i class="fas fa-bell"></i></div>
<div class="notice-content">
<h3>您有2本图书即将到期</h3>
<p>《Python编程》《算法导论》将于3天后到期请及时归还或办理续借。</p>
<div class="notice-meta">
<span class="notice-time">2023-04-27</span>
<button class="renew-btn">一键续借</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 热门图书区域 -->
<div class="content-section popular-section">
<div class="section-header">
<h2>热门图书</h2>
<a href="#" class="view-all">查看全部 <i class="fas fa-arrow-right"></i></a>
</div>
<div class="popular-books">
{% for i in range(5) %}
<div class="popular-book-item">
<div class="rank-badge">{{ i+1 }}</div>
<div class="book-cover small">
<img src="https://via.placeholder.com/80x120?text=Book" alt="Book Cover">
</div>
<div class="book-details">
<h3 class="book-title">热门图书标题示例</h3>
<p class="book-author">知名作者</p>
<div class="book-stats">
<span><i class="fas fa-eye"></i> 1024 次浏览</span>
<span><i class="fas fa-bookmark"></i> 89 次借阅</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// 设置当前日期
const now = new Date();
const options = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' };
document.getElementById('current-date').textContent = now.toLocaleDateString('zh-CN', options);
});
</script>
{% endblock %}
================================================================================
File: ./app/templates/base.html
================================================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}图书管理系统{% endblock %}</title>
<!-- 通用CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<!-- 页面特定CSS -->
{% block head %}{% endblock %}
</head>
<body>
<div class="app-container">
<!-- 侧边导航栏 -->
<nav class="sidebar">
<div class="logo-container">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo" class="logo">
<h2>图书管理系统</h2>
</div>
<ul class="nav-links">
<li class="{% if request.path == '/' %}active{% endif %}">
<a href="{{ url_for('index') }}"><i class="fas fa-home"></i> 首页</a>
</li>
<li class="{% if '/book/list' in request.path %}active{% endif %}">
<a href="{{ url_for('book.book_list') }}"><i class="fas fa-book"></i> 图书浏览</a>
</li>
<li class="{% if '/borrow' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-bookmark"></i> 我的借阅</a>
</li>
<li class="{% if '/announcement' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-bell"></i> 通知公告</a>
</li>
{% if current_user.role_id == 1 %}
<li class="nav-category">管理功能</li>
<li class="{% if '/user/manage' in request.path %}active{% endif %}">
<a href="{{ url_for('user.user_list') }}"><i class="fas fa-users"></i> 用户管理</a>
</li>
<li class="{% if '/user/roles' in request.path %}active{% endif %}">
<a href="{{ url_for('user.role_list') }}"><i class="fas fa-user-tag"></i> 角色管理</a>
</li>
<li class="{% if '/book/list' in request.path %}active{% endif %}">
<a href="{{ url_for('book.book_list') }}"><i class="fas fa-layer-group"></i> 图书管理</a>
</li>
<li class="{% if '/borrow/manage' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-exchange-alt"></i> 借阅管理</a>
</li>
<li class="{% if '/inventory' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-warehouse"></i> 库存管理</a>
</li>
<li class="{% if '/statistics' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-chart-bar"></i> 统计分析</a>
</li>
<li class="{% if '/log' in request.path %}active{% endif %}">
<a href="#"><i class="fas fa-history"></i> 日志管理</a>
</li>
{% endif %}
</ul>
</nav>
<!-- 主内容区 -->
<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>
<div class="user-menu">
<div class="notifications">
<i class="fas fa-bell"></i>
<span class="badge">3</span>
</div>
<div class="user-info">
<div class="user-avatar">
{{ current_user.username[0] }}
</div>
<div class="user-details">
<span class="user-name">{{ current_user.username }}</span>
<span class="user-role">{{ '管理员' if current_user.role_id == 1 else '普通用户' }}</span>
</div>
<div class="dropdown-menu">
<a href="{{ url_for('user.user_profile') }}"><i class="fas fa-user-circle"></i> 个人中心</a>
<a href="#"><i class="fas fa-cog"></i> 设置</a>
<a href="{{ url_for('user.logout') }}"><i class="fas fa-sign-out-alt"></i> 退出登录</a>
</div>
</div>
</div>
</header>
<!-- 内容区 - 这里是核心变化 -->
<div class="content-wrapper">
{% block content %}
<!-- 子模板将在这里添加内容 -->
{% endblock %}
</div>
</main>
</div>
<!-- 通用JavaScript -->
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 用户菜单下拉
const userInfo = document.querySelector('.user-info');
userInfo.addEventListener('click', function(e) {
userInfo.classList.toggle('active');
});
// 点击其他区域关闭下拉菜单
document.addEventListener('click', function(e) {
if (!userInfo.contains(e.target)) {
userInfo.classList.remove('active');
}
});
});
</script>
<!-- 页面特定JavaScript -->
{% block scripts %}{% endblock %}
</body>
</html>
================================================================================
File: ./app/templates/register.html
================================================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户注册 - 图书管理系统</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/register.css') }}">
</head>
<body>
<div class="overlay"></div>
<div class="theme-toggle" id="theme-toggle">☀️</div>
<div class="main-container">
<div class="login-container register-container">
<div class="logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">
</div>
<h1>图书管理系统</h1>
<p class="subtitle">创建您的新账户</p>
<div id="register-form-container">
<form id="register-form" action="{{ url_for('user.register') }}" method="post">
<div class="form-group">
<label for="username">用户名</label>
<div class="input-with-icon">
<span class="input-icon">👤</span>
<input type="text" id="username" name="username" class="form-control" placeholder="请输入用户名" required>
</div>
<div class="validation-message" id="username-error"></div>
</div>
<div class="form-group">
<label for="email">邮箱</label>
<div class="input-with-icon">
<span class="input-icon">📧</span>
<input type="email" id="email" name="email" class="form-control" placeholder="请输入邮箱" required>
</div>
<div class="validation-message" id="email-error"></div>
</div>
<div class="form-group">
<label for="verification_code">邮箱验证码</label>
<div class="verification-code-container">
<input type="text" id="verification_code" name="verification_code" class="verification-input" placeholder="请输入验证码" required>
<button type="button" id="send-code-btn" class="send-code-btn">发送验证码</button>
</div>
<div class="validation-message" id="verification-code-error"></div>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="input-with-icon">
<span class="input-icon">🔒</span>
<input type="password" id="password" name="password" class="form-control" placeholder="请设置密码" required>
<span class="password-toggle" id="password-toggle">👁️</span>
</div>
<div class="validation-message" id="password-error"></div>
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<div class="input-with-icon">
<span class="input-icon">🔒</span>
<input type="password" id="confirm_password" name="confirm_password" class="form-control" placeholder="请再次输入密码" required>
</div>
<div class="validation-message" id="confirm-password-error"></div>
</div>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<button type="submit" class="btn-login" id="register-button">
<span>注册</span>
<span class="loading">⟳</span>
</button>
</form>
<div class="signup">
已有账号? <a href="{{ url_for('user.login') }}">返回登录</a>
</div>
</div>
</div>
</div>
<footer>
<p>© 施琦图书管理系统 - 版权所有</p>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>
================================================================================
File: ./app/templates/404.html
================================================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>页面未找到 - 图书管理系统</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background-color: #fff5f7;
margin: 0;
padding: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
max-width: 650px;
padding: 40px;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 10px 30px rgba(252, 162, 193, 0.2);
position: relative;
overflow: hidden;
}
.error-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 6px;
background: linear-gradient(to right, #ff8ab3, #f17ab3, #f56eb8);
}
.error-code {
font-size: 120px;
font-weight: bold;
background: linear-gradient(to right, #ff8ab3, #f17ab3, #f56eb8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0 0 20px 0;
line-height: 1;
}
.error-title {
font-size: 28px;
color: #ff8ab3;
margin-bottom: 15px;
font-weight: 600;
}
.error-message {
font-size: 18px;
color: #7a7a7a;
margin-bottom: 30px;
line-height: 1.6;
}
.back-button {
display: inline-block;
padding: 12px 30px;
background: linear-gradient(to right, #ff8ab3, #f17ab3);
color: white;
text-decoration: none;
border-radius: 50px;
font-weight: 500;
letter-spacing: 1px;
box-shadow: 0 5px 15px rgba(241, 122, 179, 0.4);
transition: all 0.3s ease;
}
.back-button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(241, 122, 179, 0.6);
}
.decoration {
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 138, 179, 0.1);
}
.decoration-1 {
top: -20px;
left: -20px;
width: 120px;
height: 120px;
}
.decoration-2 {
bottom: -30px;
right: -30px;
width: 150px;
height: 150px;
}
.decoration-3 {
top: 60%;
left: -40px;
width: 100px;
height: 100px;
}
.book-icon {
margin-bottom: 20px;
width: 80px;
height: 80px;
display: inline-block;
position: relative;
}
.book-icon svg {
fill: #ff8ab3;
}
@media (max-width: 768px) {
.error-code {
font-size: 100px;
}
.error-title {
font-size: 24px;
}
.error-container {
margin: 0 20px;
padding: 30px;
}
}
</style>
</head>
<body>
<div class="error-container">
<div class="decoration decoration-1"></div>
<div class="decoration decoration-2"></div>
<div class="decoration decoration-3"></div>
<div class="book-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h13c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 18H6V4h13v16z"/>
<path d="M9 5h7v2H9zM9 8h7v2H9zM9 11h7v2H9zM9 14h7v2H9z"/>
</svg>
</div>
<div class="error-code">404</div>
<div class="error-title">噢!页面不见了~</div>
<div class="error-message">
<p>抱歉,您要找的页面似乎藏起来了,或者从未存在过。</p>
<p>请检查您输入的网址是否正确,或者回到首页继续浏览吧!</p>
</div>
<a href="{{ url_for('index') }}" class="back-button">返回首页</a>
</div>
</body>
</html>
================================================================================
File: ./app/templates/login.html
================================================================================
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户登录 - 图书管理系统</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
</head>
<body>
<div class="overlay"></div>
<div class="theme-toggle" id="theme-toggle">☀️</div>
<div class="main-container">
<div class="login-container">
<div class="logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">
</div>
<h1>图书管理系统</h1>
<p class="subtitle">欢迎回来,请登录您的账户</p>
<div id="account-login">
<form id="login-form" action="{{ url_for('user.login') }}" method="post">
<div class="form-group">
<label for="username">用户名/邮箱</label>
<div class="input-with-icon">
<span class="input-icon">👤</span>
<input type="text" id="username" name="username" class="form-control" placeholder="请输入账号">
</div>
<div class="validation-message" id="username-error"></div>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="input-with-icon">
<span class="input-icon">🔒</span>
<input type="password" id="password" name="password" class="form-control" placeholder="请输入密码">
<span class="password-toggle" id="password-toggle">👁️</span>
</div>
<div class="validation-message" id="password-error"></div>
</div>
<div class="remember-forgot">
<label class="custom-checkbox">
<input type="checkbox" name="remember_me">
<span class="checkmark"></span>
记住我 (7天内免登录)
</label>
<div class="forgot-password">
<a href="#">忘记密码?</a>
</div>
</div>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<button type="submit" class="btn-login" id="login-button">
<span>登录</span>
<span class="loading">⟳</span>
</button>
</form>
<div class="signup">
还没有账号? <a href="{{ url_for('user.register') }}">立即注册</a>
</div>
<div class="features">
<div class="feature-item">
<span class="feature-icon">🔒</span>
<span>安全登录</span>
</div>
<div class="feature-item">
<span class="feature-icon">🔐</span>
<span>数据加密</span>
</div>
<div class="feature-item">
<span class="feature-icon">📚</span>
<span>图书管理</span>
</div>
</div>
</div>
</div>
</div>
<footer>
<p>© 施琦图书管理系统 - 版权所有</p>
</footer>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body>
</html>
================================================================================
File: ./app/templates/user/profile.html
================================================================================
{% extends "base.html" %}
{% block title %}个人中心 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/user-profile.css') }}">
{% endblock %}
{% block content %}
<div class="profile-container">
<div class="page-header">
<h1>个人中心</h1>
</div>
<div class="profile-content">
<!-- 左侧用户信息展示 -->
<div class="profile-sidebar">
<div class="user-avatar-container">
<div class="user-avatar large">
{{ user.username[0] }}
</div>
<h3 class="user-name">{{ user.nickname or user.username }}</h3>
<p class="user-role">{{ '管理员' if user.role_id == 1 else '普通用户' }}</p>
</div>
<div class="user-stats">
<div class="stat-item">
<div class="stat-value" id="borrowCount">--</div>
<div class="stat-label">借阅中</div>
</div>
<div class="stat-item">
<div class="stat-value" id="returnedCount">--</div>
<div class="stat-label">已归还</div>
</div>
<div class="stat-item">
<div class="stat-value" id="overdueCount">--</div>
<div class="stat-label">已逾期</div>
</div>
</div>
<div class="account-info">
<div class="info-row">
<span class="info-label">用户名</span>
<span class="info-value">{{ user.username }}</span>
</div>
<div class="info-row">
<span class="info-label">用户ID</span>
<span class="info-value">{{ user.id }}</span>
</div>
<div class="info-row">
<span class="info-label">注册时间</span>
<span class="info-value">{{ user.created_at }}</span>
</div>
<div class="info-row">
<span class="info-label">最后更新</span>
<span class="info-value">{{ user.updated_at }}</span>
</div>
</div>
</div>
<!-- 右侧内容区域:包含编辑选项卡 -->
<div class="profile-main">
<!-- 提示消息 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- 选项卡导航 -->
<ul class="nav nav-tabs" id="profileTabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="basic-tab" data-toggle="tab" href="#basic" role="tab" aria-controls="basic" aria-selected="true">
<i class="fas fa-user"></i> 基本信息
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="security-tab" data-toggle="tab" href="#security" role="tab" aria-controls="security" aria-selected="false">
<i class="fas fa-lock"></i> 安全设置
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="activity-tab" data-toggle="tab" href="#activity" role="tab" aria-controls="activity" aria-selected="false">
<i class="fas fa-history"></i> 最近活动
</a>
</li>
</ul>
<!-- 选项卡内容 -->
<div class="tab-content" id="profileTabsContent">
<!-- 基本信息选项卡 -->
<div class="tab-pane fade show active" id="basic" role="tabpanel" aria-labelledby="basic-tab">
<form method="POST" action="{{ url_for('user.user_profile') }}" id="profileForm">
<div class="form-section">
<h4>个人信息</h4>
<div class="form-group">
<label for="nickname">昵称</label>
<input type="text" class="form-control" id="nickname" name="nickname" value="{{ user.nickname or '' }}" placeholder="请输入您的昵称">
</div>
<div class="form-group">
<label for="email">邮箱地址</label>
<input type="email" class="form-control" id="email" name="email" value="{{ user.email or '' }}" placeholder="请输入您的邮箱">
<small class="form-text text-muted">用于接收系统通知和找回密码</small>
</div>
<div class="form-group">
<label for="phone">手机号码</label>
<input type="text" class="form-control" id="phone" name="phone" value="{{ user.phone or '' }}" placeholder="请输入您的手机号">
<small class="form-text text-muted">用于接收借阅提醒和系统通知</small>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" name="form_type" value="profile">
<i class="fas fa-save"></i> 保存修改
</button>
</div>
</form>
</div>
<!-- 安全设置选项卡 -->
<div class="tab-pane fade" id="security" role="tabpanel" aria-labelledby="security-tab">
<form method="POST" action="{{ url_for('user.user_profile') }}" id="passwordForm">
<div class="form-section">
<h4>修改密码</h4>
<div class="form-group">
<label for="current_password">当前密码</label>
<input type="password" class="form-control" id="current_password" name="current_password" placeholder="请输入当前密码">
</div>
<div class="form-group">
<label for="new_password">新密码</label>
<input type="password" class="form-control" id="new_password" name="new_password" placeholder="请输入新密码">
<small class="form-text text-muted">密码长度至少为6个字符</small>
</div>
<div class="form-group">
<label for="confirm_password">确认新密码</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" placeholder="请再次输入新密码">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" name="form_type" value="password">
<i class="fas fa-key"></i> 更新密码
</button>
</div>
</form>
</div>
<!-- 最近活动选项卡 -->
<div class="tab-pane fade" id="activity" role="tabpanel" aria-labelledby="activity-tab">
<div class="activity-header">
<h4>最近活动</h4>
<div class="activity-filter">
<label for="activityFilter">显示:</label>
<select id="activityFilter" class="form-control form-control-sm">
<option value="all">所有活动</option>
<option value="login">登录记录</option>
<option value="borrow">借阅活动</option>
<option value="return">归还活动</option>
</select>
</div>
</div>
<div class="activity-timeline" id="activityTimeline">
<div class="timeline-loading">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Loading...</span>
</div>
<p>加载中...</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/user-profile.js') }}"></script>
{% endblock %}
================================================================================
File: ./app/templates/user/list.html
================================================================================
{% extends "base.html" %}
{% block title %}用户管理 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/user-list.css') }}">
{% endblock %}
{% block content %}
<div class="user-list-container">
<!-- 页面标题 -->
<div class="page-header">
<h1>用户管理</h1>
<div class="actions">
<a href="{{ url_for('user.register') }}" class="btn btn-primary">
<i class="fas fa-user-plus"></i> 添加用户
</a>
</div>
</div>
<!-- 搜索和过滤区域 -->
<div class="search-filter-container">
<form method="GET" action="{{ url_for('user.user_list') }}" class="search-filter-form">
<div class="form-row">
<div class="search-box">
<input type="text" name="search" value="{{ search }}" placeholder="搜索用户名/邮箱/昵称/手机" class="form-control">
<button type="submit" class="btn btn-search">
<i class="fas fa-search"></i>
</button>
</div>
<div class="filter-box">
<select name="status" class="form-control">
<option value="">所有状态</option>
<option value="1" {% if status == 1 %}selected{% endif %}>正常</option>
<option value="0" {% if status == 0 %}selected{% endif %}>禁用</option>
</select>
<select name="role_id" class="form-control">
<option value="">所有角色</option>
{% for role in roles %}
<option value="{{ role.id }}" {% if role_id == role.id %}selected{% endif %}>{{ role.role_name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-filter">筛选</button>
<a href="{{ url_for('user.user_list') }}" class="btn btn-reset">重置</a>
</div>
</div>
</form>
</div>
<!-- 用户列表表格 -->
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>昵称</th>
<th>邮箱</th>
<th>手机号</th>
<th>角色</th>
<th>状态</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in pagination.items %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.nickname or '-' }}</td>
<td>{{ user.email or '-' }}</td>
<td>{{ user.phone or '-' }}</td>
<td>
{% for role in roles %}
{% if role.id == user.role_id %}
{{ role.role_name }}
{% endif %}
{% endfor %}
</td>
<td>
<span class="status-badge {% if user.status == 1 %}active{% else %}inactive{% endif %}">
{{ '正常' if user.status == 1 else '禁用' }}
</span>
</td>
<td>{{ user.created_at }}</td>
<td class="actions">
<a href="{{ url_for('user.user_edit', user_id=user.id) }}" class="btn btn-sm btn-info" title="编辑">
<i class="fas fa-edit"></i>
</a>
{% if user.id != session.get('user_id') %}
{% if user.status == 1 %}
<button class="btn btn-sm btn-warning toggle-status" data-id="{{ user.id }}" data-status="0" title="禁用">
<i class="fas fa-ban"></i>
</button>
{% else %}
<button class="btn btn-sm btn-success toggle-status" data-id="{{ user.id }}" data-status="1" title="启用">
<i class="fas fa-check"></i>
</button>
{% endif %}
<button class="btn btn-sm btn-danger delete-user" data-id="{{ user.id }}" title="删除">
<i class="fas fa-trash"></i>
</button>
{% else %}
<span class="text-muted">(当前用户)</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="text-center">暂无用户数据</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 分页控件 -->
{% if pagination.pages > 1 %}
<div class="pagination-container">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('user.user_list', page=pagination.prev_num, search=search, status=status, role_id=role_id) }}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for page in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
{% if page %}
{% if page == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ page }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('user.user_list', page=page, search=search, status=status, role_id=role_id) }}">{{ page }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('user.user_list', page=pagination.next_num, search=search, status=status, role_id=role_id) }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
<!-- 确认删除模态框 -->
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">确认删除</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
您确定要删除这个用户吗?此操作不可逆。
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmDelete">确认删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/user-list.js') }}"></script>
{% endblock %}
================================================================================
File: ./app/templates/user/edit.html
================================================================================
{% extends "base.html" %}
{% block title %}编辑用户 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/user-edit.css') }}">
{% endblock %}
{% block content %}
<div class="user-edit-container">
<div class="page-header">
<h1>编辑用户</h1>
<div class="actions">
<a href="{{ url_for('user.user_list') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> 返回用户列表
</a>
</div>
</div>
<div class="card">
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('user.user_edit', user_id=user.id) }}" id="userEditForm">
<div class="form-row">
<!-- 用户基本信息 -->
<div class="col-md-6">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" class="form-control" id="username" value="{{ user.username }}" readonly>
<small class="form-text text-muted">用户名不可修改</small>
</div>
<div class="form-group">
<label for="email">邮箱地址</label>
<input type="email" class="form-control" id="email" name="email" value="{{ user.email or '' }}">
</div>
<div class="form-group">
<label for="phone">手机号码</label>
<input type="text" class="form-control" id="phone" name="phone" value="{{ user.phone or '' }}">
</div>
<div class="form-group">
<label for="nickname">昵称</label>
<input type="text" class="form-control" id="nickname" name="nickname" value="{{ user.nickname or '' }}">
</div>
</div>
<!-- 用户权限和密码 -->
<div class="col-md-6">
<div class="form-group">
<label for="role_id">用户角色</label>
<select class="form-control" id="role_id" name="role_id">
{% for role in roles %}
<option value="{{ role.id }}" {% if role.id == user.role_id %}selected{% endif %}>
{{ role.role_name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="status">用户状态</label>
<select class="form-control" id="status" name="status">
<option value="1" {% if user.status == 1 %}selected{% endif %}>正常</option>
<option value="0" {% if user.status == 0 %}selected{% endif %}>禁用</option>
</select>
</div>
<div class="form-group">
<label for="password">重置密码</label>
<input type="password" class="form-control" id="password" name="password">
<small class="form-text text-muted">留空表示不修改密码</small>
</div>
<div class="form-group" id="confirmPasswordGroup" style="display: none;">
<label for="confirm_password">确认密码</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password">
</div>
</div>
</div>
<!-- 附加信息 -->
<div class="form-row">
<div class="col-md-12">
<div class="user-info-box">
<div class="info-item">
<span class="info-label">用户ID:</span>
<span class="info-value">{{ user.id }}</span>
</div>
<div class="info-item">
<span class="info-label">注册时间:</span>
<span class="info-value">{{ user.created_at }}</span>
</div>
<div class="info-item">
<span class="info-label">最后更新:</span>
<span class="info-value">{{ user.updated_at }}</span>
</div>
</div>
</div>
</div>
<!-- 提交按钮区域 -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存修改
</button>
<a href="{{ url_for('user.user_list') }}" class="btn btn-secondary">
取消
</a>
</div>
</form>
</div>
</div>
</div>
<!-- 操作成功提示模态框 -->
<div class="modal fade" id="successModal" tabindex="-1" role="dialog" aria-labelledby="successModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="successModalLabel">操作成功</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
用户信息已成功更新。
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">关闭</button>
<a href="{{ url_for('user.user_list') }}" class="btn btn-primary">返回用户列表</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/user-edit.js') }}"></script>
{% endblock %}
================================================================================
File: ./app/templates/user/roles.html
================================================================================
{% extends "base.html" %}
{% block title %}角色管理 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/user-roles.css') }}">
{% endblock %}
{% block content %}
<div class="roles-container">
<!-- 页面标题 -->
<div class="page-header">
<h1>角色管理</h1>
<div class="actions">
<button class="btn btn-primary" id="addRoleBtn">
<i class="fas fa-plus"></i> 添加角色
</button>
</div>
</div>
<!-- 角色列表卡片 -->
<div class="role-list">
{% for role in roles %}
<div class="role-card" data-id="{{ role.id }}">
<div class="role-header">
<h3 class="role-name">{{ role.role_name }}</h3>
<div class="role-actions">
<button class="btn btn-sm btn-edit-role" title="编辑角色">
<i class="fas fa-edit"></i>
</button>
{% if role.id not in [1, 2] %}
<button class="btn btn-sm btn-delete-role" title="删除角色">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</div>
<div class="role-description">
{% if role.description %}
{{ role.description }}
{% else %}
<span class="text-muted">暂无描述</span>
{% endif %}
</div>
<div class="role-stats">
<div class="stat-item">
<i class="fas fa-users"></i> <span id="userCount-{{ role.id }}">--</span> 用户
</div>
{% if role.id == 1 %}
<div class="role-badge admin">管理员</div>
{% elif role.id == 2 %}
<div class="role-badge user">普通用户</div>
{% else %}
<div class="role-badge custom">自定义</div>
{% endif %}
</div>
</div>
{% else %}
<div class="no-data-message">
<i class="fas fa-users-slash"></i>
<p>暂无角色数据</p>
</div>
{% endfor %}
</div>
<!-- 权限描述 -->
<div class="permissions-info">
<h3>角色权限说明</h3>
<div class="card">
<div class="card-body">
<table class="table permission-table">
<thead>
<tr>
<th>功能模块</th>
<th>管理员</th>
<th>普通用户</th>
<th>自定义角色</th>
</tr>
</thead>
<tbody>
<tr>
<td>图书浏览</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-check text-success"></i></td>
</tr>
<tr>
<td>借阅图书</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-check text-success"></i></td>
</tr>
<tr>
<td>图书管理</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-times text-danger"></i></td>
<td>可配置</td>
</tr>
<tr>
<td>用户管理</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-times text-danger"></i></td>
<td>可配置</td>
</tr>
<tr>
<td>借阅管理</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-times text-danger"></i></td>
<td>可配置</td>
</tr>
<tr>
<td>库存管理</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-times text-danger"></i></td>
<td>可配置</td>
</tr>
<tr>
<td>统计分析</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-times text-danger"></i></td>
<td>可配置</td>
</tr>
<tr>
<td>系统设置</td>
<td><i class="fas fa-check text-success"></i></td>
<td><i class="fas fa-times text-danger"></i></td>
<td>可配置</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 角色编辑模态框 -->
<div class="modal fade" id="roleModal" tabindex="-1" role="dialog" aria-labelledby="roleModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="roleModalLabel">添加角色</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form id="roleForm">
<input type="hidden" id="roleId" value="">
<div class="form-group">
<label for="roleName">角色名称</label>
<input type="text" class="form-control" id="roleName" required>
</div>
<div class="form-group">
<label for="roleDescription">角色描述</label>
<textarea class="form-control" id="roleDescription" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="saveRoleBtn">保存</button>
</div>
</div>
</div>
</div>
<!-- 确认删除模态框 -->
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">确认删除</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
您确定要删除这个角色吗?此操作不可逆。
<p class="text-danger mt-3">注意:删除角色将会影响所有使用此角色的用户。</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">确认删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/user-roles.js') }}"></script>
{% endblock %}
================================================================================
File: ./app/templates/book/list.html
================================================================================
{% extends 'base.html' %}
{% block title %}图书列表 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/book.css') }}">
{% endblock %}
{% block content %}
<div class="book-list-container">
<!-- 添加泡泡动画元素 -->
<!-- 这些泡泡会通过 JS 动态创建 -->
<div class="page-header">
<h1>图书管理</h1>
{% if current_user.role_id == 1 %}
<div class="action-buttons">
<a href="{{ url_for('book.add_book') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> 添加图书
</a>
<a href="{{ url_for('book.import_books') }}" class="btn btn-success">
<i class="fas fa-file-upload"></i> 批量导入
</a>
<a href="{{ url_for('book.export_books') }}" class="btn btn-info">
<i class="fas fa-file-download"></i> 导出图书
</a>
<a href="{{ url_for('book.category_list') }}" class="btn btn-secondary">
<i class="fas fa-tags"></i> 分类管理
</a>
</div>
{% endif %}
</div>
<div class="filter-section">
<form method="GET" action="{{ url_for('book.book_list') }}" class="search-form">
<div class="search-row">
<div class="form-group search-group">
<input type="text" name="search" class="form-control" placeholder="搜索书名/作者/ISBN" value="{{ search }}">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="filter-row">
<div class="form-group filter-group">
<select name="category_id" class="form-control" onchange="this.form.submit()">
<option value="">全部分类</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group filter-group">
<select name="sort" class="form-control" onchange="this.form.submit()">
<option value="id" {% if sort == 'id' %}selected{% endif %}>默认排序</option>
<option value="created_at" {% if sort == 'created_at' %}selected{% endif %}>入库时间</option>
<option value="title" {% if sort == 'title' %}selected{% endif %}>书名</option>
<option value="stock" {% if sort == 'stock' %}selected{% endif %}>库存</option>
</select>
</div>
<div class="form-group filter-group">
<select name="order" class="form-control" onchange="this.form.submit()">
<option value="desc" {% if order == 'desc' %}selected{% endif %}>降序</option>
<option value="asc" {% if order == 'asc' %}selected{% endif %}>升序</option>
</select>
</div>
</div>
</form>
</div>
<div class="books-grid">
{% for book in books %}
<!-- 为每个book-card添加data-id属性 -->
<div class="book-card" data-id="{{ book.id }}">
<div class="book-cover">
{% if book.cover_url %}
<img src="{{ book.cover_url }}" alt="{{ book.title }}">
{% else %}
<div class="no-cover">
<i class="fas fa-book"></i>
<span>无封面</span>
</div>
{% endif %}
<!-- 添加书名覆盖层 -->
<div class="cover-title-bar">{{ book.title }}</div>
</div>
<div class="book-info">
<h3 class="book-title">{{ book.title }}</h3>
<p class="book-author">{{ book.author }}</p>
<div class="book-meta">
{% if book.category %}
<span class="book-category">{{ book.category.name }}</span>
{% endif %}
<span class="book-status {{ 'available' if book.stock > 0 else 'unavailable' }}">
{{ '可借阅' if book.stock > 0 else '无库存' }}
</span>
</div>
<div class="book-details">
<p><strong>ISBN:</strong> <span>{{ book.isbn or '无' }}</span></p>
<p><strong>出版社:</strong> <span>{{ book.publisher or '无' }}</span></p>
<p><strong>库存:</strong> <span>{{ book.stock }}</span></p>
</div>
<div class="book-actions">
<a href="{{ url_for('book.book_detail', book_id=book.id) }}" class="btn btn-info btn-sm">
<i class="fas fa-info-circle"></i> 详情
</a>
{% if current_user.role_id == 1 %}
<a href="{{ url_for('book.edit_book', book_id=book.id) }}" class="btn btn-primary btn-sm">
<i class="fas fa-edit"></i> 编辑
</a>
<button class="btn btn-danger btn-sm delete-book" data-id="{{ book.id }}" data-title="{{ book.title }}">
<i class="fas fa-trash"></i> 删除
</button>
{% endif %}
{% if book.stock > 0 %}
<a href="#" class="btn btn-success btn-sm borrow-book" data-id="{{ book.id }}">
<i class="fas fa-hand-holding"></i> 借阅
</a>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="no-books">
<i class="fas fa-exclamation-circle"></i>
<p>没有找到符合条件的图书</p>
</div>
{% endfor %}
</div>
<!-- 分页 -->
{% if pagination.pages > 1 %}
<div class="pagination-container">
<ul class="pagination">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('book.book_list', page=pagination.prev_num, search=search, category_id=category_id, sort=sort, order=order) }}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
{% if p %}
{% if p == pagination.page %}
<li class="page-item active">
<span class="page-link">{{ p }}</span>
</li>
{% else %}
<li class="page-item">
<a class="page-link" href="{{ url_for('book.book_list', page=p, search=search, category_id=category_id, sort=sort, order=order) }}">{{ p }}</a>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('book.book_list', page=pagination.next_num, search=search, category_id=category_id, sort=sort, order=order) }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
<div class="pagination-info">
显示 {{ pagination.total }} 条结果中的第 {{ (pagination.page - 1) * pagination.per_page + 1 }}
到 {{ min(pagination.page * pagination.per_page, pagination.total) }} 条
</div>
</div>
{% endif %}
</div>
<!-- 删除确认模态框 -->
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">确认删除</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
确定要删除《<span id="deleteBookTitle"></span>》吗?此操作不可恢复。
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmDelete">确认删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/book-list.js') }}"></script>
{{ super() }}
{% endblock %}
================================================================================
File: ./app/templates/book/add.html
================================================================================
{% extends 'base.html' %}
{% block title %}添加图书 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/book-form.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.css">
{% endblock %}
{% block content %}
<div class="book-form-container animate__animated animate__fadeIn">
<!-- 顶部导航和标题区域 -->
<div class="page-header-wrapper">
<div class="page-header">
<div class="header-title-section">
<h1 class="page-title">添加新图书</h1>
<p class="subtitle">创建新书籍记录并添加到系统库存</p>
</div>
<div class="header-actions">
<a href="{{ url_for('book.book_list') }}" class="btn-back">
<i class="fas fa-arrow-left"></i>
<span>返回列表</span>
</a>
<div class="form-progress">
<div class="progress-bar-container">
<div class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" id="formProgress"></div>
</div>
<span class="progress-text" id="progressText">完成 0%</span>
</div>
</div>
</div>
</div>
<!-- 主表单区域 -->
<form method="POST" enctype="multipart/form-data" class="book-form" id="bookForm">
<div class="form-grid">
<!-- 左侧表单区域 -->
<div class="form-main-content">
<!-- 基本信息卡片 -->
<div class="form-card">
<div class="card-header">
<span class="card-title">基本信息</span>
</div>
<div class="card-body">
<div class="form-section">
<div class="form-group">
<label for="title" class="form-label">书名 <span class="required">*</span></label>
<input type="text" class="form-control" id="title" name="title" required
placeholder="请输入完整图书名称" value="{{ book.title if book else '' }}">
<div class="form-help">完整准确的书名有助于读者查找</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="author" class="form-label">作者 <span class="required">*</span></label>
<input type="text" class="form-control" id="author" name="author" required
placeholder="请输入作者姓名" value="{{ book.author if book else '' }}">
</div>
<div class="form-group">
<label for="publisher" class="form-label">出版社</label>
<input type="text" class="form-control" id="publisher" name="publisher"
placeholder="请输入出版社名称" value="{{ book.publisher if book else '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="isbn" class="form-label">ISBN</label>
<div class="input-with-button">
<input type="text" class="form-control" id="isbn" name="isbn"
placeholder="例如: 978-7-XXXXX-XXX-X" value="{{ book.isbn if book else '' }}">
<button type="button" class="btn-append" id="isbnLookup">
<i class="fas fa-search"></i>
</button>
</div>
<div class="form-help">输入ISBN并点击查询按钮自动填充图书信息</div>
</div>
<div class="form-group">
<label for="publish_year" class="form-label">出版年份</label>
<input type="text" class="form-control" id="publish_year" name="publish_year"
placeholder="例如: 2023" value="{{ book.publish_year if book else '' }}">
</div>
</div>
</div>
</div>
</div>
<!-- 分类和标签卡片 -->
<div class="form-card">
<div class="card-header">
<span class="card-title">分类与标签</span>
</div>
<div class="card-body">
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="category_id" class="form-label">图书分类</label>
<select class="form-control select2" id="category_id" name="category_id">
<option value="">选择分类...</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if book and book.category_id|string == category.id|string %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
<div class="form-help">为图书选择合适的分类以便于管理和查找</div>
</div>
<div class="form-group">
<label for="tagInput" class="form-label">标签</label>
<div class="tag-input-wrapper">
<input type="text" class="form-control" id="tagInput" placeholder="输入标签后按回车添加">
<input type="hidden" id="tags" name="tags" value="{{ book.tags if book else '' }}">
<button type="button" class="btn-tag-add" id="addTagBtn">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="tags-container" id="tagsContainer"></div>
<div class="form-help">添加多个标签以提高图书的检索率</div>
</div>
</div>
</div>
</div>
</div>
<!-- 图书简介卡片 -->
<div class="form-card">
<div class="card-header">
<span class="card-title">图书简介</span>
</div>
<div class="card-body">
<div class="form-section">
<div class="form-group">
<label for="description" class="form-label">内容简介</label>
<textarea class="form-control" id="description" name="description" rows="8"
placeholder="请输入图书的简要介绍...">{{ book.description if book else '' }}</textarea>
<div class="form-footer">
<div class="form-help">简要描述图书的内容、特点和主要观点</div>
<div class="char-counter"><span id="charCount">0</span>/2000</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧表单区域 -->
<div class="form-sidebar">
<!-- 封面图片卡片 -->
<div class="form-card">
<div class="card-header">
<span class="card-title">封面图片</span>
</div>
<div class="card-body">
<div class="cover-preview-container">
<div class="cover-preview" id="coverPreview">
<div class="no-cover-placeholder">
<i class="fas fa-book-open"></i>
<span>暂无封面</span>
<p class="placeholder-tip">点击上传或拖放图片至此处</p>
</div>
</div>
<div class="upload-options">
<div class="upload-btn-group">
<label for="cover" class="btn-upload">
<i class="fas fa-upload"></i> 上传图片
</label>
<button type="button" class="btn-remove" id="removeCover">
<i class="fas fa-trash-alt"></i>
</button>
</div>
<input type="file" id="cover" name="cover" class="form-control-file" accept="image/*" style="display:none;">
<div class="upload-tips">
<div>推荐尺寸: 500×700px (竖版封面)</div>
<div>支持格式: JPG, PNG, WebP</div>
</div>
</div>
</div>
</div>
</div>
<!-- 库存和价格卡片 -->
<div class="form-card">
<div class="card-header">
<span class="card-title">库存和价格</span>
</div>
<div class="card-body">
<div class="form-section">
<div class="form-group">
<label for="stock" class="form-label">库存数量</label>
<div class="number-control">
<button type="button" class="number-btn decrement" id="stockDecrement"></button>
<input type="number" class="form-control text-center" id="stock" name="stock" min="0"
value="{{ book.stock if book else 0 }}">
<button type="button" class="number-btn increment" id="stockIncrement"></button>
</div>
</div>
<div class="form-group">
<label for="price" class="form-label">价格</label>
<div class="price-input">
<span class="currency-symbol">¥</span>
<input type="number" class="form-control" id="price" name="price" step="0.01" min="0"
placeholder="0.00" value="{{ book.price if book else '' }}">
</div>
<div class="price-slider">
<input type="range" class="range-slider" id="priceRange" min="0" max="500" step="0.5"
value="{{ book.price if book and book.price else 0 }}">
<div class="slider-marks">
<span>¥0</span>
<span>¥250</span>
<span>¥500</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 提交按钮区域 -->
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存图书
</button>
<div class="secondary-actions">
<button type="button" class="btn-secondary" id="previewBtn">
<i class="fas fa-eye"></i> 预览
</button>
<button type="reset" class="btn-secondary" id="resetBtn">
<i class="fas fa-undo"></i> 重置
</button>
</div>
<div class="form-tip">
<i class="fas fa-info-circle"></i>
<span>带 <span class="required">*</span> 的字段为必填项</span>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- 图片裁剪模态框 -->
<div class="modal fade" id="cropperModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">调整封面图片</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div class="img-container">
<img id="cropperImage" src="" alt="图片预览">
</div>
<div class="cropper-controls">
<div class="control-group">
<button type="button" class="control-btn" id="rotateLeft" title="向左旋转">
<i class="fas fa-undo"></i>
</button>
<button type="button" class="control-btn" id="rotateRight" title="向右旋转">
<i class="fas fa-redo"></i>
</button>
</div>
<div class="control-group">
<button type="button" class="control-btn" id="zoomOut" title="缩小">
<i class="fas fa-search-minus"></i>
</button>
<button type="button" class="control-btn" id="zoomIn" title="放大">
<i class="fas fa-search-plus"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary modal-btn" data-dismiss="modal">取消</button>
<button type="button" class="btn-primary modal-btn" id="cropImage">应用裁剪</button>
</div>
</div>
</div>
</div>
<!-- 图书预览模态框 -->
<div class="modal fade" id="previewModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header preview-header">
<h5 class="modal-title">图书预览</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body preview-body">
<div class="book-preview">
<div class="preview-cover-section">
<div class="book-preview-cover" id="previewCover">
<div class="no-cover-placeholder">
<i class="fas fa-book-open"></i>
<span>暂无封面</span>
</div>
</div>
<div class="book-meta">
<div class="book-price" id="previewPrice">¥0.00</div>
<div class="book-stock" id="previewStock">库存: 0</div>
</div>
</div>
<div class="preview-details-section">
<h2 class="book-title" id="previewTitle">书名加载中...</h2>
<div class="book-author" id="previewAuthor">作者加载中...</div>
<div class="book-info-grid">
<div class="info-item">
<div class="info-label">出版社</div>
<div class="info-value" id="previewPublisher">-</div>
</div>
<div class="info-item">
<div class="info-label">ISBN</div>
<div class="info-value" id="previewISBN">-</div>
</div>
<div class="info-item">
<div class="info-label">出版年份</div>
<div class="info-value" id="previewYear">-</div>
</div>
<div class="info-item">
<div class="info-label">分类</div>
<div class="info-value" id="previewCategory">-</div>
</div>
</div>
<div class="book-tags-preview" id="previewTags"></div>
<div class="book-description-preview">
<h3 class="section-title">图书简介</h3>
<div class="description-content" id="previewDescription">
<p class="placeholder-text">暂无简介内容</p>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer preview-footer">
<button type="button" class="btn-secondary modal-btn" data-dismiss="modal">关闭</button>
<button type="button" class="btn-primary modal-btn" data-dismiss="modal" id="continueEditBtn">继续编辑</button>
</div>
</div>
</div>
</div>
<!-- 通知容器 -->
<div class="notification-container"></div>
{% endblock %}
{% block scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.12/cropper.min.js"></script>
<script src="{{ url_for('static', filename='js/book-add.js') }}"></script>
{% endblock %}
================================================================================
File: ./app/templates/book/edit.html
================================================================================
{% extends 'base.html' %}
{% block title %}编辑图书 - {{ book.title }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/book-form.css') }}">
{% endblock %}
{% block content %}
<div class="book-form-container">
<div class="page-header">
<h1>编辑图书</h1>
<div class="actions">
<a href="{{ url_for('book.book_detail', book_id=book.id) }}" class="btn btn-info">
<i class="fas fa-eye"></i> 查看详情
</a>
<a href="{{ url_for('book.book_list') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
</div>
</div>
<form method="POST" enctype="multipart/form-data" class="book-form">
<div class="form-row">
<div class="col-md-8">
<div class="card">
<div class="card-header">基本信息</div>
<div class="card-body">
<div class="form-row">
<div class="form-group col-md-12">
<label for="title">书名 <span class="required">*</span></label>
<input type="text" class="form-control" id="title" name="title" value="{{ book.title }}" required>
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="author">作者 <span class="required">*</span></label>
<input type="text" class="form-control" id="author" name="author" value="{{ book.author }}" required>
</div>
<div class="form-group col-md-6">
<label for="publisher">出版社</label>
<input type="text" class="form-control" id="publisher" name="publisher" value="{{ book.publisher or '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="isbn">ISBN</label>
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn or '' }}">
</div>
<div class="form-group col-md-6">
<label for="publish_year">出版年份</label>
<input type="text" class="form-control" id="publish_year" name="publish_year" value="{{ book.publish_year or '' }}">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="category_id">分类</label>
<select class="form-control" id="category_id" name="category_id">
<option value="">未分类</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if book.category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group col-md-6">
<label for="tags">标签</label>
<input type="text" class="form-control" id="tags" name="tags" value="{{ book.tags or '' }}" placeholder="多个标签用逗号分隔">
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">图书简介</div>
<div class="card-body">
<div class="form-group">
<textarea class="form-control" id="description" name="description" rows="8" placeholder="请输入图书简介">{{ book.description or '' }}</textarea>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">封面图片</div>
<div class="card-body">
<div class="cover-preview-container">
<div class="cover-preview" id="coverPreview">
{% if book.cover_url %}
<img src="{{ book.cover_url }}" class="cover-image" alt="{{ book.title }}">
{% else %}
<div class="no-cover-placeholder">
<i class="fas fa-image"></i>
<span>暂无封面</span>
</div>
{% endif %}
</div>
<div class="upload-container">
<label for="cover" class="btn btn-outline-primary btn-block">
<i class="fas fa-upload"></i> 更换封面
</label>
<input type="file" id="cover" name="cover" class="form-control-file" accept="image/*" style="display:none;">
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">库存和价格</div>
<div class="card-body">
<div class="form-group">
<label for="stock">库存数量</label>
<input type="number" class="form-control" id="stock" name="stock" min="0" value="{{ book.stock }}">
</div>
<div class="form-group">
<label for="price">价格</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">¥</span>
</div>
<input type="number" class="form-control" id="price" name="price" step="0.01" min="0" value="{{ book.price or '' }}">
</div>
</div>
<div class="form-group">
<label for="status">状态</label>
<select class="form-control" id="status" name="status">
<option value="1" {% if book.status == 1 %}selected{% endif %}>上架</option>
<option value="0" {% if book.status == 0 %}selected{% endif %}>下架</option>
</select>
</div>
</div>
</div>
<div class="form-submit-container">
<button type="submit" class="btn btn-primary btn-lg btn-block">
<i class="fas fa-save"></i> 保存修改
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// 封面预览
$('#cover').change(function() {
const file = this.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
$('#coverPreview').html(`<img src="${e.target.result}" class="cover-image">`);
}
reader.readAsDataURL(file);
} else {
$('#coverPreview').html(`
{% if book.cover_url %}
<img src="{{ book.cover_url }}" class="cover-image" alt="{{ book.title }}">
{% else %}
<div class="no-cover-placeholder">
<i class="fas fa-image"></i>
<span>暂无封面</span>
</div>
{% endif %}
`);
}
});
});
</script>
{% endblock %}
================================================================================
File: ./app/templates/book/import.html
================================================================================
{% extends 'base.html' %}
{% block title %}批量导入图书 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/book-import.css') }}">
{% endblock %}
{% block content %}
<div class="import-container">
<div class="page-header">
<h1>批量导入图书</h1>
<a href="{{ url_for('book.book_list') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回图书列表
</a>
</div>
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h4>Excel文件导入</h4>
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<div class="form-group">
<label for="file">选择Excel文件</label>
<div class="custom-file">
<input type="file" class="custom-file-input" id="file" name="file" accept=".xlsx, .xls" required>
<label class="custom-file-label" for="file">选择文件...</label>
</div>
<small class="form-text text-muted">支持的文件格式: .xlsx, .xls</small>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block">
<i class="fas fa-upload"></i> 开始导入
</button>
</form>
<hr>
<div class="import-instructions">
<h5>导入说明:</h5>
<ul>
<li>Excel文件须包含以下列 (标题行必须与下列完全一致):</li>
<li class="required-field">title - 图书标题 (必填)</li>
<li class="required-field">author - 作者名称 (必填)</li>
<li>publisher - 出版社</li>
<li>category_id - 分类ID (对应系统中的分类ID)</li>
<li>tags - 标签 (多个标签用逗号分隔)</li>
<li>isbn - ISBN编号 (建议唯一)</li>
<li>publish_year - 出版年份</li>
<li>description - 图书简介</li>
<li>cover_url - 封面图片URL</li>
<li>stock - 库存数量</li>
<li>price - 价格</li>
</ul>
<div class="template-download">
<p>下载导入模板:</p>
<a href="{{ url_for('static', filename='templates/book_import_template.xlsx') }}" class="btn btn-outline-primary">
<i class="fas fa-download"></i> 下载Excel模板
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// 显示选择的文件名
$('.custom-file-input').on('change', function() {
const fileName = $(this).val().split('\\').pop();
$(this).next('.custom-file-label').html(fileName);
});
});
</script>
{% endblock %}
================================================================================
File: ./app/templates/book/detail.html
================================================================================
{% extends 'base.html' %}
{% block title %}{{ book.title }} - 图书详情{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/book-detail.css') }}">
{% endblock %}
{% block content %}
<div class="book-detail-container">
<div class="page-header">
<h1>图书详情</h1>
<div class="actions">
<a href="{{ url_for('book.book_list') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回列表
</a>
{% if current_user.role_id == 1 %}
<a href="{{ url_for('book.edit_book', book_id=book.id) }}" class="btn btn-primary">
<i class="fas fa-edit"></i> 编辑图书
</a>
{% endif %}
{% if book.stock > 0 %}
<a href="#" class="btn btn-success" id="borrowBtn">
<i class="fas fa-hand-holding"></i> 借阅此书
</a>
{% endif %}
</div>
</div>
<div class="book-content">
<div class="book-header">
<div class="book-cover-large">
{% if book.cover_url %}
<img src="{{ book.cover_url }}" alt="{{ book.title }}">
{% else %}
<div class="no-cover-large">
<i class="fas fa-book"></i>
<span>无封面</span>
</div>
{% endif %}
</div>
<div class="book-main-info">
<h2 class="book-title">{{ book.title }}</h2>
<p class="book-author"><i class="fas fa-user-edit"></i> 作者: {{ book.author }}</p>
<div class="book-meta-info">
<div class="meta-item">
<i class="fas fa-building"></i>
<span>出版社: </span>
<span class="meta-value">{{ book.publisher or '未知' }}</span>
</div>
<div class="meta-item">
<i class="fas fa-calendar-alt"></i>
<span>出版年份: </span>
<span class="meta-value">{{ book.publish_year or '未知' }}</span>
</div>
<div class="meta-item">
<i class="fas fa-barcode"></i>
<span>ISBN: </span>
<span class="meta-value">{{ book.isbn or '未知' }}</span>
</div>
<div class="meta-item">
<i class="fas fa-layer-group"></i>
<span>分类: </span>
<span class="meta-value">{{ book.category.name if book.category else '未分类' }}</span>
</div>
{% if book.tags %}
<div class="meta-item">
<i class="fas fa-tags"></i>
<span>标签: </span>
<span class="meta-value">
{% for tag in book.tags.split(',') %}
<span class="tag">{{ tag.strip() }}</span>
{% endfor %}
</span>
</div>
{% endif %}
<div class="meta-item">
<i class="fas fa-yuan-sign"></i>
<span>价格: </span>
<span class="meta-value">{{ book.price or '未知' }}</span>
</div>
</div>
<div class="book-status-info">
<div class="status-badge {{ 'available' if book.stock > 0 else 'unavailable' }}">
{{ '可借阅' if book.stock > 0 else '无库存' }}
</div>
<div class="stock-info">
<i class="fas fa-cubes"></i> 库存: {{ book.stock }}
</div>
</div>
</div>
</div>
<div class="book-details-section">
<h3>图书简介</h3>
<div class="book-description">
{% if book.description %}
<p>{{ book.description|nl2br }}</p>
{% else %}
<p class="no-description">暂无图书简介</p>
{% endif %}
</div>
</div>
<!-- 借阅历史 (仅管理员可见) -->
{% if current_user.role_id == 1 %}
<div class="book-borrow-history">
<h3>借阅历史</h3>
{% if borrow_records %}
<table class="table borrow-table">
<thead>
<tr>
<th>借阅用户</th>
<th>借阅日期</th>
<th>应还日期</th>
<th>实际归还</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for record in borrow_records %}
<tr>
<td>{{ record.user.username }}</td>
<td>{{ record.borrow_date.strftime('%Y-%m-%d') }}</td>
<td>{{ record.due_date.strftime('%Y-%m-%d') }}</td>
<td>{{ record.return_date.strftime('%Y-%m-%d') if record.return_date else '-' }}</td>
<td>
{% if record.status == 1 and record.due_date < now %}
<span class="badge badge-danger">已逾期</span>
{% elif record.status == 1 %}
<span class="badge badge-warning">借阅中</span>
{% else %}
<span class="badge badge-success">已归还</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-records">暂无借阅记录</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- 借阅确认模态框 -->
<div class="modal fade" id="borrowModal" tabindex="-1" role="dialog" aria-labelledby="borrowModalLabel" aria-hidden="true" style="display: none;">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="borrowModalLabel">借阅确认</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="borrowForm" action="{{ url_for('borrow.borrow_book') }}" method="POST">
<div class="modal-body">
<p>您确定要借阅《{{ book.title }}》吗?</p>
<input type="hidden" name="book_id" value="{{ book.id }}">
<div class="form-group">
<label for="borrow_days">借阅天数</label>
<select class="form-control" id="borrow_days" name="borrow_days">
<option value="7">7天</option>
<option value="14" selected>14天</option>
<option value="30">30天</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="submit" class="btn btn-success">确认借阅</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// 借阅按钮点击事件
$('#borrowBtn').click(function(e) {
e.preventDefault();
$('#borrowModal').modal('show');
});
});
</script>
{% endblock %}
================================================================================
File: ./app/templates/book/categories.html
================================================================================
{% extends 'base.html' %}
{% block title %}图书分类管理 - 图书管理系统{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/categories.css') }}">
{% endblock %}
{% block content %}
<div class="categories-container">
<div class="page-header">
<h1>图书分类管理</h1>
<a href="{{ url_for('book.book_list') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> 返回图书列表
</a>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
添加新分类
</div>
<div class="card-body">
<form id="categoryForm">
<div class="form-group">
<label for="categoryName">分类名称</label>
<input type="text" class="form-control" id="categoryName" name="name" required>
</div>
<div class="form-group">
<label for="parentCategory">父级分类</label>
<select class="form-control" id="parentCategory" name="parent_id">
<option value="">无 (顶级分类)</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="categorySort">排序</label>
<input type="number" class="form-control" id="categorySort" name="sort" value="0" min="0">
</div>
<button type="submit" class="btn btn-primary btn-block">
<i class="fas fa-plus"></i> 添加分类
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
分类列表
</div>
<div class="card-body">
{% if categories %}
<table class="table table-hover category-table">
<thead>
<tr>
<th width="5%">ID</th>
<th width="40%">分类名称</th>
<th width="20%">父级分类</th>
<th width="10%">排序</th>
<th width="25%">操作</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr data-id="{{ category.id }}">
<td>{{ category.id }}</td>
<td>{{ category.name }}</td>
<td>
{% if category.parent %}
{{ category.parent.name }}
{% else %}
<span class="text-muted">无</span>
{% endif %}
</td>
<td>{{ category.sort }}</td>
<td>
<button class="btn btn-sm btn-primary edit-category" data-id="{{ category.id }}"
data-name="{{ category.name }}"
data-parent="{{ category.parent_id or '' }}"
data-sort="{{ category.sort }}">
<i class="fas fa-edit"></i> 编辑
</button>
<button class="btn btn-sm btn-danger delete-category" data-id="{{ category.id }}" data-name="{{ category.name }}">
<i class="fas fa-trash"></i> 删除
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-categories">
<i class="fas fa-exclamation-circle"></i>
<p>暂无分类数据</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- 编辑分类模态框 -->
<div class="modal fade" id="editCategoryModal" tabindex="-1" role="dialog" aria-labelledby="editCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editCategoryModalLabel">编辑分类</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="editCategoryForm">
<div class="modal-body">
<input type="hidden" id="editCategoryId">
<div class="form-group">
<label for="editCategoryName">分类名称</label>
<input type="text" class="form-control" id="editCategoryName" name="name" required>
</div>
<div class="form-group">
<label for="editParentCategory">父级分类</label>
<select class="form-control" id="editParentCategory" name="parent_id">
<option value="">无 (顶级分类)</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="editCategorySort">排序</label>
<input type="number" class="form-control" id="editCategorySort" name="sort" value="0" min="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">保存修改</button>
</div>
</form>
</div>
</div>
</div>
<!-- 删除确认模态框 -->
<div class="modal fade" id="deleteCategoryModal" tabindex="-1" role="dialog" aria-labelledby="deleteCategoryModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteCategoryModalLabel">确认删除</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>您确定要删除分类 "<span id="deleteCategoryName"></span>" 吗?</p>
<p class="text-danger">注意: 如果该分类下有图书或子分类,将无法删除!</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmDeleteCategory">确认删除</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
// 添加分类
$('#categoryForm').submit(function(e) {
e.preventDefault();
const formData = {
name: $('#categoryName').val(),
parent_id: $('#parentCategory').val(),
sort: $('#categorySort').val()
};
$.ajax({
url: '{{ url_for("book.add_category") }}',
type: 'POST',
data: formData,
success: function(response) {
if (response.success) {
showNotification(response.message, 'success');
setTimeout(function() {
location.reload();
}, 1000);
} else {
showNotification(response.message, 'error');
}
},
error: function() {
showNotification('操作失败,请稍后重试', 'error');
}
});
});
// 打开编辑模态框
$('.edit-category').click(function() {
const id = $(this).data('id');
const name = $(this).data('name');
const parentId = $(this).data('parent');
const sort = $(this).data('sort');
$('#editCategoryId').val(id);
$('#editCategoryName').val(name);
$('#editParentCategory').val(parentId);
$('#editCategorySort').val(sort);
// 禁用选择自己作为父级
$('#editParentCategory option').removeAttr('disabled');
$(`#editParentCategory option[value="${id}"]`).attr('disabled', 'disabled');
$('#editCategoryModal').modal('show');
});
// 提交编辑表单
$('#editCategoryForm').submit(function(e) {
e.preventDefault();
const id = $('#editCategoryId').val();
const formData = {
name: $('#editCategoryName').val(),
parent_id: $('#editParentCategory').val(),
sort: $('#editCategorySort').val()
};
$.ajax({
url: `/book/categories/edit/${id}`,
type: 'POST',
data: formData,
success: function(response) {
if (response.success) {
$('#editCategoryModal').modal('hide');
showNotification(response.message, 'success');
setTimeout(function() {
location.reload();
}, 1000);
} else {
showNotification(response.message, 'error');
}
},
error: function() {
showNotification('操作失败,请稍后重试', 'error');
}
});
});
// 打开删除确认框
$('.delete-category').click(function() {
const id = $(this).data('id');
const name = $(this).data('name');
$('#deleteCategoryName').text(name);
$('#confirmDeleteCategory').data('id', id);
$('#deleteCategoryModal').modal('show');
});
// 确认删除
$('#confirmDeleteCategory').click(function() {
const id = $(this).data('id');
$.ajax({
url: `/book/categories/delete/${id}`,
type: 'POST',
success: function(response) {
$('#deleteCategoryModal').modal('hide');
if (response.success) {
showNotification(response.message, 'success');
setTimeout(function() {
location.reload();
}, 1000);
} else {
showNotification(response.message, 'error');
}
},
error: function() {
showNotification('操作失败,请稍后重试', 'error');
}
});
});
// 显示通知
function showNotification(message, type) {
const alertClass = type === 'success' ? 'alert-success' : 'alert-danger';
const alert = `
<div class="alert ${alertClass} alert-dismissible fade show notification-alert" role="alert">
${message}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
`;
$('body').append(alert);
// 5秒后自动关闭
setTimeout(() => {
$('.notification-alert').alert('close');
}, 5000);
}
});
</script>
{% endblock %}
================================================================================
File: ./app/controllers/user.py
================================================================================
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from werkzeug.security import generate_password_hash, check_password_hash
from app.models.user import User, db
from app.utils.email import send_verification_email, generate_verification_code
import logging
from functools import wraps
import time
from datetime import datetime, timedelta
from app.services.user_service import UserService
from flask_login import login_user, logout_user, current_user, login_required
# 创建蓝图
user_bp = Blueprint('user', __name__)
# 使用内存字典代替Redis存储验证码
class VerificationStore:
def __init__(self):
self.codes = {} # 存储格式: {email: {'code': code, 'expires': timestamp}}
def setex(self, email, seconds, code):
"""设置验证码并指定过期时间"""
expiry = datetime.now() + timedelta(seconds=seconds)
self.codes[email] = {'code': code, 'expires': expiry}
return True
def get(self, email):
"""获取验证码如果过期则返回None"""
if email not in self.codes:
return None
data = self.codes[email]
if datetime.now() > data['expires']:
# 验证码已过期,删除它
self.delete(email)
return None
return data['code']
def delete(self, email):
"""删除验证码"""
if email in self.codes:
del self.codes[email]
return True
# 使用内存存储验证码
verification_codes = VerificationStore()
# 添加管理员权限检查装饰器
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or current_user.role_id != 1:
flash('您没有管理员权限', 'error')
return redirect(url_for('index'))
return f(*args, **kwargs)
return decorated_function
@user_bp.route('/login', methods=['GET', 'POST'])
def login():
# 如果用户已经登录,直接重定向到首页
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember_me = request.form.get('remember_me') == 'on'
if not username or not password:
return render_template('login.html', error='用户名和密码不能为空')
# 检查用户是否存在
user = User.query.filter((User.username == username) | (User.email == username)).first()
if not user or not user.check_password(password):
return render_template('login.html', error='用户名或密码错误')
if user.status == 0:
return render_template('login.html', error='账号已被禁用,请联系管理员')
# 使用 Flask-Login 的 login_user 函数
login_user(user, remember=remember_me)
# 这些session信息仍然可以保留但不再用于认证
session['username'] = user.username
session['role_id'] = user.role_id
# 获取登录后要跳转的页面
next_page = request.args.get('next')
if not next_page or not next_page.startswith('/'):
next_page = url_for('index')
# 重定向到首页或其他请求的页面
return redirect(next_page)
return render_template('login.html')
@user_bp.route('/register', methods=['GET', 'POST'])
def register():
# 如果用户已登录,重定向到首页
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
verification_code = request.form.get('verification_code')
# 验证表单数据
if not username or not email or not password or not confirm_password or not verification_code:
return render_template('register.html', error='所有字段都是必填项')
if password != confirm_password:
return render_template('register.html', error='两次输入的密码不匹配')
# 检查用户名和邮箱是否已存在
if User.query.filter_by(username=username).first():
return render_template('register.html', error='用户名已存在')
if User.query.filter_by(email=email).first():
return render_template('register.html', error='邮箱已被注册')
# 验证验证码
stored_code = verification_codes.get(email)
if not stored_code or stored_code != verification_code:
return render_template('register.html', error='验证码无效或已过期')
# 创建新用户
try:
new_user = User(
username=username,
password=password, # 密码会在模型中自动哈希
email=email,
nickname=username # 默认昵称与用户名相同
)
db.session.add(new_user)
db.session.commit()
# 清除验证码
verification_codes.delete(email)
flash('注册成功,请登录', 'success')
return redirect(url_for('user.login'))
except Exception as e:
db.session.rollback()
logging.error(f"User registration failed: {str(e)}")
return render_template('register.html', error='注册失败,请稍后重试')
return render_template('register.html')
@user_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('user.login'))
@user_bp.route('/send_verification_code', methods=['POST'])
def send_verification_code():
data = request.get_json()
email = data.get('email')
if not email:
return jsonify({'success': False, 'message': '请提供邮箱地址'})
# 检查邮箱格式
import re
if not re.match(r"[^@]+@[^@]+\.[^@]+", email):
return jsonify({'success': False, 'message': '邮箱格式不正确'})
# 生成验证码
code = generate_verification_code()
# 存储验证码(10分钟有效)
verification_codes.setex(email, 600, code) # 10分钟过期
# 发送验证码邮件
if send_verification_email(email, code):
return jsonify({'success': True, 'message': '验证码已发送'})
else:
return jsonify({'success': False, 'message': '邮件发送失败,请稍后重试'})
# 用户管理列表
@user_bp.route('/manage')
@login_required
@admin_required
def user_list():
page = request.args.get('page', 1, type=int)
search = request.args.get('search', '')
status = request.args.get('status', type=int)
role_id = request.args.get('role_id', type=int)
pagination = UserService.get_users(
page=page,
per_page=10,
search_query=search,
status=status,
role_id=role_id
)
roles = UserService.get_all_roles()
return render_template(
'user/list.html',
pagination=pagination,
search=search,
status=status,
role_id=role_id,
roles=roles
)
# 用户详情/编辑页面
@user_bp.route('/edit/<int:user_id>', methods=['GET', 'POST'])
@login_required
@admin_required
def user_edit(user_id):
user = UserService.get_user_by_id(user_id)
if not user:
flash('用户不存在', 'error')
return redirect(url_for('user.user_list'))
roles = UserService.get_all_roles()
if request.method == 'POST':
data = {
'email': request.form.get('email'),
'phone': request.form.get('phone'),
'nickname': request.form.get('nickname'),
'role_id': int(request.form.get('role_id')),
'status': int(request.form.get('status')),
}
password = request.form.get('password')
if password:
data['password'] = password
success, message = UserService.update_user(user_id, data)
if success:
flash(message, 'success')
return redirect(url_for('user.user_list'))
else:
flash(message, 'error')
return render_template('user/edit.html', user=user, roles=roles)
# 用户状态管理API
@user_bp.route('/status/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def user_status(user_id):
data = request.get_json()
status = data.get('status')
if status is None or status not in [0, 1]:
return jsonify({'success': False, 'message': '无效的状态值'})
# 不能修改自己的状态
if user_id == current_user.id:
return jsonify({'success': False, 'message': '不能修改自己的状态'})
success, message = UserService.change_user_status(user_id, status)
return jsonify({'success': success, 'message': message})
# 用户删除API
@user_bp.route('/delete/<int:user_id>', methods=['POST'])
@login_required
@admin_required
def user_delete(user_id):
# 不能删除自己
if user_id == current_user.id:
return jsonify({'success': False, 'message': '不能删除自己的账号'})
success, message = UserService.delete_user(user_id)
return jsonify({'success': success, 'message': message})
# 个人中心页面
@user_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def user_profile():
user = current_user
if request.method == 'POST':
data = {
'email': request.form.get('email'),
'phone': request.form.get('phone'),
'nickname': request.form.get('nickname')
}
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
# 如果用户想要修改密码
if current_password and new_password:
if not user.check_password(current_password):
flash('当前密码不正确', 'error')
return render_template('user/profile.html', user=user)
if new_password != confirm_password:
flash('两次输入的新密码不匹配', 'error')
return render_template('user/profile.html', user=user)
data['password'] = new_password
success, message = UserService.update_user(user.id, data)
if success:
flash(message, 'success')
else:
flash(message, 'error')
return render_template('user/profile.html', user=user)
# 角色管理页面
@user_bp.route('/roles', methods=['GET'])
@login_required
@admin_required
def role_list():
roles = UserService.get_all_roles()
return render_template('user/roles.html', roles=roles)
# 创建/编辑角色API
@user_bp.route('/role/save', methods=['POST'])
@login_required
@admin_required
def role_save():
data = request.get_json()
role_id = data.get('id')
role_name = data.get('role_name')
description = data.get('description')
if not role_name:
return jsonify({'success': False, 'message': '角色名不能为空'})
if role_id: # 更新
success, message = UserService.update_role(role_id, role_name, description)
else: # 创建
success, message = UserService.create_role(role_name, description)
return jsonify({'success': success, 'message': message})
================================================================================
File: ./app/controllers/log.py
================================================================================
================================================================================
File: ./app/controllers/__init__.py
================================================================================
================================================================================
File: ./app/controllers/book.py
================================================================================
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, g, jsonify
from app.models.book import Book, Category
from app.models.user import db
from app.utils.auth import login_required, admin_required
import os
from werkzeug.utils import secure_filename
import datetime
import pandas as pd
import uuid
book_bp = Blueprint('book', __name__)
# 图书列表页面
@book_bp.route('/list')
@login_required
def book_list():
print("访问图书列表页面") # 调试输出
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# 只显示状态为1的图书未下架的图书
query = Book.query.filter_by(status=1)
# 搜索功能
search = request.args.get('search', '')
if search:
query = query.filter(
(Book.title.contains(search)) |
(Book.author.contains(search)) |
(Book.isbn.contains(search))
)
# 分类筛选
category_id = request.args.get('category_id', type=int)
if category_id:
query = query.filter_by(category_id=category_id)
# 排序
sort = request.args.get('sort', 'id')
order = request.args.get('order', 'desc')
if order == 'desc':
query = query.order_by(getattr(Book, sort).desc())
else:
query = query.order_by(getattr(Book, sort))
pagination = query.paginate(page=page, per_page=per_page)
books = pagination.items
# 获取所有分类供筛选使用
categories = Category.query.all()
return render_template('book/list.html',
books=books,
pagination=pagination,
search=search,
categories=categories,
category_id=category_id,
sort=sort,
order=order,
current_user=g.user)
# 图书详情页面
@book_bp.route('/detail/<int:book_id>')
@login_required
def book_detail(book_id):
book = Book.query.get_or_404(book_id)
# 添加当前时间用于判断借阅是否逾期
now = datetime.datetime.now()
# 如果用户是管理员,预先查询并排序借阅记录
borrow_records = []
if g.user.role_id == 1: # 假设 role_id 1 为管理员
from app.models.borrow import BorrowRecord
borrow_records = BorrowRecord.query.filter_by(book_id=book_id).order_by(BorrowRecord.borrow_date.desc()).limit(
10).all()
return render_template(
'book/detail.html',
book=book,
current_user=g.user,
borrow_records=borrow_records,
now=now
)
# 添加图书页面
# 添加图书页面
@book_bp.route('/add', methods=['GET', 'POST'])
@login_required
@admin_required
def add_book():
if request.method == 'POST':
title = request.form.get('title')
author = request.form.get('author')
publisher = request.form.get('publisher')
category_id = request.form.get('category_id')
tags = request.form.get('tags')
isbn = request.form.get('isbn')
publish_year = request.form.get('publish_year')
description = request.form.get('description')
stock = request.form.get('stock', type=int, default=0)
price = request.form.get('price')
# 表单验证
errors = []
if not title:
errors.append('书名不能为空')
if not author:
errors.append('作者不能为空')
# 检查ISBN是否已存在(如果提供了ISBN)
if isbn:
existing_book = Book.query.filter_by(isbn=isbn).first()
if existing_book:
errors.append(f'ISBN "{isbn}" 已存在请检查ISBN或查找现有图书')
if errors:
for error in errors:
flash(error, 'danger')
categories = Category.query.all()
# 保留已填写的表单数据
book_data = {
'title': title,
'author': author,
'publisher': publisher,
'category_id': category_id,
'tags': tags,
'isbn': isbn,
'publish_year': publish_year,
'description': description,
'stock': stock,
'price': price
}
return render_template('book/add.html', categories=categories,
current_user=g.user, book=book_data)
# 处理封面图片上传
cover_url = None
if 'cover' in request.files:
cover_file = request.files['cover']
if cover_file and cover_file.filename != '':
try:
# 更清晰的文件命名
original_filename = secure_filename(cover_file.filename)
# 保留原始文件扩展名
_, ext = os.path.splitext(original_filename)
if not ext:
ext = '.jpg' # 默认扩展名
filename = f"{uuid.uuid4()}{ext}"
upload_folder = os.path.join(current_app.static_folder, 'uploads', 'covers')
# 确保上传目录存在
if not os.path.exists(upload_folder):
os.makedirs(upload_folder)
file_path = os.path.join(upload_folder, filename)
cover_file.save(file_path)
cover_url = f'/static/uploads/covers/{filename}'
except Exception as e:
current_app.logger.error(f"封面上传失败: {str(e)}")
flash(f"封面上传失败: {str(e)}", 'warning')
try:
# 创建新图书
book = Book(
title=title,
author=author,
publisher=publisher,
category_id=category_id,
tags=tags,
isbn=isbn,
publish_year=publish_year,
description=description,
cover_url=cover_url,
stock=stock,
price=price,
status=1,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now()
)
db.session.add(book)
# 先提交以获取book的id
db.session.commit()
# 记录库存日志 - 在获取 book.id 后
if stock and int(stock) > 0:
from app.models.inventory import InventoryLog
inventory_log = InventoryLog(
book_id=book.id,
change_type='入库',
change_amount=stock,
after_stock=stock,
operator_id=g.user.id,
remark='新书入库',
changed_at=datetime.datetime.now()
)
db.session.add(inventory_log)
db.session.commit()
flash(f'《{title}》添加成功', 'success')
return redirect(url_for('book.book_list'))
except Exception as e:
db.session.rollback()
error_msg = str(e)
# 记录详细错误日志
current_app.logger.error(f"添加图书失败: {error_msg}")
flash(f'添加图书失败: {error_msg}', 'danger')
categories = Category.query.all()
# 保留已填写的表单数据
book_data = {
'title': title,
'author': author,
'publisher': publisher,
'category_id': category_id,
'tags': tags,
'isbn': isbn,
'publish_year': publish_year,
'description': description,
'stock': stock,
'price': price
}
return render_template('book/add.html', categories=categories,
current_user=g.user, book=book_data)
categories = Category.query.all()
return render_template('book/add.html', categories=categories, current_user=g.user)
# 编辑图书
@book_bp.route('/edit/<int:book_id>', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_book(book_id):
book = Book.query.get_or_404(book_id)
if request.method == 'POST':
title = request.form.get('title')
author = request.form.get('author')
publisher = request.form.get('publisher')
category_id = request.form.get('category_id')
tags = request.form.get('tags')
isbn = request.form.get('isbn')
publish_year = request.form.get('publish_year')
description = request.form.get('description')
price = request.form.get('price')
status = request.form.get('status', type=int)
if not title or not author:
flash('书名和作者不能为空', 'danger')
categories = Category.query.all()
return render_template('book/edit.html', book=book, categories=categories, current_user=g.user)
# 处理库存变更
new_stock = request.form.get('stock', type=int)
if new_stock != book.stock:
from app.models.inventory import InventoryLog
change_amount = new_stock - book.stock
change_type = '入库' if change_amount > 0 else '出库'
inventory_log = InventoryLog(
book_id=book.id,
change_type=change_type,
change_amount=abs(change_amount),
after_stock=new_stock,
operator_id=g.user.id,
remark=f'管理员编辑图书库存 - {book.title}',
changed_at=datetime.datetime.now()
)
db.session.add(inventory_log)
book.stock = new_stock
# 处理封面图片上传
if 'cover' in request.files:
cover_file = request.files['cover']
if cover_file and cover_file.filename != '':
filename = secure_filename(f"{uuid.uuid4()}_{cover_file.filename}")
upload_folder = os.path.join(current_app.static_folder, 'uploads/covers')
# 确保上传目录存在
if not os.path.exists(upload_folder):
os.makedirs(upload_folder)
file_path = os.path.join(upload_folder, filename)
cover_file.save(file_path)
book.cover_url = f'/static/covers/{filename}'
# 更新图书信息
book.title = title
book.author = author
book.publisher = publisher
book.category_id = category_id
book.tags = tags
book.isbn = isbn
book.publish_year = publish_year
book.description = description
book.price = price
book.status = status
book.updated_at = datetime.datetime.now()
db.session.commit()
flash('图书信息更新成功', 'success')
return redirect(url_for('book.book_list'))
categories = Category.query.all()
return render_template('book/edit.html', book=book, categories=categories, current_user=g.user)
# 删除图书
@book_bp.route('/delete/<int:book_id>', methods=['POST'])
@login_required
@admin_required
def delete_book(book_id):
book = Book.query.get_or_404(book_id)
# 检查该书是否有借阅记录
from app.models.borrow import BorrowRecord
active_borrows = BorrowRecord.query.filter_by(book_id=book_id, status=1).count()
if active_borrows > 0:
return jsonify({'success': False, 'message': '该图书有未归还的借阅记录,无法删除'})
# 考虑软删除而不是物理删除
book.status = 0 # 0表示已删除/下架
book.updated_at = datetime.datetime.now()
db.session.commit()
return jsonify({'success': True, 'message': '图书已成功下架'})
# 图书分类管理
@book_bp.route('/categories', methods=['GET'])
@login_required
@admin_required
def category_list():
categories = Category.query.all()
return render_template('book/categories.html', categories=categories, current_user=g.user)
# 添加分类
@book_bp.route('/categories/add', methods=['POST'])
@login_required
@admin_required
def add_category():
name = request.form.get('name')
parent_id = request.form.get('parent_id') or None
sort = request.form.get('sort', 0, type=int)
if not name:
return jsonify({'success': False, 'message': '分类名称不能为空'})
category = Category(name=name, parent_id=parent_id, sort=sort)
db.session.add(category)
db.session.commit()
return jsonify({'success': True, 'message': '分类添加成功', 'id': category.id, 'name': category.name})
# 编辑分类
@book_bp.route('/categories/edit/<int:category_id>', methods=['POST'])
@login_required
@admin_required
def edit_category(category_id):
category = Category.query.get_or_404(category_id)
name = request.form.get('name')
parent_id = request.form.get('parent_id') or None
sort = request.form.get('sort', 0, type=int)
if not name:
return jsonify({'success': False, 'message': '分类名称不能为空'})
category.name = name
category.parent_id = parent_id
category.sort = sort
db.session.commit()
return jsonify({'success': True, 'message': '分类更新成功'})
# 删除分类
@book_bp.route('/categories/delete/<int:category_id>', methods=['POST'])
@login_required
@admin_required
def delete_category(category_id):
category = Category.query.get_or_404(category_id)
# 检查是否有书籍使用此分类
books_count = Book.query.filter_by(category_id=category_id).count()
if books_count > 0:
return jsonify({'success': False, 'message': f'该分类下有{books_count}本图书,无法删除'})
# 检查是否有子分类
children_count = Category.query.filter_by(parent_id=category_id).count()
if children_count > 0:
return jsonify({'success': False, 'message': f'该分类下有{children_count}个子分类,无法删除'})
db.session.delete(category)
db.session.commit()
return jsonify({'success': True, 'message': '分类删除成功'})
# 批量导入图书
@book_bp.route('/import', methods=['GET', 'POST'])
@login_required
@admin_required
def import_books():
if request.method == 'POST':
if 'file' not in request.files:
flash('未选择文件', 'danger')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash('未选择文件', 'danger')
return redirect(request.url)
if file and file.filename.endswith(('.xlsx', '.xls')):
try:
# 读取Excel文件
df = pd.read_excel(file)
success_count = 0
error_count = 0
errors = []
# 处理每一行数据
for index, row in df.iterrows():
try:
# 检查必填字段
if pd.isna(row.get('title')) or pd.isna(row.get('author')):
errors.append(f'第{index + 2}行: 书名或作者为空')
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
# 创建新书籍记录
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,
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,
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,
status=1,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now()
)
db.session.add(book)
# 提交以获取book的id
db.session.flush()
# 创建库存日志
if book.stock > 0:
from app.models.inventory import InventoryLog
inventory_log = InventoryLog(
book_id=book.id,
change_type='入库',
change_amount=book.stock,
after_stock=book.stock,
operator_id=g.user.id,
remark='批量导入图书',
changed_at=datetime.datetime.now()
)
db.session.add(inventory_log)
success_count += 1
except Exception as e:
errors.append(f'第{index + 2}行: {str(e)}')
error_count += 1
db.session.commit()
flash(f'导入完成: 成功{success_count}条,失败{error_count}条', 'info')
if errors:
flash('<br>'.join(errors[:10]) + (f'<br>...等共{len(errors)}个错误' if len(errors) > 10 else ''),
'warning')
return redirect(url_for('book.book_list'))
except Exception as e:
flash(f'导入失败: {str(e)}', 'danger')
return redirect(request.url)
else:
flash('只支持Excel文件(.xlsx, .xls)', 'danger')
return redirect(request.url)
return render_template('book/import.html', current_user=g.user)
# 导出图书
@book_bp.route('/export')
@login_required
@admin_required
def export_books():
# 获取查询参数
search = request.args.get('search', '')
category_id = request.args.get('category_id', type=int)
query = Book.query
if search:
query = query.filter(
(Book.title.contains(search)) |
(Book.author.contains(search)) |
(Book.isbn.contains(search))
)
if category_id:
query = query.filter_by(category_id=category_id)
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)
# 提供下载链接
return redirect(url_for('static', filename=f'temp/{filename}'))
================================================================================
File: ./app/controllers/statistics.py
================================================================================
================================================================================
File: ./app/controllers/borrow.py
================================================================================
from flask import Blueprint, request, redirect, url_for, flash, g
from app.models.book import Book
from app.models.borrow import BorrowRecord
from app.models.inventory import InventoryLog
from app.models.user import db # 修正:从 user 模型导入 db
from app.utils.auth import login_required
import datetime
# 创建借阅蓝图
borrow_bp = Blueprint('borrow', __name__, url_prefix='/borrow')
@borrow_bp.route('/book', methods=['POST'])
@login_required
def borrow_book():
book_id = request.form.get('book_id', type=int)
borrow_days = request.form.get('borrow_days', type=int, default=14)
if not book_id:
flash('请选择要借阅的图书', 'danger')
return redirect(url_for('book.book_list'))
book = Book.query.get_or_404(book_id)
# 检查库存
if book.stock <= 0:
flash(f'《{book.title}》当前无库存,无法借阅', 'danger')
return redirect(url_for('book.book_detail', book_id=book_id))
# 检查当前用户是否已借阅此书
existing_borrow = BorrowRecord.query.filter_by(
user_id=g.user.id,
book_id=book_id,
status=1 # 1表示借阅中
).first()
if existing_borrow:
flash(f'您已借阅《{book.title}》,请勿重复借阅', 'warning')
return redirect(url_for('book.book_detail', book_id=book_id))
try:
# 创建借阅记录
now = datetime.datetime.now()
due_date = now + datetime.timedelta(days=borrow_days)
borrow_record = BorrowRecord(
user_id=g.user.id,
book_id=book_id,
borrow_date=now,
due_date=due_date,
status=1, # 1表示借阅中
created_at=now,
updated_at=now
)
# 更新图书库存
book.stock -= 1
book.updated_at = now
db.session.add(borrow_record)
db.session.commit()
# 添加库存变更日志
inventory_log = InventoryLog(
book_id=book_id,
change_type='借出',
change_amount=-1,
after_stock=book.stock,
operator_id=g.user.id,
remark='用户借书',
changed_at=now
)
db.session.add(inventory_log)
db.session.commit()
flash(f'成功借阅《{book.title}》,请在 {due_date.strftime("%Y-%m-%d")} 前归还', 'success')
except Exception as e:
db.session.rollback()
flash(f'借阅失败: {str(e)}', 'danger')
return redirect(url_for('book.book_detail', book_id=book_id))
================================================================================
File: ./app/controllers/announcement.py
================================================================================
================================================================================
File: ./app/controllers/inventory.py
================================================================================
================================================================================
File: ./app/services/borrow_service.py
================================================================================
================================================================================
File: ./app/services/inventory_service.py
================================================================================
================================================================================
File: ./app/services/__init__.py
================================================================================
================================================================================
File: ./app/services/book_service.py
================================================================================
================================================================================
File: ./app/services/user_service.py
================================================================================
# app/services/user_service.py
from app.models.user import User, Role, db
from sqlalchemy import or_
from datetime import datetime
class UserService:
@staticmethod
def get_users(page=1, per_page=10, search_query=None, status=None, role_id=None):
"""
获取用户列表,支持分页、搜索和过滤
"""
query = User.query
# 搜索条件
if search_query:
query = query.filter(or_(
User.username.like(f'%{search_query}%'),
User.email.like(f'%{search_query}%'),
User.nickname.like(f'%{search_query}%'),
User.phone.like(f'%{search_query}%')
))
# 状态过滤
if status is not None:
query = query.filter(User.status == status)
# 角色过滤
if role_id is not None:
query = query.filter(User.role_id == role_id)
# 分页
pagination = query.order_by(User.id.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return pagination
@staticmethod
def get_user_by_id(user_id):
"""通过ID获取用户"""
return User.query.get(user_id)
@staticmethod
def update_user(user_id, data):
"""更新用户信息"""
user = User.query.get(user_id)
if not user:
return False, "用户不存在"
try:
# 更新可编辑字段
if 'email' in data:
# 检查邮箱是否已被其他用户使用
existing = User.query.filter(User.email == data['email'], User.id != user_id).first()
if existing:
return False, "邮箱已被使用"
user.email = data['email']
if 'phone' in data:
# 检查手机号是否已被其他用户使用
existing = User.query.filter(User.phone == data['phone'], User.id != user_id).first()
if existing:
return False, "手机号已被使用"
user.phone = data['phone']
if 'nickname' in data and data['nickname']:
user.nickname = data['nickname']
# 只有管理员可以修改这些字段
if 'role_id' in data:
user.role_id = data['role_id']
if 'status' in data:
user.status = data['status']
if 'password' in data and data['password']:
user.set_password(data['password'])
user.updated_at = datetime.now()
db.session.commit()
return True, "用户信息更新成功"
except Exception as e:
db.session.rollback()
return False, f"更新失败: {str(e)}"
@staticmethod
def change_user_status(user_id, status):
"""变更用户状态 (启用/禁用)"""
user = User.query.get(user_id)
if not user:
return False, "用户不存在"
try:
user.status = status
user.updated_at = datetime.now()
db.session.commit()
status_text = "启用" if status == 1 else "禁用"
return True, f"用户已{status_text}"
except Exception as e:
db.session.rollback()
return False, f"状态变更失败: {str(e)}"
@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()
db.session.commit()
return True, "用户已删除"
except Exception as e:
db.session.rollback()
return False, f"删除失败: {str(e)}"
@staticmethod
def get_all_roles():
"""获取所有角色"""
return Role.query.all()
@staticmethod
def create_role(role_name, description=None):
"""创建新角色"""
existing = Role.query.filter_by(role_name=role_name).first()
if existing:
return False, "角色名已存在"
try:
role = Role(role_name=role_name, description=description)
db.session.add(role)
db.session.commit()
return True, "角色创建成功"
except Exception as e:
db.session.rollback()
return False, f"创建失败: {str(e)}"
@staticmethod
def update_role(role_id, role_name, description=None):
"""更新角色信息"""
role = Role.query.get(role_id)
if not role:
return False, "角色不存在"
# 检查角色名是否已被使用
existing = Role.query.filter(Role.role_name == role_name, Role.id != role_id).first()
if existing:
return False, "角色名已存在"
try:
role.role_name = role_name
if description is not None:
role.description = description
db.session.commit()
return True, "角色更新成功"
except Exception as e:
db.session.rollback()
return False, f"更新失败: {str(e)}"