online_shopping_07_04

This commit is contained in:
superlishunqin 2025-07-04 19:07:35 +08:00
commit 5fcd6b7017
119 changed files with 32333 additions and 0 deletions

178
.gitignore vendored Normal file
View File

@ -0,0 +1,178 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Flask stuff:
instance/
.webassets-cache
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# Environments (保留配置文件,只排除本地环境变量)
.env.local
.env.development
.env.test
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# 操作系统
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
# 虚拟环境
venv/
.venv/
env/
ENV/
env.bak/
venv.bak/
# 日志文件
logs/*.log
*.log
# 上传文件(用户上传的内容不需要版本控制)
app/static/uploads/*
!app/static/uploads/.gitkeep
# 数据库文件如果使用SQLite
*.db
*.sqlite
*.sqlite3
# 临时文件
temp/
tmp/
*.tmp
*.bak
*.backup
# 测试输出
test_output/
test_results/
# 缓存文件
.cache/
# 压缩文件
*.zip
*.tar.gz
*.rar
# 编译文件
*.pyc
# 性能分析
*.prof
# 本地开发临时文件
scratch/
notes.txt
todo.txt
# Docker临时文件保留主要Docker文件
.dockerignore.bak
# 部署相关临时文件
deploy_temp/

211
README.md Normal file
View File

@ -0,0 +1,211 @@
<div align="center">
# 🛍️ Python Flask 电商系统 🛍️
**一个功能完善、技术栈现代、可直接部署的线上电商平台毕业设计项目。**
![Python](https://img.shields.io/badge/Python-3.8%2B-blue?style=for-the-badge&logo=python)![Flask](https://img.shields.io/badge/Flask-2.x-black?style=for-the-badge&logo=flask)![MySQL](https://img.shields.io/badge/MySQL-5.7%2B-orange?style=for-the-badge&logo=mysql)![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=for-the-badge&logo=docker)![Tencent Cloud](https://img.shields.io/badge/Tencent_Cloud-COS_%26_CDN-red?style=for-the-badge&logo=tencent-cloud)
</div>
---
## ✨ 项目简介
本项目是一个基于 **Python + Flask** 构建的全功能线上电商系统。它涵盖了从用户注册登录、商品浏览、购物车、下单支付到后台管理的全流程。项目架构清晰,代码结构规范,并集成了**腾讯云COS**、**CDN**等云服务,最终可通过 **Docker** 实现快速、标准化的部署。
## 🚀 技术栈概览
| 分类 | 技术 | 描述 |
| :--- | :--- | :--- |
| 🖥️ **后端** | `Python`, `Flask`, `SQLAlchemy` | 核心开发语言、Web框架与ORM |
| 🗃️ **数据库** | `MySQL` | 持久化数据存储 |
| 🎨 **前端** | `HTML`, `CSS`, `JavaScript`, `Bootstrap` | 页面构建、样式与交互逻辑 |
| ☁️ **云服务** | `腾讯云COS`, `腾讯云CDN`, `微信支付` | 对象存储、内容分发网络与支付服务 |
| 🐳 **部署** | `Docker`, `Nginx` | 容器化部署与反向代理 |
| ⚙️ **工具** | `Flask-Login`, `Flask-WTF`, `Flask-Mail` | 用户认证、表单处理、邮件服务 |
## 🌟 系统核心功能
<details>
<summary><b>🛍️ 用户端功能 (点击展开)</b></summary>
- **👤 用户中心**
- 手机/邮箱注册登录,支持短信/邮箱验证码
- 微信授权登录(可选)
- 个人信息编辑(昵称、头像、性别等)
- 头像上传至腾讯云COS
- 收货地址管理(增删改查、设为默认)
- 我的收藏夹 & 浏览历史
- **🛒 购物流程**
- 多级商品分类导航
- 商品列表(分页、排序、筛选、搜索)
- 商品详情页(轮播图、规格选择、用户评价)
- 购物车(添加、修改数量、删除、结算)
- 未登录用户购物车(`localStorage` 支持)
- **💳 订单与支付**
- 创建订单,填写备注
- **微信支付**PC端扫码、移动端JSAPI
- 订单状态跟踪(待支付、待发货、待收货、待评价...
- 查看订单详情与物流信息
- 取消订单、申请退款、确认收货
- **✍️ 评价系统**
- 对已完成订单的商品进行评价(评分、文字、图片)
- 匿名评价选项
</details>
<details>
<summary><b>🔧 管理后台功能 (点击展开)</b></summary>
- **📊 数据看板 (Dashboard)**
- 销售额、订单量、用户增长等核心指标可视化
- **📦 商品管理**
- 商品分类的增删改查
- 商品信息管理(上架/下架、编辑、库存、价格)
- 商品规格与属性管理
- **📋 订单管理**
- 按状态筛选和搜索订单
- 查看订单详情
- 执行发货操作(填写物流信息)
- 处理用户退款申请
- **👥 用户管理**
- 查看用户列表
- 禁用/启用用户账户
- **⚙️ 系统设置**
- 网站基本信息配置
- 支付接口与云存储配置
</details>
## 📂 项目结构
```
Online_shopping_platform/
├── app/ # 核心应用目录
│ ├── models/ # 📦 数据模型 (ORM)
│ ├── views/ # 👁️ 视图函数 (Controllers)
│ ├── templates/ # 📄 HTML模板
│ ├── static/ # 🎨 静态资源 (CSS, JS, Images)
│ ├── utils/ # 🛠️ 工具模块 (支付, COS, 邮件等)
│ ├── forms.py # 📝 表单定义
│ └── __init__.py # 🚀 应用工厂函数
├── config/ # ⚙️ 配置文件
├── docker/ # 🐳 Docker 相关配置
├── logs/ # 📜 日志文件
├── tests/ # 🧪 测试用例
├── requirements.txt # 📦 Python 依赖
├── run.py # ▶️ 应用启动脚本
└── README.md # 📖 你正在阅读的文件
```
## 🗄️ 数据库设计
项目数据库设计遵循电商业务逻辑,结构清晰,关系明确。
<details>
<summary><b>查看核心数据表 (点击展开)</b></summary>
| 表名 | 用途 |
| :--- | :--- |
| `users` | 存储用户信息 |
| `user_addresses` | 用户收货地址 |
| `products` | 商品基本信息 (SPU) |
| `product_inventory` | 商品库存单元 (SKU) |
| `categories` | 商品分类 |
| `cart` | 购物车 |
| `orders` | 订单主表 |
| `order_items` | 订单详情表 |
| `payments` | 支付记录 |
| `reviews` | 商品评价 |
| `admin_users` | 后台管理员 |
</details>
> 完整的 `CREATE TABLE` SQL语句已在项目文件中提供包含了详细的字段、索引和外键设计。
## 🛠️ 本地运行与部署
### 1. 环境准备
- 克隆项目到本地
```bash
git clone <your-repository-url>
cd Online_shopping_platform
```
- 创建并激活Python虚拟环境
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
```
- 安装项目依赖
```bash
pip install -r requirements.txt
```
### 2. 配置
- 复制 `.env.example` 文件为 `.env` (如果提供)。
- 根据提示修改 `config/` 目录下的配置文件,或设置环境变量,填入你的:
- **数据库连接信息**
- **Flask `SECRET_KEY`**
- **腾讯云 `COS``CDN` 配置**
- **微信支付商户信息**
- **邮件服务器配置**
### 3. 初始化
- 初始化数据库表结构
```bash
# 在Flask应用上下文中执行
flask db init # 如果使用Flask-Migrate
flask db migrate
flask db upgrade
# 或者使用自定义脚本
python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all()"
```
- 创建第一个管理员账户
```bash
python create_admin.py
```
### 4. 启动!
- 运行开发服务器
```bash
python run.py
```
- 🎉 恭喜!现在可以访问 `http://127.0.0.1:5000` 查看你的电商网站了。
### 5. Docker 部署 (推荐)
- 确保已安装 Docker 和 Docker Compose。
- 在项目根目录下执行:
```bash
docker-compose up --build -d
```
- 服务将在后台启动,并通过 Nginx 代理对外提供服务。
## 💡 项目亮点
- **🛡️ 安全性**: 全面考虑了常见的Web安全问题`SQL注入``XSS``CSRF`,并进行了有效防护。
- **⚡ 性能优化**: 集成了 `CDN加速``图片懒加载``数据库查询优化` 等多种性能优化手段。
- **📱 响应式设计**: 基于 Bootstrap 框架完美适配PC和移动设备提供一致的用户体验。
- **🧩 模块化设计**: 代码结构清晰,功能高度解耦,易于维护和二次开发。
- **☁️ 云原生支持**: 无缝集成云存储和CDN为高并发和大数据量场景打下基础。
---
<div align="center">
<p><em>本项目为毕业设计作品旨在展示一个完整的Web应用开发流程。</em></p>
<p>作者:林金兴 | 指导老师:[指导老师姓名]</p>
</div>

95
app/__init__.py Normal file
View File

@ -0,0 +1,95 @@
"""
Flask应用工厂
"""
from flask import Flask
from flask_mail import Mail
from config.config import Config
from config.database import db
import re
# 初始化邮件服务
mail = Mail()
def create_app(config_name='default'):
app = Flask(__name__)
# 加载配置
app.config.from_object(Config)
# 初始化数据库
db.init_app(app)
# 初始化邮件服务
mail.init_app(app)
# 注册自定义过滤器
register_filters(app)
# 注册蓝图
register_blueprints(app)
# 创建数据库表
with app.app_context():
try:
db.create_all()
print("✅ 数据库表创建/同步成功")
except Exception as e:
print(f"❌ 数据库表创建失败: {str(e)}")
return app
def register_filters(app):
"""注册自定义过滤器"""
@app.template_filter('nl2br')
def nl2br_filter(text):
"""将换行符转换为HTML <br> 标签"""
if not text:
return ''
# 将换行符替换为 <br> 标签
return text.replace('\n', '<br>')
@app.template_filter('truncate_chars')
def truncate_chars_filter(text, length=50):
"""截断字符串"""
if not text:
return ''
if len(text) <= length:
return text
return text[:length] + '...'
def register_blueprints(app):
"""注册蓝图"""
from app.views.main import main_bp
from app.views.auth import auth_bp
from app.views.user import user_bp
from app.views.admin import admin_bp
from app.views.product import product_bp
from app.views.cart import cart_bp
from app.views.address import address_bp
from app.views.order import order_bp
from app.views.payment import payment_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(user_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(product_bp)
app.register_blueprint(cart_bp)
app.register_blueprint(address_bp)
app.register_blueprint(order_bp)
app.register_blueprint(payment_bp)
# 修复正确注册upload蓝图并设置URL前缀
try:
from app.views.upload import upload_bp
app.register_blueprint(upload_bp, url_prefix='/upload') # 添加URL前缀
print("✅ 上传功能蓝图注册成功")
except ImportError as e:
print(f"⚠️ 上传功能暂时不可用: {str(e)}")
print("✅ 商品管理蓝图注册成功")
print("✅ 购物车蓝图注册成功")

128
app/forms.py Normal file
View File

@ -0,0 +1,128 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length, Email, ValidationError, Regexp, EqualTo
from app.models.user import User
from wtforms import TextAreaField, SelectField, DecimalField, IntegerField, HiddenField
class LoginForm(FlaskForm):
username = StringField('用户名/手机号/邮箱', validators=[
DataRequired(message='请输入用户名、手机号或邮箱'),
Length(min=3, max=50, message='长度必须在3-50个字符之间')
])
password = PasswordField('密码', validators=[
DataRequired(message='请输入密码'),
Length(min=6, max=20, message='密码长度必须在6-20个字符之间')
])
remember_me = BooleanField('记住我')
submit = SubmitField('登录')
class RegisterForm(FlaskForm):
username = StringField('用户名', validators=[
DataRequired(message='请输入用户名'),
Length(min=3, max=20, message='用户名长度必须在3-20个字符之间'),
Regexp(r'^[a-zA-Z0-9_]+$', message='用户名只能包含字母、数字和下划线')
])
email = StringField('邮箱', validators=[
DataRequired(message='请输入邮箱'),
Email(message='请输入有效的邮箱地址')
])
email_code = StringField('邮箱验证码', validators=[
DataRequired(message='请输入邮箱验证码'),
Length(min=6, max=6, message='验证码为6位数字')
])
phone = StringField('手机号', validators=[
DataRequired(message='请输入手机号'),
Regexp(r'^1[3-9]\d{9}$', message='请输入有效的手机号')
])
password = PasswordField('密码', validators=[
DataRequired(message='请输入密码'),
Length(min=6, max=20, message='密码长度必须在6-20个字符之间'),
Regexp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]{6,}$',
message='密码必须包含至少一个字母和一个数字')
])
confirm_password = PasswordField('确认密码', validators=[
DataRequired(message='请确认密码'),
EqualTo('password', message='两次输入的密码不一致')
])
submit = SubmitField('注册')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('用户名已存在')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('邮箱已被注册')
def validate_phone(self, phone):
user = User.query.filter_by(phone=phone.data).first()
if user:
raise ValidationError('手机号已被注册')
class SendEmailCodeForm(FlaskForm):
"""发送邮箱验证码表单"""
email = StringField('邮箱', validators=[
DataRequired(message='请输入邮箱'),
Email(message='请输入有效的邮箱地址')
])
submit = SubmitField('发送验证码')
class AddressForm(FlaskForm):
"""地址表单"""
receiver_name = StringField('收货人', validators=[
DataRequired(message='请输入收货人姓名'),
Length(min=2, max=20, message='收货人姓名长度必须在2-20个字符之间')
])
receiver_phone = StringField('手机号', validators=[
DataRequired(message='请输入手机号'),
Regexp(r'^1[3-9]\d{9}$', message='请输入有效的手机号')
])
province = SelectField('省份', validators=[
DataRequired(message='请选择省份')
], choices=[])
city = SelectField('城市', validators=[
DataRequired(message='请选择城市')
], choices=[])
district = SelectField('区县', validators=[
DataRequired(message='请选择区县')
], choices=[])
detail_address = StringField('详细地址', validators=[
DataRequired(message='请输入详细地址'),
Length(min=5, max=200, message='详细地址长度必须在5-200个字符之间')
])
postal_code = StringField('邮政编码', validators=[
Length(max=10, message='邮政编码长度不能超过10个字符')
])
is_default = BooleanField('设为默认地址')
submit = SubmitField('保存地址')
class CheckoutForm(FlaskForm):
"""结算表单"""
address_id = SelectField('收货地址', validators=[
DataRequired(message='请选择收货地址')
], coerce=int, choices=[])
shipping_method = SelectField('配送方式', validators=[
DataRequired(message='请选择配送方式')
], choices=[
('standard', '标准配送(免费)'),
('express', '次日达(+10元'),
('same_day', '当日达(+20元')
], default='standard')
payment_method = SelectField('支付方式', validators=[
DataRequired(message='请选择支付方式')
], choices=[
('wechat', '微信支付'),
('alipay', '支付宝'),
('bank', '银行卡支付')
], default='wechat')
remark = TextAreaField('订单备注', validators=[
Length(max=200, message='备注长度不能超过200个字符')
])
selected_items = HiddenField('选中商品')
submit = SubmitField('提交订单')

18
app/models/__init__.py Normal file
View File

@ -0,0 +1,18 @@
from app.models.user import User
from app.models.verification import EmailVerification
from app.models.admin import AdminUser
from app.models.operation_log import OperationLog
from app.models.product import Category, Product, ProductImage, SpecName, SpecValue, ProductInventory, InventoryLog, ProductSpecRelation
from app.models.cart import Cart
from app.models.address import UserAddress
from app.models.order import Order, OrderItem, ShippingInfo
from app.models.payment import Payment
from app.models.review import Review
__all__ = [
'User', 'EmailVerification', 'AdminUser', 'OperationLog',
'Category', 'Product', 'ProductImage', 'SpecName', 'SpecValue',
'ProductInventory', 'InventoryLog', 'ProductSpecRelation',
'Cart', 'UserAddress', 'Order', 'OrderItem', 'ShippingInfo',
'Payment', 'Review'
]

79
app/models/address.py Normal file
View File

@ -0,0 +1,79 @@
"""
用户地址模型
"""
from datetime import datetime
from config.database import db
class UserAddress(db.Model):
"""用户地址模型"""
__tablename__ = 'user_addresses'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
receiver_name = db.Column(db.String(50), nullable=False)
receiver_phone = db.Column(db.String(20), nullable=False)
province = db.Column(db.String(50), nullable=False)
city = db.Column(db.String(50), nullable=False)
district = db.Column(db.String(50), nullable=False)
detail_address = db.Column(db.String(200), nullable=False)
postal_code = db.Column(db.String(10))
is_default = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
user = db.relationship('User', backref='addresses')
def get_full_address(self):
"""获取完整地址"""
return f"{self.province} {self.city} {self.district} {self.detail_address}"
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'user_id': self.user_id,
'receiver_name': self.receiver_name,
'receiver_phone': self.receiver_phone,
'province': self.province,
'city': self.city,
'district': self.district,
'detail_address': self.detail_address,
'postal_code': self.postal_code,
'full_address': self.get_full_address(),
'is_default': self.is_default,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@classmethod
def set_default_address(cls, user_id, address_id):
"""设置默认地址"""
try:
# 先取消所有默认地址
cls.query.filter_by(user_id=user_id).update({'is_default': 0})
# 设置新的默认地址
address = cls.query.filter_by(id=address_id, user_id=user_id).first()
if address:
address.is_default = 1
db.session.commit()
return True
return False
except Exception:
db.session.rollback()
return False
@classmethod
def get_default_address(cls, user_id):
"""获取默认地址"""
return cls.query.filter_by(user_id=user_id, is_default=1).first()
@classmethod
def get_user_addresses(cls, user_id):
"""获取用户所有地址"""
return cls.query.filter_by(user_id=user_id).order_by(
cls.is_default.desc(), cls.created_at.desc()
).all()
def __repr__(self):
return f'<UserAddress {self.receiver_name}-{self.get_full_address()}>'

52
app/models/admin.py Normal file
View File

@ -0,0 +1,52 @@
"""
管理员模型
"""
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from config.database import db
class AdminUser(db.Model):
__tablename__ = 'admin_users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50), unique=True, nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
real_name = db.Column(db.String(50))
email = db.Column(db.String(100))
phone = db.Column(db.String(20))
role = db.Column(db.String(20), default='admin')
status = db.Column(db.Integer, default=1) # 0-禁用 1-正常
last_login_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def set_password(self, password):
"""设置密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def update_last_login(self):
"""更新最后登录时间"""
self.last_login_at = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'username': self.username,
'real_name': self.real_name,
'email': self.email,
'phone': self.phone,
'role': self.role,
'status': self.status,
'last_login_at': self.last_login_at.isoformat() if self.last_login_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<AdminUser {self.username}>'

136
app/models/cart.py Normal file
View File

@ -0,0 +1,136 @@
"""
购物车模型
"""
from datetime import datetime
from config.database import db
from app.models.product import Product, ProductInventory
class Cart(db.Model):
"""购物车模型"""
__tablename__ = 'cart'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
sku_code = db.Column(db.String(100))
spec_combination = db.Column(db.String(255))
quantity = db.Column(db.Integer, nullable=False, default=1)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
user = db.relationship('User', backref='cart_items')
product = db.relationship('Product', backref='cart_items')
def get_sku_info(self):
"""获取SKU信息"""
if self.sku_code:
return ProductInventory.query.filter_by(sku_code=self.sku_code).first()
else:
# 如果没有SKU返回默认库存信息
return ProductInventory.query.filter_by(
product_id=self.product_id,
is_default=1
).first()
def get_price(self):
"""获取商品价格"""
sku_info = self.get_sku_info()
if sku_info:
return sku_info.get_final_price()
return float(self.product.price) if self.product and self.product.price else 0
def get_total_price(self):
"""获取小计金额"""
return self.get_price() * self.quantity
def get_stock(self):
"""获取库存数量"""
sku_info = self.get_sku_info()
return sku_info.stock if sku_info else 0
def is_available(self):
"""检查商品是否可用"""
# 检查商品是否上架
if not self.product or self.product.status != 1:
return False
# 检查库存
if self.get_stock() < self.quantity:
return False
return True
def to_dict(self):
"""转换为字典"""
sku_info = self.get_sku_info()
return {
'id': self.id,
'user_id': self.user_id,
'product_id': self.product_id,
'product_name': self.product.name if self.product else '',
'product_image': self.product.main_image if self.product else '',
'brand': self.product.brand if self.product else '',
'sku_code': self.sku_code,
'spec_combination': self.spec_combination,
'quantity': self.quantity,
'price': self.get_price(),
'total_price': self.get_total_price(),
'stock': self.get_stock(),
'is_available': self.is_available(),
'created_at': self.created_at.isoformat() if self.created_at else None
}
@classmethod
def add_to_cart(cls, user_id, product_id, sku_code=None, spec_combination=None, quantity=1):
"""添加商品到购物车"""
# 检查是否已存在相同商品
existing_item = cls.query.filter_by(
user_id=user_id,
product_id=product_id,
sku_code=sku_code
).first()
if existing_item:
# 更新数量
existing_item.quantity += quantity
existing_item.updated_at = datetime.utcnow()
db.session.commit()
return existing_item
else:
# 创建新记录
cart_item = cls(
user_id=user_id,
product_id=product_id,
sku_code=sku_code,
spec_combination=spec_combination,
quantity=quantity
)
db.session.add(cart_item)
db.session.commit()
return cart_item
@classmethod
def get_user_cart(cls, user_id):
"""获取用户购物车"""
return cls.query.filter_by(user_id=user_id)\
.order_by(cls.created_at.desc()).all()
@classmethod
def get_cart_count(cls, user_id):
"""获取购物车商品数量"""
return cls.query.filter_by(user_id=user_id).count()
@classmethod
def get_cart_total(cls, user_id):
"""获取购物车总金额"""
cart_items = cls.get_user_cart(user_id)
total = 0
for item in cart_items:
if item.is_available():
total += item.get_total_price()
return total
def __repr__(self):
return f'<Cart {self.user_id}-{self.product_id}>'

View File

@ -0,0 +1,57 @@
"""
操作日志模型
"""
from datetime import datetime
from config.database import db
import json
class OperationLog(db.Model):
__tablename__ = 'operation_logs'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer)
user_type = db.Column(db.Integer) # 1-普通用户 2-管理员
action = db.Column(db.String(100), nullable=False)
resource_type = db.Column(db.String(50))
resource_id = db.Column(db.Integer)
ip_address = db.Column(db.String(45))
user_agent = db.Column(db.Text)
request_data = db.Column(db.JSON)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@classmethod
def create_log(cls, user_id=None, user_type=1, action='', resource_type=None,
resource_id=None, ip_address=None, user_agent=None, request_data=None):
"""创建操作日志"""
log = cls(
user_id=user_id,
user_type=user_type,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_data=request_data
)
db.session.add(log)
db.session.commit()
return log
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'user_id': self.user_id,
'user_type': self.user_type,
'action': self.action,
'resource_type': self.resource_type,
'resource_id': self.resource_id,
'ip_address': self.ip_address,
'user_agent': self.user_agent,
'request_data': self.request_data,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<OperationLog {self.action}>'

182
app/models/order.py Normal file
View File

@ -0,0 +1,182 @@
"""
订单模型
"""
from datetime import datetime, timedelta
import json
from config.database import db
from app.models.user import User
from app.models.product import Product
class Order(db.Model):
"""订单模型"""
__tablename__ = 'orders'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
order_sn = db.Column(db.String(50), unique=True, nullable=False)
total_amount = db.Column(db.Numeric(10, 2), nullable=False)
actual_amount = db.Column(db.Numeric(10, 2), nullable=False)
shipping_fee = db.Column(db.Numeric(10, 2), default=0)
status = db.Column(db.Integer, default=1) # 1-待支付 2-待发货 3-待收货 4-待评价 5-已完成 6-已取消 7-退款中
payment_method = db.Column(db.String(20))
shipping_method = db.Column(db.String(50))
receiver_info = db.Column(db.Text) # JSON格式存储收货人信息
remark = db.Column(db.Text)
shipped_at = db.Column(db.DateTime)
received_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
user = db.relationship('User', backref='orders')
order_items = db.relationship('OrderItem', backref='order', cascade='all, delete-orphan')
# 状态常量
STATUS_PENDING_PAYMENT = 1 # 待支付
STATUS_PENDING_SHIPMENT = 2 # 待发货
STATUS_SHIPPED = 3 # 待收货
STATUS_PENDING_REVIEW = 4 # 待评价
STATUS_COMPLETED = 5 # 已完成
STATUS_CANCELLED = 6 # 已取消
STATUS_REFUNDING = 7 # 退款中
STATUS_CHOICES = {
STATUS_PENDING_PAYMENT: '待支付',
STATUS_PENDING_SHIPMENT: '待发货',
STATUS_SHIPPED: '待收货',
STATUS_PENDING_REVIEW: '待评价',
STATUS_COMPLETED: '已完成',
STATUS_CANCELLED: '已取消',
STATUS_REFUNDING: '退款中'
}
def get_status_text(self):
"""获取状态文本"""
return self.STATUS_CHOICES.get(self.status, '未知状态')
def get_receiver_info(self):
"""获取收货人信息"""
if self.receiver_info:
try:
return json.loads(self.receiver_info)
except:
return {}
return {}
def set_receiver_info(self, info):
"""设置收货人信息"""
if isinstance(info, dict):
self.receiver_info = json.dumps(info, ensure_ascii=False)
def is_expired(self):
"""检查订单是否已过期15分钟未支付"""
if self.status == self.STATUS_PENDING_PAYMENT:
expire_time = self.created_at + timedelta(minutes=15)
return datetime.utcnow() > expire_time
return False
def can_cancel(self):
"""检查是否可以取消"""
return self.status in [self.STATUS_PENDING_PAYMENT, self.STATUS_PENDING_SHIPMENT]
def can_pay(self):
"""检查是否可以支付"""
return self.status == self.STATUS_PENDING_PAYMENT and not self.is_expired()
def can_confirm_receipt(self):
"""检查是否可以确认收货"""
return self.status == self.STATUS_SHIPPED
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'order_sn': self.order_sn,
'total_amount': float(self.total_amount),
'actual_amount': float(self.actual_amount),
'shipping_fee': float(self.shipping_fee),
'status': self.status,
'status_text': self.get_status_text(),
'payment_method': self.payment_method,
'shipping_method': self.shipping_method,
'receiver_info': self.get_receiver_info(),
'remark': self.remark,
'can_cancel': self.can_cancel(),
'can_pay': self.can_pay(),
'can_confirm_receipt': self.can_confirm_receipt(),
'is_expired': self.is_expired(),
'created_at': self.created_at.isoformat() if self.created_at else None,
'shipped_at': self.shipped_at.isoformat() if self.shipped_at else None,
'received_at': self.received_at.isoformat() if self.received_at else None
}
@classmethod
def generate_order_sn(cls):
"""生成订单号"""
import time
import random
timestamp = str(int(time.time()))
random_str = str(random.randint(100000, 999999))
return f"TB{timestamp}{random_str}"
def __repr__(self):
return f'<Order {self.order_sn}>'
class OrderItem(db.Model):
"""订单商品明细模型"""
__tablename__ = 'order_items'
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
sku_code = db.Column(db.String(100))
product_name = db.Column(db.String(200), nullable=False)
product_image = db.Column(db.String(255))
spec_combination = db.Column(db.String(255))
price = db.Column(db.Numeric(10, 2), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
total_price = db.Column(db.Numeric(10, 2), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 关联关系
product = db.relationship('Product', backref='order_items')
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'product_id': self.product_id,
'product_name': self.product_name,
'product_image': self.product_image,
'spec_combination': self.spec_combination,
'price': float(self.price),
'quantity': self.quantity,
'total_price': float(self.total_price)
}
def __repr__(self):
return f'<OrderItem {self.product_name}>'
class ShippingInfo(db.Model):
"""物流信息模型"""
__tablename__ = 'shipping_info'
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False)
shipping_company = db.Column(db.String(50))
tracking_number = db.Column(db.String(100))
shipping_status = db.Column(db.Integer, default=1) # 1-已发货 2-运输中 3-已送达
shipping_address = db.Column(db.Text)
estimated_delivery = db.Column(db.DateTime)
actual_delivery = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
order = db.relationship('Order', backref='shipping_info')
def __repr__(self):
return f'<ShippingInfo {self.tracking_number}>'

68
app/models/payment.py Normal file
View File

@ -0,0 +1,68 @@
"""
支付模型
"""
from datetime import datetime
from config.database import db
class Payment(db.Model):
"""支付记录模型"""
__tablename__ = 'payments'
id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False)
payment_sn = db.Column(db.String(64), unique=True, nullable=False)
payment_method = db.Column(db.String(20), nullable=False)
amount = db.Column(db.Numeric(10, 2), nullable=False)
status = db.Column(db.Integer, default=1) # 1-待支付 2-支付成功 3-支付失败 4-已退款
third_party_sn = db.Column(db.String(100)) # 第三方支付流水号
callback_data = db.Column(db.Text) # 支付回调数据
paid_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联关系
order = db.relationship('Order', backref='payments')
# 状态常量
STATUS_PENDING = 1 # 待支付
STATUS_SUCCESS = 2 # 支付成功
STATUS_FAILED = 3 # 支付失败
STATUS_REFUNDED = 4 # 已退款
STATUS_CHOICES = {
STATUS_PENDING: '待支付',
STATUS_SUCCESS: '支付成功',
STATUS_FAILED: '支付失败',
STATUS_REFUNDED: '已退款'
}
def get_status_text(self):
"""获取状态文本"""
return self.STATUS_CHOICES.get(self.status, '未知状态')
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'payment_sn': self.payment_sn,
'payment_method': self.payment_method,
'amount': float(self.amount),
'status': self.status,
'status_text': self.get_status_text(),
'third_party_sn': self.third_party_sn,
'paid_at': self.paid_at.isoformat() if self.paid_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@classmethod
def generate_payment_sn(cls):
"""生成支付流水号"""
import time
import random
timestamp = str(int(time.time()))
random_str = str(random.randint(100000, 999999))
return f"PAY{timestamp}{random_str}"
def __repr__(self):
return f'<Payment {self.payment_sn}>'

265
app/models/product.py Normal file
View File

@ -0,0 +1,265 @@
"""
商品相关模型
"""
from datetime import datetime
from config.database import db
import json
class Category(db.Model):
"""商品分类模型"""
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
parent_id = db.Column(db.Integer, default=0)
level = db.Column(db.Integer, default=1)
sort_order = db.Column(db.Integer, default=0)
icon_url = db.Column(db.String(255))
is_active = db.Column(db.Integer, default=1)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'parent_id': self.parent_id,
'level': self.level,
'sort_order': self.sort_order,
'icon_url': self.icon_url,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Category {self.name}>'
class SpecName(db.Model):
"""规格名称模型"""
__tablename__ = 'spec_names'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), nullable=False)
sort_order = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'sort_order': self.sort_order
}
def __repr__(self):
return f'<SpecName {self.name}>'
class SpecValue(db.Model):
"""规格值模型"""
__tablename__ = 'spec_values'
id = db.Column(db.Integer, primary_key=True)
spec_name_id = db.Column(db.Integer, db.ForeignKey('spec_names.id'), nullable=False)
value = db.Column(db.String(100), nullable=False)
sort_order = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
spec_name = db.relationship('SpecName', backref='values')
def to_dict(self):
return {
'id': self.id,
'spec_name_id': self.spec_name_id,
'value': self.value,
'sort_order': self.sort_order
}
def __repr__(self):
return f'<SpecValue {self.value}>'
class Product(db.Model):
"""商品模型"""
__tablename__ = 'products'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=False)
brand = db.Column(db.String(100))
price = db.Column(db.Numeric(10, 2), nullable=False)
original_price = db.Column(db.Numeric(10, 2))
description = db.Column(db.Text)
main_image = db.Column(db.String(255))
status = db.Column(db.Integer, default=1) # 0-下架 1-上架
has_specs = db.Column(db.Integer, default=0) # 0-无规格 1-有规格
sales_count = db.Column(db.Integer, default=0)
view_count = db.Column(db.Integer, default=0)
weight = db.Column(db.Numeric(8, 2))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category = db.relationship('Category', backref='products')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'category_id': self.category_id,
'category_name': self.category.name if self.category else '',
'brand': self.brand,
'price': float(self.price) if self.price else 0,
'original_price': float(self.original_price) if self.original_price else None,
'description': self.description,
'main_image': self.main_image,
'status': self.status,
'has_specs': self.has_specs,
'sales_count': self.sales_count,
'view_count': self.view_count,
'weight': float(self.weight) if self.weight else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Product {self.name}>'
class ProductImage(db.Model):
"""商品图片模型"""
__tablename__ = 'product_images'
id = db.Column(db.Integer, primary_key=True)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
image_url = db.Column(db.String(255), nullable=False)
sort_order = db.Column(db.Integer, default=0)
is_main = db.Column(db.Integer, default=0) # 0-否 1-是
created_at = db.Column(db.DateTime, default=datetime.utcnow)
product = db.relationship('Product', backref='images')
def to_dict(self):
return {
'id': self.id,
'product_id': self.product_id,
'image_url': self.image_url,
'sort_order': self.sort_order,
'is_main': self.is_main,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<ProductImage {self.id}>'
class ProductSpecRelation(db.Model):
"""商品规格关联模型"""
__tablename__ = 'product_spec_relations'
id = db.Column(db.Integer, primary_key=True)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
spec_name_id = db.Column(db.Integer, db.ForeignKey('spec_names.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
product = db.relationship('Product', backref='spec_relations')
spec_name = db.relationship('SpecName')
def __repr__(self):
return f'<ProductSpecRelation {self.product_id}-{self.spec_name_id}>'
class ProductInventory(db.Model):
"""商品库存模型(SKU)"""
__tablename__ = 'product_inventory'
id = db.Column(db.Integer, primary_key=True)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
sku_code = db.Column(db.String(100), unique=True, nullable=False)
spec_combination = db.Column(db.JSON)
price_adjustment = db.Column(db.Numeric(10, 2), default=0)
stock = db.Column(db.Integer, nullable=False, default=0)
warning_stock = db.Column(db.Integer, default=10)
is_default = db.Column(db.Integer, default=0)
status = db.Column(db.Integer, default=1)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
product = db.relationship('Product', backref='inventory')
def get_final_price(self):
"""获取最终价格"""
base_price = float(self.product.price) if self.product and self.product.price else 0
adjustment = float(self.price_adjustment) if self.price_adjustment else 0
return base_price + adjustment
def to_dict(self):
return {
'id': self.id,
'product_id': self.product_id,
'sku_code': self.sku_code,
'spec_combination': self.spec_combination,
'price_adjustment': float(self.price_adjustment) if self.price_adjustment else 0,
'final_price': self.get_final_price(),
'stock': self.stock,
'warning_stock': self.warning_stock,
'is_default': self.is_default,
'status': self.status,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<ProductInventory {self.sku_code}>'
class InventoryLog(db.Model):
"""库存变更日志模型"""
__tablename__ = 'inventory_logs'
id = db.Column(db.Integer, primary_key=True)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
sku_code = db.Column(db.String(100), nullable=False)
change_type = db.Column(db.Integer, nullable=False) # 1-入库 2-出库 3-调整
change_quantity = db.Column(db.Integer, nullable=False)
before_stock = db.Column(db.Integer, nullable=False)
after_stock = db.Column(db.Integer, nullable=False)
related_order_id = db.Column(db.Integer)
remark = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
product = db.relationship('Product')
@classmethod
def create_log(cls, product_id, sku_code, change_type, change_quantity,
before_stock, after_stock, related_order_id=None, remark=None):
"""创建库存变更日志"""
log = cls(
product_id=product_id,
sku_code=sku_code,
change_type=change_type,
change_quantity=change_quantity,
before_stock=before_stock,
after_stock=after_stock,
related_order_id=related_order_id,
remark=remark
)
db.session.add(log)
db.session.commit()
return log
def to_dict(self):
return {
'id': self.id,
'product_id': self.product_id,
'sku_code': self.sku_code,
'change_type': self.change_type,
'change_quantity': self.change_quantity,
'before_stock': self.before_stock,
'after_stock': self.after_stock,
'related_order_id': self.related_order_id,
'remark': self.remark,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<InventoryLog {self.sku_code}>'

64
app/models/review.py Normal file
View File

@ -0,0 +1,64 @@
"""
评价模型
"""
from datetime import datetime
import json
from config.database import db
class Review(db.Model):
"""商品评价模型"""
__tablename__ = 'reviews'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
product_id = db.Column(db.Integer, db.ForeignKey('products.id'), nullable=False)
order_id = db.Column(db.Integer, db.ForeignKey('orders.id'), nullable=False)
rating = db.Column(db.Integer, nullable=False) # 1-5星
content = db.Column(db.Text)
images = db.Column(db.Text) # JSON格式存储图片URLs
is_anonymous = db.Column(db.Integer, default=0)
status = db.Column(db.Integer, default=1) # 0-隐藏 1-显示
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 关联关系
user = db.relationship('User', backref='reviews')
product = db.relationship('Product', backref='reviews')
order = db.relationship('Order', backref='reviews')
def get_images(self):
"""获取评价图片列表"""
if self.images:
try:
return json.loads(self.images)
except:
return []
return []
def set_images(self, image_list):
"""设置评价图片"""
if isinstance(image_list, list):
self.images = json.dumps(image_list)
def get_rating_stars(self):
"""获取星级显示"""
return '' * self.rating + '' * (5 - self.rating)
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'user_id': self.user_id,
'username': self.user.username if not self.is_anonymous else '匿名用户',
'product_id': self.product_id,
'order_id': self.order_id,
'rating': self.rating,
'rating_stars': self.get_rating_stars(),
'content': self.content,
'images': self.get_images(),
'is_anonymous': self.is_anonymous,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<Review {self.id}-{self.rating}星>'

50
app/models/user.py Normal file
View File

@ -0,0 +1,50 @@
"""
用户模型
"""
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from config.database import db # 确保从正确位置导入
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50), unique=True, nullable=False)
phone = db.Column(db.String(20), unique=True)
email = db.Column(db.String(100), unique=True)
password_hash = db.Column(db.String(255), nullable=False)
nickname = db.Column(db.String(50))
avatar_url = db.Column(db.String(255))
gender = db.Column(db.Integer, default=0)
birthday = db.Column(db.Date)
status = db.Column(db.Integer, default=1)
wechat_openid = db.Column(db.String(100))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def set_password(self, password):
"""设置密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'username': self.username,
'phone': self.phone,
'email': self.email,
'nickname': self.nickname,
'avatar_url': self.avatar_url,
'gender': self.gender,
'birthday': self.birthday.isoformat() if self.birthday else None,
'status': self.status,
'created_at': self.created_at.isoformat() if self.created_at else None
}
def __repr__(self):
return f'<User {self.username}>'

View File

@ -0,0 +1,61 @@
from datetime import datetime, timedelta
from config.database import db
import random
import string
class EmailVerification(db.Model):
__tablename__ = 'email_verifications'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(100), nullable=False, index=True)
code = db.Column(db.String(6), nullable=False)
type = db.Column(db.SmallInteger, nullable=False) # 1-注册 2-登录 3-找回密码
is_used = db.Column(db.SmallInteger, default=0) # 0-未使用 1-已使用
expired_at = db.Column(db.DateTime, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
@staticmethod
def generate_code():
"""生成6位数字验证码"""
return ''.join(random.choices(string.digits, k=6))
@classmethod
def create_verification(cls, email, code_type, expire_minutes=10):
"""创建验证码记录"""
code = cls.generate_code()
expired_at = datetime.utcnow() + timedelta(minutes=expire_minutes)
verification = cls(
email=email,
code=code,
type=code_type,
expired_at=expired_at
)
db.session.add(verification)
db.session.commit()
return verification
@classmethod
def verify_code(cls, email, code, code_type):
"""验证验证码"""
verification = cls.query.filter_by(
email=email,
code=code,
type=code_type,
is_used=0
).filter(
cls.expired_at > datetime.utcnow()
).first()
if verification:
verification.is_used = 1
db.session.commit()
return True
return False
def is_expired(self):
"""检查是否过期"""
return datetime.utcnow() > self.expired_at

View File

@ -0,0 +1,96 @@
/* 地址表单页面样式 */
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
.form-label .text-danger {
font-size: 0.9em;
}
.form-select:focus,
.form-control:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
.text-danger {
font-size: 0.875em;
margin-top: 0.25rem;
}
/* 调试信息样式 */
.alert-info {
border-left: 4px solid #0dcaf0;
background-color: #cff4fc;
border-color: #b8daff;
}
#debugInfo {
font-family: 'Courier New', monospace;
font-size: 0.9em;
margin-top: 0.5rem;
}
/* 表单布局优化 */
.row .col-md-4,
.row .col-md-6,
.row .col-md-8 {
margin-bottom: 0;
}
.mb-3 {
margin-bottom: 1rem !important;
}
/* 按钮组样式 */
.d-flex.gap-2 {
gap: 0.5rem !important;
}
.btn {
padding: 0.5rem 1rem;
font-weight: 500;
}
/* 复选框样式 */
.form-check {
padding-left: 1.5em;
}
.form-check-input {
margin-top: 0.25em;
}
.form-check-label {
font-weight: 500;
cursor: pointer;
}
/* 响应式设计 */
@media (max-width: 768px) {
.col-md-4,
.col-md-6,
.col-md-8 {
margin-bottom: 1rem;
}
.d-flex.gap-2 {
flex-direction: column;
}
.d-flex.gap-2 .btn {
width: 100%;
}
}
/* 加载状态样式 */
.form-select:disabled {
background-color: #e9ecef;
opacity: 0.65;
}
.loading-text {
color: #6c757d;
font-style: italic;
}

View File

@ -0,0 +1,79 @@
/* 地址管理页面样式 */
.address-card {
transition: all 0.3s ease;
cursor: pointer;
}
.address-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.address-card.border-primary {
border-width: 2px !important;
}
.address-card .card-body {
position: relative;
}
.dropdown-toggle::after {
display: none;
}
/* 空状态样式 */
.empty-state {
padding: 3rem 0;
}
.empty-state i {
opacity: 0.5;
}
/* 地址卡片内容样式 */
.address-card .card-title {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.address-card .badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
.address-card .text-muted {
font-size: 0.9rem;
}
/* 下拉菜单样式 */
.dropdown-menu {
min-width: 120px;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0,0,0,.125);
}
.dropdown-item {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
.dropdown-item.text-danger:hover {
background-color: #f8d7da;
color: #721c24 !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.address-card {
margin-bottom: 1rem;
}
.col-md-6 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
}

View File

@ -0,0 +1,117 @@
:root {
--admin-primary: #0d6efd;
--admin-sidebar: #212529;
--admin-sidebar-hover: #495057;
--admin-bg: #f8f9fa;
}
body {
background-color: var(--admin-bg);
}
.admin-sidebar {
min-height: 100vh;
background-color: var(--admin-sidebar);
width: 250px;
position: fixed;
top: 0;
left: 0;
z-index: 1000;
padding-top: 20px;
}
.admin-sidebar .nav-link {
color: #fff;
padding: 12px 20px;
border-radius: 0;
margin-bottom: 2px;
}
.admin-sidebar .nav-link:hover,
.admin-sidebar .nav-link.active {
background-color: var(--admin-sidebar-hover);
color: #fff;
}
.admin-sidebar .nav-link i {
margin-right: 10px;
width: 20px;
}
.admin-main {
margin-left: 250px;
padding: 0;
}
.admin-header {
background-color: #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 15px 30px;
margin-bottom: 30px;
}
.admin-content {
padding: 0 30px 30px;
}
.stats-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
border: none;
}
.stats-card.success {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
}
.stats-card.warning {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.stats-card.info {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.admin-table {
background: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.admin-table th {
background-color: #f8f9fa;
border: none;
font-weight: 600;
color: #495057;
}
.admin-table td {
border: none;
vertical-align: middle;
}
.admin-table tbody tr {
border-bottom: 1px solid #f8f9fa;
}
.admin-table tbody tr:hover {
background-color: #f8f9fa;
}
.sidebar-brand {
color: #fff;
font-size: 1.2rem;
font-weight: bold;
padding: 0 20px 30px;
border-bottom: 1px solid #495057;
margin-bottom: 20px;
}
.sidebar-brand i {
margin-right: 10px;
color: var(--admin-primary);
}

View File

@ -0,0 +1,155 @@
/* 分类管理页面样式 */
.category-tree {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.category-item {
border: 1px solid #e9ecef;
border-radius: 8px;
margin-bottom: 10px;
transition: all 0.3s ease;
}
.category-item:hover {
border-color: #0d6efd;
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.15);
}
.category-header {
padding: 15px 20px;
background: #f8f9fa;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.category-level-1 .category-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.category-level-2 .category-header {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.category-level-3 .category-header {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.category-info {
display: flex;
align-items: center;
gap: 15px;
}
.category-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
border: 2px solid rgba(255,255,255,0.3);
}
.default-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.category-details h6 {
margin: 0;
font-weight: 600;
}
.category-meta {
font-size: 12px;
opacity: 0.8;
margin-top: 2px;
}
.category-actions {
display: flex;
gap: 8px;
}
.children-categories {
padding: 0 20px 20px;
margin-left: 40px;
border-left: 2px dashed #dee2e6;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.add-category-form {
background: #f8f9fa;
border-radius: 10px;
padding: 25px;
margin-bottom: 30px;
border: 2px dashed #dee2e6;
}
.icon-upload-area {
width: 80px;
height: 80px;
border: 2px dashed #dee2e6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
background: white;
}
.icon-upload-area:hover {
border-color: #0d6efd;
background: #e3f2fd;
}
.icon-upload-area img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.sort-handle {
cursor: move;
color: #6c757d;
margin-right: 10px;
}
.sort-handle:hover {
color: #0d6efd;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.5;
}

View File

@ -0,0 +1,39 @@
/* Dashboard specific styles */
.dashboard-stats {
margin-bottom: 30px;
}
.chart-container {
position: relative;
height: 300px;
}
.system-status-item {
display: flex;
justify-content: between;
align-items: center;
margin-bottom: 15px;
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.system-status-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.log-table-container {
margin-top: 30px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.5;
}

View File

@ -0,0 +1,82 @@
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: white;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
padding: 40px;
min-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
color: #333;
font-weight: 600;
margin-bottom: 10px;
}
.login-header p {
color: #666;
margin-bottom: 0;
}
.form-control {
padding: 12px 15px;
border-radius: 8px;
border: 1px solid #ddd;
font-size: 16px;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 12px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.input-group-text {
background-color: #f8f9fa;
border: 1px solid #ddd;
border-radius: 8px 0 0 8px;
}
.form-control {
border-radius: 0 8px 8px 0;
}
.form-control:first-child {
border-radius: 8px 0 0 8px;
}
.back-link {
text-decoration: none;
color: #667eea;
font-size: 14px;
}
.back-link:hover {
color: #764ba2;
}

View File

@ -0,0 +1,231 @@
/* 管理员个人资料页面样式 */
.profile-container {
padding: 20px 0;
}
.profile-card {
border: none;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 20px;
}
.profile-card .card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px 12px 0 0 !important;
padding: 15px 20px;
}
.profile-card .card-header h5 {
margin: 0;
font-weight: 600;
}
.profile-card .card-header i {
margin-right: 8px;
}
.profile-card .card-body {
padding: 25px;
}
.form-label {
font-weight: 600;
color: #495057;
margin-bottom: 8px;
}
.form-control {
border-radius: 8px;
border: 1px solid #e0e6ed;
padding: 12px 15px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.form-control[readonly] {
background-color: #f8f9fa;
color: #6c757d;
}
.form-text {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
padding: 12px 24px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-warning {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border: none;
border-radius: 8px;
padding: 12px 24px;
font-weight: 600;
color: #8b4513;
transition: all 0.3s ease;
}
.btn-warning:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(252, 182, 159, 0.4);
color: #8b4513;
}
.btn i {
margin-right: 6px;
}
/* 账号信息卡片 */
.info-card {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border: none;
border-radius: 12px;
margin-bottom: 20px;
}
.info-card .card-header {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 12px 12px 0 0 !important;
}
.info-card .card-body {
background: rgba(255, 255, 255, 0.1);
border-radius: 0 0 12px 12px;
}
.info-item {
margin-bottom: 15px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.info-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.info-item strong {
color: #2c3e50;
font-weight: 600;
}
.badge {
font-size: 12px;
padding: 6px 12px;
border-radius: 20px;
font-weight: 500;
}
.badge.bg-success {
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%) !important;
color: #2c3e50;
}
.badge.bg-danger {
background: linear-gradient(135deg, #fc466b 0%, #3f5efb 100%) !important;
color: white;
}
/* 密码修改卡片 */
.password-card {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border: none;
border-radius: 12px;
}
.password-card .card-header {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 12px 12px 0 0 !important;
}
.password-card .card-body {
background: rgba(255, 255, 255, 0.1);
border-radius: 0 0 12px 12px;
}
.password-card .form-control {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.3);
}
.password-card .form-control:focus {
background: white;
border-color: #fcb69f;
box-shadow: 0 0 0 0.2rem rgba(252, 182, 159, 0.25);
}
/* 响应式设计 */
@media (max-width: 768px) {
.profile-container {
padding: 10px 0;
}
.profile-card .card-body {
padding: 20px 15px;
}
.row .col-md-6 {
margin-bottom: 15px;
}
}
/* 动画效果 */
.profile-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 表单验证样式 */
.form-control.is-invalid {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
.form-control.is-valid {
border-color: #28a745;
box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.25);
}
.invalid-feedback {
display: block;
color: #dc3545;
font-size: 12px;
margin-top: 5px;
}
.valid-feedback {
display: block;
color: #28a745;
font-size: 12px;
margin-top: 5px;
}

65
app/static/css/auth.css Normal file
View File

@ -0,0 +1,65 @@
/* 认证页面样式 */
.auth-container {
min-height: 60vh;
display: flex;
align-items: center;
}
.auth-card {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: none;
border-radius: 10px;
}
.auth-card .card-header {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white;
border-radius: 10px 10px 0 0;
border: none;
}
.auth-card .card-header h4 {
margin: 0;
font-weight: 500;
}
.auth-card .card-body {
padding: 2rem;
}
/* 表单样式 */
.form-control:focus {
border-color: #007bff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.btn-primary {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
border: none;
padding: 0.75rem;
font-weight: 500;
}
.btn-primary:hover {
background: linear-gradient(135deg, #0056b3 0%, #004085 100%);
transform: translateY(-1px);
}
/* 链接样式 */
.auth-link {
color: #007bff;
text-decoration: none;
font-weight: 500;
}
.auth-link:hover {
color: #0056b3;
text-decoration: underline;
}
/* 响应式设计 */
@media (max-width: 576px) {
.auth-card .card-body {
padding: 1.5rem;
}
}

47
app/static/css/base.css Normal file
View File

@ -0,0 +1,47 @@
/* 基础样式 */
.navbar-brand {
font-weight: bold;
color: #007bff !important;
}
.footer {
background-color: #f8f9fa;
padding: 2rem 0;
margin-top: 3rem;
}
.alert {
margin-bottom: 0;
}
.search-form {
max-width: 300px;
}
.cart-badge {
position: relative;
top: -2px;
font-size: 0.7rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-form {
max-width: 100%;
margin: 10px 0;
}
}
/* 返回顶部按钮 */
#backToTop {
display: none;
z-index: 1000;
}
/* 成功提示框样式 */
.success-toast {
top: 20px;
right: 20px;
z-index: 9999;
min-width: 300px;
}

26
app/static/css/cart.css Normal file
View File

@ -0,0 +1,26 @@
.cart-item {
transition: background-color 0.2s;
}
.cart-item:hover {
background-color: #f8f9fa;
}
.quantity-input {
width: 60px;
}
.item-checkbox {
transform: scale(1.2);
}
.position-sticky {
top: 20px !important;
}
@media (max-width: 768px) {
.col-md-4 .position-sticky {
position: relative !important;
top: auto !important;
}
}

View File

@ -0,0 +1,46 @@
/* 订单结算页面样式 */
.checkout-section {
margin-bottom: 20px;
}
.address-card {
cursor: pointer;
transition: all 0.3s ease;
}
.address-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.address-card.selected {
border-color: #007bff;
background-color: #f8f9ff;
}
.product-item {
border-bottom: 1px solid #eee;
padding: 15px 0;
}
.product-item:last-child {
border-bottom: none;
}
.order-summary {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.price-row {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.total-price {
font-size: 1.2em;
font-weight: bold;
color: #e74c3c;
}

68
app/static/css/index.css Normal file
View File

@ -0,0 +1,68 @@
/* 首页样式 */
.product-card {
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.category-card {
transition: all 0.2s;
}
.category-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
/* 欢迎横幅样式 */
.jumbotron {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
}
/* 商品图片样式 */
.product-image {
height: 200px;
object-fit: cover;
}
.product-image-placeholder {
height: 200px;
background-color: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
}
/* 价格样式 */
.price-current {
color: #dc3545;
font-weight: bold;
}
.price-original {
color: #6c757d;
text-decoration: line-through;
font-size: 0.875rem;
}
/* 服务特色卡片 */
.feature-card {
transition: transform 0.2s;
}
.feature-card:hover {
transform: translateY(-3px);
}
/* 用户专区卡片 */
.user-zone-card {
transition: all 0.2s;
}
.user-zone-card:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

View File

@ -0,0 +1,80 @@
/* 订单详情页面样式 */
.order-status-timeline {
position: relative;
padding-left: 30px;
}
.timeline-item {
position: relative;
padding-bottom: 20px;
}
.timeline-item::before {
content: '';
position: absolute;
left: -30px;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #dee2e6;
}
.timeline-item.completed::before {
background-color: #28a745;
}
.timeline-item.current::before {
background-color: #007bff;
box-shadow: 0 0 0 4px rgba(0,123,255,0.2);
}
.timeline-item::after {
content: '';
position: absolute;
left: -24px;
top: 12px;
width: 2px;
height: calc(100% - 12px);
background-color: #dee2e6;
}
.timeline-item:last-child::after {
display: none;
}
.timeline-item.completed::after {
background-color: #28a745;
}
.order-detail-card {
margin-bottom: 20px;
}
.product-item {
border-bottom: 1px solid #f0f0f0;
padding: 15px 0;
}
.product-item:last-child {
border-bottom: none;
}
.product-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 4px;
}
.info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.total-amount {
color: #e74c3c;
font-weight: bold;
font-size: 1.2em;
}

160
app/static/css/orders.css Normal file
View File

@ -0,0 +1,160 @@
/* 订单页面样式 */
.order-card {
margin-bottom: 20px;
border: 1px solid #dee2e6;
border-radius: 8px;
transition: box-shadow 0.3s ease;
}
.order-card:hover {
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.order-header {
background-color: #f8f9fa;
padding: 15px;
border-bottom: 1px solid #dee2e6;
border-radius: 8px 8px 0 0;
}
.order-item {
padding: 15px;
border-bottom: 1px solid #f0f0f0;
}
.order-item:last-child {
border-bottom: none;
}
.order-footer {
background-color: #f8f9fa;
padding: 15px;
border-top: 1px solid #dee2e6;
border-radius: 0 0 8px 8px;
}
.status-badge {
font-size: 0.85em;
padding: 4px 8px;
}
.order-amount {
color: #e74c3c;
font-weight: bold;
font-size: 1.1em;
}
/* 强制限制商品图片尺寸 - 多重选择器确保优先级 */
.product-image,
img.product-image,
.order-item .product-image,
.order-item img.product-image {
width: 80px !important;
height: 80px !important;
object-fit: cover !important;
border-radius: 4px !important;
display: block !important;
max-width: 80px !important;
max-height: 80px !important;
min-width: 80px !important;
min-height: 80px !important;
}
/* 防止任何外部样式影响 */
.col-md-2 .product-image,
.order-item .col-md-2 img {
width: 80px !important;
height: 80px !important;
object-fit: cover !important;
border-radius: 4px !important;
}
/* 导航标签样式 */
.nav-pills .nav-link {
border-radius: 20px;
padding: 0.5rem 1rem;
margin-right: 0.5rem;
transition: all 0.3s ease;
}
.nav-pills .nav-link:hover {
background-color: #e9ecef;
color: #495057;
}
.nav-pills .nav-link.active {
background-color: #007bff;
color: white;
}
/* 空状态样式 */
.empty-state {
padding: 3rem 0;
}
.empty-state i {
opacity: 0.5;
}
/* 分页样式 */
.pagination {
margin-top: 2rem;
}
.page-link {
color: #007bff;
border-color: #dee2e6;
}
.page-link:hover {
color: #0056b3;
background-color: #e9ecef;
border-color: #dee2e6;
}
.page-item.active .page-link {
background-color: #007bff;
border-color: #007bff;
}
/* 按钮组样式 */
.btn-group .btn {
margin-right: 0.5rem;
}
.btn-group .btn:last-child {
margin-right: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.order-header .row,
.order-footer .row {
flex-direction: column;
}
.order-header .col-md-3,
.order-footer .col-md-6 {
margin-bottom: 0.5rem;
text-align: left !important;
}
.order-item .row {
flex-direction: column;
text-align: center;
}
.order-item .col-md-2,
.order-item .col-md-6 {
margin-bottom: 1rem;
}
.nav-pills {
flex-wrap: wrap;
}
.nav-pills .nav-link {
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
}

49
app/static/css/pay.css Normal file
View File

@ -0,0 +1,49 @@
/* 订单支付页面样式 */
.pay-container {
max-width: 600px;
margin: 50px auto;
}
.order-info {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.payment-method {
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 15px;
cursor: pointer;
transition: all 0.3s ease;
}
.payment-method:hover {
border-color: #007bff;
background-color: #f8f9ff;
}
.payment-method.selected {
border-color: #007bff;
background-color: #f8f9ff;
}
.qr-code {
text-align: center;
padding: 30px;
background-color: #f8f9fa;
border-radius: 8px;
}
.countdown {
font-size: 1.2em;
color: #e74c3c;
font-weight: bold;
}
.payment-status {
text-align: center;
padding: 20px;
}

View File

@ -0,0 +1,71 @@
.product-card {
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.spec-option {
border-radius: 4px;
transition: all 0.2s;
}
.spec-option:hover {
transform: translateY(-1px);
}
.thumbnail-image {
transition: all 0.2s;
}
.thumbnail-image:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.price-section {
background: linear-gradient(135deg, #fff5f5 0%, #ffeee8 100%);
padding: 20px;
border-radius: 8px;
border: 1px solid #ffe6e6;
}
.product-description {
line-height: 1.8;
white-space: pre-line;
}
.service-promises li {
padding: 5px 0;
}
/* 规格选择动效 */
.spec-option.btn-primary {
background-color: #007bff;
border-color: #007bff;
color: white;
transform: scale(1.05);
}
/* 按钮禁用状态样式 */
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式优化 */
@media (max-width: 768px) {
.price-section {
text-align: center;
}
.action-buttons .d-md-flex {
flex-direction: column;
}
.action-buttons .btn {
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,14 @@
.product-card {
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.btn-check:checked + .btn {
background-color: #007bff;
border-color: #007bff;
color: white;
}

231
app/static/css/profile.css Normal file
View File

@ -0,0 +1,231 @@
/* 个人中心页面样式 */
/* 头像上传相关CSS */
.avatar-upload {
position: relative !important;
display: inline-block !important;
width: 120px !important;
height: 120px !important;
overflow: hidden !important;
}
/* 强制限制头像尺寸 - 多重选择器确保优先级 */
.avatar-preview,
#avatarPreview,
.avatar-upload .avatar-preview,
.avatar-upload #avatarPreview {
width: 120px !important;
height: 120px !important;
border-radius: 50% !important;
border: 3px solid #ddd !important;
object-fit: cover !important;
cursor: pointer !important;
transition: all 0.3s ease !important;
display: block !important;
max-width: 120px !important;
max-height: 120px !important;
min-width: 120px !important;
min-height: 120px !important;
}
.avatar-preview:hover,
#avatarPreview:hover {
border-color: #007bff !important;
transform: scale(1.05) !important;
}
.avatar-placeholder {
width: 120px !important;
height: 120px !important;
border-radius: 50% !important;
border: 3px dashed #ddd !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
cursor: pointer !important;
transition: all 0.3s ease !important;
background-color: #f8f9fa !important;
}
.avatar-placeholder:hover {
border-color: #007bff !important;
background-color: #e3f2fd !important;
}
.upload-overlay {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 120px !important;
height: 120px !important;
border-radius: 50% !important;
background: rgba(0, 0, 0, 0.5) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
opacity: 0 !important;
transition: opacity 0.3s ease !important;
cursor: pointer !important;
}
.avatar-upload:hover .upload-overlay {
opacity: 1 !important;
}
.upload-progress {
display: none !important;
margin-top: 10px !important;
}
.upload-progress.show {
display: block !important;
}
/* 图片预览模态框样式 */
.image-preview-modal .modal-dialog {
max-width: 500px !important;
margin: 1.75rem auto !important;
display: flex !important;
align-items: center !important;
min-height: calc(100% - 3.5rem) !important;
}
.image-preview-modal .modal-content {
max-height: 90vh !important;
display: flex !important;
flex-direction: column !important;
}
.image-preview-modal .modal-body {
overflow-y: auto !important;
}
.preview-container {
background: #f8f9fa !important;
border-radius: 12px !important;
padding: 30px !important;
text-align: center !important;
}
.preview-image-wrapper {
position: relative !important;
display: block !important;
margin-bottom: 20px !important;
max-width: 100% !important;
}
/* 强制限制预览图片大小 */
.preview-image,
#previewImage,
.preview-image-wrapper .preview-image,
.preview-image-wrapper #previewImage {
max-width: 280px !important;
max-height: 280px !important;
width: auto !important;
height: auto !important;
object-fit: contain !important;
border-radius: 12px !important;
box-shadow: 0 8px 25px rgba(0,0,0,0.15) !important;
border: 3px solid #fff !important;
}
.preview-info {
background: #fff !important;
border-radius: 8px !important;
padding: 15px !important;
margin-top: 15px !important;
box-shadow: 0 2px 10px rgba(0,0,0,0.1) !important;
}
.preview-stats {
display: flex !important;
justify-content: space-around !important;
margin-top: 10px !important;
}
.stat-item {
text-align: center !important;
}
.stat-value {
font-weight: bold !important;
color: #007bff !important;
font-size: 1.1em !important;
}
.stat-label {
font-size: 0.85em !important;
color: #6c757d !important;
margin-top: 2px !important;
}
/* 进度条样式 */
.upload-progress .progress {
height: 8px !important;
margin-bottom: 5px !important;
border-radius: 4px !important;
}
.upload-progress .progress-bar {
transition: width 0.3s ease !important;
border-radius: 4px !important;
}
/* 模态框动画 */
.modal.fade .modal-dialog {
transition: transform 0.3s ease-out !important;
transform: translate(0, -50px) !important;
}
.modal.show .modal-dialog {
transform: none !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.image-preview-modal .modal-dialog {
max-width: 95% !important;
margin: 10px auto !important;
}
.preview-image,
#previewImage {
max-width: 250px !important;
max-height: 250px !important;
}
.preview-container {
padding: 20px !important;
}
}
/* 大屏幕优化 */
@media (min-width: 1200px) {
.preview-image,
#previewImage {
max-width: 300px !important;
max-height: 300px !important;
}
}
/* 终极覆盖规则 - 确保所有情况下样式都生效 */
img.avatar-preview,
img#avatarPreview {
width: 120px !important;
height: 120px !important;
border-radius: 50% !important;
object-fit: cover !important;
max-width: 120px !important;
max-height: 120px !important;
min-width: 120px !important;
min-height: 120px !important;
}
/* 防止任何外部样式影响 */
.col-md-4 .avatar-upload img,
.text-center .avatar-upload img {
width: 120px !important;
height: 120px !important;
border-radius: 50% !important;
object-fit: cover !important;
}

View File

@ -0,0 +1,36 @@
/* 注册页面样式 */
.is-valid {
border-color: #28a745 !important;
}
.is-invalid {
border-color: #dc3545 !important;
}
.text-success {
color: #28a745 !important;
}
.text-danger {
color: #dc3545 !important;
}
/* 验证码按钮样式 */
.btn.disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 表单增强样式 */
.form-text {
font-size: 0.875em;
margin-top: 0.25rem;
}
.input-group .btn {
border-left: 0;
}
.input-group .form-control:focus + .btn {
border-color: #86b7fe;
}

View File

@ -0,0 +1,229 @@
// 地址表单页面JavaScript功能
// 全局变量,避免重复初始化
let addressFormInitialized = false;
// 页面完全加载后初始化
window.addEventListener('load', function() {
console.log('=== 地址表单初始化开始 ===');
// 显示调试信息
document.getElementById('debugAlert').style.display = 'block';
// 检查数据是否加载
if (typeof cityData === 'undefined') {
console.error('❌ cityData 未加载');
document.getElementById('debugInfo').innerHTML = '<span class="text-danger">❌ 地址数据加载失败</span>';
showAlert('地址数据加载失败,请刷新页面重试', 'error');
return;
}
console.log('✅ cityData 已加载,省份数量:', Object.keys(cityData).length);
document.getElementById('debugInfo').innerHTML = '<span class="text-success">✅ 地址数据已加载,省份数量: ' + Object.keys(cityData).length + '</span>';
// 避免重复初始化
if (addressFormInitialized) {
console.log('地址表单已初始化,跳过');
return;
}
addressFormInitialized = true;
// 初始化省份列表
initializeProvinces();
// 设置事件监听器
setupEventListeners();
// 如果是编辑模式,设置初始值
const savedProvince = document.getElementById('provinceValue').value;
const savedCity = document.getElementById('cityValue').value;
const savedDistrict = document.getElementById('districtValue').value;
console.log('初始值:', {savedProvince, savedCity, savedDistrict});
if (savedProvince) {
setTimeout(() => {
setInitialValues(savedProvince, savedCity, savedDistrict);
}, 500); // 增加延迟确保DOM完全准备好
}
console.log('=== 地址表单初始化完成 ===');
});
// 初始化省份列表
function initializeProvinces() {
const provinceSelect = document.getElementById('province');
if (!provinceSelect) {
console.error('省份选择框未找到');
return;
}
console.log('开始初始化省份列表...');
// 清空并添加默认选项
provinceSelect.innerHTML = '<option value="">请选择省份</option>';
// 添加所有省份
const provinces = Object.keys(cityData);
console.log('可用省份:', provinces);
provinces.forEach(province => {
const option = document.createElement('option');
option.value = province;
option.textContent = province;
provinceSelect.appendChild(option);
console.log('添加省份:', province);
});
console.log('省份列表初始化完成,总计:', provinces.length, '个');
}
// 设置事件监听器
function setupEventListeners() {
console.log('设置事件监听器...');
// 省份变化事件
const provinceSelect = document.getElementById('province');
if (provinceSelect) {
provinceSelect.addEventListener('change', function() {
const province = this.value;
console.log('选择省份:', province);
updateCities(province);
clearDistricts();
});
}
// 城市变化事件
const citySelect = document.getElementById('city');
if (citySelect) {
citySelect.addEventListener('change', function() {
const province = document.getElementById('province').value;
const city = this.value;
console.log('选择城市:', city, '省份:', province);
updateDistricts(province, city);
});
}
// 表单提交验证
document.getElementById('addressForm').addEventListener('submit', function(e) {
const province = document.getElementById('province').value;
const city = document.getElementById('city').value;
const district = document.getElementById('district').value;
console.log('表单验证:', {province, city, district});
if (!province) {
e.preventDefault();
showAlert('请选择省份', 'warning');
return false;
}
if (!city) {
e.preventDefault();
showAlert('请选择城市', 'warning');
return false;
}
if (!district) {
e.preventDefault();
showAlert('请选择区县', 'warning');
return false;
}
return true;
});
console.log('事件监听器设置完成');
}
// 更新城市列表
function updateCities(province) {
const citySelect = document.getElementById('city');
citySelect.innerHTML = '<option value="">请选择城市</option>';
console.log('更新城市列表,省份:', province);
if (!province || !cityData[province]) {
console.log('省份为空或数据不存在');
return;
}
const cities = Object.keys(cityData[province]);
console.log('可用城市:', cities);
cities.forEach(city => {
const option = document.createElement('option');
option.value = city;
option.textContent = city;
citySelect.appendChild(option);
});
console.log('城市列表更新完成,总计:', cities.length, '个');
}
// 更新区县列表
function updateDistricts(province, city) {
const districtSelect = document.getElementById('district');
districtSelect.innerHTML = '<option value="">请选择区县</option>';
console.log('更新区县列表,省份:', province, '城市:', city);
if (!province || !city || !cityData[province] || !cityData[province][city]) {
console.log('省份或城市为空或数据不存在');
return;
}
const districts = cityData[province][city];
console.log('可用区县:', districts);
districts.forEach(district => {
const option = document.createElement('option');
option.value = district;
option.textContent = district;
districtSelect.appendChild(option);
});
console.log('区县列表更新完成,总计:', districts.length, '个');
}
// 清空区县列表
function clearDistricts() {
const districtSelect = document.getElementById('district');
districtSelect.innerHTML = '<option value="">请选择区县</option>';
console.log('区县列表已清空');
}
// 设置初始值(编辑模式)
function setInitialValues(province, city, district) {
console.log('设置初始值:', {province, city, district});
const provinceSelect = document.getElementById('province');
const citySelect = document.getElementById('city');
const districtSelect = document.getElementById('district');
// 设置省份
if (province && provinceSelect) {
provinceSelect.value = province;
console.log('省份设置为:', province);
updateCities(province);
// 延迟设置城市
setTimeout(() => {
if (city && citySelect) {
citySelect.value = city;
console.log('城市设置为:', city);
updateDistricts(province, city);
// 延迟设置区县
setTimeout(() => {
if (district && districtSelect) {
districtSelect.value = district;
console.log('区县设置为:', district);
}
}, 200);
}
}, 200);
}
}

View File

@ -0,0 +1,64 @@
// 地址管理页面JavaScript功能
function setDefaultAddress(addressId) {
if (confirm('确定要设置为默认地址吗?')) {
fetch(`/address/set_default/${addressId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('操作失败,请重试', 'error');
});
}
}
function deleteAddress(addressId) {
if (confirm('确定要删除这个地址吗?删除后无法恢复。')) {
fetch(`/address/delete/${addressId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('删除失败,请重试', 'error');
});
}
}
// 页面加载完成后的处理
document.addEventListener('DOMContentLoaded', function() {
// 为地址卡片添加点击效果
const addressCards = document.querySelectorAll('.address-card');
addressCards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-2px)';
this.style.boxShadow = '0 4px 15px rgba(0,0,0,0.1)';
});
card.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
this.style.boxShadow = '';
});
});
});

View File

@ -0,0 +1,276 @@
// 分类管理页面JavaScript
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
initIconUpload();
initEditIconUpload();
initFormSubmission();
});
// 初始化图标上传
function initIconUpload() {
const uploadArea = document.getElementById('iconUploadArea');
const iconInput = document.getElementById('iconInput');
const iconPreview = document.getElementById('iconPreview');
if (uploadArea && iconInput && iconPreview) {
uploadArea.addEventListener('click', () => iconInput.click());
iconInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
iconPreview.src = e.target.result;
iconPreview.style.display = 'block';
uploadArea.querySelector('i').style.display = 'none';
};
reader.readAsDataURL(file);
}
});
}
}
// 初始化编辑图标上传
function initEditIconUpload() {
const uploadArea = document.getElementById('editIconUploadArea');
const iconInput = document.getElementById('editIconInput');
const iconPreview = document.getElementById('editIconPreview');
if (uploadArea && iconInput && iconPreview) {
uploadArea.addEventListener('click', () => iconInput.click());
iconInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
iconPreview.src = e.target.result;
iconPreview.style.display = 'block';
uploadArea.querySelector('i').style.display = 'none';
};
reader.readAsDataURL(file);
}
});
}
}
// 编辑分类
function editCategory(categoryId) {
fetch(`/admin/products/categories/${categoryId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const category = data.category;
document.getElementById('edit_category_id').value = category.id;
document.getElementById('edit_name').value = category.name;
document.getElementById('edit_parent_id').value = category.parent_id;
document.getElementById('edit_sort_order').value = category.sort_order;
document.getElementById('edit_is_active').value = category.is_active;
// 设置图标预览
const iconPreview = document.getElementById('editIconPreview');
const uploadIcon = document.getElementById('editIconUploadArea').querySelector('i');
if (category.icon_url) {
iconPreview.src = category.icon_url;
iconPreview.style.display = 'block';
uploadIcon.style.display = 'none';
} else {
iconPreview.style.display = 'none';
uploadIcon.style.display = 'block';
}
// 禁用当前分类及其子分类作为父分类选项
const parentSelect = document.getElementById('edit_parent_id');
Array.from(parentSelect.options).forEach(option => {
option.disabled = false;
if (option.value == categoryId) {
option.disabled = true;
}
});
new bootstrap.Modal(document.getElementById('editCategoryModal')).show();
} else {
alert('获取分类信息失败');
}
})
.catch(error => {
console.error('Error:', error);
alert('获取分类信息失败: ' + error);
});
}
// 添加子分类
function addSubCategory(parentId) {
const parentSelect = document.getElementById('parent_id');
const nameInput = document.getElementById('name');
if (parentSelect) {
parentSelect.value = parentId;
}
if (nameInput) {
nameInput.focus();
}
// 滚动到添加表单
const addForm = document.querySelector('.add-category-form');
if (addForm) {
addForm.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
// 删除分类
function deleteCategory(categoryId) {
if (confirm('确定要删除这个分类吗?删除后无法恢复!')) {
fetch(`/admin/products/categories/${categoryId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('分类删除成功');
location.reload();
} else {
alert('删除失败: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('删除失败: ' + error);
});
}
}
// 切换分类展开/收起
function toggleCategory(categoryId) {
const categoryItem = document.querySelector(`[data-id="${categoryId}"]`);
if (!categoryItem) return;
const childrenDiv = categoryItem.querySelector('.children-categories');
const toggleBtn = categoryItem.querySelector('.category-header .bi-chevron-down, .category-header .bi-chevron-up');
if (childrenDiv && toggleBtn) {
if (childrenDiv.style.display === 'none') {
childrenDiv.style.display = 'block';
toggleBtn.className = 'bi bi-chevron-up';
} else {
childrenDiv.style.display = 'none';
toggleBtn.className = 'bi bi-chevron-down';
}
}
}
// 展开全部
function expandAll() {
document.querySelectorAll('.children-categories').forEach(div => {
div.style.display = 'block';
});
document.querySelectorAll('.bi-chevron-down').forEach(icon => {
icon.className = 'bi bi-chevron-up';
});
}
// 收起全部
function collapseAll() {
document.querySelectorAll('.children-categories').forEach(div => {
div.style.display = 'none';
});
document.querySelectorAll('.bi-chevron-up').forEach(icon => {
icon.className = 'bi bi-chevron-down';
});
}
// 初始化表单提交
function initFormSubmission() {
const addForm = document.getElementById('addCategoryForm');
if (addForm) {
addForm.addEventListener('submit', function(e) {
setTimeout(() => {
if (!document.querySelector('.alert-danger')) {
// 重置表单
this.reset();
const iconPreview = document.getElementById('iconPreview');
const uploadIcon = document.getElementById('iconUploadArea').querySelector('i');
if (iconPreview) iconPreview.style.display = 'none';
if (uploadIcon) uploadIcon.style.display = 'block';
}
}, 100);
});
}
}
// 表单验证
function validateCategoryForm(formId) {
const form = document.getElementById(formId);
if (!form) return false;
const nameInput = form.querySelector('input[name="name"]');
if (!nameInput || !nameInput.value.trim()) {
alert('请输入分类名称');
if (nameInput) nameInput.focus();
return false;
}
return true;
}
// 工具函数:显示加载状态
function showLoading(element) {
if (element) {
element.disabled = true;
const originalText = element.innerHTML;
element.innerHTML = '<i class="bi bi-hourglass-split"></i> 处理中...';
setTimeout(() => {
element.disabled = false;
element.innerHTML = originalText;
}, 2000);
}
}
// 工具函数:显示成功消息
function showSuccess(message) {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const container = document.querySelector('.container-fluid');
if (container) {
container.insertBefore(alertDiv, container.firstChild);
setTimeout(() => {
alertDiv.remove();
}, 3000);
}
}
// 工具函数:显示错误消息
function showError(message) {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-danger alert-dismissible fade show';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const container = document.querySelector('.container-fluid');
if (container) {
container.insertBefore(alertDiv, container.firstChild);
setTimeout(() => {
alertDiv.remove();
}, 5000);
}
}

View File

@ -0,0 +1,80 @@
// Dashboard JavaScript functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize user trend chart if canvas exists
const chartCanvas = document.getElementById('userTrendChart');
if (chartCanvas) {
initUserTrendChart();
}
// Auto refresh dashboard data every 5 minutes
setInterval(function() {
refreshDashboardStats();
}, 300000); // 5 minutes
});
function initUserTrendChart() {
const ctx = document.getElementById('userTrendChart').getContext('2d');
// Get data from template variables (these will be rendered by Jinja2)
const labels = window.userTrendLabels || [];
const data = window.userTrendData || [];
const userTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '注册用户数',
data: data,
borderColor: 'rgb(102, 126, 234)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
function refreshDashboardStats() {
// This function could be used to refresh dashboard statistics via AJAX
// For now, it's a placeholder for future implementation
console.log('Refreshing dashboard stats...');
}
// Utility function to format numbers
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
// Function to update stats cards (for future AJAX updates)
function updateStatsCard(cardSelector, value) {
const card = document.querySelector(cardSelector);
if (card) {
const valueElement = card.querySelector('h3');
if (valueElement) {
valueElement.textContent = formatNumber(value);
}
}
}

76
app/static/js/base.js Normal file
View File

@ -0,0 +1,76 @@
// 基础JavaScript功能
// 返回顶部功能
window.addEventListener('scroll', function() {
const backToTop = document.getElementById('backToTop');
if (window.pageYOffset > 300) {
backToTop.style.display = 'block';
} else {
backToTop.style.display = 'none';
}
});
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
// 购物车数量更新
function updateCartBadge(count) {
const badge = document.getElementById('cartBadge');
if (count > 0) {
badge.textContent = count;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
}
// 页面加载完成后的初始化
document.addEventListener('DOMContentLoaded', function() {
// 当前页面高亮
const currentPath = window.location.pathname;
const navLinks = document.querySelectorAll('.navbar-nav .nav-link');
navLinks.forEach(link => {
if (link.getAttribute('href') === currentPath) {
link.classList.add('active');
}
});
// 初始化购物车数量
// TODO: 实现购物车数量获取
});
// 通用AJAX错误处理
function handleAjaxError(xhr) {
if (xhr.status === 401) {
alert('请先登录');
window.location.href = '/auth/login';
} else if (xhr.status === 403) {
alert('没有权限执行此操作');
} else {
alert('操作失败,请稍后再试');
}
}
// 通用成功提示
function showSuccessMessage(message) {
// 创建临时提示框
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show position-fixed success-toast';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
// 3秒后自动消失
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.parentNode.removeChild(alertDiv);
}
}, 3000);
}

219
app/static/js/cart.js Normal file
View File

@ -0,0 +1,219 @@
let selectedItems = new Set();
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
updateSelectAllState();
updateTotalPrice();
});
// 全选/取消全选
document.getElementById('selectAll').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.item-checkbox:not(:disabled)');
checkboxes.forEach(checkbox => {
checkbox.checked = this.checked;
if (this.checked) {
selectedItems.add(parseInt(checkbox.value));
} else {
selectedItems.delete(parseInt(checkbox.value));
}
});
updateTotalPrice();
});
// 单个商品选择
document.querySelectorAll('.item-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const cartId = parseInt(this.value);
if (this.checked) {
selectedItems.add(cartId);
} else {
selectedItems.delete(cartId);
}
updateSelectAllState();
updateTotalPrice();
});
});
// 更新全选状态
function updateSelectAllState() {
const selectAllCheckbox = document.getElementById('selectAll');
const availableCheckboxes = document.querySelectorAll('.item-checkbox:not(:disabled)');
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:not(:disabled):checked');
if (availableCheckboxes.length === 0) {
selectAllCheckbox.disabled = true;
selectAllCheckbox.checked = false;
} else {
selectAllCheckbox.disabled = false;
selectAllCheckbox.checked = availableCheckboxes.length === checkedCheckboxes.length;
}
}
// 更新总价
function updateTotalPrice() {
let totalPrice = 0;
let selectedCount = 0;
selectedItems.forEach(cartId => {
const cartItem = document.querySelector(`[data-cart-id="${cartId}"]`);
if (cartItem) {
const itemTotal = parseFloat(cartItem.querySelector('.item-total').textContent);
const quantity = parseInt(cartItem.querySelector('.quantity-input').value);
totalPrice += itemTotal;
selectedCount += quantity;
}
});
document.getElementById('selectedCount').textContent = selectedCount;
document.getElementById('selectedTotal').textContent = totalPrice.toFixed(2);
document.getElementById('finalTotal').textContent = totalPrice.toFixed(2);
// 更新结算按钮状态
const checkoutBtn = document.getElementById('checkoutBtn');
checkoutBtn.disabled = selectedItems.size === 0;
}
// 修改数量
function changeQuantity(cartId, delta) {
const input = document.querySelector(`[data-cart-id="${cartId}"]`);
const currentValue = parseInt(input.value);
const newValue = currentValue + delta;
if (newValue >= 1 && newValue <= parseInt(input.max)) {
updateQuantity(cartId, newValue);
}
}
// 更新数量
function updateQuantity(cartId, quantity) {
if (quantity < 1) return;
fetch('/cart/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cart_id: cartId,
quantity: parseInt(quantity)
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 更新页面显示
const cartItem = document.querySelector(`[data-cart-id="${cartId}"]`);
cartItem.querySelector('.quantity-input').value = quantity;
cartItem.querySelector('.item-total').textContent = data.item_total.toFixed(2);
// 更新总价
updateTotalPrice();
// 更新全局购物车数量
updateCartBadge(data.cart_count);
showSuccessMessage('数量更新成功');
} else {
alert(data.message);
// 恢复原始值
location.reload();
}
})
.catch(error => {
console.error('Error:', error);
alert('更新失败');
location.reload();
});
}
// 删除商品
function removeItem(cartId) {
if (!confirm('确定要删除这件商品吗?')) {
return;
}
fetch('/cart/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
cart_id: cartId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 从页面中移除商品
const cartItem = document.querySelector(`[data-cart-id="${cartId}"]`);
cartItem.remove();
// 从选中列表中移除
selectedItems.delete(cartId);
// 更新显示
updateSelectAllState();
updateTotalPrice();
updateCartBadge(data.cart_count);
showSuccessMessage('商品已删除');
// 如果购物车为空,刷新页面
if (data.cart_count === 0) {
setTimeout(() => {
location.reload();
}, 1000);
}
} else {
alert(data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('删除失败');
});
}
// 清空购物车
function clearCart() {
if (!confirm('确定要清空购物车吗?')) {
return;
}
fetch('/cart/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showSuccessMessage('购物车已清空');
setTimeout(() => {
location.reload();
}, 1000);
} else {
alert(data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('清空失败');
});
}
// 去结算
function checkout() {
if (selectedItems.size === 0) {
alert('请选择要购买的商品');
return;
}
const params = new URLSearchParams();
selectedItems.forEach(cartId => {
params.append('items', cartId);
});
window.location.href = `/cart/checkout?${params.toString()}`;
}

114
app/static/js/checkout.js Normal file
View File

@ -0,0 +1,114 @@
// 订单结算页面脚本
let selectedAddressId = 0;
let subtotal = 0;
// 初始化页面
document.addEventListener('DOMContentLoaded', function() {
// 从页面获取初始数据
const defaultAddress = document.querySelector('input[name="address_id"]:checked');
if (defaultAddress) {
selectedAddressId = parseInt(defaultAddress.value);
}
// 获取商品总价
const subtotalElement = document.getElementById('subtotal');
if (subtotalElement) {
subtotal = parseFloat(subtotalElement.textContent.replace('¥', ''));
}
});
// 选择地址
function selectAddress(addressId) {
selectedAddressId = addressId;
// 更新UI
document.querySelectorAll('.address-card').forEach(card => {
card.classList.remove('selected');
});
document.querySelector(`[data-address-id="${addressId}"]`).classList.add('selected');
// 更新单选按钮
document.querySelector(`input[value="${addressId}"]`).checked = true;
}
// 更新运费
function updateShippingFee() {
const shippingMethod = document.querySelector('input[name="shipping_method"]:checked').value;
let fee = 0;
switch(shippingMethod) {
case 'express':
fee = 10;
break;
case 'same_day':
fee = 20;
break;
default:
fee = 0;
}
document.getElementById('shippingFee').textContent = `¥${fee.toFixed(2)}`;
document.getElementById('totalAmount').textContent = `¥${(subtotal + fee).toFixed(2)}`;
}
// 提交订单
function submitOrder() {
if (!selectedAddressId) {
showAlert('请选择收货地址', 'warning');
return;
}
const shippingMethod = document.querySelector('input[name="shipping_method"]:checked').value;
const paymentMethod = document.querySelector('input[name="payment_method"]:checked').value;
const remark = document.getElementById('orderRemark').value;
// 获取选中的购物车商品ID
const urlParams = new URLSearchParams(window.location.search);
const selectedItems = urlParams.getAll('items');
if (selectedItems.length === 0) {
showAlert('没有选中的商品', 'error');
return;
}
const orderData = {
selected_items: selectedItems,
address_id: selectedAddressId,
shipping_method: shippingMethod,
payment_method: paymentMethod,
remark: remark
};
// 显示加载状态
const submitBtn = document.querySelector('.btn-danger');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> 提交中...';
submitBtn.disabled = true;
fetch('/order/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(orderData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('订单创建成功!正在跳转到支付页面...', 'success');
setTimeout(() => {
window.location.href = `/order/pay/${data.payment_sn}`;
}, 1500);
} else {
showAlert(data.message, 'error');
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}
})
.catch(error => {
showAlert('提交订单失败,请重试', 'error');
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
});
}

466
app/static/js/city_data.js Normal file
View File

@ -0,0 +1,466 @@
// 中国省市区数据
const cityData = {
'北京市': {
'北京市': ['东城区', '西城区', '朝阳区', '丰台区', '石景山区', '海淀区', '门头沟区', '房山区', '通州区', '顺义区', '昌平区', '大兴区', '怀柔区', '平谷区', '密云区', '延庆区']
},
'上海市': {
'上海市': ['黄浦区', '徐汇区', '长宁区', '静安区', '普陀区', '虹口区', '杨浦区', '闵行区', '宝山区', '嘉定区', '浦东新区', '金山区', '松江区', '青浦区', '奉贤区', '崇明区']
},
'天津市': {
'天津市': ['和平区', '河东区', '河西区', '南开区', '河北区', '红桥区', '东丽区', '西青区', '津南区', '北辰区', '武清区', '宝坻区', '滨海新区', '宁河区', '静海区', '蓟州区']
},
'重庆市': {
'重庆市': ['万州区', '涪陵区', '渝中区', '大渡口区', '江北区', '沙坪坝区', '九龙坡区', '南岸区', '北碚区', '綦江区', '大足区', '渝北区', '巴南区', '黔江区', '长寿区', '江津区', '合川区', '永川区', '南川区', '璧山区', '铜梁区', '潼南区', '荣昌区', '开州区', '梁平区', '武隆区', '城口县', '丰都县', '垫江县', '忠县', '云阳县', '奉节县', '巫山县', '巫溪县', '石柱土家族自治县', '秀山土家族苗族自治县', '酉阳土家族苗族自治县', '彭水苗族土家族自治县']
},
'河北省': {
'石家庄市': ['长安区', '桥西区', '新华区', '井陉矿区', '裕华区', '藁城区', '鹿泉区', '栾城区', '井陉县', '正定县', '行唐县', '灵寿县', '高邑县', '深泽县', '赞皇县', '无极县', '平山县', '元氏县', '赵县', '辛集市', '晋州市', '新乐市'],
'唐山市': ['路南区', '路北区', '古冶区', '开平区', '丰南区', '丰润区', '曹妃甸区', '滦州市', '滦南县', '乐亭县', '迁西县', '玉田县', '遵化市', '迁安市'],
'秦皇岛市': ['海港区', '山海关区', '北戴河区', '抚宁区', '青龙满族自治县', '昌黎县', '卢龙县'],
'邯郸市': ['邯山区', '丛台区', '复兴区', '峰峰矿区', '肥乡区', '永年区', '临漳县', '成安县', '大名县', '涉县', '磁县', '邱县', '鸡泽县', '广平县', '馆陶县', '魏县', '曲周县', '武安市'],
'邢台市': ['桥东区', '桥西区', '邢台县', '临城县', '内丘县', '柏乡县', '隆尧县', '任县', '南和县', '宁晋县', '巨鹿县', '新河县', '广宗县', '平乡县', '威县', '清河县', '临西县', '南宫市', '沙河市'],
'保定市': ['竞秀区', '莲池区', '满城区', '清苑区', '徐水区', '涞水县', '阜平县', '定兴县', '唐县', '高阳县', '容城县', '涞源县', '望都县', '安新县', '易县', '曲阳县', '蠡县', '顺平县', '博野县', '雄县', '涿州市', '定州市', '安国市', '高碑店市'],
'张家口市': ['桥东区', '桥西区', '宣化区', '下花园区', '万全区', '崇礼区', '张北县', '康保县', '沽源县', '尚义县', '蔚县', '阳原县', '怀安县', '怀来县', '涿鹿县', '赤城县'],
'承德市': ['双桥区', '双滦区', '鹰手营子矿区', '承德县', '兴隆县', '平泉市', '滦平县', '隆化县', '丰宁满族自治县', '宽城满族自治县', '围场满族蒙古族自治县'],
'沧州市': ['新华区', '运河区', '沧县', '青县', '东光县', '海兴县', '盐山县', '肃宁县', '南皮县', '吴桥县', '献县', '孟村回族自治县', '泊头市', '任丘市', '黄骅市', '河间市'],
'廊坊市': ['安次区', '广阳区', '固安县', '永清县', '香河县', '大城县', '文安县', '大厂回族自治县', '霸州市', '三河市'],
'衡水市': ['桃城区', '冀州区', '枣强县', '武邑县', '武强县', '饶阳县', '安平县', '故城县', '景县', '阜城县', '深州市']
},
'山西省': {
'太原市': ['小店区', '迎泽区', '杏花岭区', '尖草坪区', '万柏林区', '晋源区', '清徐县', '阳曲县', '娄烦县', '古交市'],
'大同市': ['平城区', '云冈区', '新荣区', '左云县', '阳高县', '天镇县', '广灵县', '灵丘县', '浑源县', '云州区'],
'阳泉市': ['城区', '矿区', '郊区', '平定县', '盂县'],
'长治市': ['潞州区', '上党区', '屯留区', '潞城区', '襄垣县', '平顺县', '黎城县', '壶关县', '长子县', '武乡县', '沁县', '沁源县'],
'晋城市': ['城区', '沁水县', '阳城县', '陵川县', '泽州县', '高平市'],
'朔州市': ['朔城区', '平鲁区', '山阴县', '应县', '右玉县', '怀仁市'],
'晋中市': ['榆次区', '榆社县', '左权县', '和顺县', '昔阳县', '寿阳县', '太谷县', '祁县', '平遥县', '灵石县', '介休市'],
'运城市': ['盐湖区', '临猗县', '万荣县', '闻喜县', '稷山县', '新绛县', '绛县', '垣曲县', '夏县', '平陆县', '芮城县', '永济市', '河津市'],
'忻州市': ['忻府区', '定襄县', '五台县', '代县', '繁峙县', '宁武县', '静乐县', '神池县', '五寨县', '岢岚县', '河曲县', '保德县', '偏关县', '原平市'],
'临汾市': ['尧都区', '曲沃县', '翼城县', '襄汾县', '洪洞县', '古县', '安泽县', '浮山县', '吉县', '乡宁县', '大宁县', '隰县', '永和县', '蒲县', '汾西县', '侯马市', '霍州市'],
'吕梁市': ['离石区', '文水县', '交城县', '兴县', '临县', '柳林县', '石楼县', '岚县', '方山县', '中阳县', '交口县', '孝义市', '汾阳市']
},
'内蒙古自治区': {
'呼和浩特市': ['新城区', '回民区', '玉泉区', '赛罕区', '土默特左旗', '托克托县', '和林格尔县', '清水河县', '武川县'],
'包头市': ['东河区', '昆都仑区', '青山区', '石拐区', '白云鄂博矿区', '九原区', '土默特右旗', '固阳县', '达尔罕茂明安联合旗'],
'乌海市': ['海勃湾区', '海南区', '乌达区'],
'赤峰市': ['红山区', '元宝山区', '松山区', '阿鲁科尔沁旗', '巴林左旗', '巴林右旗', '林西县', '克什克腾旗', '翁牛特旗', '喀喇沁旗', '宁城县', '敖汉旗'],
'通辽市': ['科尔沁区', '科尔沁左翼中旗', '科尔沁左翼后旗', '开鲁县', '库伦旗', '奈曼旗', '扎鲁特旗', '霍林郭勒市'],
'鄂尔多斯市': ['东胜区', '康巴什区', '达拉特旗', '准格尔旗', '鄂托克前旗', '鄂托克旗', '杭锦旗', '乌审旗', '伊金霍洛旗'],
'呼伦贝尔市': ['海拉尔区', '扎赉诺尔区', '阿荣旗', '莫力达瓦达斡尔族自治旗', '鄂伦春自治旗', '鄂温克族自治旗', '陈巴尔虎旗', '新巴尔虎左旗', '新巴尔虎右旗', '满洲里市', '牙克石市', '扎兰屯市', '额尔古纳市', '根河市'],
'巴彦淖尔市': ['临河区', '五原县', '磴口县', '乌拉特前旗', '乌拉特中旗', '乌拉特后旗', '杭锦后旗'],
'乌兰察布市': ['集宁区', '卓资县', '化德县', '商都县', '兴和县', '凉城县', '察哈尔右翼前旗', '察哈尔右翼中旗', '察哈尔右翼后旗', '四子王旗', '丰镇市'],
'兴安盟': ['乌兰浩特市', '阿尔山市', '科尔沁右翼前旗', '科尔沁右翼中旗', '扎赉特旗', '突泉县'],
'锡林郭勒盟': ['锡林浩特市', '阿巴嘎旗', '苏尼特左旗', '苏尼特右旗', '东乌珠穆沁旗', '西乌珠穆沁旗', '太仆寺旗', '镶黄旗', '正镶白旗', '正蓝旗', '多伦县', '二连浩特市'],
'阿拉善盟': ['阿拉善左旗', '阿拉善右旗', '额济纳旗']
},
'辽宁省': {
'沈阳市': ['和平区', '沈河区', '大东区', '皇姑区', '铁西区', '苏家屯区', '浑南区', '沈北新区', '于洪区', '辽中区', '康平县', '法库县', '新民市'],
'大连市': ['中山区', '西岗区', '沙河口区', '甘井子区', '旅顺口区', '金州区', '普兰店区', '长海县', '瓦房店市', '庄河市'],
'鞍山市': ['铁东区', '铁西区', '立山区', '千山区', '台安县', '岫岩满族自治县', '海城市'],
'抚顺市': ['新抚区', '东洲区', '望花区', '顺城区', '抚顺县', '新宾满族自治县', '清原满族自治县'],
'本溪市': ['平山区', '溪湖区', '明山区', '南芬区', '本溪满族自治县', '桓仁满族自治县'],
'丹东市': ['元宝区', '振兴区', '振安区', '宽甸满族自治县', '东港市', '凤城市'],
'锦州市': ['古塔区', '凌河区', '太和区', '黑山县', '义县', '凌海市', '北镇市'],
'营口市': ['站前区', '西市区', '鲅鱼圈区', '老边区', '盖州市', '大石桥市'],
'阜新市': ['海州区', '新邱区', '太平区', '清河门区', '细河区', '阜新蒙古族自治县', '彰武县'],
'辽阳市': ['白塔区', '文圣区', '宏伟区', '弓长岭区', '太子河区', '辽阳县', '灯塔市'],
'盘锦市': ['双台子区', '兴隆台区', '大洼区', '盘山县'],
'铁岭市': ['银州区', '清河区', '铁岭县', '西丰县', '昌图县', '调兵山市', '开原市'],
'朝阳市': ['双塔区', '龙城区', '朝阳县', '建平县', '喀喇沁左翼蒙古族自治县', '北票市', '凌源市'],
'葫芦岛市': ['连山区', '龙港区', '南票区', '绥中县', '建昌县', '兴城市']
},
'吉林省': {
'长春市': ['南关区', '宽城区', '朝阳区', '二道区', '绿园区', '双阳区', '九台区', '农安县', '榆树市', '德惠市'],
'吉林市': ['昌邑区', '龙潭区', '船营区', '丰满区', '永吉县', '蛟河市', '桦甸市', '舒兰市', '磐石市'],
'四平市': ['铁西区', '铁东区', '梨树县', '伊通满族自治县', '公主岭市', '双辽市'],
'辽源市': ['龙山区', '西安区', '东丰县', '东辽县'],
'通化市': ['东昌区', '二道江区', '通化县', '辉南县', '柳河县', '梅河口市', '集安市'],
'白山市': ['浑江区', '江源区', '抚松县', '靖宇县', '长白朝鲜族自治县', '临江市'],
'松原市': ['宁江区', '前郭尔罗斯蒙古族自治县', '长岭县', '乾安县', '扶余市'],
'白城市': ['洮北区', '镇赖县', '通榆县', '洮南市', '大安市'],
'延边朝鲜族自治州': ['延吉市', '图们市', '敦化市', '珲春市', '龙井市', '和龙市', '汪清县', '安图县']
},
'黑龙江省': {
'哈尔滨市': ['道里区', '南岗区', '道外区', '平房区', '松北区', '香坊区', '呼兰区', '阿城区', '双城区', '依兰县', '方正县', '宾县', '巴彦县', '木兰县', '通河县', '延寿县', '尚志市', '五常市'],
'齐齐哈尔市': ['龙沙区', '建华区', '铁锋区', '昂昂溪区', '富拉尔基区', '碾子山区', '梅里斯达斡尔族区', '龙江县', '依安县', '泰来县', '甘南县', '富裕县', '克山县', '克东县', '拜泉县', '讷河市'],
'鸡西市': ['鸡冠区', '恒山区', '滴道区', '梨树区', '城子河区', '麻山区', '鸡东县', '虎林市', '密山市'],
'鹤岗市': ['向阳区', '工农区', '南山区', '兴安区', '东山区', '兴山区', '萝北县', '绥滨县'],
'双鸭山市': ['尖山区', '岭东区', '四方台区', '宝山区', '集贤县', '友谊县', '宝清县', '饶河县'],
'大庆市': ['萨尔图区', '龙凤区', '让胡路区', '红岗区', '大同区', '肇州县', '肇源县', '林甸县', '杜尔伯特蒙古族自治县'],
'伊春市': ['伊春区', '南岔区', '友好区', '西林区', '翠峦区', '新青区', '美溪区', '金山屯区', '五营区', '乌马河区', '汤旺河区', '带岭区', '乌伊岭区', '红星区', '上甘岭区', '嘉荫县', '铁力市'],
'佳木斯市': ['向阳区', '前进区', '东风区', '郊区', '桦南县', '桦川县', '汤原县', '抚远市', '同江市', '富锦市'],
'七台河市': ['新兴区', '桃山区', '茄子河区', '勃利县'],
'牡丹江市': ['东安区', '阳明区', '爱民区', '西安区', '林口县', '绥芬河市', '海林市', '宁安市', '穆棱市', '东宁市'],
'黑河市': ['爱辉区', '嫩江县', '逊克县', '孙吴县', '北安市', '五大连池市'],
'绥化市': ['北林区', '望奎县', '兰西县', '青冈县', '庆安县', '明水县', '绥棱县', '安达市', '肇东市', '海伦市'],
'大兴安岭地区': ['呼玛县', '塔河县', '漠河市']
},
'江苏省': {
'南京市': ['玄武区', '秦淮区', '建邺区', '鼓楼区', '浦口区', '栖霞区', '雨花台区', '江宁区', '六合区', '溧水区', '高淳区'],
'无锡市': ['锡山区', '惠山区', '滨湖区', '梁溪区', '新吴区', '江阴市', '宜兴市'],
'徐州市': ['鼓楼区', '云龙区', '贾汪区', '泉山区', '铜山区', '丰县', '沛县', '睢宁县', '新沂市', '邳州市'],
'常州市': ['天宁区', '钟楼区', '新北区', '武进区', '金坛区', '溧阳市'],
'苏州市': ['虎丘区', '吴中区', '相城区', '姑苏区', '吴江区', '常熟市', '张家港市', '昆山市', '太仓市'],
'南通市': ['崇川区', '港闸区', '通州区', '海安市', '如东县', '启东市', '如皋市', '海门市'],
'连云港市': ['连云区', '海州区', '赣榆区', '东海县', '灌云县', '灌南县'],
'淮安市': ['淮安区', '淮阴区', '清江浦区', '洪泽区', '涟水县', '盱眙县', '金湖县'],
'盐城市': ['亭湖区', '盐都区', '大丰区', '响水县', '滨海县', '阜宁县', '射阳县', '建湖县', '东台市'],
'扬州市': ['广陵区', '邗江区', '江都区', '宝应县', '仪征市', '高邮市'],
'镇江市': ['京口区', '润州区', '丹徒区', '丹阳市', '扬中市', '句容市'],
'泰州市': ['海陵区', '高港区', '姜堰区', '兴化市', '靖江市', '泰兴市'],
'宿迁市': ['宿城区', '宿豫区', '沭阳县', '泗阳县', '泗洪县']
},
'浙江省': {
'杭州市': ['上城区', '下城区', '江干区', '拱墅区', '西湖区', '滨江区', '萧山区', '余杭区', '富阳区', '临安区', '桐庐县', '淳安县', '建德市'],
'宁波市': ['海曙区', '江北区', '北仑区', '镇海区', '鄞州区', '奉化区', '象山县', '宁海县', '余姚市', '慈溪市'],
'温州市': ['鹿城区', '龙湾区', '瓯海区', '洞头区', '永嘉县', '平阳县', '苍南县', '文成县', '泰顺县', '瑞安市', '乐清市'],
'嘉兴市': ['南湖区', '秀洲区', '嘉善县', '海盐县', '海宁市', '平湖市', '桐乡市'],
'湖州市': ['吴兴区', '南浔区', '德清县', '长兴县', '安吉县'],
'绍兴市': ['越城区', '柯桥区', '上虞区', '新昌县', '诸暨市', '嵊州市'],
'金华市': ['婺城区', '金东区', '武义县', '浦江县', '磐安县', '兰溪市', '义乌市', '东阳市', '永康市'],
'衢州市': ['柯城区', '衢江区', '常山县', '开化县', '龙游县', '江山市'],
'舟山市': ['定海区', '普陀区', '岱山县', '嵊泗县'],
'台州市': ['椒江区', '黄岩区', '路桥区', '三门县', '天台县', '仙居县', '温岭市', '临海市', '玉环市'],
'丽水市': ['莲都区', '青田县', '缙云县', '遂昌县', '松阳县', '云和县', '庆元县', '景宁畲族自治县', '龙泉市']
},
'安徽省': {
'合肥市': ['瑶海区', '庐阳区', '蜀山区', '包河区', '长丰县', '肥东县', '肥西县', '庐江县', '巢湖市'],
'芜湖市': ['镜湖区', '弋江区', '鸠江区', '三山区', '芜湖县', '繁昌县', '南陵县', '无为市'],
'蚌埠市': ['龙子湖区', '蚌山区', '禹会区', '淮上区', '怀远县', '五河县', '固镇县'],
'淮南市': ['大通区', '田家庵区', '谢家集区', '八公山区', '潘集区', '凤台县', '寿县'],
'马鞍山市': ['花山区', '雨山区', '博望区', '当涂县', '含山县', '和县'],
'淮北市': ['杜集区', '相山区', '烈山区', '濉溪县'],
'铜陵市': ['铜官区', '义安区', '郊区', '枞阳县'],
'安庆市': ['迎江区', '大观区', '宜秀区', '怀宁县', '潜山市', '太湖县', '宿松县', '望江县', '岳西县', '桐城市'],
'黄山市': ['屯溪区', '黄山区', '徽州区', '歙县', '休宁县', '黟县', '祁门县'],
'滁州市': ['琅琊区', '南谯区', '来安县', '全椒县', '定远县', '凤阳县', '天长市', '明光市'],
'阜阳市': ['颍州区', '颍东区', '颍泉区', '临泉县', '太和县', '阜南县', '颍上县', '界首市'],
'宿州市': ['埇桥区', '砀山县', '萧县', '灵璧县', '泗县'],
'六安市': ['金安区', '裕安区', '叶集区', '霍邱县', '舒城县', '金寨县', '霍山县'],
'亳州市': ['谯城区', '涡阳县', '蒙城县', '利辛县'],
'池州市': ['贵池区', '东至县', '石台县', '青阳县'],
'宣城市': ['宣州区', '郎溪县', '广德市', '泾县', '绩溪县', '旌德县', '宁国市']
},
'福建省': {
'福州市': ['鼓楼区', '台江区', '仓山区', '马尾区', '晋安区', '长乐区', '闽侯县', '连江县', '罗源县', '闽清县', '永泰县', '平潭县', '福清市'],
'厦门市': ['思明区', '海沧区', '湖里区', '集美区', '同安区', '翔安区'],
'莆田市': ['城厢区', '涵江区', '荔城区', '秀屿区', '仙游县'],
'三明市': ['梅列区', '三元区', '明溪县', '清流县', '宁化县', '大田县', '尤溪县', '沙县', '将乐县', '泰宁县', '建宁县', '永安市'],
'泉州市': ['鲤城区', '丰泽区', '洛江区', '泉港区', '惠安县', '安溪县', '永春县', '德化县', '金门县', '石狮市', '晋江市', '南安市'],
'漳州市': ['芗城区', '龙文区', '云霄县', '漳浦县', '诏安县', '长泰县', '东山县', '南靖县', '平和县', '华安县', '龙海市'],
'南平市': ['延平区', '建阳区', '顺昌县', '浦城县', '光泽县', '松溪县', '政和县', '邵武市', '武夷山市', '建瓯市'],
'龙岩市': ['新罗区', '永定区', '长汀县', '上杭县', '武平县', '连城县', '漳平市'],
'宁德市': ['蕉城区', '霞浦县', '古田县', '屏南县', '寿宁县', '周宁县', '柘荣县', '福安市', '福鼎市']
},
'江西省': {
'南昌市': ['东湖区', '西湖区', '青云谱区', '湾里区', '青山湖区', '新建区', '南昌县', '安义县', '进贤县'],
'景德镇市': ['昌江区', '珠山区', '浮梁县', '乐平市'],
'萍乡市': ['安源区', '湘东区', '莲花县', '上栗县', '芦溪县'],
'九江市': ['濂溪区', '浔阳区', '柴桑区', '武宁县', '修水县', '永修县', '德安县', '都昌县', '湖口县', '彭泽县', '瑞昌市', '共青城市', '庐山市'],
'新余市': ['渝水区', '分宜县'],
'鹰潭市': ['月湖区', '余江区', '贵溪市'],
'赣州市': ['章贡区', '南康区', '赣县区', '信丰县', '大余县', '上犹县', '崇义县', '安远县', '龙南县', '定南县', '全南县', '宁都县', '于都县', '兴国县', '会昌县', '寻乌县', '石城县', '瑞金市'],
'吉安市': ['吉州区', '青原区', '吉安县', '吉水县', '峡江县', '新干县', '永丰县', '泰和县', '遂川县', '万安县', '安福县', '永新县', '井冈山市'],
'宜春市': ['袁州区', '奉新县', '万载县', '上高县', '宜丰县', '靖安县', '铜鼓县', '丰城市', '樟树市', '高安市'],
'抚州市': ['临川区', '东乡区', '南城县', '黎川县', '南丰县', '崇仁县', '乐安县', '宜黄县', '金溪县', '资溪县', '广昌县'],
'上饶市': ['信州区', '广丰区', '广信区', '玉山县', '铅山县', '横峰县', '弋阳县', '余干县', '鄱阳县', '万年县', '婺源县', '德兴市']
},
'山东省': {
'济南市': ['历下区', '市中区', '槐荫区', '天桥区', '历城区', '长清区', '章丘区', '济阳区', '莱芜区', '钢城区', '平阴县', '商河县'],
'青岛市': ['市南区', '市北区', '黄岛区', '崂山区', '李沧区', '城阳区', '即墨区', '胶州市', '平度市', '莱西市'],
'淄博市': ['淄川区', '张店区', '博山区', '临淄区', '周村区', '桓台县', '高青县', '沂源县'],
'枣庄市': ['市中区', '薛城区', '峄城区', '台儿庄区', '山亭区', '滕州市'],
'东营市': ['东营区', '河口区', '垦利区', '利津县', '广饶县'],
'烟台市': ['芝罘区', '福山区', '牟平区', '莱山区', '长岛县', '龙口市', '莱阳市', '莱州市', '蓬莱市', '招远市', '栖霞市', '海阳市'],
'潍坊市': ['潍城区', '寒亭区', '坊子区', '奎文区', '临朐县', '昌乐县', '青州市', '诸城市', '寿光市', '安丘市', '高密市', '昌邑市'],
'济宁市': ['任城区', '兖州区', '微山县', '鱼台县', '金乡县', '嘉祥县', '汶上县', '泗水县', '梁山县', '曲阜市', '邹城市'],
'泰安市': ['泰山区', '岱岳区', '宁阳县', '东平县', '新泰市', '肥城市'],
'威海市': ['环翠区', '文登区', '荣成市', '乳山市'],
'日照市': ['东港区', '岚山区', '五莲县', '莒县'],
'临沂市': ['兰山区', '罗庄区', '河东区', '沂南县', '郯城县', '沂水县', '兰陵县', '费县', '平邑县', '莒南县', '蒙阴县', '临沭县'],
'德州市': ['德城区', '陵城区', '宁津县', '庆云县', '临邑县', '齐河县', '平原县', '夏津县', '武城县', '乐陵市', '禹城市'],
'聊城市': ['东昌府区', '茌平区', '阳谷县', '莘县', '茌平县', '东阿县', '冠县', '高唐县', '临清市'],
'滨州市': ['滨城区', '沾化区', '惠民县', '阳信县', '无棣县', '博兴县', '邹平市'],
'菏泽市': ['牡丹区', '定陶区', '曹县', '单县', '成武县', '巨野县', '郓城县', '鄄城县', '东明县']
},
'河南省': {
'郑州市': ['中原区', '二七区', '管城回族区', '金水区', '上街区', '惠济区', '中牟县', '巩义市', '荥阳市', '新密市', '新郑市', '登封市'],
'开封市': ['龙亭区', '顺河回族区', '鼓楼区', '禹王台区', '祥符区', '杞县', '通许县', '尉氏县', '兰考县'],
'洛阳市': ['老城区', '西工区', '瀍河回族区', '涧西区', '吉利区', '洛龙区', '孟津县', '新安县', '栾川县', '嵩县', '汝阳县', '宜阳县', '洛宁县', '伊川县', '偃师市'],
'平顶山市': ['新华区', '卫东区', '石龙区', '湛河区', '宝丰县', '叶县', '鲁山县', '郏县', '舞钢市', '汝州市'],
'安阳市': ['文峰区', '北关区', '殷都区', '龙安区', '安阳县', '汤阴县', '滑县', '内黄县', '林州市'],
'鹤壁市': ['鹤山区', '山城区', '淇滨区', '浚县', '淇县'],
'新乡市': ['红旗区', '卫滨区', '凤泉区', '牧野区', '新乡县', '获嘉县', '原阳县', '延津县', '封丘县', '长垣市', '卫辉市', '辉县市'],
'焦作市': ['解放区', '中站区', '马村区', '山阳区', '修武县', '博爱县', '武陟县', '温县', '沁阳市', '孟州市'],
'濮阳市': ['华龙区', '清丰县', '南乐县', '范县', '台前县', '濮阳县'],
'许昌市': ['魏都区', '建安区', '鄢陵县', '襄城县', '禹州市', '长葛市'],
'漯河市': ['源汇区', '郾城区', '召陵区', '舞阳县', '临颍县'],
'三门峡市': ['湖滨区', '陕州区', '渑池县', '卢氏县', '义马市', '灵宝市'],
'南阳市': ['宛城区', '卧龙区', '南召县', '方城县', '西峡县', '镇平县', '内乡县', '淅川县', '社旗县', '唐河县', '新野县', '桐柏县', '邓州市'],
'商丘市': ['梁园区', '睢阳区', '民权县', '睢县', '宁陵县', '柘城县', '虞城县', '夏邑县', '永城市'],
'信阳市': ['浉河区', '平桥区', '罗山县', '光山县', '新县', '商城县', '固始县', '潢川县', '淮滨县', '息县'],
'周口市': ['川汇区', '扶沟县', '西华县', '商水县', '沈丘县', '郸城县', '淮阳区', '太康县', '鹿邑县', '项城市'],
'驻马店市': ['驿城区', '西平县', '上蔡县', '平舆县', '正阳县', '确山县', '泌阳县', '汝南县', '遂平县', '新蔡县'],
'济源市': ['济源市']
},
'湖北省': {
'武汉市': ['江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区', '东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区'],
'黄石市': ['黄石港区', '西塞山区', '下陆区', '铁山区', '阳新县', '大冶市'],
'十堰市': ['茅箭区', '张湾区', '郧阳区', '郧西县', '竹山县', '竹溪县', '房县', '丹江口市'],
'宜昌市': ['西陵区', '伍家岗区', '点军区', '猇亭区', '夷陵区', '远安县', '兴山县', '秭归县', '长阳土家族自治县', '五峰土家族自治县', '宜都市', '当阳市', '枝江市'],
'襄阳市': ['襄城区', '樊城区', '襄州区', '南漳县', '谷城县', '保康县', '老河口市', '枣阳市', '宜城市'],
'鄂州市': ['梁子湖区', '华容区', '鄂城区'],
'荆门市': ['东宝区', '掇刀区', '京山市', '沙洋县', '钟祥市'],
'孝感市': ['孝南区', '孝昌县', '大悟县', '云梦县', '应城市', '安陆市', '汉川市'],
'荆州市': ['沙市区', '荆州区', '公安县', '监利县', '江陵县', '石首市', '洪湖市', '松滋市'],
'黄冈市': ['黄州区', '团风县', '红安县', '罗田县', '英山县', '浠水县', '蕲春县', '黄梅县', '麻城市', '武穴市'],
'咸宁市': ['咸安区', '嘉鱼县', '通城县', '崇阳县', '通山县', '赤壁市'],
'随州市': ['曾都区', '随县', '广水市'],
'恩施土家族苗族自治州': ['恩施市', '利川市', '建始县', '巴东县', '宣恩县', '咸丰县', '来凤县', '鹤峰县'],
'仙桃市': ['仙桃市'],
'潜江市': ['潜江市'],
'天门市': ['天门市'],
'神农架林区': ['神农架林区']
},
'湖南省': {
'长沙市': ['芙蓉区', '天心区', '岳麓区', '开福区', '雨花区', '望城区', '长沙县', '宁乡市', '浏阳市'],
'株洲市': ['荷塘区', '芦淞区', '石峰区', '天元区', '渌口区', '攸县', '茶陵县', '炎陵县', '醴陵市'],
'湘潭市': ['雨湖区', '岳塘区', '湘潭县', '湘乡市', '韶山市'],
'衡阳市': ['珠晖区', '雁峰区', '石鼓区', '蒸湘区', '南岳区', '衡阳县', '衡南县', '衡山县', '衡东县', '祁东县', '耒阳市', '常宁市'],
'邵阳市': ['双清区', '大祥区', '北塔区', '邵东市', '新邵县', '邵阳县', '隆回县', '洞口县', '绥宁县', '新宁县', '城步苗族自治县', '武冈市'],
'岳阳市': ['岳阳楼区', '云溪区', '君山区', '岳阳县', '华容县', '湘阴县', '平江县', '汨罗市', '临湘市'],
'常德市': ['武陵区', '鼎城区', '安乡县', '汉寿县', '澧县', '临澧县', '桃源县', '石门县', '津市市'],
'张家界市': ['永定区', '武陵源区', '慈利县', '桑植县'],
'益阳市': ['资阳区', '赫山区', '南县', '桃江县', '安化县', '沅江市'],
'郴州市': ['北湖区', '苏仙区', '桂阳县', '宜章县', '永兴县', '嘉禾县', '临武县', '汝城县', '桂东县', '安仁县', '资兴市'],
'永州市': ['零陵区', '冷水滩区', '祁阳县', '东安县', '双牌县', '道县', '江永县', '宁远县', '蓝山县', '新田县', '江华瑶族自治县'],
'怀化市': ['鹤城区', '中方县', '沅陵县', '辰溪县', '溆浦县', '会同县', '麻阳苗族自治县', '新晃侗族自治县', '芷江侗族自治县', '靖州苗族侗族自治县', '通道侗族自治县', '洪江市'],
'娄底市': ['娄星区', '双峰县', '新化县', '冷水江市', '涟源市'],
'湘西土家族苗族自治州': ['吉首市', '泸溪县', '凤凰县', '花垣县', '保靖县', '古丈县', '永顺县', '龙山县']
},
'广东省': {
'广州市': ['荔湾区', '越秀区', '海珠区', '天河区', '白云区', '黄埔区', '番禺区', '花都区', '南沙区', '从化区', '增城区'],
'深圳市': ['罗湖区', '福田区', '南山区', '宝安区', '龙岗区', '盐田区', '龙华区', '坪山区', '光明区', '大鹏新区'],
'珠海市': ['香洲区', '斗门区', '金湾区'],
'汕头市': ['龙湖区', '金平区', '濠江区', '潮阳区', '潮南区', '澄海区', '南澳县'],
'佛山市': ['禅城区', '南海区', '顺德区', '三水区', '高明区'],
'韶关市': ['武江区', '浈江区', '曲江区', '始兴县', '仁化县', '翁源县', '乳源瑶族自治县', '新丰县', '乐昌市', '南雄市'],
'湛江市': ['赤坎区', '霞山区', '坡头区', '麻章区', '遂溪县', '徐闻县', '廉江市', '雷州市', '吴川市'],
'肇庆市': ['端州区', '鼎湖区', '高要区', '广宁县', '怀集县', '封开县', '德庆县', '四会市'],
'江门市': ['蓬江区', '江海区', '新会区', '台山市', '开平市', '鹤山市', '恩平市'],
'茂名市': ['茂南区', '电白区', '高州市', '化州市', '信宜市'],
'惠州市': ['惠城区', '惠阳区', '博罗县', '惠东县', '龙门县'],
'梅州市': ['梅江区', '梅县区', '大埔县', '丰顺县', '五华县', '平远县', '蕉岭县', '兴宁市'],
'汕尾市': ['城区', '海丰县', '陆河县', '陆丰市'],
'河源市': ['源城区', '紫金县', '龙川县', '连平县', '和平县', '东源县'],
'阳江市': ['江城区', '阳东区', '阳西县', '阳春市'],
'清远市': ['清城区', '清新区', '佛冈县', '阳山县', '连山壮族瑶族自治县', '连南瑶族自治县', '英德市', '连州市'],
'东莞市': ['东莞市'],
'中山市': ['中山市'],
'潮州市': ['湘桥区', '潮安区', '饶平县'],
'揭阳市': ['榕城区', '揭东区', '揭西县', '惠来县', '普宁市'],
'云浮市': ['云城区', '云安区', '新兴县', '郁南县', '罗定市']
},
'广西壮族自治区': {
'南宁市': ['兴宁区', '青秀区', '江南区', '西乡塘区', '良庆区', '邕宁区', '武鸣区', '隆安县', '马山县', '上林县', '宾阳县', '横县'],
'柳州市': ['城中区', '鱼峰区', '柳南区', '柳北区', '柳江区', '柳城县', '鹿寨县', '融安县', '融水苗族自治县', '三江侗族自治县'],
'桂林市': ['秀峰区', '叠彩区', '象山区', '七星区', '雁山区', '临桂区', '阳朔县', '灵川县', '全州县', '兴安县', '永福县', '灌阳县', '龙胜各族自治县', '资源县', '平乐县', '荔浦市', '恭城瑶族自治县'],
'梧州市': ['万秀区', '长洲区', '龙圩区', '苍梧县', '藤县', '蒙山县', '岑溪市'],
'北海市': ['海城区', '银海区', '铁山港区', '合浦县'],
'防城港市': ['港口区', '防城区', '上思县', '东兴市'],
'钦州市': ['钦南区', '钦北区', '灵山县', '浦北县'],
'贵港市': ['港北区', '港南区', '覃塘区', '平南县', '桂平市'],
'玉林市': ['玉州区', '福绵区', '容县', '陆川县', '博白县', '兴业县', '北流市'],
'百色市': ['右江区', '田阳区', '田东县', '平果市', '德保县', '那坡县', '凌云县', '乐业县', '田林县', '西林县', '隆林各族自治县', '靖西市'],
'贺州市': ['八步区', '平桂区', '昭平县', '钟山县', '富川瑶族自治县'],
'河池市': ['金城江区', '宜州区', '南丹县', '天峨县', '凤山县', '东兰县', '罗城仫佬族自治县', '环江毛南族自治县', '巴马瑶族自治县', '都安瑶族自治县', '大化瑶族自治县'],
'来宾市': ['兴宾区', '忻城县', '象州县', '武宣县', '金秀瑶族自治县', '合山市'],
'崇左市': ['江州区', '扶绥县', '宁明县', '龙州县', '大新县', '天等县', '凭祥市']
},
'海南省': {
'海口市': ['秀英区', '龙华区', '琼山区', '美兰区'],
'三亚市': ['海棠区', '吉阳区', '天涯区', '崖州区'],
'三沙市': ['西沙群岛', '南沙群岛', '中沙群岛'],
'儋州市': ['儋州市'],
'五指山市': ['五指山市'],
'琼海市': ['琼海市'],
'文昌市': ['文昌市'],
'万宁市': ['万宁市'],
'东方市': ['东方市'],
'定安县': ['定安县'],
'屯昌县': ['屯昌县'],
'澄迈县': ['澄迈县'],
'临高县': ['临高县'],
'白沙黎族自治县': ['白沙黎族自治县'],
'昌江黎族自治县': ['昌江黎族自治县'],
'乐东黎族自治县': ['乐东黎族自治县'],
'陵水黎族自治县': ['陵水黎族自治县'],
'保亭黎族苗族自治县': ['保亭黎族苗族自治县'],
'琼中黎族苗族自治县': ['琼中黎族苗族自治县']
},
'四川省': {
'成都市': ['锦江区', '青羊区', '金牛区', '武侯区', '成华区', '龙泉驿区', '青白江区', '新都区', '温江区', '双流区', '郫都区', '新津区', '金堂县', '大邑县', '蒲江县', '都江堰市', '彭州市', '邛崃市', '崇州市', '简阳市'],
'自贡市': ['自流井区', '贡井区', '大安区', '沿滩区', '荣县', '富顺县'],
'攀枝花市': ['东区', '西区', '仁和区', '米易县', '盐边县'],
'泸州市': ['江阳区', '纳溪区', '龙马潭区', '泸县', '合江县', '叙永县', '古蔺县'],
'德阳市': ['旌阳区', '罗江区', '中江县', '广汉市', '什邡市', '绵竹市'],
'绵阳市': ['涪城区', '游仙区', '安州区', '三台县', '盐亭县', '梓潼县', '北川羌族自治县', '平武县', '江油市'],
'广元市': ['利州区', '昭化区', '朝天区', '旺苍县', '青川县', '剑阁县', '苍溪县'],
'遂宁市': ['船山区', '安居区', '蓬溪县', '射洪市', '大英县'],
'内江市': ['市中区', '东兴区', '威远县', '资中县', '隆昌市'],
'乐山市': ['市中区', '沙湾区', '五通桥区', '金口河区', '犍为县', '井研县', '夹江县', '沐川县', '峨边彝族自治县', '马边彝族自治县', '峨眉山市'],
'南充市': ['顺庆区', '高坪区', '嘉陵区', '南部县', '营山县', '蓬安县', '仪陇县', '西充县', '阆中市'],
'眉山市': ['东坡区', '彭山区', '仁寿县', '洪雅县', '丹棱县', '青神县'],
'宜宾市': ['翠屏区', '南溪区', '叙州区', '江安县', '长宁县', '高县', '珙县', '筠连县', '兴文县', '屏山县'],
'广安市': ['广安区', '前锋区', '岳池县', '武胜县', '邻水县', '华蓥市'],
'达州市': ['通川区', '达川区', '宣汉县', '开江县', '大竹县', '渠县', '万源市'],
'雅安市': ['雨城区', '名山区', '荥经县', '汉源县', '石棉县', '天全县', '芦山县', '宝兴县'],
'巴中市': ['巴州区', '恩阳区', '通江县', '南江县', '平昌县'],
'资阳市': ['雁江区', '安岳县', '乐至县'],
'阿坝藏族羌族自治州': ['马尔康市', '汶川县', '理县', '茂县', '松潘县', '九寨沟县', '金川县', '小金县', '黑水县', '壤塘县', '阿坝县', '若尔盖县', '红原县'],
'甘孜藏族自治州': ['康定市', '泸定县', '丹巴县', '九龙县', '雅江县', '道孚县', '炉霍县', '甘孜县', '新龙县', '德格县', '白玉县', '石渠县', '色达县', '理塘县', '巴塘县', '乡城县', '稻城县', '得荣县'],
'凉山彝族自治州': ['西昌市', '木里藏族自治县', '盐源县', '德昌县', '会理市', '会东县', '宁南县', '普格县', '布拖县', '金阳县', '昭觉县', '喜德县', '冕宁县', '越西县', '甘洛县', '美姑县', '雷波县']
},
'贵州省': {
'贵阳市': ['南明区', '云岩区', '花溪区', '乌当区', '白云区', '观山湖区', '开阳县', '息烽县', '修文县', '清镇市'],
'六盘水市': ['钟山区', '六枝特区', '水城区', '盘州市'],
'遵义市': ['红花岗区', '汇川区', '播州区', '桐梓县', '绥阳县', '正安县', '道真仡佬族苗族自治县', '务川仡佬族苗族自治县', '凤冈县', '湄潭县', '余庆县', '习水县', '赤水市', '仁怀市'],
'安顺市': ['西秀区', '平坝区', '普定县', '镇宁布依族苗族自治县', '关岭布依族苗族自治县', '紫云苗族布依族自治县'],
'毕节市': ['七星关区', '大方县', '黔西市', '金沙县', '织金县', '纳雍县', '威宁彝族回族苗族自治县', '赫章县'],
'铜仁市': ['碧江区', '万山区', '江口县', '玉屏侗族自治县', '石阡县', '思南县', '印江土家族苗族自治县', '德江县', '沿河土家族自治县', '松桃苗族自治县'],
'黔西南布依族苗族自治州': ['兴义市', '兴仁市', '普安县', '晴隆县', '贞丰县', '望谟县', '册亨县', '安龙县'],
'黔东南苗族侗族自治州': ['凯里市', '黄平县', '施秉县', '三穗县', '镇远县', '岑巩县', '天柱县', '锦屏县', '剑河县', '台江县', '黎平县', '榕江县', '从江县', '雷山县', '麻江县', '丹寨县'],
'黔南布依族苗族自治州': ['都匀市', '福泉市', '荔波县', '贵定县', '瓮安县', '独山县', '平塘县', '罗甸县', '长顺县', '龙里县', '惠水县', '三都水族自治县']
},
'云南省': {
'昆明市': ['五华区', '盘龙区', '官渡区', '西山区', '东川区', '呈贡区', '晋宁区', '富民县', '宜良县', '石林彝族自治县', '嵩明县', '禄劝彝族苗族自治县', '寻甸回族彝族自治县', '安宁市'],
'曲靖市': ['麒麟区', '沾益区', '马龙区', '陆良县', '师宗县', '罗平县', '富源县', '会泽县', '宣威市'],
'玉溪市': ['红塔区', '江川区', '澄江市', '通海县', '华宁县', '易门县', '峨山彝族自治县', '新平彝族傣族自治县', '元江哈尼族彝族傣族自治县'],
'保山市': ['隆阳区', '施甸县', '龙陵县', '昌宁县', '腾冲市'],
'昭通市': ['昭阳区', '鲁甸县', '巧家县', '盐津县', '大关县', '永善县', '绥江县', '镇雄县', '彝良县', '威信县', '水富市'],
'丽江市': ['古城区', '玉龙纳西族自治县', '永胜县', '华坪县', '宁蒗彝族自治县'],
'普洱市': ['思茅区', '宁洱哈尼族彝族自治县', '墨江哈尼族自治县', '景东彝族自治县', '景谷傣族彝族自治县', '镇沅彝族哈尼族拉祜族自治县', '江城哈尼族彝族自治县', '孟连傣族拉祜族佤族自治县', '澜沧拉祜族自治县', '西盟佤族自治县'],
'临沧市': ['临翔区', '凤庆县', '云县', '永德县', '镇康县', '双江拉祜族佤族布朗族傣族自治县', '耿马傣族佤族自治县', '沧源佤族自治县'],
'楚雄彝族自治州': ['楚雄市', '双柏县', '牟定县', '南华县', '姚安县', '大姚县', '永仁县', '元谋县', '武定县', '禄丰市'],
'红河哈尼族彝族自治州': ['个旧市', '开远市', '蒙自市', '弥勒市', '屏边苗族自治县', '建水县', '石屏县', '泸西县', '元阳县', '红河县', '金平苗族瑶族傣族自治县', '绿春县', '河口瑶族自治县'],
'文山壮族苗族自治州': ['文山市', '砚山县', '西畴县', '麻栗坡县', '马关县', '丘北县', '广南县', '富宁县'],
'西双版纳傣族自治州': ['景洪市', '勐海县', '勐腊县'],
'大理白族自治州': ['大理市', '漾濞彝族自治县', '祥云县', '宾川县', '弥渡县', '南涧彝族自治县', '巍山彝族回族自治县', '永平县', '云龙县', '洱源县', '剑川县', '鹤庆县'],
'德宏傣族景颇族自治州': ['瑞丽市', '芒市', '梁河县', '盈江县', '陇川县'],
'怒江傈僳族自治州': ['泸水市', '福贡县', '贡山独龙族怒族自治县', '兰坪白族普米族自治县'],
'迪庆藏族自治州': ['香格里拉市', '德钦县', '维西傈僳族自治县']
},
'西藏自治区': {
'拉萨市': ['城关区', '堆龙德庆区', '达孜区', '林周县', '当雄县', '尼木县', '曲水县', '墨竹工卡县'],
'日喀则市': ['桑珠孜区', '南木林县', '江孜县', '定日县', '萨迦县', '拉孜县', '昂仁县', '谢通门县', '白朗县', '仁布县', '康马县', '定结县', '仲巴县', '亚东县', '吉隆县', '聂拉木县', '萨嘎县', '岗巴县'],
'昌都市': ['卡若区', '江达县', '贡觉县', '类乌齐县', '丁青县', '察雅县', '八宿县', '左贡县', '芒康县', '洛隆县', '边坝县'],
'林芝市': ['巴宜区', '工布江达县', '米林县', '墨脱县', '波密县', '察隅县', '朗县'],
'山南市': ['乃东区', '扎囊县', '贡嘎县', '桑日县', '琼结县', '曲松县', '措美县', '洛扎县', '加查县', '隆子县', '错那县', '浪卡子县'],
'那曲市': ['色尼区', '嘉黎县', '比如县', '聂荣县', '安多县', '申扎县', '索县', '班戈县', '巴青县', '尼玛县', '双湖县'],
'阿里地区': ['普兰县', '札达县', '噶尔县', '日土县', '革吉县', '改则县', '措勤县']
},
'陕西省': {
'西安市': ['新城区', '碑林区', '莲湖区', '灞桥区', '未央区', '雁塔区', '阎良区', '临潼区', '长安区', '高陵区', '鄠邑区', '蓝田县', '周至县'],
'铜川市': ['王益区', '印台区', '耀州区', '宜君县'],
'宝鸡市': ['渭滨区', '金台区', '陈仓区', '凤翔区', '岐山县', '扶风县', '眉县', '陇县', '千阳县', '麟游县', '凤县', '太白县'],
'咸阳市': ['秦都区', '杨陵区', '渭城区', '三原县', '泾阳县', '乾县', '礼泉县', '永寿县', '长武县', '旬邑县', '淳化县', '武功县', '兴平市', '彬州市'],
'渭南市': ['临渭区', '华州区', '潼关县', '大荔县', '合阳县', '澄城县', '蒲城县', '白水县', '富平县', '韩城市', '华阴市'],
'延安市': ['宝塔区', '安塞区', '延长县', '延川县', '志丹县', '吴起县', '甘泉县', '富县', '洛川县', '宜川县', '黄龙县', '黄陵县', '子长市'],
'汉中市': ['汉台区', '南郑区', '城固县', '洋县', '西乡县', '勉县', '宁强县', '略阳县', '镇巴县', '留坝县', '佛坪县'],
'榆林市': ['榆阳区', '横山区', '府谷县', '靖边县', '定边县', '绥德县', '米脂县', '佳县', '吴堡县', '清涧县', '子洲县', '神木市'],
'安康市': ['汉滨区', '汉阴县', '石泉县', '宁陕县', '紫阳县', '岚皋县', '平利县', '镇坪县', '旬阳县', '白河县'],
'商洛市': ['商州区', '洛南县', '丹凤县', '商南县', '山阳县', '镇安县', '柞水县']
},
'甘肃省': {
'兰州市': ['城关区', '七里河区', '西固区', '安宁区', '红古区', '永登县', '皋兰县', '榆中县'],
'嘉峪关市': ['嘉峪关市'],
'金昌市': ['金川区', '永昌县'],
'白银市': ['白银区', '平川区', '靖远县', '会宁县', '景泰县'],
'天水市': ['秦州区', '麦积区', '清水县', '秦安县', '甘谷县', '武山县', '张家川回族自治县'],
'武威市': ['凉州区', '民勤县', '古浪县', '天祝藏族自治县'],
'张掖市': ['甘州区', '肃南裕固族自治县', '民乐县', '临泽县', '高台县', '山丹县'],
'平凉市': ['崆峒区', '泾川县', '灵台县', '崇信县', '华亭市', '庄浪县', '静宁县'],
'酒泉市': ['肃州区', '金塔县', '瓜州县', '肃北蒙古族自治县', '阿克塞哈萨克族自治县', '玉门市', '敦煌市'],
'庆阳市': ['西峰区', '庆城县', '环县', '华池县', '合水县', '正宁县', '宁县', '镇原县'],
'定西市': ['安定区', '通渭县', '陇西县', '渭源县', '临洮县', '漳县', '岷县'],
'陇南市': ['武都区', '成县', '文县', '宕昌县', '康县', '西和县', '礼县', '徽县', '两当县'],
'临夏回族自治州': ['临夏市', '临夏县', '康乐县', '永靖县', '广河县', '和政县', '东乡族自治县', '积石山保安族东乡族撒拉族自治县'],
'甘南藏族自治州': ['合作市', '临潭县', '卓尼县', '舟曲县', '迭部县', '玛曲县', '碌曲县', '夏河县']
},
'青海省': {
'西宁市': ['城东区', '城中区', '城西区', '城北区', '大通回族土族自治县', '湟中区', '湟源县'],
'海东市': ['乐都区', '平安区', '民和回族土族自治县', '互助土族自治县', '化隆回族自治县', '循化撒拉族自治县'],
'海北藏族自治州': ['门源回族自治县', '祁连县', '海晏县', '刚察县'],
'黄南藏族自治州': ['同仁市', '尖扎县', '泽库县', '河南蒙古族自治县'],
'海南藏族自治州': ['共和县', '同德县', '贵德县', '兴海县', '贵南县'],
'果洛藏族自治州': ['玛沁县', '班玛县', '甘德县', '达日县', '久治县', '玛多县'],
'玉树藏族自治州': ['玉树市', '杂多县', '称多县', '治多县', '囊谦县', '曲麻莱县'],
'海西蒙古族藏族自治州': ['德令哈市', '格尔木市', '茫崖市', '乌兰县', '都兰县', '天峻县', '大柴旦行委']
},
'宁夏回族自治区': {
'银川市': ['兴庆区', '西夏区', '金凤区', '永宁县', '贺兰县', '灵武市'],
'石嘴山市': ['大武口区', '惠农区', '平罗县'],
'吴忠市': ['利通区', '红寺堡区', '盐池县', '同心县', '青铜峡市'],
'固原市': ['原州区', '西吉县', '隆德县', '泾源县', '彭阳县'],
'中卫市': ['沙坡头区', '中宁县', '海原县']
},
'新疆维吾尔自治区': {
'乌鲁木齐市': ['天山区', '沙依巴克区', '新市区', '水磨沟区', '头屯河区', '达坂城区', '米东区', '乌鲁木齐县'],
'克拉玛依市': ['独山子区', '克拉玛依区', '白碱滩区', '乌尔禾区'],
'吐鲁番市': ['高昌区', '鄯善县', '托克逊县'],
'哈密市': ['伊州区', '巴里坤哈萨克自治县', '伊吾县'],
'昌吉回族自治州': ['昌吉市', '阜康市', '呼图壁县', '玛纳斯县', '奇台县', '吉木萨尔县', '木垒哈萨克自治县'],
'博尔塔拉蒙古自治州': ['博乐市', '阿拉山口市', '精河县', '温泉县'],
'巴音郭楞蒙古自治州': ['库尔勒市', '轮台县', '尉犁县', '若羌县', '且末县', '焉耆回族自治县', '和静县', '和硕县', '博湖县'],
'阿克苏地区': ['阿克苏市', '温宿县', '库车市', '沙雅县', '新和县', '拜城县', '乌什县', '阿瓦提县', '柯坪县'],
'克孜勒苏柯尔克孜自治州': ['阿图什市', '阿克陶县', '阿合奇县', '乌恰县'],
'喀什地区': ['喀什市', '疏附县', '疏勒县', '英吉沙县', '泽普县', '莎车县', '叶城县', '麦盖提县', '岳普湖县', '伽师县', '巴楚县', '塔什库尔干塔吉克自治县'],
'和田地区': ['和田市', '和田县', '墨玉县', '皮山县', '洛浦县', '策勒县', '于田县', '民丰县'],
'伊犁哈萨克自治州': ['伊宁市', '奎屯市', '霍尔果斯市', '伊宁县', '察布查尔锡伯自治县', '霍城县', '巩留县', '新源县', '昭苏县', '特克斯县', '尼勒克县'],
'塔城地区': ['塔城市', '乌苏市', '额敏县', '沙湾市', '托里县', '裕民县', '和布克赛尔蒙古自治县'],
'阿勒泰地区': ['阿勒泰市', '布尔津县', '富蕴县', '福海县', '哈巴河县', '青河县', '吉木乃县'],
'石河子市': ['石河子市'],
'阿拉尔市': ['阿拉尔市'],
'图木舒克市': ['图木舒克市'],
'五家渠市': ['五家渠市'],
'北屯市': ['北屯市'],
'铁门关市': ['铁门关市'],
'双河市': ['双河市'],
'可克达拉市': ['可克达拉市'],
'昆玉市': ['昆玉市'],
'胡杨河市': ['胡杨河市']
},
'香港特别行政区': {
'香港岛': ['中西区', '湾仔区', '东区', '南区'],
'九龙': ['油尖旺区', '深水埗区', '九龙城区', '黄大仙区', '观塘区'],
'新界': ['北区', '大埔区', '沙田区', '西贡区', '荃湾区', '屯门区', '元朗区', '葵青区', '离岛区']
},
'澳门特别行政区': {
'澳门半岛': ['花地玛堂区', '圣安多尼堂区', '大堂区', '望德堂区', '风顺堂区'],
'氹仔': ['氹仔'],
'路环': ['路环']
},
'台湾省': {
'台北市': ['中正区', '大同区', '中山区', '松山区', '大安区', '万华区', '信义区', '士林区', '北投区', '内湖区', '南港区', '文山区'],
'新北市': ['万里区', '金山区', '板桥区', '汐止区', '深坑区', '石碇区', '瑞芳区', '平溪区', '双溪区', '贡寮区', '新店区', '坪林区', '乌来区', '永和区', '中和区', '土城区', '三峡区', '树林区', '莺歌区', '三重区', '新庄区', '泰山区', '林口区', '芦洲区', '五股区', '八里区', '淡水区', '三芝区', '石门区'],
'桃园市': ['中坜区', '平镇区', '龙潭区', '杨梅区', '新屋区', '观音区', '桃园区', '龟山区', '八德区', '大溪区', '复兴区', '大园区', '芦竹区'],
'台中市': ['中区', '东区', '南区', '西区', '北区', '北屯区', '西屯区', '南屯区', '太平区', '大里区', '雾峰区', '乌日区', '丰原区', '后里区', '石冈区', '东势区', '和平区', '新社区', '潭子区', '大雅区', '神冈区', '大肚区', '沙鹿区', '龙井区', '梧栖区', '清水区', '大甲区', '外埔区', '大安区'],
'台南市': ['中西区', '东区', '南区', '北区', '安平区', '安南区', '永康区', '归仁区', '新化区', '左镇区', '玉井区', '楠西区', '南化区', '仁德区', '关庙区', '龙崎区', '官田区', '麻豆区', '佳里区', '西港区', '七股区', '将军区', '学甲区', '北门区', '新营区', '后壁区', '白河区', '东山区', '六甲区', '下营区', '柳营区', '盐水区', '善化区', '大内区', '山上区', '新市区', '安定区'],
'高雄市': ['新兴区', '前金区', '苓雅区', '盐埕区', '鼓山区', '旗津区', '前镇区', '三民区', '楠梓区', '小港区', '左营区', '仁武区', '大社区', '冈山区', '路竹区', '阿莲区', '田寮区', '燕巢区', '桥头区', '梓官区', '弥陀区', '永安区', '湖内区', '凤山区', '大寮区', '林园区', '鸟松区', '大树区', '旗山区', '美浓区', '六龟区', '内门区', '杉林区', '甲仙区', '桃源区', '那玛夏区', '茂林区', '茄萣区'],
'基隆市': ['仁爱区', '信义区', '中正区', '中山区', '安乐区', '暖暖区', '七堵区'],
'新竹市': ['东区', '北区', '香山区'],
'嘉义市': ['东区', '西区'],
'新竹县': ['竹北市', '湖口乡', '新丰乡', '新埔镇', '关西镇', '芎林乡', '宝山乡', '竹东镇', '五峰乡', '横山乡', '尖石乡', '北埔乡', '峨眉乡'],
'苗栗县': ['竹南镇', '头份市', '三湾乡', '南庄乡', '狮潭乡', '后龙镇', '通霄镇', '苑里镇', '苗栗市', '造桥乡', '头屋乡', '公馆乡', '大湖乡', '泰安乡', '铜锣乡', '三义乡', '西湖乡', '卓兰镇'],
'彰化县': ['彰化市', '芬园乡', '花坛乡', '秀水乡', '鹿港镇', '福兴乡', '线西乡', '和美镇', '伸港乡', '员林市', '社头乡', '永靖乡', '埔心乡', '溪湖镇', '大村乡', '埔盐乡', '田中镇', '北斗镇', '田尾乡', '埤头乡', '溪州乡', '竹塘乡', '二林镇', '大城乡', '芳苑乡', '二水乡'],
'南投县': ['南投市', '中寮乡', '草屯镇', '国姓乡', '埔里镇', '仁爱乡', '名间乡', '集集镇', '水里乡', '鱼池乡', '信义乡', '竹山镇', '鹿谷乡'],
'云林县': ['斗南镇', '大埤乡', '虎尾镇', '土库镇', '褒忠乡', '东势乡', '台西乡', '仑背乡', '麦寮乡', '斗六市', '林内乡', '古坑乡', '莿桐乡', '西螺镇', '二仑乡', '北港镇', '水林乡', '口湖乡', '四湖乡', '元长乡'],
'嘉义县': ['番路乡', '梅山乡', '竹崎乡', '阿里山乡', '中埔乡', '大埔乡', '水上乡', '鹿草乡', '太保市', '朴子市', '东石乡', '六脚乡', '新港乡', '民雄乡', '大林镇', '溪口乡', '义竹乡', '布袋镇'],
'屏东县': ['屏东市', '三地门乡', '雾台乡', '玛家乡', '九如乡', '里港乡', '高树乡', '盐埔乡', '长治乡', '麟洛乡', '竹田乡', '内埔乡', '万丹乡', '潮州镇', '泰武乡', '来义乡', '万峦乡', '崁顶乡', '新埤乡', '南州乡', '林边乡', '东港镇', '琉球乡', '佳冬乡', '新园乡', '枋寮乡', '枋山乡', '春日乡', '狮子乡', '车城乡', '牡丹乡', '恒春镇', '满州乡'],
'宜兰县': ['宜兰市', '头城镇', '礁溪乡', '壮围乡', '员山乡', '罗东镇', '三星乡', '大同乡', '五结乡', '冬山乡', '苏澳镇', '南澳乡'],
'花莲县': ['花莲市', '新城乡', '秀林乡', '吉安乡', '寿丰乡', '凤林镇', '光复乡', '丰滨乡', '瑞穗乡', '万荣乡', '玉里镇', '卓溪乡', '富里乡'],
'台东县': ['台东市', '绿岛乡', '兰屿乡', '延平乡', '卑南乡', '鹿野乡', '关山镇', '海端乡', '池上乡', '东河乡', '成功镇', '长滨乡', '太麻里乡', '金峰乡', '大武乡', '达仁乡'],
'澎湖县': ['马公市', '西屿乡', '望安乡', '七美乡', '白沙乡', '湖西乡'],
'金门县': ['金沙镇', '金湖镇', '金宁乡', '金城镇', '烈屿乡', '乌坵乡'],
'连江县': ['南竿乡', '北竿乡', '莒光乡', '东引乡']
}
};

View File

@ -0,0 +1,49 @@
// 订单详情页面脚本
// 取消订单
function cancelOrder(orderId) {
if (confirm('确定要取消这个订单吗?取消后无法恢复。')) {
fetch(`/order/cancel/${orderId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('操作失败,请重试', 'error');
});
}
}
// 确认收货
function confirmReceipt(orderId) {
if (confirm('确定已收到商品吗?确认后订单将完成。')) {
fetch(`/order/confirm_receipt/${orderId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('操作失败,请重试', 'error');
});
}
}

109
app/static/js/orders.js Normal file
View File

@ -0,0 +1,109 @@
// 订单页面JavaScript功能
// 强制设置商品图片样式的函数
function forceProductImageStyle(imgElement) {
if (!imgElement) return;
// 强制设置所有样式属性
imgElement.style.width = '80px';
imgElement.style.height = '80px';
imgElement.style.objectFit = 'cover';
imgElement.style.borderRadius = '4px';
imgElement.style.display = 'block';
imgElement.style.maxWidth = '80px';
imgElement.style.maxHeight = '80px';
imgElement.style.minWidth = '80px';
imgElement.style.minHeight = '80px';
// 设置属性避免被覆盖
imgElement.setAttribute('width', '80');
imgElement.setAttribute('height', '80');
}
// 页面加载完成后的处理
document.addEventListener('DOMContentLoaded', function() {
// 强制设置所有商品图片样式
const productImages = document.querySelectorAll('.product-image');
productImages.forEach(function(img) {
forceProductImageStyle(img);
// 图片加载完成后再次强制设置
if (img.complete) {
forceProductImageStyle(img);
} else {
img.onload = function() {
forceProductImageStyle(img);
};
}
});
// 为订单卡片添加悬停效果
const orderCards = document.querySelectorAll('.order-card');
orderCards.forEach(card => {
card.addEventListener('mouseenter', function() {
this.style.boxShadow = '0 4px 15px rgba(0,0,0,0.1)';
});
card.addEventListener('mouseleave', function() {
this.style.boxShadow = '';
});
});
});
// 额外的保险措施:定期检查并修正商品图片样式
setInterval(function() {
const productImages = document.querySelectorAll('.product-image');
productImages.forEach(function(img) {
// 检查图片是否超出预期尺寸
const rect = img.getBoundingClientRect();
if (rect.width > 85 || rect.height > 85) {
forceProductImageStyle(img);
}
});
}, 1000); // 每秒检查一次
function cancelOrder(orderId) {
if (confirm('确定要取消这个订单吗?取消后无法恢复。')) {
fetch(`/order/cancel/${orderId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('操作失败,请重试', 'error');
});
}
}
function confirmReceipt(orderId) {
if (confirm('确定已收到商品吗?确认后订单将完成。')) {
fetch(`/order/confirm_receipt/${orderId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('操作失败,请重试', 'error');
});
}
}

200
app/static/js/pay.js Normal file
View File

@ -0,0 +1,200 @@
// 订单支付页面脚本
let countdownTimer;
let statusCheckTimer;
let timeLeft = 15 * 60; // 15分钟
// 页面加载时开始倒计时
document.addEventListener('DOMContentLoaded', function() {
startCountdown();
});
// 页面卸载时清理定时器
window.addEventListener('beforeunload', function() {
if (countdownTimer) clearInterval(countdownTimer);
if (statusCheckTimer) clearInterval(statusCheckTimer);
});
// 开始倒计时
function startCountdown() {
countdownTimer = setInterval(() => {
timeLeft--;
const minutes = Math.floor(timeLeft / 60);
const seconds = timeLeft % 60;
document.getElementById('countdown').textContent =
`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
if (timeLeft <= 0) {
clearInterval(countdownTimer);
showAlert('订单已过期,请重新下单', 'warning');
setTimeout(() => {
window.location.href = '/order/list';
}, 2000);
}
}, 1000);
}
// 开始支付
function startPayment() {
const paymentSn = document.querySelector('[data-payment-sn]')?.dataset.paymentSn;
const paymentMethod = document.querySelector('[data-payment-method]')?.dataset.paymentMethod;
if (!paymentSn || !paymentMethod) {
showAlert('支付信息获取失败', 'error');
return;
}
fetch('/payment/process', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
payment_sn: paymentSn,
payment_method: paymentMethod
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.payment_type === 'qrcode') {
showQRCode(data.qr_code_url);
startStatusCheck();
} else if (data.payment_type === 'redirect') {
window.open(data.pay_url, '_blank');
startStatusCheck();
}
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('支付启动失败,请重试', 'error');
});
}
// 显示二维码
function showQRCode(qrUrl) {
const qrArea = document.getElementById('qrCodeArea');
const qrImage = document.getElementById('qrCodeImage');
// 这里应该使用真实的二维码生成库,现在用文本模拟
qrImage.innerHTML = `
<div style="width: 200px; height: 200px; margin: 0 auto; background: #f0f0f0;
display: flex; align-items: center; justify-content: center; border: 1px solid #ddd;">
<div style="text-align: center;">
<i class="bi bi-qr-code display-4"></i><br>
<small>微信支付二维码</small>
</div>
</div>
`;
qrArea.style.display = 'block';
}
// 开始检查支付状态
function startStatusCheck() {
statusCheckTimer = setInterval(() => {
checkPaymentStatus();
}, 3000); // 每3秒检查一次
}
// 检查支付状态
function checkPaymentStatus() {
const paymentSn = document.querySelector('[data-payment-sn]')?.dataset.paymentSn;
if (!paymentSn) return;
fetch(`/payment/check_status/${paymentSn}`)
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.status === 2) { // 支付成功
clearInterval(statusCheckTimer);
clearInterval(countdownTimer);
showPaymentSuccess();
}
}
})
.catch(error => {
console.error('状态检查失败:', error);
});
}
// 显示支付成功
function showPaymentSuccess() {
document.getElementById('paymentArea').style.display = 'none';
document.getElementById('paymentStatus').style.display = 'block';
const orderId = document.querySelector('[data-order-id]')?.dataset.orderId;
setTimeout(() => {
window.location.href = `/order/detail/${orderId}`;
}, 2000);
}
// 取消订单
function cancelOrder() {
const orderId = document.querySelector('[data-order-id]')?.dataset.orderId;
if (!orderId) {
showAlert('订单信息获取失败', 'error');
return;
}
if (confirm('确定要取消这个订单吗?')) {
fetch(`/order/cancel/${orderId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('订单已取消', 'success');
setTimeout(() => {
window.location.href = '/order/list';
}, 1500);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('取消失败,请重试', 'error');
});
}
}
// 模拟支付成功(开发测试用)
function simulatePayment() {
const paymentSn = document.querySelector('[data-payment-sn]')?.dataset.paymentSn;
if (!paymentSn) {
showAlert('支付信息获取失败', 'error');
return;
}
if (confirm('这是测试功能,确定要模拟支付成功吗?')) {
fetch(`/payment/simulate_success/${paymentSn}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showAlert('模拟支付成功', 'success');
setTimeout(() => {
showPaymentSuccess();
}, 1000);
} else {
showAlert(data.message, 'error');
}
})
.catch(error => {
showAlert('模拟支付失败', 'error');
});
}
}

View File

@ -0,0 +1,236 @@
// 获取库存数据
const inventoryData = JSON.parse(document.getElementById('inventoryData').textContent);
let selectedSpecs = {};
let currentSku = null;
// 初始化
document.addEventListener('DOMContentLoaded', function() {
// 如果只有一个SKU自动选择
if (inventoryData.length === 1) {
currentSku = inventoryData[0];
updateStockInfo();
}
// 绑定规格选择事件
document.querySelectorAll('.spec-option').forEach(button => {
button.addEventListener('click', function() {
selectSpec(this);
});
});
// 初始化购物车数量显示
if (typeof loadCartCount === 'function') {
loadCartCount();
}
});
// 规格选择
function selectSpec(button) {
const specName = button.getAttribute('data-spec-name');
const specValue = button.getAttribute('data-spec-value');
// 清除同组其他选择
document.querySelectorAll(`[data-spec-name="${specName}"]`).forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-secondary');
});
// 选中当前项
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-primary');
// 更新选择状态
selectedSpecs[specName] = specValue;
// 查找匹配的SKU
findMatchingSku();
}
// 查找匹配的SKU
function findMatchingSku() {
for (let sku of inventoryData) {
if (sku.spec_combination) {
let isMatch = true;
for (let [specName, specValue] of Object.entries(selectedSpecs)) {
if (sku.spec_combination[specName] !== specValue) {
isMatch = false;
break;
}
}
if (isMatch && Object.keys(selectedSpecs).length === Object.keys(sku.spec_combination).length) {
currentSku = sku;
updateStockInfo();
return;
}
}
}
// 未找到完全匹配的SKU
currentSku = null;
updateStockInfo();
}
// 更新库存信息
function updateStockInfo() {
const stockElement = document.getElementById('stockCount');
const priceElement = document.getElementById('currentPrice');
const addToCartBtn = document.getElementById('addToCartBtn');
const buyNowBtn = document.getElementById('buyNowBtn');
const quantityInput = document.getElementById('quantity');
if (currentSku) {
stockElement.textContent = currentSku.stock;
stockElement.className = currentSku.stock > 0 ? 'text-success' : 'text-danger';
priceElement.textContent = currentSku.final_price.toFixed(2);
if (currentSku.stock > 0) {
addToCartBtn.disabled = false;
buyNowBtn.disabled = false;
quantityInput.max = currentSku.stock;
} else {
addToCartBtn.disabled = true;
buyNowBtn.disabled = true;
quantityInput.max = 0;
}
} else if (inventoryData.length > 1) {
stockElement.textContent = '请选择规格';
stockElement.className = 'text-warning';
addToCartBtn.disabled = true;
buyNowBtn.disabled = true;
}
}
// 数量变更
function changeQuantity(delta) {
const quantityInput = document.getElementById('quantity');
let quantity = parseInt(quantityInput.value) + delta;
const min = parseInt(quantityInput.min) || 1;
const max = parseInt(quantityInput.max) || 999;
quantity = Math.max(min, Math.min(max, quantity));
quantityInput.value = quantity;
}
// 轮播图跳转
function goToSlide(index) {
const carousel = new bootstrap.Carousel(document.getElementById('productImageCarousel'));
carousel.to(index);
}
// 加载购物车数量
function loadCartCount() {
fetch('/cart/count')
.then(response => response.json())
.then(data => {
updateCartBadge(data.cart_count);
})
.catch(error => {
console.error('Error loading cart count:', error);
});
}
// 加入购物车
function addToCart() {
if (!currentSku) {
alert('请选择商品规格');
return;
}
const quantity = parseInt(document.getElementById('quantity').value);
if (quantity <= 0 || quantity > currentSku.stock) {
alert('请选择正确的购买数量');
return;
}
// 禁用按钮,防止重复点击
const addToCartBtn = document.getElementById('addToCartBtn');
addToCartBtn.disabled = true;
addToCartBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> 添加中...';
// 提交到购物车
fetch('/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
product_id: window.productId,
sku_code: currentSku.sku_code,
spec_combination: Object.keys(selectedSpecs).length > 0 ? JSON.stringify(selectedSpecs) : '',
quantity: quantity
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showSuccessMessage(data.message);
updateCartBadge(data.cart_count);
// 询问是否查看购物车
setTimeout(() => {
if (confirm('商品已添加到购物车,是否查看购物车?')) {
window.location.href = '/cart/';
}
}, 500);
} else {
alert(data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('加入购物车失败,请稍后再试');
})
.finally(() => {
// 恢复按钮状态
addToCartBtn.disabled = false;
addToCartBtn.innerHTML = '<i class="bi bi-cart-plus"></i> 加入购物车';
});
}
// 立即购买
function buyNow() {
if (!currentSku) {
alert('请选择商品规格');
return;
}
const quantity = parseInt(document.getElementById('quantity').value);
if (quantity <= 0 || quantity > currentSku.stock) {
alert('请选择正确的购买数量');
return;
}
// 先添加到购物车,然后跳转到结算页面
fetch('/cart/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
product_id: window.productId,
sku_code: currentSku.sku_code,
spec_combination: Object.keys(selectedSpecs).length > 0 ? JSON.stringify(selectedSpecs) : '',
quantity: quantity
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 立即跳转到购物车结算
window.location.href = '/cart/';
} else {
alert(data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('购买失败,请稍后再试');
});
}
// 收藏商品
function addToFavorites() {
// TODO: 实现收藏功能
alert('收藏功能开发中...');
}

View File

@ -0,0 +1,6 @@
function changeSort(sortType) {
const url = new URL(window.location);
url.searchParams.set('sort', sortType);
url.searchParams.set('page', '1'); // 重置到第一页
window.location.href = url.toString();
}

343
app/static/js/profile.js Normal file
View File

@ -0,0 +1,343 @@
// 个人中心页面JavaScript功能
let selectedFile = null;
// 强制设置头像样式的函数
function forceAvatarStyle(imgElement) {
if (!imgElement) return;
// 强制设置所有样式属性
imgElement.style.width = '120px';
imgElement.style.height = '120px';
imgElement.style.borderRadius = '50%';
imgElement.style.border = '3px solid #ddd';
imgElement.style.objectFit = 'cover';
imgElement.style.cursor = 'pointer';
imgElement.style.transition = 'all 0.3s ease';
imgElement.style.display = 'block';
imgElement.style.maxWidth = '120px';
imgElement.style.maxHeight = '120px';
imgElement.style.minWidth = '120px';
imgElement.style.minHeight = '120px';
// 设置属性避免被覆盖
imgElement.setAttribute('width', '120');
imgElement.setAttribute('height', '120');
}
// 页面加载完成后的处理
document.addEventListener('DOMContentLoaded', function() {
// 隐藏进度条
const progressContainer = document.getElementById('uploadProgress');
if (progressContainer) {
progressContainer.classList.remove('show');
progressContainer.style.display = 'none';
}
// *** 关键:强制设置已存在的头像样式 ***
const existingAvatar = document.getElementById('avatarPreview');
if (existingAvatar) {
forceAvatarStyle(existingAvatar);
// 图片加载完成后再次强制设置
if (existingAvatar.complete) {
forceAvatarStyle(existingAvatar);
} else {
existingAvatar.onload = function() {
forceAvatarStyle(existingAvatar);
};
}
}
// 添加拖拽上传支持
initDragAndDrop();
});
// 触发文件选择
function triggerFileInput() {
document.getElementById('avatarInput').click();
}
// 处理文件选择
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
// 验证文件类型
if (!file.type.match('image.*')) {
showAlert('请选择图片文件!', 'error');
return;
}
// 验证文件大小 (2MB)
if (file.size > 2 * 1024 * 1024) {
showAlert('图片大小不能超过 2MB', 'error');
return;
}
selectedFile = file;
// 预览图片
const reader = new FileReader();
reader.onload = function(e) {
const previewImage = document.getElementById('previewImage');
previewImage.src = e.target.result;
// 更新文件信息
updateFileInfo(file);
// 确保图片加载完成后再显示模态框
previewImage.onload = function() {
// *** 强制设置预览图片样式 ***
previewImage.style.maxWidth = '280px';
previewImage.style.maxHeight = '280px';
previewImage.style.width = 'auto';
previewImage.style.height = 'auto';
previewImage.style.objectFit = 'contain';
previewImage.style.borderRadius = '12px';
previewImage.style.boxShadow = '0 8px 25px rgba(0,0,0,0.15)';
previewImage.style.border = '3px solid #fff';
// 更新图片尺寸信息
document.getElementById('imageWidth').textContent = previewImage.naturalWidth;
document.getElementById('imageHeight').textContent = previewImage.naturalHeight;
const modal = new bootstrap.Modal(document.getElementById('imagePreviewModal'));
modal.show();
};
};
reader.readAsDataURL(file);
}
// 更新文件信息
function updateFileInfo(file) {
// 文件大小
const sizeInMB = (file.size / 1024 / 1024).toFixed(2);
document.getElementById('imageSize').textContent = sizeInMB + ' MB';
// 文件类型
const fileType = file.type.split('/')[1].toUpperCase();
document.getElementById('imageType').textContent = fileType;
}
// 确认上传
function confirmUpload() {
if (!selectedFile) return;
// 关闭模态框
const modal = bootstrap.Modal.getInstance(document.getElementById('imagePreviewModal'));
modal.hide();
// 开始上传
uploadAvatar(selectedFile);
}
// 上传头像
function uploadAvatar(file) {
const formData = new FormData();
formData.append('avatar', file);
// 显示上传进度
const progressContainer = document.getElementById('uploadProgress');
const progressBar = progressContainer.querySelector('.progress-bar');
progressContainer.style.display = 'block';
progressContainer.classList.add('show');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
// 创建XMLHttpRequest以支持进度显示
const xhr = new XMLHttpRequest();
// 上传进度
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressBar.style.width = percentComplete + '%';
progressBar.textContent = Math.round(percentComplete) + '%';
}
});
// 上传完成
xhr.addEventListener('load', function() {
// 隐藏进度条
progressContainer.style.display = 'none';
progressContainer.classList.remove('show');
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
// 更新头像显示
updateAvatarDisplay(response.avatar_url);
showAlert('头像上传成功!', 'success');
} else {
showAlert(response.message || '上传失败', 'error');
}
} catch (e) {
showAlert('服务器响应错误', 'error');
}
} else {
showAlert('上传失败,请重试', 'error');
}
// 清理文件输入
document.getElementById('avatarInput').value = '';
selectedFile = null;
});
// 上传错误
xhr.addEventListener('error', function() {
progressContainer.style.display = 'none';
progressContainer.classList.remove('show');
showAlert('网络错误,请重试', 'error');
// 清理文件输入
document.getElementById('avatarInput').value = '';
selectedFile = null;
});
// 发送请求
xhr.open('POST', '/upload/avatar');
xhr.send(formData);
}
// *** 关键:更新头像显示函数 ***
function updateAvatarDisplay(avatarUrl) {
const avatarPreview = document.getElementById('avatarPreview');
const avatarPlaceholder = document.getElementById('avatarPlaceholder');
if (avatarPreview) {
// 更新现有头像
avatarPreview.src = avatarUrl + '?t=' + new Date().getTime();
// *** 强制设置头像样式 ***
avatarPreview.onload = function() {
forceAvatarStyle(avatarPreview);
// 延迟再次确保样式生效
setTimeout(function() {
forceAvatarStyle(avatarPreview);
}, 100);
};
} else if (avatarPlaceholder) {
// 替换占位符为头像
const avatarUpload = avatarPlaceholder.parentElement;
avatarPlaceholder.remove();
const img = document.createElement('img');
img.src = avatarUrl + '?t=' + new Date().getTime();
img.alt = '头像';
img.className = 'avatar-preview';
img.id = 'avatarPreview';
// *** 创建新头像时强制设置样式 ***
img.onload = function() {
forceAvatarStyle(img);
// 延迟再次确保样式生效
setTimeout(function() {
forceAvatarStyle(img);
}, 100);
};
avatarUpload.insertBefore(img, avatarUpload.firstChild);
}
}
// 显示提示信息
function showAlert(message, type = 'info') {
// 移除现有的提示框
const existingAlerts = document.querySelectorAll('.alert.position-fixed');
existingAlerts.forEach(alert => alert.remove());
// 创建提示框
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show position-fixed`;
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px; max-width: 400px;';
const icon = type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-triangle' : 'info-circle';
alertDiv.innerHTML = `
<i class="bi bi-${icon} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
// 3秒后自动消失
setTimeout(() => {
if (alertDiv.parentElement) {
alertDiv.classList.remove('show');
setTimeout(() => {
if (alertDiv.parentElement) {
alertDiv.remove();
}
}, 150);
}
}, 3000);
}
// 初始化拖拽上传功能
function initDragAndDrop() {
const avatarUpload = document.querySelector('.avatar-upload');
if (avatarUpload) {
// 防止默认拖拽行为
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
avatarUpload.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// 拖拽进入和离开的视觉反馈
['dragenter', 'dragover'].forEach(eventName => {
avatarUpload.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
avatarUpload.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
avatarUpload.style.transform = 'scale(1.05)';
avatarUpload.style.boxShadow = '0 0 20px rgba(0,123,255,0.5)';
}
function unhighlight(e) {
avatarUpload.style.transform = '';
avatarUpload.style.boxShadow = '';
}
// 处理文件拖拽
avatarUpload.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
const file = files[0];
// 模拟文件输入事件
const event = { target: { files: [file] } };
handleFileSelect(event);
}
}
}
}
// 额外的保险措施:定期检查并修正头像样式
setInterval(function() {
const avatar = document.getElementById('avatarPreview');
if (avatar) {
// 检查头像是否超出预期尺寸
const rect = avatar.getBoundingClientRect();
if (rect.width > 125 || rect.height > 125) {
forceAvatarStyle(avatar);
}
}
}, 1000); // 每秒检查一次

116
app/static/js/register.js Normal file
View File

@ -0,0 +1,116 @@
// 注册页面JavaScript功能
document.addEventListener('DOMContentLoaded', function() {
const sendBtn = document.getElementById('sendEmailCodeBtn');
const emailInput = document.getElementById('emailInput');
const btnText = document.getElementById('btnText');
const passwordInput = document.getElementById('passwordInput');
const confirmPasswordInput = document.getElementById('confirmPasswordInput');
const passwordMatchMessage = document.getElementById('passwordMatchMessage');
let countdown = 0;
let timer = null;
// 密码确认实时验证
function checkPasswordMatch() {
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (confirmPassword === '') {
passwordMatchMessage.textContent = '';
passwordMatchMessage.className = 'form-text';
return;
}
if (password === confirmPassword) {
passwordMatchMessage.textContent = '✓ 密码匹配';
passwordMatchMessage.className = 'form-text text-success';
confirmPasswordInput.classList.remove('is-invalid');
confirmPasswordInput.classList.add('is-valid');
} else {
passwordMatchMessage.textContent = '✗ 密码不匹配';
passwordMatchMessage.className = 'form-text text-danger';
confirmPasswordInput.classList.remove('is-valid');
confirmPasswordInput.classList.add('is-invalid');
}
}
// 监听密码输入
passwordInput.addEventListener('input', checkPasswordMatch);
confirmPasswordInput.addEventListener('input', checkPasswordMatch);
// 发送验证码
sendBtn.addEventListener('click', function() {
const email = emailInput.value.trim();
// 简单的邮箱格式验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
alert('请输入邮箱地址');
emailInput.focus();
return;
}
if (!emailRegex.test(email)) {
alert('请输入有效的邮箱地址');
emailInput.focus();
return;
}
// 发送AJAX请求
fetch('/auth/send_email_code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('input[name="csrf_token"]').value
},
body: JSON.stringify({
email: email,
type: 1 // 1表示注册
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('验证码已发送到您的邮箱,请查收!');
startCountdown();
} else {
alert(data.message || '发送失败,请重试');
}
})
.catch(error => {
console.error('Error:', error);
alert('发送失败,请检查网络连接');
});
});
// 倒计时功能
function startCountdown() {
countdown = 60;
sendBtn.disabled = true;
sendBtn.classList.add('disabled');
timer = setInterval(function() {
btnText.textContent = `${countdown}秒后重发`;
countdown--;
if (countdown < 0) {
clearInterval(timer);
sendBtn.disabled = false;
sendBtn.classList.remove('disabled');
btnText.textContent = '发送验证码';
}
}, 1000);
}
// 表单提交前验证
document.getElementById('registerForm').addEventListener('submit', function(e) {
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (password !== confirmPassword) {
e.preventDefault();
alert('两次输入的密码不一致,请重新输入');
confirmPasswordInput.focus();
return false;
}
});
});

View File

View File

@ -0,0 +1,123 @@
<!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>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css" rel="stylesheet">
<!-- Admin Base CSS -->
<link href="{{ url_for('static', filename='css/admin_base.css') }}" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- 侧边栏 -->
<div class="admin-sidebar">
<div class="sidebar-brand">
<i class="bi bi-shop"></i>
太白购物商城
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.dashboard' %}active{% endif %}"
href="{{ url_for('admin.dashboard') }}">
<i class="bi bi-speedometer2"></i>
仪表板
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.users' %}active{% endif %}"
href="{{ url_for('admin.users') }}">
<i class="bi bi-people"></i>
用户管理
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint.startswith('product.') %}active{% endif %}"
href="{{ url_for('product.index') }}">
<i class="bi bi-box"></i>
商品管理
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.orders' %}active{% endif %}"
href="#">
<i class="bi bi-receipt"></i>
订单管理
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.logs' %}active{% endif %}"
href="{{ url_for('admin.logs') }}">
<i class="bi bi-journal-text"></i>
操作日志
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.profile' %}active{% endif %}"
href="{{ url_for('admin.profile') }}">
<i class="bi bi-person-gear"></i>
个人资料
</a>
</li>
</ul>
</div>
<!-- 主要内容区域 -->
<div class="admin-main">
<!-- 顶部导航 -->
<div class="admin-header d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-0">{% block page_title %}管理后台{% endblock %}</h4>
<small class="text-muted">{% block page_description %}{% endblock %}</small>
</div>
<div class="d-flex align-items-center">
<div class="dropdown">
<a class="btn btn-link text-decoration-none dropdown-toggle" href="#" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-person-circle"></i>
{{ session.admin_username }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.profile') }}">
<i class="bi bi-person-gear"></i> 个人资料
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('admin.logout') }}">
<i class="bi bi-box-arrow-right"></i> 退出登录
</a></li>
</ul>
</div>
</div>
</div>
<!-- 内容区域 -->
<div class="admin-content">
<!-- 消息提示 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,326 @@
{% extends "admin/base.html" %}
{% block title %}分类管理 - 太白购物商城管理后台{% endblock %}
{% block page_title %}分类管理{% endblock %}
{% block page_description %}商品分类层级管理{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_categories.css') }}">
{% endblock %}
{% block content %}
<!-- 添加分类表单 -->
<div class="add-category-form">
<h5 class="mb-3">
<i class="bi bi-plus-circle"></i> 添加新分类
</h5>
<form id="addCategoryForm" method="POST" action="{{ url_for('product.save_category') }}" enctype="multipart/form-data">
<div class="row">
<div class="col-md-2">
<label class="form-label">分类图标</label>
<div class="icon-upload-area" id="iconUploadArea">
<i class="bi bi-image fs-3 text-muted"></i>
<img id="iconPreview" style="display: none;">
</div>
<input type="file" id="iconInput" name="icon" accept="image/*" style="display: none;">
<small class="text-muted">点击上传图标</small>
</div>
<div class="col-md-3">
<label for="name" class="form-label">分类名称 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="col-md-3">
<label for="parent_id" class="form-label">父分类</label>
<select name="parent_id" id="parent_id" class="form-select">
<option value="0">顶级分类</option>
{% for category in categories %}
{% if category.level <= 2 %}
<option value="{{ category.id }}">
{{ '└─' * (category.level - 1) }}{{ category.name }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label for="sort_order" class="form-label">排序</label>
<input type="number" class="form-control" id="sort_order" name="sort_order" value="0">
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-lg"></i> 添加
</button>
</div>
</div>
</form>
</div>
<!-- 分类列表 -->
<div class="category-tree">
{% if categories %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">
<i class="bi bi-diagram-3"></i> 分类结构
<span class="badge bg-secondary">{{ categories|length }}</span>
</h5>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="expandAll()">
<i class="bi bi-arrows-expand"></i> 展开全部
</button>
<button class="btn btn-outline-secondary" onclick="collapseAll()">
<i class="bi bi-arrows-collapse"></i> 收起全部
</button>
</div>
</div>
<div id="categoryList">
<!-- 渲染顶级分类 -->
{% set top_categories = categories | selectattr('parent_id', 'equalto', 0) | sort(attribute='sort_order') %}
{% for category in top_categories %}
<div class="category-item category-level-{{ category.level }}" data-id="{{ category.id }}">
<div class="category-header">
<div class="category-info">
<i class="bi bi-grip-vertical sort-handle"></i>
{% if category.icon_url %}
<img src="{{ category.icon_url }}" alt="{{ category.name }}" class="category-icon">
{% else %}
<div class="default-icon">
<i class="bi bi-folder"></i>
</div>
{% endif %}
<div class="category-details">
<h6>{{ category.name }}</h6>
<div class="category-meta">
ID: {{ category.id }} |
层级: {{ category.level }} |
排序: {{ category.sort_order }} |
{% if category.is_active %}
<span class="text-success">启用</span>
{% else %}
<span class="text-danger">禁用</span>
{% endif %}
</div>
</div>
</div>
<div class="category-actions">
<button class="btn btn-light btn-icon" onclick="editCategory({{ category.id }})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-light btn-icon" onclick="addSubCategory({{ category.id }})" title="添加子分类">
<i class="bi bi-plus"></i>
</button>
{% if category.level < 3 %}
<button class="btn btn-light btn-icon" onclick="toggleCategory({{ category.id }})" title="展开/收起">
<i class="bi bi-chevron-down"></i>
</button>
{% endif %}
<button class="btn btn-light btn-icon text-danger" onclick="deleteCategory({{ category.id }})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<!-- 二级分类 -->
{% set level2_categories = categories | selectattr('parent_id', 'equalto', category.id) | sort(attribute='sort_order') %}
{% if level2_categories %}
<div class="children-categories">
{% for child in level2_categories %}
<div class="category-item category-level-{{ child.level }}" data-id="{{ child.id }}">
<div class="category-header">
<div class="category-info">
<i class="bi bi-grip-vertical sort-handle"></i>
{% if child.icon_url %}
<img src="{{ child.icon_url }}" alt="{{ child.name }}" class="category-icon">
{% else %}
<div class="default-icon">
<i class="bi bi-folder"></i>
</div>
{% endif %}
<div class="category-details">
<h6>{{ child.name }}</h6>
<div class="category-meta">
ID: {{ child.id }} |
层级: {{ child.level }} |
排序: {{ child.sort_order }} |
{% if child.is_active %}
<span class="text-success">启用</span>
{% else %}
<span class="text-danger">禁用</span>
{% endif %}
</div>
</div>
</div>
<div class="category-actions">
<button class="btn btn-light btn-icon" onclick="editCategory({{ child.id }})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
{% if child.level < 3 %}
<button class="btn btn-light btn-icon" onclick="addSubCategory({{ child.id }})" title="添加子分类">
<i class="bi bi-plus"></i>
</button>
{% endif %}
<button class="btn btn-light btn-icon text-danger" onclick="deleteCategory({{ child.id }})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<!-- 三级分类 -->
{% set level3_categories = categories | selectattr('parent_id', 'equalto', child.id) | sort(attribute='sort_order') %}
{% if level3_categories %}
<div class="children-categories">
{% for grandchild in level3_categories %}
<div class="category-item category-level-{{ grandchild.level }}" data-id="{{ grandchild.id }}">
<div class="category-header">
<div class="category-info">
<i class="bi bi-grip-vertical sort-handle"></i>
{% if grandchild.icon_url %}
<img src="{{ grandchild.icon_url }}" alt="{{ grandchild.name }}" class="category-icon">
{% else %}
<div class="default-icon">
<i class="bi bi-folder"></i>
</div>
{% endif %}
<div class="category-details">
<h6>{{ grandchild.name }}</h6>
<div class="category-meta">
ID: {{ grandchild.id }} |
层级: {{ grandchild.level }} |
排序: {{ grandchild.sort_order }} |
{% if grandchild.is_active %}
<span class="text-success">启用</span>
{% else %}
<span class="text-danger">禁用</span>
{% endif %}
</div>
</div>
</div>
<div class="category-actions">
<button class="btn btn-light btn-icon" onclick="editCategory({{ grandchild.id }})" title="编辑">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-light btn-icon text-danger" onclick="deleteCategory({{ grandchild.id }})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-folder"></i>
<h5>还没有创建任何分类</h5>
<p class="text-muted">点击上方的"添加新分类"来创建第一个商品分类</p>
</div>
{% endif %}
</div>
<!-- 编辑分类模态框 -->
<div class="modal fade" id="editCategoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pencil"></i> 编辑分类
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editCategoryForm" method="POST" action="{{ url_for('product.save_category') }}" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" id="edit_category_id" name="category_id">
<div class="row">
<div class="col-md-4">
<label class="form-label">分类图标</label>
<div class="icon-upload-area" id="editIconUploadArea">
<i class="bi bi-image fs-3 text-muted"></i>
<img id="editIconPreview" style="display: none;">
</div>
<input type="file" id="editIconInput" name="icon" accept="image/*" style="display: none;">
<small class="text-muted">点击更换图标</small>
</div>
<div class="col-md-8">
<div class="mb-3">
<label for="edit_name" class="form-label">分类名称 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="edit_name" name="name" required>
</div>
<div class="mb-3">
<label for="edit_parent_id" class="form-label">父分类</label>
<select name="parent_id" id="edit_parent_id" class="form-select">
<option value="0">顶级分类</option>
{% for category in categories %}
{% if category.level <= 2 %}
<option value="{{ category.id }}">
{{ '└─' * (category.level - 1) }}{{ category.name }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="row">
<div class="col-6">
<label for="edit_sort_order" class="form-label">排序</label>
<input type="number" class="form-control" id="edit_sort_order" name="sort_order">
</div>
<div class="col-6">
<label for="edit_is_active" class="form-label">状态</label>
<select name="is_active" id="edit_is_active" class="form-select">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> 保存修改
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/admin_categories.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,207 @@
{% extends "admin/base.html" %}
{% block title %}仪表板 - 太白购物商城管理后台{% endblock %}
{% block page_title %}仪表板{% endblock %}
{% block page_description %}系统概览和数据统计{% endblock %}
{% block extra_css %}
<link href="{{ url_for('static', filename='css/admin_dashboard.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="row dashboard-stats">
<!-- 统计卡片 -->
<div class="col-md-3 mb-4">
<div class="card stats-card">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1">{{ stats.total_users or 0 }}</h3>
<p class="mb-0">总用户数</p>
</div>
<div class="fs-1">
<i class="bi bi-people"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-4">
<div class="card stats-card success">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1">{{ stats.active_users or 0 }}</h3>
<p class="mb-0">活跃用户</p>
</div>
<div class="fs-1">
<i class="bi bi-person-check"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-4">
<div class="card stats-card warning">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1">{{ stats.total_admins or 0 }}</h3>
<p class="mb-0">管理员数</p>
</div>
<div class="fs-1">
<i class="bi bi-shield-check"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-4">
<div class="card stats-card info">
<div class="d-flex justify-content-between align-items-center">
<div>
<h3 class="mb-1">{{ stats.recent_logs_count or 0 }}</h3>
<p class="mb-0">7天操作数</p>
</div>
<div class="fs-1">
<i class="bi bi-activity"></i>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- 用户注册趋势 -->
<div class="col-md-8 mb-4">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-graph-up"></i>
用户注册趋势最近7天
</h5>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="userTrendChart"></canvas>
</div>
</div>
</div>
</div>
<!-- 系统状态 -->
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-info-circle"></i>
系统状态
</h5>
</div>
<div class="card-body">
<div class="system-status-item">
<span>数据库连接</span>
<span class="badge bg-success">正常</span>
</div>
<div class="system-status-item">
<span>文件存储</span>
<span class="badge bg-success">正常</span>
</div>
<div class="system-status-item">
<span>邮件服务</span>
<span class="badge bg-success">正常</span>
</div>
<div class="system-status-item">
<span>系统版本</span>
<span class="badge bg-info">v1.0.0</span>
</div>
</div>
</div>
</div>
</div>
<!-- 最近操作日志 -->
<div class="row log-table-container">
<div class="col-12">
<div class="card admin-table">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-journal-text"></i>
最近操作日志
</h5>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>时间</th>
<th>操作者</th>
<th>操作类型</th>
<th>操作内容</th>
<th>IP地址</th>
</tr>
</thead>
<tbody>
{% if recent_logs %}
{% for log in recent_logs %}
<tr>
<td>{{ log.created_at.strftime('%m-%d %H:%M') if log.created_at else '' }}</td>
<td>
{% if log.user_type == 2 %}
<span class="badge bg-warning">管理员</span>
{% else %}
<span class="badge bg-info">用户</span>
{% endif %}
{{ log.user_id }}
</td>
<td>{{ log.action }}</td>
<td>
{% if log.resource_type %}
{{ log.resource_type }}
{% if log.resource_id %}#{{ log.resource_id }}{% endif %}
{% else %}
-
{% endif %}
</td>
<td>{{ log.ip_address or '-' }}</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="5" class="empty-state">
<i class="bi bi-inbox"></i>
<div>暂无操作日志</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% if recent_logs %}
<div class="card-footer bg-white text-center">
<a href="{{ url_for('admin.logs') }}" class="btn btn-outline-primary btn-sm">
查看全部日志 <i class="bi bi-arrow-right"></i>
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
// Pass data from template to JavaScript
window.userTrendLabels = [
{% for item in user_trend %}
'{{ item.date }}'{% if not loop.last %},{% endif %}
{% endfor %}
];
window.userTrendData = [
{% for item in user_trend %}
{{ item.count }}{% if not loop.last %},{% endif %}
{% endfor %}
];
</script>
<script src="{{ url_for('static', filename='js/admin_dashboard.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理员登录 - 太白购物商城</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css" rel="stylesheet">
<!-- Admin Login CSS -->
<link href="{{ url_for('static', filename='css/admin_login.css') }}" rel="stylesheet">
</head>
<body>
<div class="login-card">
<div class="login-header">
<h2><i class="bi bi-shield-lock"></i> 管理员登录</h2>
<p>太白购物商城管理后台</p>
</div>
<!-- 消息提示 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div class="mb-3">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-person"></i>
</span>
<input type="text" class="form-control" name="username" placeholder="管理员用户名" required>
</div>
</div>
<div class="mb-4">
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-lock"></i>
</span>
<input type="password" class="form-control" name="password" placeholder="登录密码" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-login w-100">
<i class="bi bi-box-arrow-in-right"></i>
登录管理后台
</button>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('main.index') }}" class="back-link">
<i class="bi bi-arrow-left"></i> 返回前台
</a>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

View File

@ -0,0 +1,859 @@
{% extends "admin/base.html" %}
{% block title %}
{% if product %}编辑商品{% else %}添加商品{% endif %} - 太白购物商城管理后台
{% endblock %}
{% block page_title %}
{% if product %}编辑商品{% else %}添加商品{% endif %}
{% endblock %}
{% block page_description %}
商品信息管理
{% endblock %}
{% block extra_css %}
<style>
.image-upload-area {
border: 2px dashed #dee2e6;
border-radius: 10px;
padding: 30px;
text-align: center;
background-color: #f8f9fa;
transition: all 0.3s ease;
cursor: pointer;
}
.image-upload-area:hover {
border-color: #0d6efd;
background-color: #e3f2fd;
}
.image-upload-area.dragover {
border-color: #0d6efd;
background-color: #e3f2fd;
}
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 15px;
margin-top: 20px;
}
.image-item {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 2px solid #dee2e6;
transition: all 0.3s ease;
}
.image-item:hover {
border-color: #0d6efd;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.image-item.main-image {
border-color: #198754;
}
.image-item img {
width: 100%;
height: 120px;
object-fit: cover;
}
.image-controls {
position: absolute;
top: 5px;
right: 5px;
display: flex;
gap: 5px;
}
.image-controls .btn {
padding: 2px 6px;
font-size: 12px;
}
.main-badge {
position: absolute;
bottom: 5px;
left: 5px;
background-color: rgba(25, 135, 84, 0.9);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
}
.upload-progress {
display: none;
margin-top: 10px;
}
.inventory-table {
font-size: 0.9rem;
}
.inventory-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.spec-selector {
max-height: 200px;
overflow-y: auto;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 10px;
}
.spec-group {
margin-bottom: 15px;
}
.spec-option {
margin: 2px;
}
.inventory-row {
background-color: #f8f9fa;
}
.inventory-row:nth-child(even) {
background-color: white;
}
</style>
{% endblock %}
{% block content %}
<form method="POST" action="{{ url_for('product.save') }}" id="productForm">
{% if product %}
<input type="hidden" name="product_id" value="{{ product.id }}">
{% endif %}
<div class="row">
<!-- 基本信息 -->
<div class="col-md-8">
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-info-circle"></i> 基本信息
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="name" class="form-label">商品名称 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
value="{{ product.name if product else '' }}" required>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="category_id" class="form-label">商品分类 <span class="text-danger">*</span></label>
<select name="category_id" id="category_id" class="form-select" required>
<option value="">选择分类</option>
{% for category in categories %}
<option value="{{ category.id }}"
{% if product and product.category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="price" class="form-label">销售价格 <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text">¥</span>
<input type="number" class="form-control" id="price" name="price"
step="0.01" min="0" value="{{ product.price if product else '' }}" required>
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="original_price" class="form-label">原价</label>
<div class="input-group">
<span class="input-group-text">¥</span>
<input type="number" class="form-control" id="original_price" name="original_price"
step="0.01" min="0" value="{{ product.original_price if product else '' }}">
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="brand" class="form-label">品牌</label>
<input type="text" class="form-control" id="brand" name="brand"
value="{{ product.brand if product else '' }}">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="weight" class="form-label">重量 (kg)</label>
<input type="number" class="form-control" id="weight" name="weight"
step="0.01" min="0" value="{{ product.weight if product else '' }}">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="status" class="form-label">状态</label>
<select name="status" id="status" class="form-select">
<option value="1" {% if not product or product.status == 1 %}selected{% endif %}>上架</option>
<option value="0" {% if product and product.status == 0 %}selected{% endif %}>下架</option>
</select>
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">商品描述</label>
<textarea class="form-control" id="description" name="description" rows="5">{{ product.description if product else '' }}</textarea>
</div>
</div>
</div>
<!-- 库存管理 -->
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-boxes"></i> 库存管理
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="radio" name="inventory_type"
id="single_sku" value="single" checked onchange="toggleInventoryType()">
<label class="form-check-label" for="single_sku">
单一规格(无变体)
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="radio" name="inventory_type"
id="multi_sku" value="multi" onchange="toggleInventoryType()">
<label class="form-check-label" for="multi_sku">
多规格(有变体)
</label>
</div>
</div>
</div>
<!-- 单一规格库存 -->
<div id="singleInventorySection">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="single_stock" class="form-label">库存数量</label>
<input type="number" class="form-control" id="single_stock"
name="single_stock" min="0" value="0">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="warning_stock" class="form-label">预警库存</label>
<input type="number" class="form-control" id="warning_stock"
name="warning_stock" min="0" value="10">
</div>
</div>
</div>
</div>
<!-- 多规格管理 -->
<div id="multiInventorySection" style="display: none;">
<!-- 规格选择 -->
<div class="mb-3">
<label class="form-label">选择规格类型:</label>
<div class="spec-selector">
{% for spec_name in spec_names %}
<div class="spec-group">
<div class="form-check">
<input class="form-check-input spec-checkbox" type="checkbox"
id="spec_{{ spec_name.id }}" value="{{ spec_name.id }}"
data-spec-name="{{ spec_name.name }}" onchange="updateSpecValues()">
<label class="form-check-label fw-bold" for="spec_{{ spec_name.id }}">
{{ spec_name.name }}
</label>
</div>
<div class="spec-values ms-3" id="values_{{ spec_name.id }}" style="display: none;">
{% for value in spec_name.values %}
<div class="form-check form-check-inline">
<input class="form-check-input spec-value-checkbox" type="checkbox"
id="value_{{ value.id }}" value="{{ value.value }}"
data-spec-id="{{ spec_name.id }}" data-spec-name="{{ spec_name.name }}">
<label class="form-check-label" for="value_{{ value.id }}">
{{ value.value }}
</label>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="mb-3">
<button type="button" class="btn btn-success" onclick="generateSkuTable()">
<i class="bi bi-gear"></i> 生成SKU表格
</button>
</div>
<!-- SKU表格 -->
<div id="skuTableContainer" style="display: none;">
<div class="table-responsive">
<table class="table table-bordered inventory-table" id="skuTable">
<thead>
<tr>
<th>规格组合</th>
<th>SKU编码</th>
<th>价格调整</th>
<th>库存数量</th>
<th>预警库存</th>
<th>默认规格</th>
</tr>
</thead>
<tbody id="skuTableBody">
</tbody>
</table>
</div>
</div>
</div>
<!-- 现有库存信息展示(编辑时) -->
{% if product and product.inventory %}
<div class="mt-4">
<h6>当前库存信息:</h6>
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>SKU编码</th>
<th>规格组合</th>
<th>库存</th>
<th>价格</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{% for inventory in product.inventory %}
<tr>
<td>{{ inventory.sku_code }}</td>
<td>
{% if inventory.spec_combination %}
{% for key, value in inventory.spec_combination.items() %}
{{ key }}:{{ value }}{% if not loop.last %}, {% endif %}
{% endfor %}
{% else %}
默认规格
{% endif %}
</td>
<td>{{ inventory.stock }}</td>
<td>¥{{ "%.2f"|format(inventory.get_final_price()) }}</td>
<td>
{% if inventory.status %}
<span class="badge bg-success">启用</span>
{% else %}
<span class="badge bg-secondary">禁用</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
<!-- 商品图片 -->
{% if product %}
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-images"></i> 商品图片
</h5>
</div>
<div class="card-body">
<!-- 图片上传区域 -->
<div class="image-upload-area" id="imageUploadArea">
<i class="bi bi-cloud-upload fs-1 text-muted"></i>
<h5 class="mt-2">拖拽图片到这里或点击选择</h5>
<p class="text-muted mb-0">支持 JPG、PNG、GIF 格式,单张图片不超过 5MB</p>
<input type="file" id="imageInput" multiple accept="image/*" style="display: none;">
</div>
<!-- 上传进度 -->
<div class="upload-progress">
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
</div>
<small class="text-muted">上传中...</small>
</div>
<!-- 图片画廊 -->
<div class="image-gallery" id="imageGallery">
{% if product.images %}
{% for image in product.images|sort(attribute='sort_order') %}
<div class="image-item {% if image.is_main %}main-image{% endif %}" data-id="{{ image.id }}">
<img src="{{ image.image_url }}" alt="商品图片">
<div class="image-controls">
{% if not image.is_main %}
<button type="button" class="btn btn-success btn-sm"
onclick="setMainImage({{ image.id }})" title="设为主图">
<i class="bi bi-star"></i>
</button>
{% endif %}
<button type="button" class="btn btn-danger btn-sm"
onclick="deleteImage({{ image.id }})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
{% if image.is_main %}
<div class="main-badge">主图</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
<!-- 侧边栏 -->
<div class="col-md-4">
<div class="card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-gear"></i> 操作
</h5>
</div>
<div class="card-body">
<button type="submit" class="btn btn-primary w-100 mb-2">
<i class="bi bi-check-lg"></i> 保存商品
</button>
{% if product %}
<a href="{{ url_for('product.index') }}" class="btn btn-outline-secondary w-100 mb-2">
<i class="bi bi-arrow-left"></i> 返回列表
</a>
<button type="button" class="btn btn-outline-danger w-100"
onclick="deleteProduct({{ product.id }})">
<i class="bi bi-trash"></i> 删除商品
</button>
{% else %}
<a href="{{ url_for('product.index') }}" class="btn btn-outline-secondary w-100">
<i class="bi bi-arrow-left"></i> 返回列表
</a>
{% endif %}
</div>
</div>
{% if product %}
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="bi bi-info-circle"></i> 商品信息
</h5>
</div>
<div class="card-body">
<div class="mb-2">
<strong>商品ID</strong>{{ product.id }}
</div>
<div class="mb-2">
<strong>销量:</strong>{{ product.sales_count }}
</div>
<div class="mb-2">
<strong>浏览量:</strong>{{ product.view_count }}
</div>
<div class="mb-2">
<strong>创建时间:</strong><br>
{{ product.created_at.strftime('%Y-%m-%d %H:%M:%S') if product.created_at else '' }}
</div>
<div class="mb-2">
<strong>更新时间:</strong><br>
{{ product.updated_at.strftime('%Y-%m-%d %H:%M:%S') if product.updated_at else '' }}
</div>
</div>
</div>
{% endif %}
</div>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script>
let skuCounter = 0;
// 切换库存类型
function toggleInventoryType() {
const singleSection = document.getElementById('singleInventorySection');
const multiSection = document.getElementById('multiInventorySection');
const singleRadio = document.getElementById('single_sku');
if (singleRadio.checked) {
singleSection.style.display = 'block';
multiSection.style.display = 'none';
} else {
singleSection.style.display = 'none';
multiSection.style.display = 'block';
}
}
// 更新规格值显示
function updateSpecValues() {
document.querySelectorAll('.spec-checkbox').forEach(checkbox => {
const valuesDiv = document.getElementById('values_' + checkbox.value);
if (checkbox.checked) {
valuesDiv.style.display = 'block';
} else {
valuesDiv.style.display = 'none';
// 取消选中该规格下的所有值
valuesDiv.querySelectorAll('.spec-value-checkbox').forEach(valueCheckbox => {
valueCheckbox.checked = false;
});
}
});
}
// 生成SKU表格
function generateSkuTable() {
const selectedSpecs = {};
// 收集选中的规格和值
document.querySelectorAll('.spec-value-checkbox:checked').forEach(checkbox => {
const specId = checkbox.getAttribute('data-spec-id');
const specName = checkbox.getAttribute('data-spec-name');
const value = checkbox.value;
if (!selectedSpecs[specName]) {
selectedSpecs[specName] = [];
}
selectedSpecs[specName].push(value);
});
if (Object.keys(selectedSpecs).length === 0) {
alert('请先选择规格');
return;
}
// 生成SKU组合
const combinations = generateCombinations(selectedSpecs);
// 创建表格
const tableBody = document.getElementById('skuTableBody');
tableBody.innerHTML = '';
combinations.forEach((combination, index) => {
const row = document.createElement('tr');
row.className = 'inventory-row';
// 规格组合显示
const specText = Object.entries(combination).map(([key, value]) => `${key}:${value}`).join(', ');
// 生成SKU编码
const skuCode = generateSkuCode(combination);
row.innerHTML = `
<td>${specText}</td>
<td>
<input type="text" class="form-control form-control-sm"
name="sku_codes[]" value="${skuCode}" required>
<input type="hidden" name="spec_combinations[]" value='${JSON.stringify(combination)}'>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-text">¥</span>
<input type="number" class="form-control" name="price_adjustments[]"
value="0" step="0.01">
</div>
</td>
<td>
<input type="number" class="form-control form-control-sm"
name="stocks[]" value="0" min="0" required>
</td>
<td>
<input type="number" class="form-control form-control-sm"
name="warning_stocks[]" value="10" min="0">
</td>
<td>
<div class="form-check">
<input class="form-check-input" type="radio" name="default_sku"
value="${index}" ${index === 0 ? 'checked' : ''}>
</div>
</td>
`;
tableBody.appendChild(row);
});
document.getElementById('skuTableContainer').style.display = 'block';
}
// 生成组合
function generateCombinations(specs) {
const keys = Object.keys(specs);
const values = Object.values(specs);
function cartesian(arrays) {
return arrays.reduce((a, b) =>
a.flatMap(x => b.map(y => [...x, y])), [[]]
);
}
const combinations = cartesian(values);
return combinations.map(combination => {
const result = {};
keys.forEach((key, index) => {
result[key] = combination[index];
});
return result;
});
}
// 生成SKU编码
function generateSkuCode(combination) {
const productName = document.getElementById('name').value || 'PRODUCT';
const shortName = productName.substring(0, 3).toUpperCase();
const specCode = Object.values(combination).map(v => v.substring(0, 2)).join('');
const timestamp = Date.now().toString().slice(-4);
return `${shortName}-${specCode}-${timestamp}`;
}
// 表单提交前的验证
document.getElementById('productForm').addEventListener('submit', function(e) {
const inventoryType = document.querySelector('input[name="inventory_type"]:checked').value;
if (inventoryType === 'single') {
// 单规格验证
const stock = document.getElementById('single_stock').value;
if (!stock || stock < 0) {
e.preventDefault();
alert('请输入正确的库存数量');
return;
}
} else {
// 多规格验证
const skuTable = document.getElementById('skuTableContainer');
if (skuTable.style.display === 'none') {
e.preventDefault();
alert('请生成SKU表格并设置库存信息');
return;
}
const stockInputs = document.querySelectorAll('input[name="stocks[]"]');
if (stockInputs.length === 0) {
e.preventDefault();
alert('请添加至少一个SKU');
return;
}
}
});
{% if product %}
// 图片上传功能
document.addEventListener('DOMContentLoaded', function() {
const uploadArea = document.getElementById('imageUploadArea');
const imageInput = document.getElementById('imageInput');
const imageGallery = document.getElementById('imageGallery');
const progressDiv = document.querySelector('.upload-progress');
const progressBar = document.querySelector('.progress-bar');
// 点击上传区域
uploadArea.addEventListener('click', function() {
imageInput.click();
});
// 文件选择
imageInput.addEventListener('change', function(e) {
uploadImages(e.target.files);
});
// 拖拽上传
uploadArea.addEventListener('dragover', function(e) {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', function(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
uploadImages(e.dataTransfer.files);
});
// 上传图片
function uploadImages(files) {
if (files.length === 0) return;
const formData = new FormData();
for (let file of files) {
formData.append('files', file);
}
// 显示进度条
progressDiv.style.display = 'block';
progressBar.style.width = '0%';
fetch(`/admin/products/upload-images/{{ product.id }}`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
progressDiv.style.display = 'none';
if (data.success) {
// 添加新图片到画廊
data.images.forEach(image => {
addImageToGallery(image);
});
alert(`成功上传 ${data.images.length} 张图片`);
} else {
alert('上传失败: ' + data.message);
}
})
.catch(error => {
progressDiv.style.display = 'none';
alert('上传失败: ' + error);
});
}
// 添加图片到画廊
function addImageToGallery(image) {
const imageItem = document.createElement('div');
imageItem.className = 'image-item';
imageItem.setAttribute('data-id', image.id);
imageItem.innerHTML = `
<img src="${image.url}" alt="商品图片">
<div class="image-controls">
<button type="button" class="btn btn-success btn-sm"
onclick="setMainImage(${image.id})" title="设为主图">
<i class="bi bi-star"></i>
</button>
<button type="button" class="btn btn-danger btn-sm"
onclick="deleteImage(${image.id})" title="删除">
<i class="bi bi-trash"></i>
</button>
</div>
`;
imageGallery.appendChild(imageItem);
}
});
// 设置主图
function setMainImage(imageId) {
fetch(`/admin/products/set-main-image/${imageId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 更新UI
document.querySelectorAll('.image-item').forEach(item => {
item.classList.remove('main-image');
const badge = item.querySelector('.main-badge');
if (badge) badge.remove();
const starBtn = item.querySelector('.btn-success');
if (starBtn) starBtn.style.display = 'block';
});
const currentItem = document.querySelector(`[data-id="${imageId}"]`);
currentItem.classList.add('main-image');
currentItem.querySelector('.btn-success').style.display = 'none';
const badge = document.createElement('div');
badge.className = 'main-badge';
badge.textContent = '主图';
currentItem.appendChild(badge);
alert('主图设置成功');
} else {
alert('设置失败: ' + data.message);
}
})
.catch(error => {
alert('设置失败: ' + error);
});
}
// 删除图片
function deleteImage(imageId) {
if (confirm('确定要删除这张图片吗?')) {
fetch(`/admin/products/delete-image/${imageId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.querySelector(`[data-id="${imageId}"]`).remove();
alert('图片删除成功');
} else {
alert('删除失败: ' + data.message);
}
})
.catch(error => {
alert('删除失败: ' + error);
});
}
}
// 删除商品
function deleteProduct(productId) {
if (confirm('确定要删除这个商品吗?此操作不可恢复!')) {
fetch(`/admin/products/delete/${productId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('商品删除成功');
window.location.href = '{{ url_for("product.index") }}';
} else {
alert('删除失败: ' + data.message);
}
})
.catch(error => {
alert('删除失败: ' + error);
});
}
}
{% endif %}
</script>
{% endblock %}

View File

@ -0,0 +1,441 @@
{% extends "admin/base.html" %}
{% block title %}商品管理 - 太白购物商城管理后台{% endblock %}
{% block page_title %}商品管理{% endblock %}
{% block page_description %}商品信息管理{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-8">
<!-- 搜索和筛选 -->
<form method="GET" class="d-flex">
<input type="text" class="form-control me-2" name="search"
placeholder="搜索商品名称..." value="{{ search }}">
<select name="category_id" class="form-select me-2">
<option value="">全部分类</option>
{% for category in categories %}
<option value="{{ category.id }}"
{% if category_id == category.id|string %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
<select name="status" class="form-select me-2">
<option value="">全部状态</option>
<option value="1" {% if status == '1' %}selected{% endif %}>上架</option>
<option value="0" {% if status == '0' %}selected{% endif %}>下架</option>
</select>
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-search"></i> 搜索
</button>
</form>
</div>
<div class="col-md-4 text-end">
<a href="{{ url_for('product.add') }}" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> 添加商品
</a>
<a href="{{ url_for('product.categories') }}" class="btn btn-outline-secondary">
<i class="bi bi-tags"></i> 分类管理
</a>
</div>
</div>
<!-- 商品列表 -->
<div class="card admin-table">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th width="80">ID</th>
<th width="100">商品图片</th>
<th>商品名称</th>
<th width="120">分类</th>
<th width="100">价格</th>
<th width="100">库存</th>
<th width="80">状态</th>
<th width="100">销量</th>
<th width="120">创建时间</th>
<th width="150">操作</th>
</tr>
</thead>
<tbody>
{% if products.items %}
{% for product in products.items %}
<tr>
<td>{{ product.id }}</td>
<td>
{% if product.main_image %}
<img src="{{ product.main_image }}" class="img-thumbnail"
style="width: 60px; height: 60px; object-fit: cover;">
{% else %}
<div class="bg-light text-center d-flex align-items-center justify-content-center"
style="width: 60px; height: 60px;">
<i class="bi bi-image text-muted"></i>
</div>
{% endif %}
</td>
<td>
<div>
<strong>{{ product.name[:40] }}{% if product.name|length > 40 %}...{% endif %}</strong>
{% if product.brand %}
<br><small class="text-muted"><i class="bi bi-award"></i> {{ product.brand }}</small>
{% endif %}
{% if product.has_specs %}
<br><span class="badge bg-info badge-sm">多规格</span>
{% endif %}
</div>
</td>
<td>
<span class="badge bg-secondary">{{ product.category.name if product.category else '未分类' }}</span>
</td>
<td>
<strong class="text-danger">¥{{ "%.2f"|format(product.price) }}</strong>
{% if product.original_price and product.original_price > product.price %}
<br><small class="text-muted text-decoration-line-through">
¥{{ "%.2f"|format(product.original_price) }}
</small>
{% endif %}
</td>
<td>
{% set total_stock = product.inventory|sum(attribute='stock') if product.inventory else 0 %}
{% set sku_count = product.inventory|length if product.inventory else 0 %}
<div class="text-center">
<span class="fw-bold {% if total_stock <= 0 %}text-danger{% elif total_stock <= 10 %}text-warning{% else %}text-success{% endif %}">
{{ total_stock }}
</span>
{% if sku_count > 1 %}
<br><small class="text-muted">{{ sku_count }}个SKU</small>
{% endif %}
{% if total_stock <= 0 %}
<br><small class="text-danger">缺货</small>
{% elif total_stock <= 10 %}
<br><small class="text-warning">库存不足</small>
{% endif %}
</div>
</td>
<td>
{% if product.status == 1 %}
<span class="badge bg-success">上架</span>
{% else %}
<span class="badge bg-secondary">下架</span>
{% endif %}
</td>
<td class="text-center">
<div>{{ product.sales_count }}</div>
<small class="text-muted">浏览:{{ product.view_count }}</small>
</td>
<td>
<div class="text-center">
{{ product.created_at.strftime('%m-%d') if product.created_at else '' }}
<br><small class="text-muted">{{ product.created_at.strftime('%H:%M') if product.created_at else '' }}</small>
</div>
</td>
<td>
<div class="btn-group-vertical btn-group-sm" role="group">
<a href="{{ url_for('product.edit', product_id=product.id) }}"
class="btn btn-outline-primary btn-sm" title="编辑">
<i class="bi bi-pencil"></i> 编辑
</a>
{% if product.inventory %}
<button class="btn btn-outline-info btn-sm"
onclick="showInventory({{ product.id }})" title="库存详情">
<i class="bi bi-boxes"></i> 库存
</button>
{% endif %}
<button class="btn btn-outline-danger btn-sm"
onclick="deleteProduct({{ product.id }})" title="删除">
<i class="bi bi-trash"></i> 删除
</button>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="10" class="text-center py-4">
<div class="text-muted">
<i class="bi bi-inbox display-4"></i>
<p class="mt-2">暂无商品数据</p>
{% if search or category_id or status %}
<a href="{{ url_for('product.index') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-clockwise"></i> 清除筛选
</a>
{% endif %}
</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- 分页 -->
{% if products.pages > 1 %}
<div class="card-footer bg-white">
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
显示第 {{ (products.page - 1) * products.per_page + 1 }} -
{{ products.page * products.per_page if products.page * products.per_page < products.total else products.total }}
条,共 {{ products.total }} 条
</small>
<nav aria-label="商品分页">
<ul class="pagination pagination-sm mb-0">
{% if products.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('product.index', page=products.prev_num, search=search, category_id=category_id, status=status) }}">
<i class="bi bi-chevron-left"></i> 上一页
</a>
</li>
{% endif %}
{% for page_num in products.iter_pages() %}
{% if page_num %}
{% if page_num != products.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('product.index', page=page_num, search=search, category_id=category_id, status=status) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if products.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('product.index', page=products.next_num, search=search, category_id=category_id, status=status) }}">
下一页 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
</div>
{% endif %}
</div>
<!-- 库存详情模态框 -->
<div class="modal fade" id="inventoryModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-boxes"></i> 库存详情
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="inventoryModalBody">
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function deleteProduct(productId) {
if (confirm('确定要删除这个商品吗?此操作不可恢复!\n删除商品将同时删除所有相关的库存信息。')) {
fetch(`/admin/products/delete/${productId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showSuccessMessage('商品删除成功');
setTimeout(() => {
location.reload();
}, 1000);
} else {
alert('删除失败: ' + data.message);
}
})
.catch(error => {
alert('删除失败: ' + error);
});
}
}
function showInventory(productId) {
const modal = new bootstrap.Modal(document.getElementById('inventoryModal'));
const modalBody = document.getElementById('inventoryModalBody');
// 显示加载状态
modalBody.innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p class="mt-2">正在加载库存信息...</p>
</div>
`;
modal.show();
// 获取库存信息
fetch(`/admin/products/inventory/${productId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
displayInventoryInfo(data.inventory);
} else {
modalBody.innerHTML = `<div class="alert alert-danger">加载失败: ${data.message}</div>`;
}
})
.catch(error => {
modalBody.innerHTML = `<div class="alert alert-danger">加载失败: ${error}</div>`;
});
}
function displayInventoryInfo(inventoryList) {
const modalBody = document.getElementById('inventoryModalBody');
if (!inventoryList || inventoryList.length === 0) {
modalBody.innerHTML = '<div class="alert alert-info">暂无库存信息</div>';
return;
}
let html = `
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>SKU编码</th>
<th>规格组合</th>
<th>库存数量</th>
<th>预警库存</th>
<th>价格调整</th>
<th>状态</th>
</tr>
</thead>
<tbody>
`;
inventoryList.forEach(item => {
const specText = item.spec_combination ?
Object.entries(item.spec_combination).map(([k, v]) => `${k}:${v}`).join(', ') :
'默认规格';
const stockClass = item.stock <= 0 ? 'text-danger' :
item.stock <= item.warning_stock ? 'text-warning' : 'text-success';
html += `
<tr>
<td>
<code>${item.sku_code}</code>
${item.is_default ? '<span class="badge bg-primary badge-sm ms-1">默认</span>' : ''}
</td>
<td>${specText}</td>
<td class="${stockClass} fw-bold">${item.stock}</td>
<td>${item.warning_stock}</td>
<td>
${item.price_adjustment > 0 ? '+' : ''}¥${item.price_adjustment.toFixed(2)}
</td>
<td>
${item.status ? '<span class="badge bg-success">启用</span>' : '<span class="badge bg-secondary">禁用</span>'}
</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
modalBody.innerHTML = html;
}
function showSuccessMessage(message) {
const alertDiv = document.createElement('div');
alertDiv.className = 'alert alert-success alert-dismissible fade show position-fixed';
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.parentNode.removeChild(alertDiv);
}
}, 3000);
}
// 页面加载完成后的初始化
document.addEventListener('DOMContentLoaded', function() {
// 添加表格行悬停效果
const tableRows = document.querySelectorAll('.table tbody tr');
tableRows.forEach(row => {
row.addEventListener('mouseenter', function() {
this.style.backgroundColor = '#f8f9fa';
});
row.addEventListener('mouseleave', function() {
this.style.backgroundColor = '';
});
});
});
</script>
<style>
.badge-sm {
font-size: 0.7em;
}
.btn-group-vertical .btn {
margin-bottom: 2px;
}
.table td {
vertical-align: middle;
}
.img-thumbnail {
border-radius: 8px;
}
.admin-table .table thead th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
}
@media (max-width: 768px) {
.btn-group-vertical {
width: 100%;
}
.btn-group-vertical .btn {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
}
}
</style>
{% endblock %}

View File

@ -0,0 +1,138 @@
{% extends "admin/base.html" %}
{% block title %}个人资料 - 太白购物商城管理后台{% endblock %}
{% block page_title %}个人资料{% endblock %}
{% block page_description %}管理员个人信息设置{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin_profile.css') }}">
{% endblock %}
{% block content %}
<div class="profile-container">
<div class="row">
<div class="col-md-8">
<div class="card profile-card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-person-gear"></i>
基本信息
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.edit_profile') }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username"
value="{{ admin.username }}" readonly>
<div class="form-text">用户名不可修改</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="real_name" class="form-label">真实姓名</label>
<input type="text" class="form-control" id="real_name" name="real_name"
value="{{ admin.real_name or '' }}">
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="email" class="form-label">邮箱地址</label>
<input type="email" class="form-control" id="email" name="email"
value="{{ admin.email or '' }}">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="phone" class="form-label">手机号</label>
<input type="tel" class="form-control" id="phone" name="phone"
value="{{ admin.phone or '' }}">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> 保存修改
</button>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card info-card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-info-circle"></i>
账号信息
</h5>
</div>
<div class="card-body">
<div class="info-item">
<strong>角色:</strong>
<span class="badge bg-success">{{ admin.role }}</span>
</div>
<div class="info-item">
<strong>状态:</strong>
{% if admin.status == 1 %}
<span class="badge bg-success">正常</span>
{% else %}
<span class="badge bg-danger">禁用</span>
{% endif %}
</div>
<div class="info-item">
<strong>创建时间:</strong><br>
{{ admin.created_at.strftime('%Y-%m-%d %H:%M:%S') if admin.created_at else '' }}
</div>
<div class="info-item">
<strong>最后登录:</strong><br>
{{ admin.last_login_at.strftime('%Y-%m-%d %H:%M:%S') if admin.last_login_at else '从未登录' }}
</div>
</div>
</div>
<!-- 修改密码 -->
<div class="card password-card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-key"></i>
修改密码
</h5>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">当前密码</label>
<input type="password" class="form-control" id="current_password"
name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">新密码</label>
<input type="password" class="form-control" id="new_password"
name="new_password" required>
<div class="form-text">密码长度至少6位建议包含字母和数字</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">确认新密码</label>
<input type="password" class="form-control" id="confirm_password"
name="confirm_password" required>
</div>
<button type="submit" class="btn btn-warning">
<i class="bi bi-key"></i> 修改密码
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

161
app/templates/base.html Normal file
View File

@ -0,0 +1,161 @@
<!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>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css" rel="stylesheet">
<link href="{{ url_for('static', filename='css/base.css') }}" rel="stylesheet">
{% block styles %}{% endblock %}
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
<i class="bi bi-shop"></i> 太白购物商城
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.index') }}">首页</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.product_list') }}">全部商品</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownCategory" role="button" data-bs-toggle="dropdown">
商品分类
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('main.product_list') }}">全部分类</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('main.product_list', category_id=1) }}">手机数码</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.product_list', category_id=2) }}">电脑办公</a></li>
<li><a class="dropdown-item" href="{{ url_for('main.product_list', category_id=3) }}">家居家装</a></li>
</ul>
</li>
</ul>
<!-- 搜索框 -->
<form class="d-flex me-3 search-form" method="GET" action="{{ url_for('main.product_list') }}">
<input class="form-control me-2" type="search" name="search" placeholder="搜索商品..." style="min-width: 200px;">
<button class="btn btn-outline-primary" type="submit">
<i class="bi bi-search"></i>
</button>
</form>
<ul class="navbar-nav">
{% if session.user_id %}
<li class="nav-item">
<a class="nav-link position-relative" href="{{ url_for('cart.index') }}" title="购物车">
<i class="bi bi-cart"></i> 购物车
<span class="badge bg-danger cart-badge" id="cartBadge" style="display: none;">0</span>
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ session.nickname or session.username }}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('user.profile') }}">个人中心</a></li>
<li><a class="dropdown-item" href="{{ url_for('user.orders') }}">我的订单</a></li>
<li><a class="dropdown-item" href="#">我的收藏</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">退出登录</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">注册</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- 消息提示 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container mt-3">
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- 主要内容 -->
<main class="container mt-4">
{% block content %}{% endblock %}
</main>
<!-- 页脚 -->
<footer class="footer mt-auto">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>太白购物商城</h5>
<p class="text-muted">您的购物首选平台</p>
<div class="mb-3">
<a href="#" class="text-muted me-3"><i class="bi bi-telephone"></i> 客服热线400-888-8888</a>
<a href="#" class="text-muted"><i class="bi bi-envelope"></i> service@taibai.com</a>
</div>
</div>
<div class="col-md-3">
<h6>快捷导航</h6>
<ul class="list-unstyled">
<li><a href="{{ url_for('main.index') }}" class="text-muted">首页</a></li>
<li><a href="{{ url_for('main.product_list') }}" class="text-muted">全部商品</a></li>
<li><a href="#" class="text-muted">关于我们</a></li>
<li><a href="#" class="text-muted">联系我们</a></li>
</ul>
</div>
<div class="col-md-3">
<h6>客户服务</h6>
<ul class="list-unstyled">
<li><a href="#" class="text-muted">帮助中心</a></li>
<li><a href="#" class="text-muted">售后服务</a></li>
<li><a href="#" class="text-muted">配送说明</a></li>
<li><a href="#" class="text-muted">退换货政策</a></li>
</ul>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-6">
<p class="text-muted small mb-0">
<i class="bi bi-shield-check"></i>
正品保证 | 7天无理由退换 | 全国包邮
</p>
</div>
<div class="col-md-6 text-md-end">
<p class="text-muted small mb-0">&copy; 2025 太白购物商城. All rights reserved.</p>
</div>
</div>
</div>
</footer>
<!-- 返回顶部按钮 -->
<button type="button" class="btn btn-primary position-fixed bottom-0 end-0 m-3" id="backToTop" onclick="scrollToTop()">
<i class="bi bi-arrow-up"></i>
</button>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/base.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block title %}购物车 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/cart.css') }}">
{% endblock %}
{% block content %}
<div class="container">
<!-- 面包屑导航 -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}">首页</a></li>
<li class="breadcrumb-item active">购物车</li>
</ol>
</nav>
<div class="row">
<div class="col-12">
<h3><i class="bi bi-cart"></i> 我的购物车</h3>
<hr>
</div>
</div>
{% if cart_items %}
<div class="row">
<!-- 购物车商品列表 -->
<div class="col-md-8">
<div class="card">
<div class="card-header">
<div class="row align-items-center">
<div class="col-1">
<input type="checkbox" class="form-check-input" id="selectAll">
</div>
<div class="col-5">商品信息</div>
<div class="col-2 text-center">单价</div>
<div class="col-2 text-center">数量</div>
<div class="col-2 text-center">操作</div>
</div>
</div>
<div class="card-body p-0">
{% for item in cart_items %}
<div class="cart-item border-bottom p-3" data-cart-id="{{ item.id }}">
<div class="row align-items-center">
<!-- 选择框 -->
<div class="col-1">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ item.id }}" {% if item.is_available() %}{% else %}disabled{% endif %}>
</div>
<!-- 商品信息 -->
<div class="col-5">
<div class="d-flex">
<a href="{{ url_for('main.product_detail', product_id=item.product_id) }}"
class="text-decoration-none">
{% if item.product.main_image %}
<img src="{{ item.product.main_image }}" alt="{{ item.product.name }}"
class="me-3" style="width: 80px; height: 80px; object-fit: cover;">
{% else %}
<div class="bg-light me-3 d-flex align-items-center justify-content-center"
style="width: 80px; height: 80px;">
<i class="bi bi-image text-muted"></i>
</div>
{% endif %}
</a>
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="{{ url_for('main.product_detail', product_id=item.product_id) }}"
class="text-decoration-none text-dark">
{{ item.product.name }}
</a>
</h6>
{% if item.product.brand %}
<small class="text-muted">品牌:{{ item.product.brand }}</small><br>
{% endif %}
{% if item.spec_combination %}
<small class="text-muted">规格:{{ item.spec_combination }}</small><br>
{% endif %}
<small class="text-muted">库存:{{ item.get_stock() }}件</small>
{% if not item.is_available() %}
<div class="mt-1">
{% if item.product.status != 1 %}
<span class="badge bg-danger">商品已下架</span>
{% elif item.get_stock() < item.quantity %}
<span class="badge bg-warning">库存不足</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- 单价 -->
<div class="col-2 text-center">
<span class="fw-bold text-danger">¥{{ "%.2f"|format(item.get_price()) }}</span>
</div>
<!-- 数量 -->
<div class="col-2 text-center">
<div class="input-group" style="width: 100px; margin: 0 auto;">
<button class="btn btn-outline-secondary btn-sm" type="button"
onclick="changeQuantity({{ item.id }}, -1)"
{% if not item.is_available() or item.quantity <= 1 %}disabled{% endif %}>-</button>
<input type="number" class="form-control form-control-sm text-center quantity-input"
value="{{ item.quantity }}" min="1" max="{{ item.get_stock() }}"
data-cart-id="{{ item.id }}" onchange="updateQuantity({{ item.id }}, this.value)"
{% if not item.is_available() %}disabled{% endif %}>
<button class="btn btn-outline-secondary btn-sm" type="button"
onclick="changeQuantity({{ item.id }}, 1)"
{% if not item.is_available() or item.quantity >= item.get_stock() %}disabled{% endif %}>+</button>
</div>
<div class="mt-1">
<small class="text-muted">小计:¥<span class="item-total">{{ "%.2f"|format(item.get_total_price()) }}</span></small>
</div>
</div>
<!-- 操作 -->
<div class="col-2 text-center">
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="removeItem({{ item.id }})">
<i class="bi bi-trash"></i> 删除
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- 购物车操作栏 -->
<div class="col-md-4">
<div class="card position-sticky" style="top: 20px;">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-calculator"></i> 结算信息</h6>
</div>
<div class="card-body">
<div class="d-flex justify-content-between mb-2">
<span>已选商品:</span>
<span id="selectedCount">0</span>
</div>
<div class="d-flex justify-content-between mb-3">
<span>商品总价:</span>
<span class="text-danger fw-bold h5">¥<span id="selectedTotal">0.00</span></span>
</div>
<hr>
<div class="d-flex justify-content-between mb-3">
<span class="fw-bold">应付总额:</span>
<span class="text-danger fw-bold h4">¥<span id="finalTotal">0.00</span></span>
</div>
<div class="d-grid gap-2">
<button type="button" class="btn btn-danger btn-lg" id="checkoutBtn"
onclick="checkout()" disabled>
<i class="bi bi-credit-card"></i> 去结算
</button>
<button type="button" class="btn btn-outline-secondary" onclick="clearCart()">
<i class="bi bi-trash"></i> 清空购物车
</button>
</div>
<div class="mt-3">
<small class="text-muted">
<i class="bi bi-shield-check"></i> 7天无理由退换<br>
<i class="bi bi-truck"></i> 全国包邮<br>
<i class="bi bi-award"></i> 正品保证
</small>
</div>
</div>
</div>
</div>
</div>
{% else %}
<!-- 空购物车 -->
<div class="text-center py-5">
<i class="bi bi-cart-x text-muted" style="font-size: 5rem;"></i>
<h4 class="text-muted mt-3">购物车是空的</h4>
<p class="text-muted">快去选购您喜欢的商品吧!</p>
<a href="{{ url_for('main.product_list') }}" class="btn btn-primary btn-lg">
<i class="bi bi-shop"></i> 去购物
</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/cart.js') }}"></script>
{% endblock %}

View File

View File

View File

227
app/templates/index.html Normal file
View File

@ -0,0 +1,227 @@
{% extends "base.html" %}
{% block title %}首页 - 太白购物商城{% endblock %}
{% block styles %}
<link href="{{ url_for('static', filename='css/index.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<!-- 欢迎横幅 -->
<div class="jumbotron text-white rounded p-5 mb-4">
<div class="container-fluid py-5">
<h1 class="display-5 fw-bold">欢迎来到太白购物商城</h1>
{% if user %}
<p class="col-md-8 fs-4">你好,{{ user.nickname or user.username }}!开始您的购物之旅吧!</p>
{% else %}
<p class="col-md-8 fs-4">发现优质商品,享受便捷购物体验</p>
<a class="btn btn-light btn-lg" href="{{ url_for('auth.register') }}" role="button">立即注册</a>
{% endif %}
</div>
</div>
<!-- 商品分类导航 -->
{% if top_categories %}
<div class="row mb-4">
<div class="col-12">
<h4><i class="bi bi-grid"></i> 商品分类</h4>
<hr>
</div>
{% for category in top_categories %}
<div class="col-md-2 col-6 mb-3">
<a href="{{ url_for('main.product_list', category_id=category.id) }}" class="text-decoration-none">
<div class="card text-center h-100 category-card">
<div class="card-body">
{% if category.icon_url %}
<img src="{{ category.icon_url }}" alt="{{ category.name }}" class="mb-2" style="width: 48px; height: 48px; object-fit: cover;">
{% else %}
<i class="bi bi-tag display-4 text-primary mb-2"></i>
{% endif %}
<h6 class="card-title">{{ category.name }}</h6>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 热门商品 -->
{% if hot_products %}
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<h4><i class="bi bi-fire"></i> 热门商品</h4>
<a href="{{ url_for('main.product_list', sort='sales') }}" class="btn btn-outline-primary btn-sm">
查看更多 <i class="bi bi-arrow-right"></i>
</a>
</div>
<hr>
{% for product in hot_products %}
<div class="col-lg-3 col-md-4 col-sm-6 mb-4">
<div class="card h-100 product-card">
<a href="{{ url_for('main.product_detail', product_id=product.id) }}" class="text-decoration-none">
{% if product.main_image %}
<img src="{{ product.main_image }}" class="card-img-top product-image" alt="{{ product.name }}">
{% else %}
<div class="card-img-top product-image-placeholder">
<i class="bi bi-image text-muted" style="font-size: 3rem;"></i>
</div>
{% endif %}
</a>
<div class="card-body">
<h6 class="card-title">
<a href="{{ url_for('main.product_detail', product_id=product.id) }}" class="text-decoration-none text-dark">
{{ product.name[:50] }}{% if product.name|length > 50 %}...{% endif %}
</a>
</h6>
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="price-current">¥{{ "%.2f"|format(product.price) }}</span>
{% if product.original_price and product.original_price > product.price %}
<small class="price-original">¥{{ "%.2f"|format(product.original_price) }}</small>
{% endif %}
</div>
<small class="text-muted">销量{{ product.sales_count }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 最新商品 -->
{% if new_products %}
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<h4><i class="bi bi-stars"></i> 最新商品</h4>
<a href="{{ url_for('main.product_list', sort='newest') }}" class="btn btn-outline-primary btn-sm">
查看更多 <i class="bi bi-arrow-right"></i>
</a>
</div>
<hr>
{% for product in new_products %}
<div class="col-lg-3 col-md-4 col-sm-6 mb-4">
<div class="card h-100 product-card">
<a href="{{ url_for('main.product_detail', product_id=product.id) }}" class="text-decoration-none">
{% if product.main_image %}
<img src="{{ product.main_image }}" class="card-img-top product-image" alt="{{ product.name }}">
{% else %}
<div class="card-img-top product-image-placeholder">
<i class="bi bi-image text-muted" style="font-size: 3rem;"></i>
</div>
{% endif %}
</a>
<div class="card-body">
<h6 class="card-title">
<a href="{{ url_for('main.product_detail', product_id=product.id) }}" class="text-decoration-none text-dark">
{{ product.name[:50] }}{% if product.name|length > 50 %}...{% endif %}
</a>
</h6>
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="price-current">¥{{ "%.2f"|format(product.price) }}</span>
{% if product.original_price and product.original_price > product.price %}
<small class="price-original">¥{{ "%.2f"|format(product.original_price) }}</small>
{% endif %}
</div>
<small class="text-muted">销量{{ product.sales_count }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if user %}
<!-- 用户专区 -->
<div class="row mt-5">
<div class="col-12">
<h4><i class="bi bi-person-circle"></i> 我的专区</h4>
<hr>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center user-zone-card">
<div class="card-body">
<i class="bi bi-person display-4 text-info mb-2"></i>
<h6 class="card-title">个人中心</h6>
<a href="{{ url_for('user.profile') }}" class="btn btn-sm btn-outline-info">进入</a>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center user-zone-card">
<div class="card-body">
<i class="bi bi-bag display-4 text-primary mb-2"></i>
<h6 class="card-title">我的订单</h6>
<a href="{{ url_for('user.orders') }}" class="btn btn-sm btn-outline-primary">查看</a>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center user-zone-card">
<div class="card-body">
<i class="bi bi-cart display-4 text-success mb-2"></i>
<h6 class="card-title">购物车</h6>
<a href="{{ url_for('cart.index') }}" class="btn btn-sm btn-outline-success">查看</a>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center user-zone-card">
<div class="card-body">
<i class="bi bi-heart display-4 text-danger mb-2"></i>
<h6 class="card-title">我的收藏</h6>
<a href="#" class="btn btn-sm btn-outline-danger">查看</a>
</div>
</div>
</div>
</div>
{% endif %}
<!-- 功能特色 -->
<div class="row mt-5">
<div class="col-12">
<h4><i class="bi bi-star"></i> 服务特色</h4>
<hr>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 feature-card">
<div class="card-body text-center">
<i class="bi bi-tags display-4 text-primary mb-3"></i>
<h5 class="card-title">精选商品</h5>
<p class="card-text">汇聚全球优质商品,品质保证,价格实惠</p>
<a href="{{ url_for('main.product_list') }}" class="btn btn-outline-primary">浏览商品</a>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 feature-card">
<div class="card-body text-center">
<i class="bi bi-truck display-4 text-success mb-3"></i>
<h5 class="card-title">快速配送</h5>
<p class="card-text">全国包邮,快速配送,让您尽快收到心仪商品</p>
<a href="#" class="btn btn-outline-success">了解更多</a>
</div>
</div>
</div>
<div class="col-md-4 mb-4">
<div class="card h-100 feature-card">
<div class="card-body text-center">
<i class="bi bi-shield-check display-4 text-warning mb-3"></i>
<h5 class="card-title">安全保障</h5>
<p class="card-text">正品保证,售后无忧,让您购物更放心</p>
<a href="#" class="btn btn-outline-warning">服务保障</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,224 @@
{% extends "base.html" %}
{% block title %}订单结算 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/checkout.css') }}">
{% endblock %}
{% block content %}
<div class="container">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}">首页</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('cart.index') }}">购物车</a></li>
<li class="breadcrumb-item active">订单结算</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<!-- 收货地址 -->
<div class="card checkout-section">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="bi bi-geo-alt"></i> 收货地址</h5>
<a href="{{ url_for('address.add') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-plus"></i> 新增地址
</a>
</div>
<div class="card-body">
<div class="row" id="addressList">
{% for address in addresses %}
<div class="col-md-6 mb-3">
<div class="card address-card {% if address.is_default %}selected{% endif %}"
data-address-id="{{ address.id }}" onclick="selectAddress({{ address.id }})">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">{{ address.receiver_name }}</h6>
<p class="text-muted mb-1">{{ address.receiver_phone }}</p>
<p class="mb-0">{{ address.get_full_address() }}</p>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="address_id"
value="{{ address.id }}" {% if address.is_default %}checked{% endif %}>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- 商品信息 -->
<div class="card checkout-section">
<div class="card-header">
<h5><i class="bi bi-box"></i> 商品信息</h5>
</div>
<div class="card-body">
{% for item in cart_items %}
<div class="product-item">
<div class="row align-items-center">
<div class="col-md-2">
<img src="{{ item.product.main_image or '/static/images/default-product.jpg' }}"
class="img-fluid rounded" alt="{{ item.product.name }}">
</div>
<div class="col-md-6">
<h6>{{ item.product.name }}</h6>
{% if item.spec_combination %}
<p class="text-muted mb-0">{{ item.spec_combination }}</p>
{% endif %}
{% if item.product.brand %}
<small class="text-muted">{{ item.product.brand }}</small>
{% endif %}
</div>
<div class="col-md-2 text-center">
<span class="text-muted">× {{ item.quantity }}</span>
</div>
<div class="col-md-2 text-end">
<span class="fw-bold">¥{{ "%.2f"|format(item.get_total_price()) }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 配送方式 -->
<div class="card checkout-section">
<div class="card-header">
<h5><i class="bi bi-truck"></i> 配送方式</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="shipping_method"
value="standard" id="shipping_standard" checked onchange="updateShippingFee()">
<label class="form-check-label" for="shipping_standard">
<strong>标准配送</strong><br>
<small class="text-muted">免费 • 3-5个工作日</small>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="shipping_method"
value="express" id="shipping_express" onchange="updateShippingFee()">
<label class="form-check-label" for="shipping_express">
<strong>次日达</strong><br>
<small class="text-muted">+10元 • 次日送达</small>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="shipping_method"
value="same_day" id="shipping_same_day" onchange="updateShippingFee()">
<label class="form-check-label" for="shipping_same_day">
<strong>当日达</strong><br>
<small class="text-muted">+20元 • 当日送达</small>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 支付方式 -->
<div class="card checkout-section">
<div class="card-header">
<h5><i class="bi bi-credit-card"></i> 支付方式</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="payment_method"
value="wechat" id="payment_wechat" checked>
<label class="form-check-label" for="payment_wechat">
<i class="bi bi-wechat text-success me-2"></i>
<strong>微信支付</strong>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="payment_method"
value="alipay" id="payment_alipay">
<label class="form-check-label" for="payment_alipay">
<i class="bi bi-alipay text-primary me-2"></i>
<strong>支付宝</strong>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check">
<input class="form-check-input" type="radio" name="payment_method"
value="bank" id="payment_bank">
<label class="form-check-label" for="payment_bank">
<i class="bi bi-credit-card text-info me-2"></i>
<strong>银行卡</strong>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- 备注 -->
<div class="card checkout-section">
<div class="card-header">
<h5><i class="bi bi-chat-text"></i> 订单备注</h5>
</div>
<div class="card-body">
<textarea class="form-control" id="orderRemark" rows="3"
placeholder="如有特殊需求请在此说明(选填)"></textarea>
</div>
</div>
</div>
<!-- 订单摘要 -->
<div class="col-lg-4">
<div class="card position-sticky" style="top: 20px;">
<div class="card-header">
<h5><i class="bi bi-receipt"></i> 订单摘要</h5>
</div>
<div class="card-body">
<div class="order-summary">
<div class="price-row">
<span>商品总价:</span>
<span id="subtotal">¥{{ "%.2f"|format(total_amount) }}</span>
</div>
<div class="price-row">
<span>运费:</span>
<span id="shippingFee">¥{{ "%.2f"|format(shipping_fee) }}</span>
</div>
<hr>
<div class="price-row total-price">
<span>应付总额:</span>
<span id="totalAmount">¥{{ "%.2f"|format(final_amount) }}</span>
</div>
</div>
<button class="btn btn-danger w-100 mt-3 btn-lg" onclick="submitOrder()">
<i class="bi bi-check-circle"></i> 提交订单
</button>
<div class="mt-3 text-center">
<small class="text-muted">
点击"提交订单"表示您同意
<a href="#" class="text-decoration-none">《用户协议》</a>
</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/checkout.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,253 @@
{% extends "base.html" %}
{% block title %}订单详情 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/order_detail.css') }}">
{% endblock %}
{% block content %}
<div class="container">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}">首页</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('order.list') }}">我的订单</a></li>
<li class="breadcrumb-item active">订单详情</li>
</ol>
</nav>
<div class="row">
<div class="col-lg-8">
<!-- 订单状态 -->
<div class="card order-detail-card">
<div class="card-header">
<h5><i class="bi bi-clock-history"></i> 订单状态</h5>
</div>
<div class="card-body">
<div class="order-status-timeline">
<div class="timeline-item completed">
<h6>订单已提交</h6>
<p class="text-muted mb-0">{{ order.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
</div>
<div class="timeline-item {% if order.status >= 2 %}completed{% elif order.status == 1 %}current{% endif %}">
<h6>等待买家付款</h6>
{% if order.status >= 2 %}
<p class="text-muted mb-0">已完成</p>
{% else %}
<p class="text-muted mb-0">请在15分钟内完成支付</p>
{% endif %}
</div>
<div class="timeline-item {% if order.status >= 3 %}completed{% elif order.status == 2 %}current{% endif %}">
<h6>卖家发货</h6>
{% if order.status >= 3 %}
<p class="text-muted mb-0">{{ order.shipped_at.strftime('%Y-%m-%d %H:%M:%S') if order.shipped_at else '已发货' }}</p>
{% else %}
<p class="text-muted mb-0">等待卖家发货</p>
{% endif %}
</div>
<div class="timeline-item {% if order.status >= 4 %}completed{% elif order.status == 3 %}current{% endif %}">
<h6>确认收货</h6>
{% if order.status >= 4 %}
<p class="text-muted mb-0">{{ order.received_at.strftime('%Y-%m-%d %H:%M:%S') if order.received_at else '已确认收货' }}</p>
{% else %}
<p class="text-muted mb-0">等待买家确认收货</p>
{% endif %}
</div>
<div class="timeline-item {% if order.status == 5 %}completed{% endif %}">
<h6>交易完成</h6>
{% if order.status == 5 %}
<p class="text-muted mb-0">交易成功</p>
{% else %}
<p class="text-muted mb-0">等待交易完成</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 商品信息 -->
<div class="card order-detail-card">
<div class="card-header">
<h5><i class="bi bi-box"></i> 商品信息</h5>
</div>
<div class="card-body">
{% for item in order.order_items %}
<div class="product-item">
<div class="row align-items-center">
<div class="col-md-2">
<img src="{{ item.product_image or '/static/images/default-product.jpg' }}"
class="product-image" alt="{{ item.product_name }}">
</div>
<div class="col-md-6">
<h6 class="mb-1">{{ item.product_name }}</h6>
{% if item.spec_combination %}
<p class="text-muted mb-1">{{ item.spec_combination }}</p>
{% endif %}
<small class="text-muted">单价:¥{{ "%.2f"|format(item.price) }}</small>
</div>
<div class="col-md-2 text-center">
<span class="text-muted">× {{ item.quantity }}</span>
</div>
<div class="col-md-2 text-end">
<span class="fw-bold">¥{{ "%.2f"|format(item.total_price) }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- 物流信息 -->
{% if order.shipping_info %}
<div class="card order-detail-card">
<div class="card-header">
<h5><i class="bi bi-truck"></i> 物流信息</h5>
</div>
<div class="card-body">
{% for shipping in order.shipping_info %}
<div class="info-row">
<span>物流公司:</span>
<span>{{ shipping.shipping_company or '待发货' }}</span>
</div>
<div class="info-row">
<span>快递单号:</span>
<span>{{ shipping.tracking_number or '待发货' }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="col-lg-4">
<!-- 订单信息 -->
<div class="card order-detail-card">
<div class="card-header">
<h5><i class="bi bi-receipt"></i> 订单信息</h5>
</div>
<div class="card-body">
<div class="info-row">
<span>订单号:</span>
<span>{{ order.order_sn }}</span>
</div>
<div class="info-row">
<span>下单时间:</span>
<span>{{ order.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</div>
<div class="info-row">
<span>订单状态:</span>
<span>
{% if order.status == 1 %}
<span class="badge bg-warning">{{ order.get_status_text() }}</span>
{% elif order.status == 2 %}
<span class="badge bg-info">{{ order.get_status_text() }}</span>
{% elif order.status == 3 %}
<span class="badge bg-primary">{{ order.get_status_text() }}</span>
{% elif order.status == 5 %}
<span class="badge bg-success">{{ order.get_status_text() }}</span>
{% elif order.status == 6 %}
<span class="badge bg-secondary">{{ order.get_status_text() }}</span>
{% else %}
<span class="badge bg-dark">{{ order.get_status_text() }}</span>
{% endif %}
</span>
</div>
<div class="info-row">
<span>支付方式:</span>
<span>{{ order.payment_method or '未选择' }}</span>
</div>
<div class="info-row">
<span>配送方式:</span>
<span>{{ order.shipping_method or '标准配送' }}</span>
</div>
{% if order.remark %}
<div class="info-row">
<span>备注:</span>
<span>{{ order.remark }}</span>
</div>
{% endif %}
</div>
</div>
<!-- 收货信息 -->
<div class="card order-detail-card">
<div class="card-header">
<h5><i class="bi bi-geo-alt"></i> 收货信息</h5>
</div>
<div class="card-body">
{% set receiver = order.get_receiver_info() %}
<div class="info-row">
<span>收货人:</span>
<span>{{ receiver.receiver_name or '未知' }}</span>
</div>
<div class="info-row">
<span>联系电话:</span>
<span>{{ receiver.receiver_phone or '未知' }}</span>
</div>
<div class="info-row">
<span>收货地址:</span>
<span>{{ receiver.full_address or '未知' }}</span>
</div>
</div>
</div>
<!-- 费用明细 -->
<div class="card order-detail-card">
<div class="card-header">
<h5><i class="bi bi-calculator"></i> 费用明细</h5>
</div>
<div class="card-body">
<div class="info-row">
<span>商品总价:</span>
<span>¥{{ "%.2f"|format(order.total_amount) }}</span>
</div>
<div class="info-row">
<span>运费:</span>
<span>¥{{ "%.2f"|format(order.shipping_fee) }}</span>
</div>
<hr>
<div class="info-row">
<span><strong>应付总额:</strong></span>
<span class="total-amount">¥{{ "%.2f"|format(order.actual_amount) }}</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="d-grid gap-2">
{% if order.can_pay() %}
<a href="{{ url_for('order.pay', payment_sn=order.payments[0].payment_sn) }}"
class="btn btn-danger">立即支付</a>
{% endif %}
{% if order.can_cancel() %}
<button class="btn btn-outline-secondary" onclick="cancelOrder({{ order.id }})">
取消订单
</button>
{% endif %}
{% if order.can_confirm_receipt() %}
<button class="btn btn-success" onclick="confirmReceipt({{ order.id }})">
确认收货
</button>
{% endif %}
{% if order.status == 4 %}
<a href="#" class="btn btn-outline-warning">评价商品</a>
{% endif %}
<a href="{{ url_for('order.list') }}" class="btn btn-outline-primary">
返回订单列表
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/order_detail.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}订单支付 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/pay.css') }}">
{% endblock %}
{% block content %}
<div class="pay-container">
<div class="card">
<div class="card-header text-center">
<h4><i class="bi bi-credit-card"></i> 订单支付</h4>
</div>
<div class="card-body">
<!-- 隐藏数据属性 -->
<div style="display: none;"
data-payment-sn="{{ payment.payment_sn }}"
data-payment-method="{{ order.payment_method }}"
data-order-id="{{ order.id }}"></div>
<!-- 订单信息 -->
<div class="order-info">
<h6 class="mb-3">订单信息</h6>
<div class="row">
<div class="col-6">
<strong>订单号:</strong>{{ order.order_sn }}
</div>
<div class="col-6 text-end">
<strong class="text-danger">¥{{ "%.2f"|format(order.actual_amount) }}</strong>
</div>
</div>
<div class="row mt-2">
<div class="col-6">
<strong>支付方式:</strong>{{ order.payment_method }}
</div>
<div class="col-6 text-end">
<span class="countdown" id="countdown">14:59</span>
</div>
</div>
</div>
<!-- 支付区域 -->
<div id="paymentArea">
{% if order.payment_method == 'wechat' %}
<div class="payment-method selected">
<div class="d-flex align-items-center">
<i class="bi bi-wechat text-success fs-1 me-3"></i>
<div>
<h6>微信支付</h6>
<p class="text-muted mb-0">请使用微信扫描二维码完成支付</p>
</div>
</div>
</div>
<div class="qr-code" id="qrCodeArea" style="display: none;">
<div id="qrCodeImage">
<i class="bi bi-qr-code display-1 text-muted"></i>
<p class="mt-2">正在生成支付二维码...</p>
</div>
<p class="mt-3 text-muted">请使用微信扫描上方二维码完成支付</p>
</div>
{% endif %}
{% if order.payment_method == 'alipay' %}
<div class="payment-method selected">
<div class="d-flex align-items-center">
<i class="bi bi-alipay text-primary fs-1 me-3"></i>
<div>
<h6>支付宝</h6>
<p class="text-muted mb-0">正在跳转到支付宝...</p>
</div>
</div>
</div>
{% endif %}
{% if order.payment_method == 'bank' %}
<div class="payment-method selected">
<div class="d-flex align-items-center">
<i class="bi bi-credit-card text-info fs-1 me-3"></i>
<div>
<h6>银行卡支付</h6>
<p class="text-muted mb-0">正在跳转到网银...</p>
</div>
</div>
</div>
{% endif %}
</div>
<!-- 支付状态 -->
<div class="payment-status" id="paymentStatus" style="display: none;">
<i class="bi bi-check-circle-fill text-success display-1"></i>
<h5 class="mt-3">支付成功</h5>
<p class="text-muted">正在跳转到订单详情...</p>
</div>
<!-- 操作按钮 -->
<div class="d-flex gap-2 mt-4">
<button class="btn btn-primary flex-fill" onclick="startPayment()">
<i class="bi bi-credit-card"></i> 立即支付
</button>
<button class="btn btn-outline-secondary" onclick="checkPaymentStatus()">
<i class="bi bi-arrow-clockwise"></i> 刷新状态
</button>
<button class="btn btn-outline-danger" onclick="cancelOrder()">
<i class="bi bi-x-circle"></i> 取消订单
</button>
</div>
<!-- 开发测试按钮 -->
<div class="mt-3 text-center">
<button class="btn btn-warning btn-sm" onclick="simulatePayment()">
<i class="bi bi-bug"></i> 模拟支付成功(测试用)
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/pay.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,374 @@
{% extends "base.html" %}
{% block title %}{{ product.name }} - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/product_detail.css') }}">
{% endblock %}
{% block content %}
<div class="container">
<!-- 面包屑导航 -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}">首页</a></li>
<li class="breadcrumb-item">
<a href="{{ url_for('main.product_list', category_id=product.category_id) }}">
{{ product.category.name }}
</a>
</li>
<li class="breadcrumb-item active">{{ product.name }}</li>
</ol>
</nav>
<div class="row">
<!-- 左侧:商品图片 -->
<div class="col-md-6">
{% if images %}
<!-- 主图显示区域 -->
<div id="productImageCarousel" class="carousel slide mb-3" data-bs-ride="carousel">
<div class="carousel-inner">
{% for image in images %}
<div class="carousel-item {% if loop.first or image.is_main %}active{% endif %}">
<img src="{{ image.image_url }}" class="d-block w-100" alt="{{ product.name }}"
style="height: 400px; object-fit: cover; border-radius: 8px;">
</div>
{% endfor %}
</div>
{% if images|length > 1 %}
<button class="carousel-control-prev" type="button" data-bs-target="#productImageCarousel" data-bs-slide="prev">
<span class="carousel-control-prev-icon"></span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#productImageCarousel" data-bs-slide="next">
<span class="carousel-control-next-icon"></span>
</button>
{% endif %}
</div>
<!-- 缩略图 -->
{% if images|length > 1 %}
<div class="row">
{% for image in images %}
<div class="col-3 mb-2">
<img src="{{ image.image_url }}" class="img-thumbnail thumbnail-image"
alt="{{ product.name }}" style="height: 80px; object-fit: cover; cursor: pointer;"
onclick="goToSlide({{ loop.index0 }})">
</div>
{% endfor %}
</div>
{% endif %}
{% else %}
<!-- 无图片占位 -->
<div class="bg-light d-flex align-items-center justify-content-center"
style="height: 400px; border-radius: 8px;">
<i class="bi bi-image text-muted" style="font-size: 5rem;"></i>
</div>
{% endif %}
</div>
<!-- 右侧:商品信息 -->
<div class="col-md-6">
<h2 class="mb-3">{{ product.name }}</h2>
<!-- 品牌 -->
{% if product.brand %}
<p class="text-muted mb-2">
<strong>品牌:</strong>{{ product.brand }}
</p>
{% endif %}
<!-- 价格 -->
<div class="price-section mb-4">
<div class="d-flex align-items-baseline">
<span class="text-danger fw-bold" style="font-size: 2rem;">
¥<span id="currentPrice">{{ "%.2f"|format(product.price) }}</span>
</span>
{% if product.original_price and product.original_price > product.price %}
<span class="text-muted text-decoration-line-through ms-3" style="font-size: 1.2rem;">
¥{{ "%.2f"|format(product.original_price) }}
</span>
<span class="badge bg-danger ms-2">
省{{ "%.0f"|format(((product.original_price - product.price) / product.original_price * 100)) }}%
</span>
{% endif %}
</div>
<div class="mt-2">
<small class="text-muted">销量:{{ product.sales_count }} | 浏览:{{ product.view_count }}</small>
</div>
</div>
<!-- 商品规格选择 -->
{% if inventory_list and inventory_list|length > 1 %}
<div class="specs-section mb-4">
<h6>选择规格:</h6>
<div id="specsContainer">
{% set spec_groups = {} %}
{% for sku in inventory_list %}
{% if sku.spec_combination %}
{% for spec_name, spec_value in sku.spec_combination.items() %}
{% if spec_name not in spec_groups %}
{% set _ = spec_groups.update({spec_name: []}) %}
{% endif %}
{% if spec_value not in spec_groups[spec_name] %}
{% set _ = spec_groups[spec_name].append(spec_value) %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% for spec_name, spec_values in spec_groups.items() %}
<div class="spec-group mb-3">
<label class="form-label">{{ spec_name }}</label>
<div class="spec-options">
{% for spec_value in spec_values %}
<button type="button" class="btn btn-outline-secondary spec-option me-2 mb-2"
data-spec-name="{{ spec_name }}" data-spec-value="{{ spec_value }}">
{{ spec_value }}
</button>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- 库存信息 -->
<div class="stock-section mb-4">
<div class="row">
<div class="col-6">
<strong>库存:</strong>
<span id="stockCount" class="text-success">
{% if inventory_list %}
{{ inventory_list[0].stock if inventory_list|length == 1 else '请选择规格' }}
{% else %}
暂无库存
{% endif %}
</span>
<span id="stockUnit"></span>
</div>
{% if product.weight %}
<div class="col-6">
<strong>重量:</strong>{{ product.weight }}kg
</div>
{% endif %}
</div>
</div>
<!-- 购买数量 -->
<div class="quantity-section mb-4">
<label class="form-label"><strong>数量:</strong></label>
<div class="input-group" style="width: 150px;">
<button class="btn btn-outline-secondary" type="button" onclick="changeQuantity(-1)">-</button>
<input type="number" class="form-control text-center" id="quantity" value="1" min="1" max="999">
<button class="btn btn-outline-secondary" type="button" onclick="changeQuantity(1)">+</button>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons mb-4">
<div class="d-grid gap-2 d-md-flex">
<button type="button" class="btn btn-warning btn-lg flex-fill" id="addToCartBtn"
onclick="addToCart()" {% if not inventory_list %}disabled{% endif %}>
<i class="bi bi-cart-plus"></i> 加入购物车
</button>
<button type="button" class="btn btn-danger btn-lg flex-fill" id="buyNowBtn"
onclick="buyNow()" {% if not inventory_list %}disabled{% endif %}>
<i class="bi bi-lightning-fill"></i> 立即购买
</button>
</div>
<div class="mt-2">
<button type="button" class="btn btn-outline-secondary" onclick="addToFavorites()">
<i class="bi bi-heart"></i> 收藏商品
</button>
</div>
</div>
<!-- 服务承诺 -->
<div class="service-promises">
<h6>服务承诺:</h6>
<ul class="list-unstyled">
<li><i class="bi bi-check-circle text-success"></i> 正品保证</li>
<li><i class="bi bi-check-circle text-success"></i> 7天无理由退换</li>
<li><i class="bi bi-check-circle text-success"></i> 全国包邮</li>
<li><i class="bi bi-check-circle text-success"></i> 售后服务</li>
</ul>
</div>
</div>
</div>
<!-- 商品详情标签页 -->
<div class="row mt-5">
<div class="col-12">
<ul class="nav nav-tabs" id="productDetailTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="description-tab" data-bs-toggle="tab"
data-bs-target="#description" type="button" role="tab">商品详情</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="specs-tab" data-bs-toggle="tab"
data-bs-target="#specs" type="button" role="tab">规格参数</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="reviews-tab" data-bs-toggle="tab"
data-bs-target="#reviews" type="button" role="tab">商品评价</button>
</li>
</ul>
<div class="tab-content" id="productDetailTabContent">
<!-- 商品详情 -->
<div class="tab-pane fade show active" id="description" role="tabpanel">
<div class="card">
<div class="card-body">
{% if product.description %}
<div class="product-description">
{{ product.description|replace('\n', '<br>')|safe }}
</div>
{% else %}
<p class="text-muted">暂无详细描述</p>
{% endif %}
</div>
</div>
</div>
<!-- 规格参数 -->
<div class="tab-pane fade" id="specs" role="tabpanel">
<div class="card">
<div class="card-body">
<table class="table table-striped">
<tbody>
<tr>
<td width="150"><strong>商品名称</strong></td>
<td>{{ product.name }}</td>
</tr>
{% if product.brand %}
<tr>
<td><strong>商品品牌</strong></td>
<td>{{ product.brand }}</td>
</tr>
{% endif %}
<tr>
<td><strong>商品分类</strong></td>
<td>{{ product.category.name }}</td>
</tr>
{% if product.weight %}
<tr>
<td><strong>商品重量</strong></td>
<td>{{ product.weight }}kg</td>
</tr>
{% endif %}
<tr>
<td><strong>上架时间</strong></td>
<td>{{ product.created_at.strftime('%Y-%m-%d') }}</td>
</tr>
{% if inventory_list %}
<tr>
<td><strong>库存信息</strong></td>
<td>
{% if inventory_list|length == 1 %}
{{ inventory_list[0].stock }}件
{% else %}
多规格商品,请选择具体规格查看库存
{% endif %}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- 商品评价 -->
<div class="tab-pane fade" id="reviews" role="tabpanel">
<div class="card">
<div class="card-body">
<p class="text-muted">评价功能开发中...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 推荐商品 -->
{% if recommended_products %}
<div class="row mt-5">
<div class="col-12">
<h4><i class="bi bi-heart-fill text-danger"></i> 相关推荐</h4>
<hr>
</div>
{% for rec_product in recommended_products %}
<div class="col-lg-3 col-md-6 mb-4">
<div class="card h-100 product-card">
<a href="{{ url_for('main.product_detail', product_id=rec_product.id) }}" class="text-decoration-none">
{% if rec_product.main_image %}
<img src="{{ rec_product.main_image }}" class="card-img-top" alt="{{ rec_product.name }}"
style="height: 200px; object-fit: cover;">
{% else %}
<div class="card-img-top bg-light d-flex align-items-center justify-content-center"
style="height: 200px;">
<i class="bi bi-image text-muted" style="font-size: 3rem;"></i>
</div>
{% endif %}
</a>
<div class="card-body">
<h6 class="card-title">
<a href="{{ url_for('main.product_detail', product_id=rec_product.id) }}"
class="text-decoration-none text-dark">
{{ rec_product.name[:40] }}{% if rec_product.name|length > 40 %}...{% endif %}
</a>
</h6>
<div class="d-flex justify-content-between align-items-center">
<span class="text-danger fw-bold">¥{{ "%.2f"|format(rec_product.price) }}</span>
<small class="text-muted">销量{{ rec_product.sales_count }}</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<!-- 库存数据用于JavaScript -->
<script type="application/json" id="inventoryData">
{% if inventory_data %}
{{ inventory_data|tojson }}
{% else %}
[]
{% endif %}
</script>
<script>
// 设置全局变量供JS使用
window.productId = {{ product.id }};
window.isLoggedIn = {% if session.user_id %}true{% else %}false{% endif %};
</script>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/product_detail.js') }}"></script>
<script>
// 处理登录状态检查
{% if not session.user_id %}
function addToCart() {
if (confirm('请先登录后再加入购物车,是否前往登录?')) {
window.location.href = '/auth/login?next=' + encodeURIComponent(window.location.pathname);
}
}
function buyNow() {
if (confirm('请先登录后再购买,是否前往登录?')) {
window.location.href = '/auth/login?next=' + encodeURIComponent(window.location.pathname);
}
}
function addToFavorites() {
if (confirm('请先登录后再收藏,是否前往登录?')) {
window.location.href = '/auth/login?next=' + encodeURIComponent(window.location.pathname);
}
}
{% endif %}
</script>
{% endblock %}

View File

@ -0,0 +1,280 @@
{% extends "base.html" %}
{% block title %}
{% if current_category %}{{ current_category.name }} - {% endif %}
{% if search %}搜索"{{ search }}" - {% endif %}
商品列表 - 太白购物商城
{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/product_list.css') }}">
{% endblock %}
{% block content %}
<div class="row">
<!-- 侧边栏 -->
<div class="col-md-3">
<!-- 搜索框 -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-search"></i> 商品搜索</h6>
</div>
<div class="card-body">
<form method="GET" action="{{ url_for('main.product_list') }}">
<div class="input-group">
<input type="text" class="form-control" name="search"
placeholder="搜索商品..." value="{{ search or '' }}">
<button class="btn btn-primary" type="submit">
<i class="bi bi-search"></i>
</button>
</div>
<!-- 保持其他筛选条件 -->
{% if category_id %}<input type="hidden" name="category_id" value="{{ category_id }}">{% endif %}
{% if sort %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
</form>
</div>
</div>
<!-- 分类筛选 -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-grid"></i> 商品分类</h6>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<a href="{{ url_for('main.product_list') }}"
class="list-group-item list-group-item-action {% if not category_id %}active{% endif %}">
全部商品
</a>
{% for category in categories %}
<a href="{{ url_for('main.product_list', category_id=category.id) }}"
class="list-group-item list-group-item-action {% if category_id == category.id %}active{% endif %}">
{% if category.icon_url %}
<img src="{{ category.icon_url }}" alt="{{ category.name }}"
style="width: 20px; height: 20px; margin-right: 8px;">
{% endif %}
{{ category.name }}
</a>
{% endfor %}
</div>
</div>
</div>
<!-- 价格筛选 -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-currency-dollar"></i> 价格筛选</h6>
</div>
<div class="card-body">
<form method="GET" action="{{ url_for('main.product_list') }}" id="priceForm">
<div class="row">
<div class="col-6">
<input type="number" class="form-control form-control-sm"
name="min_price" placeholder="最低价"
value="{{ min_price or '' }}" step="0.01">
</div>
<div class="col-6">
<input type="number" class="form-control form-control-sm"
name="max_price" placeholder="最高价"
value="{{ max_price or '' }}" step="0.01">
</div>
</div>
<div class="mt-2">
<button type="submit" class="btn btn-primary btn-sm me-2">筛选</button>
<a href="{{ url_for('main.product_list') }}" class="btn btn-outline-secondary btn-sm">重置</a>
</div>
<!-- 保持其他条件 -->
{% if search %}<input type="hidden" name="search" value="{{ search }}">{% endif %}
{% if category_id %}<input type="hidden" name="category_id" value="{{ category_id }}">{% endif %}
{% if sort %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
</form>
<!-- 快速价格选择 -->
<div class="mt-3">
<div class="d-grid gap-1">
{% set filtered_args = request.args.copy() %}
{% set _ = filtered_args.pop('min_price', None) %}
{% set _ = filtered_args.pop('max_price', None) %}
<a href="{{ url_for('main.product_list', max_price=100, **filtered_args) }}"
class="btn btn-outline-secondary btn-sm">100元以下</a>
<a href="{{ url_for('main.product_list', min_price=100, max_price=500, **filtered_args) }}"
class="btn btn-outline-secondary btn-sm">100-500元</a>
<a href="{{ url_for('main.product_list', min_price=500, max_price=1000, **filtered_args) }}"
class="btn btn-outline-secondary btn-sm">500-1000元</a>
<a href="{{ url_for('main.product_list', min_price=1000, **filtered_args) }}"
class="btn btn-outline-secondary btn-sm">1000元以上</a>
</div>
</div>
</div>
</div>
</div>
<!-- 主内容区 -->
<div class="col-md-9">
<!-- 面包屑导航 -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.index') }}">首页</a></li>
{% if current_category %}
<li class="breadcrumb-item active">{{ current_category.name }}</li>
{% elif search %}
<li class="breadcrumb-item active">搜索结果</li>
{% else %}
<li class="breadcrumb-item active">全部商品</li>
{% endif %}
</ol>
</nav>
<!-- 筛选和排序栏 -->
<div class="row mb-3">
<div class="col-md-6">
<h5>
{% if current_category %}
{{ current_category.name }}
{% elif search %}
搜索"{{ search }}"
{% else %}
全部商品
{% endif %}
<small class="text-muted">(共{{ products.total }}个商品)</small>
</h5>
</div>
<div class="col-md-6 text-end">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="sort" id="sort_default"
{% if not sort or sort == 'default' %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="sort_default"
onclick="changeSort('default')">综合</label>
<input type="radio" class="btn-check" name="sort" id="sort_newest"
{% if sort == 'newest' %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="sort_newest"
onclick="changeSort('newest')">最新</label>
<input type="radio" class="btn-check" name="sort" id="sort_sales"
{% if sort == 'sales' %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="sort_sales"
onclick="changeSort('sales')">销量</label>
<input type="radio" class="btn-check" name="sort" id="sort_price_asc"
{% if sort == 'price_asc' %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="sort_price_asc"
onclick="changeSort('price_asc')">价格↑</label>
<input type="radio" class="btn-check" name="sort" id="sort_price_desc"
{% if sort == 'price_desc' %}checked{% endif %}>
<label class="btn btn-outline-secondary btn-sm" for="sort_price_desc"
onclick="changeSort('price_desc')">价格↓</label>
</div>
</div>
</div>
<!-- 商品列表 -->
{% if products.items %}
<div class="row">
{% for product in products.items %}
<div class="col-lg-4 col-md-6 mb-4">
<div class="card h-100 product-card">
<a href="{{ url_for('main.product_detail', product_id=product.id) }}" class="text-decoration-none">
{% if product.main_image %}
<img src="{{ product.main_image }}" class="card-img-top" alt="{{ product.name }}"
style="height: 200px; object-fit: cover;">
{% else %}
<div class="card-img-top bg-light d-flex align-items-center justify-content-center"
style="height: 200px;">
<i class="bi bi-image text-muted" style="font-size: 3rem;"></i>
</div>
{% endif %}
</a>
<div class="card-body">
<h6 class="card-title">
<a href="{{ url_for('main.product_detail', product_id=product.id) }}"
class="text-decoration-none text-dark">
{{ product.name[:60] }}{% if product.name|length > 60 %}...{% endif %}
</a>
</h6>
{% if product.brand %}
<p class="card-text text-muted small">{{ product.brand }}</p>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<div>
<span class="text-danger fw-bold h6">¥{{ "%.2f"|format(product.price) }}</span>
{% if product.original_price and product.original_price > product.price %}
<small class="text-muted text-decoration-line-through d-block">
¥{{ "%.2f"|format(product.original_price) }}
</small>
{% endif %}
</div>
<div class="text-end">
<small class="text-muted d-block">销量{{ product.sales_count }}</small>
<small class="text-muted">浏览{{ product.view_count }}</small>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 分页 -->
{% if products.pages > 1 %}
<nav aria-label="商品分页">
<ul class="pagination justify-content-center">
{% if products.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.product_list', page=products.prev_num, **request.args) }}">
<i class="bi bi-chevron-left"></i> 上一页
</a>
</li>
{% endif %}
{% for page_num in products.iter_pages() %}
{% if page_num %}
{% if page_num != products.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.product_list', page=page_num, **request.args) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if products.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.product_list', page=products.next_num, **request.args) }}">
下一页 <i class="bi bi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<!-- 无商品提示 -->
<div class="text-center py-5">
<i class="bi bi-search text-muted" style="font-size: 5rem;"></i>
<h4 class="text-muted mt-3">暂无找到相关商品</h4>
<p class="text-muted">请尝试调整搜索条件或浏览其他分类</p>
<a href="{{ url_for('main.product_list') }}" class="btn btn-primary">
<i class="bi bi-arrow-left"></i> 返回全部商品
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/product_list.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>COS上传测试</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4>腾讯云COS上传测试</h4>
</div>
<div class="card-body">
<!-- 头像上传测试 -->
<div class="mb-4">
<h5>头像上传测试</h5>
<form id="avatarForm" enctype="multipart/form-data">
<div class="mb-3">
<input type="file" class="form-control" id="avatarFile" name="avatar" accept="image/*">
</div>
<button type="submit" class="btn btn-primary">上传头像</button>
</form>
<div id="avatarResult" class="mt-3"></div>
</div>
<hr>
<!-- 通用图片上传测试 -->
<div class="mb-4">
<h5>通用图片上传测试</h5>
<form id="imageForm" enctype="multipart/form-data">
<div class="mb-3">
<input type="file" class="form-control" id="imageFile" name="image" accept="image/*">
</div>
<div class="mb-3">
<select class="form-select" name="folder_type">
<option value="temp">临时文件</option>
<option value="product">商品图片</option>
<option value="review">评价图片</option>
</select>
</div>
<button type="submit" class="btn btn-success">上传图片</button>
</form>
<div id="imageResult" class="mt-3"></div>
</div>
<!-- 上传历史 -->
<div class="mb-4">
<h5>上传历史</h5>
<div id="uploadHistory"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 上传历史记录
let uploadHistory = [];
// 头像上传
document.getElementById('avatarForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData();
const fileInput = document.getElementById('avatarFile');
if (!fileInput.files[0]) {
showResult('avatarResult', false, '请选择文件');
return;
}
formData.append('avatar', fileInput.files[0]);
uploadFile('/upload/avatar', formData, 'avatarResult', '头像');
});
// 通用图片上传
document.getElementById('imageForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const fileInput = document.getElementById('imageFile');
if (!fileInput.files[0]) {
showResult('imageResult', false, '请选择文件');
return;
}
uploadFile('/upload/image', formData, 'imageResult', '图片');
});
// 上传文件函数
function uploadFile(url, formData, resultId, fileType) {
const resultDiv = document.getElementById(resultId);
resultDiv.innerHTML = '<div class="alert alert-info">上传中...</div>';
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showResult(resultId, true, `${fileType}上传成功!`, data.url);
addToHistory(fileType, data.url, data.file_key);
} else {
showResult(resultId, false, data.error || `${fileType}上传失败`);
}
})
.catch(error => {
showResult(resultId, false, `上传失败: ${error.message}`);
});
}
// 显示结果
function showResult(resultId, success, message, imageUrl = null) {
const resultDiv = document.getElementById(resultId);
const alertClass = success ? 'alert-success' : 'alert-danger';
let html = `<div class="alert ${alertClass}">${message}</div>`;
if (success && imageUrl) {
html += `
<div class="mt-2">
<img src="${imageUrl}" class="img-thumbnail" style="max-width: 200px;">
<p class="mt-2"><small>访问地址: <a href="${imageUrl}" target="_blank">${imageUrl}</a></small></p>
</div>
`;
}
resultDiv.innerHTML = html;
}
// 添加到历史记录
function addToHistory(type, url, fileKey) {
uploadHistory.unshift({
type: type,
url: url,
fileKey: fileKey,
time: new Date().toLocaleString()
});
updateHistoryDisplay();
}
// 更新历史记录显示
function updateHistoryDisplay() {
const historyDiv = document.getElementById('uploadHistory');
if (uploadHistory.length === 0) {
historyDiv.innerHTML = '<p class="text-muted">暂无上传记录</p>';
return;
}
let html = '<div class="list-group">';
uploadHistory.slice(0, 5).forEach(item => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">${item.type}</h6>
<p class="mb-1"><small>${item.fileKey}</small></p>
<small class="text-muted">${item.time}</small>
</div>
<div>
<a href="${item.url}" target="_blank" class="btn btn-sm btn-outline-primary">查看</a>
</div>
</div>
</div>
`;
});
html += '</div>';
historyDiv.innerHTML = html;
}
// 初始化历史记录显示
updateHistoryDisplay();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>COS上传测试</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4>腾讯云COS上传测试</h4>
</div>
<div class="card-body">
<!-- 头像上传测试 -->
<div class="mb-4">
<h5>头像上传测试</h5>
<form id="avatarForm" enctype="multipart/form-data">
<div class="mb-3">
<input type="file" class="form-control" id="avatarFile" name="avatar" accept="image/*">
</div>
<button type="submit" class="btn btn-primary">上传头像</button>
</form>
<div id="avatarResult" class="mt-3"></div>
</div>
<hr>
<!-- 通用图片上传测试 -->
<div class="mb-4">
<h5>通用图片上传测试</h5>
<form id="imageForm" enctype="multipart/form-data">
<div class="mb-3">
<input type="file" class="form-control" id="imageFile" name="image" accept="image/*">
</div>
<div class="mb-3">
<select class="form-select" name="folder_type">
<option value="temp">临时文件</option>
<option value="product">商品图片</option>
<option value="review">评价图片</option>
</select>
</div>
<button type="submit" class="btn btn-success">上传图片</button>
</form>
<div id="imageResult" class="mt-3"></div>
</div>
<!-- 上传历史 -->
<div class="mb-4">
<h5>上传历史</h5>
<div id="uploadHistory"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// 上传历史记录
let uploadHistory = [];
// 头像上传
document.getElementById('avatarForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData();
const fileInput = document.getElementById('avatarFile');
if (!fileInput.files[0]) {
showResult('avatarResult', false, '请选择文件');
return;
}
formData.append('avatar', fileInput.files[0]);
uploadFile('/upload/avatar', formData, 'avatarResult', '头像');
});
// 通用图片上传
document.getElementById('imageForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const fileInput = document.getElementById('imageFile');
if (!fileInput.files[0]) {
showResult('imageResult', false, '请选择文件');
return;
}
uploadFile('/upload/image', formData, 'imageResult', '图片');
});
// 上传文件函数
function uploadFile(url, formData, resultId, fileType) {
const resultDiv = document.getElementById(resultId);
resultDiv.innerHTML = '<div class="alert alert-info">上传中...</div>';
fetch(url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showResult(resultId, true, `${fileType}上传成功!`, data.url);
addToHistory(fileType, data.url, data.file_key);
} else {
showResult(resultId, false, data.error || `${fileType}上传失败`);
}
})
.catch(error => {
showResult(resultId, false, `上传失败: ${error.message}`);
});
}
// 显示结果
function showResult(resultId, success, message, imageUrl = null) {
const resultDiv = document.getElementById(resultId);
const alertClass = success ? 'alert-success' : 'alert-danger';
let html = `<div class="alert ${alertClass}">${message}</div>`;
if (success && imageUrl) {
html += `
<div class="mt-2">
<img src="${imageUrl}" class="img-thumbnail" style="max-width: 200px;">
<p class="mt-2"><small>访问地址: <a href="${imageUrl}" target="_blank">${imageUrl}</a></small></p>
</div>
`;
}
resultDiv.innerHTML = html;
}
// 添加到历史记录
function addToHistory(type, url, fileKey) {
uploadHistory.unshift({
type: type,
url: url,
fileKey: fileKey,
time: new Date().toLocaleString()
});
updateHistoryDisplay();
}
// 更新历史记录显示
function updateHistoryDisplay() {
const historyDiv = document.getElementById('uploadHistory');
if (uploadHistory.length === 0) {
historyDiv.innerHTML = '<p class="text-muted">暂无上传记录</p>';
return;
}
let html = '<div class="list-group">';
uploadHistory.slice(0, 5).forEach(item => {
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<h6 class="mb-1">${item.type}</h6>
<p class="mb-1"><small>${item.fileKey}</small></p>
<small class="text-muted">${item.time}</small>
</div>
<div>
<a href="${item.url}" target="_blank" class="btn btn-sm btn-outline-primary">查看</a>
</div>
</div>
</div>
`;
});
html += '</div>';
historyDiv.innerHTML = html;
}
// 初始化历史记录显示
updateHistoryDisplay();
</script>
</body>
</html>

View File

@ -0,0 +1,150 @@
{% extends "base.html" %}
{% block title %}{% if action == 'add' %}添加地址{% else %}编辑地址{% endif %} - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/address_form.css') }}">
{% endblock %}
{% block content %}
<div class="row">
<!-- 侧边栏 -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-person-circle"></i> 个人中心</h5>
</div>
<div class="list-group list-group-flush">
<a href="{{ url_for('user.profile') }}" class="list-group-item list-group-item-action">
<i class="bi bi-person"></i> 基本信息
</a>
<a href="{{ url_for('user.orders') }}" class="list-group-item list-group-item-action">
<i class="bi bi-bag"></i> 我的订单
</a>
<a href="{{ url_for('address.index') }}" class="list-group-item list-group-item-action active">
<i class="bi bi-geo-alt"></i> 收货地址
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-heart"></i> 我的收藏
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-clock-history"></i> 浏览历史
</a>
</div>
</div>
</div>
<!-- 主要内容 -->
<div class="col-md-9">
<div class="card">
<div class="card-header">
<h5>
<i class="bi bi-geo-alt"></i>
{% if action == 'add' %}添加地址{% else %}编辑地址{% endif %}
</h5>
</div>
<div class="card-body">
<!-- 调试信息 -->
<div class="alert alert-info" id="debugAlert" style="display: none;">
<strong>调试信息:</strong>
<div id="debugInfo">加载中...</div>
</div>
<form method="POST" id="addressForm">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{{ form.receiver_name.label.text }} <span class="text-danger">*</span></label>
{{ form.receiver_name(class="form-control") }}
{% if form.receiver_name.errors %}
<div class="text-danger">{{ form.receiver_name.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{{ form.receiver_phone.label.text }} <span class="text-danger">*</span></label>
{{ form.receiver_phone(class="form-control") }}
{% if form.receiver_phone.errors %}
<div class="text-danger">{{ form.receiver_phone.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">省份 <span class="text-danger">*</span></label>
<select class="form-select" id="province" name="province" required>
<option value="">加载中...</option>
</select>
<input type="hidden" id="provinceValue" value="{% if address %}{{ address.province }}{% endif %}">
{% if form.province.errors %}
<div class="text-danger">{{ form.province.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">城市 <span class="text-danger">*</span></label>
<select class="form-select" id="city" name="city" required>
<option value="">请选择城市</option>
</select>
<input type="hidden" id="cityValue" value="{% if address %}{{ address.city }}{% endif %}">
{% if form.city.errors %}
<div class="text-danger">{{ form.city.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">区县 <span class="text-danger">*</span></label>
<select class="form-select" id="district" name="district" required>
<option value="">请选择区县</option>
</select>
<input type="hidden" id="districtValue" value="{% if address %}{{ address.district }}{% endif %}">
{% if form.district.errors %}
<div class="text-danger">{{ form.district.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">{{ form.detail_address.label.text }} <span class="text-danger">*</span></label>
{{ form.detail_address(class="form-control", placeholder="街道、门牌号等详细信息") }}
{% if form.detail_address.errors %}
<div class="text-danger">{{ form.detail_address.errors[0] }}</div>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label class="form-label">{{ form.postal_code.label.text }}</label>
{{ form.postal_code(class="form-control", placeholder="选填") }}
{% if form.postal_code.errors %}
<div class="text-danger">{{ form.postal_code.errors[0] }}</div>
{% endif %}
</div>
</div>
<div class="mb-3">
<div class="form-check">
{{ form.is_default(class="form-check-input") }}
<label class="form-check-label" for="{{ form.is_default.id }}">
{{ form.is_default.label.text }}
</label>
</div>
</div>
<div class="d-flex gap-2">
{{ form.submit(class="btn btn-primary") }}
<a href="{{ url_for('address.index') }}" class="btn btn-outline-secondary">取消</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- 在scripts块中引入城市数据确保在base.html的脚本之后加载 -->
<script src="{{ url_for('static', filename='js/city_data.js') }}"></script>
<script src="{{ url_for('static', filename='js/address_form.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}收货地址 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/addresses.css') }}">
{% endblock %}
{% block content %}
<div class="row">
<!-- 侧边栏 -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-person-circle"></i> 个人中心</h5>
</div>
<div class="list-group list-group-flush">
<a href="{{ url_for('user.profile') }}" class="list-group-item list-group-item-action">
<i class="bi bi-person"></i> 基本信息
</a>
<a href="{{ url_for('user.orders') }}" class="list-group-item list-group-item-action">
<i class="bi bi-bag"></i> 我的订单
</a>
<a href="{{ url_for('address.index') }}" class="list-group-item list-group-item-action active">
<i class="bi bi-geo-alt"></i> 收货地址
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-heart"></i> 我的收藏
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-clock-history"></i> 浏览历史
</a>
</div>
</div>
</div>
<!-- 主要内容 -->
<div class="col-md-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5><i class="bi bi-geo-alt"></i> 收货地址</h5>
<a href="{{ url_for('address.add') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> 添加地址
</a>
</div>
<div class="card-body">
{% if addresses %}
<div class="row">
{% for address in addresses %}
<div class="col-md-6 mb-3">
<div class="card address-card {% if address.is_default %}border-primary{% endif %}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="card-title mb-1">
{{ address.receiver_name }}
{% if address.is_default %}
<span class="badge bg-primary ms-2">默认</span>
{% endif %}
</h6>
<p class="text-muted mb-0">{{ address.receiver_phone }}</p>
</div>
<div class="dropdown">
<button class="btn btn-link btn-sm" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('address.edit', address_id=address.id) }}">
<i class="bi bi-pencil"></i> 编辑
</a>
</li>
{% if not address.is_default %}
<li>
<a class="dropdown-item" href="#" onclick="setDefaultAddress({{ address.id }})">
<i class="bi bi-star"></i> 设为默认
</a>
</li>
{% endif %}
<li>
<a class="dropdown-item text-danger" href="#" onclick="deleteAddress({{ address.id }})">
<i class="bi bi-trash"></i> 删除
</a>
</li>
</ul>
</div>
</div>
<p class="card-text">
<i class="bi bi-geo-alt text-muted"></i>
{{ address.get_full_address() }}
</p>
{% if address.postal_code %}
<p class="text-muted mb-0">
<small>邮编:{{ address.postal_code }}</small>
</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center empty-state">
<i class="bi bi-geo-alt-fill display-1 text-muted"></i>
<h5 class="mt-3 text-muted">暂无收货地址</h5>
<p class="text-muted">请添加您的收货地址,方便下单购物</p>
<a href="{{ url_for('address.add') }}" class="btn btn-primary">
<i class="bi bi-plus"></i> 添加地址
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/addresses.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}用户登录 - 太白购物商城{% endblock %}
{% block styles %}
<link href="{{ url_for('static', filename='css/auth.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="auth-container">
<div class="row justify-content-center w-100">
<div class="col-md-6 col-lg-4">
<div class="card auth-card">
<div class="card-header text-center">
<h4><i class="bi bi-person-circle"></i> 用户登录</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
{% if form.username.errors %}
<div class="invalid-feedback">
{% for error in form.username.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
{% if form.password.errors %}
<div class="invalid-feedback">
{% for error in form.password.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
<hr>
<div class="text-center">
<p class="mb-0">还没有账户? <a href="{{ url_for('auth.register') }}" class="auth-link">立即注册</a></p>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,230 @@
{% extends "base.html" %}
{% block title %}我的订单 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/orders.css') }}">
{% endblock %}
{% block content %}
<div class="row">
<!-- 侧边栏 -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-person-circle"></i> 个人中心</h5>
</div>
<div class="list-group list-group-flush">
<a href="{{ url_for('user.profile') }}" class="list-group-item list-group-item-action">
<i class="bi bi-person"></i> 基本信息
</a>
<a href="{{ url_for('user.orders') }}" class="list-group-item list-group-item-action active">
<i class="bi bi-bag"></i> 我的订单
</a>
<a href="{{ url_for('address.index') }}" class="list-group-item list-group-item-action">
<i class="bi bi-geo-alt"></i> 收货地址
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-heart"></i> 我的收藏
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-clock-history"></i> 浏览历史
</a>
</div>
</div>
</div>
<!-- 主要内容 -->
<div class="col-md-9">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-bag"></i> 我的订单</h5>
</div>
<div class="card-body">
<!-- 订单状态筛选 -->
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<a class="nav-link {% if not current_status %}active{% endif %}"
href="{{ url_for('order.list') }}">全部订单</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_status == 1 %}active{% endif %}"
href="{{ url_for('order.list', status=1) }}">待支付</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_status == 2 %}active{% endif %}"
href="{{ url_for('order.list', status=2) }}">待发货</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_status == 3 %}active{% endif %}"
href="{{ url_for('order.list', status=3) }}">待收货</a>
</li>
<li class="nav-item">
<a class="nav-link {% if current_status == 5 %}active{% endif %}"
href="{{ url_for('order.list', status=5) }}">已完成</a>
</li>
</ul>
<!-- 订单列表 -->
{% if orders.items %}
{% for order in orders.items %}
<div class="order-card">
<!-- 订单头部 -->
<div class="order-header">
<div class="row align-items-center">
<div class="col-md-3">
<strong>订单号:</strong>{{ order.order_sn }}
</div>
<div class="col-md-3">
<strong>下单时间:</strong>{{ order.created_at.strftime('%Y-%m-%d %H:%M') }}
</div>
<div class="col-md-3">
{% if order.status == 1 %}
<span class="badge bg-warning status-badge">{{ order.get_status_text() }}</span>
{% elif order.status == 2 %}
<span class="badge bg-info status-badge">{{ order.get_status_text() }}</span>
{% elif order.status == 3 %}
<span class="badge bg-primary status-badge">{{ order.get_status_text() }}</span>
{% elif order.status == 5 %}
<span class="badge bg-success status-badge">{{ order.get_status_text() }}</span>
{% elif order.status == 6 %}
<span class="badge bg-secondary status-badge">{{ order.get_status_text() }}</span>
{% else %}
<span class="badge bg-dark status-badge">{{ order.get_status_text() }}</span>
{% endif %}
</div>
<div class="col-md-3 text-end">
<a href="{{ url_for('order.detail', order_id=order.id) }}"
class="btn btn-outline-primary btn-sm">查看详情</a>
</div>
</div>
</div>
<!-- 订单商品 -->
{% for item in order.order_items[:3] %}
<div class="order-item">
<div class="row align-items-center">
<div class="col-md-2">
<img src="{{ item.product_image or '/static/images/default-product.jpg' }}"
class="product-image" alt="{{ item.product_name }}">
</div>
<div class="col-md-6">
<h6 class="mb-1">{{ item.product_name }}</h6>
{% if item.spec_combination %}
<p class="text-muted mb-0">{{ item.spec_combination }}</p>
{% endif %}
</div>
<div class="col-md-2 text-center">
<span class="text-muted">× {{ item.quantity }}</span>
</div>
<div class="col-md-2 text-end">
<span class="fw-bold">¥{{ "%.2f"|format(item.total_price) }}</span>
</div>
</div>
</div>
{% endfor %}
{% if order.order_items|length > 3 %}
<div class="order-item text-center text-muted">
<small>还有 {{ order.order_items|length - 3 }} 件商品...</small>
</div>
{% endif %}
<!-- 订单底部 -->
<div class="order-footer">
<div class="row align-items-center">
<div class="col-md-6">
<div class="d-flex gap-2">
{% if order.can_pay() %}
<a href="{{ url_for('order.pay', payment_sn=order.payments[0].payment_sn) }}"
class="btn btn-danger btn-sm">立即支付</a>
{% endif %}
{% if order.can_cancel() %}
<button class="btn btn-outline-secondary btn-sm"
onclick="cancelOrder({{ order.id }})">取消订单</button>
{% endif %}
{% if order.can_confirm_receipt() %}
<button class="btn btn-success btn-sm"
onclick="confirmReceipt({{ order.id }})">确认收货</button>
{% endif %}
{% if order.status == 4 %}
<a href="#" class="btn btn-outline-warning btn-sm">评价商品</a>
{% endif %}
</div>
</div>
<div class="col-md-6 text-end">
<div>
<span class="text-muted">共 {{ order.order_items|length }} 件商品,</span>
<span class="text-muted">应付:</span>
<span class="order-amount">¥{{ "%.2f"|format(order.actual_amount) }}</span>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- 分页 -->
{% if orders.pages > 1 %}
<nav aria-label="订单分页">
<ul class="pagination justify-content-center">
{% if orders.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('order.list', page=orders.prev_num, status=current_status) }}">上一页</a>
</li>
{% endif %}
{% for page_num in orders.iter_pages() %}
{% if page_num %}
{% if page_num != orders.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('order.list', page=page_num, status=current_status) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if orders.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('order.list', page=orders.next_num, status=current_status) }}">下一页</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center empty-state">
<i class="bi bi-bag-x display-1 text-muted"></i>
<h5 class="mt-3 text-muted">
{% if current_status %}
暂无该状态订单
{% else %}
暂无订单
{% endif %}
</h5>
<p class="text-muted">您还没有任何订单,快去购物吧!</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">
<i class="bi bi-shop"></i> 去购物
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/orders.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,234 @@
{% extends "base.html" %}
{% block title %}个人中心 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/profile.css') }}">
{% endblock %}
{% block content %}
<div class="row">
<!-- 侧边栏 -->
<div class="col-md-3">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-person-circle"></i> 个人中心</h5>
</div>
<div class="list-group list-group-flush">
<a href="{{ url_for('user.profile') }}" class="list-group-item list-group-item-action active">
<i class="bi bi-person"></i> 基本信息
</a>
<a href="{{ url_for('user.orders') }}" class="list-group-item list-group-item-action">
<i class="bi bi-bag"></i> 我的订单
</a>
<a href="{{ url_for('address.index') }}" class="list-group-item list-group-item-action">
<i class="bi bi-geo-alt"></i> 收货地址
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-heart"></i> 我的收藏
</a>
<a href="#" class="list-group-item list-group-item-action">
<i class="bi bi-clock-history"></i> 浏览历史
</a>
</div>
</div>
</div>
<!-- 主要内容 -->
<div class="col-md-9">
<div class="card">
<div class="card-header">
<h5><i class="bi bi-person"></i> 基本信息</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<table class="table table-borderless">
<tr>
<td width="120"><strong>用户名:</strong></td>
<td>{{ user.username }}</td>
</tr>
<tr>
<td><strong>昵称:</strong></td>
<td>{{ user.nickname or '未设置' }}</td>
</tr>
<tr>
<td><strong>手机号:</strong></td>
<td>{{ user.phone or '未绑定' }}</td>
</tr>
<tr>
<td><strong>邮箱:</strong></td>
<td>{{ user.email or '未绑定' }}</td>
</tr>
<tr>
<td><strong>性别:</strong></td>
<td>
{% if user.gender == 1 %}男
{% elif user.gender == 2 %}女
{% else %}未设置
{% endif %}
</td>
</tr>
<tr>
<td><strong>注册时间:</strong></td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') if user.created_at else '未知' }}</td>
</tr>
</table>
<div class="mt-3">
<button class="btn btn-primary me-2">
<i class="bi bi-pencil"></i> 编辑资料
</button>
<button class="btn btn-outline-secondary">
<i class="bi bi-key"></i> 修改密码
</button>
</div>
</div>
<div class="col-md-4 text-center">
<div class="mb-3">
<div class="avatar-upload">
{% if user.avatar_url %}
<img src="{{ user.avatar_url }}" alt="头像" class="avatar-preview" id="avatarPreview">
{% else %}
<div class="avatar-placeholder" id="avatarPlaceholder">
<i class="bi bi-person display-4 text-muted"></i>
</div>
{% endif %}
<div class="upload-overlay" onclick="triggerFileInput()">
<i class="bi bi-camera text-white fs-3"></i>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input type="file" id="avatarInput" accept="image/*" style="display: none;" onchange="handleFileSelect(event)">
<!-- 上传进度 -->
<div class="upload-progress" id="uploadProgress">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
</div>
<small class="text-muted">上传中...</small>
</div>
<button class="btn btn-outline-primary btn-sm" onclick="triggerFileInput()">
<i class="bi bi-camera"></i> 更换头像
</button>
<div class="mt-2">
<small class="text-muted">支持 JPG、PNG 格式,大小不超过 2MB</small>
</div>
</div>
</div>
</div>
</div>
<!-- 快捷操作 -->
<div class="row mt-4">
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-bag display-4 text-primary mb-2"></i>
<h6 class="card-title">我的订单</h6>
<small class="text-muted">查看所有订单</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-cart display-4 text-success mb-2"></i>
<h6 class="card-title">购物车</h6>
<small class="text-muted">查看购物车</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-heart display-4 text-danger mb-2"></i>
<h6 class="card-title">我的收藏</h6>
<small class="text-muted">收藏的商品</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<i class="bi bi-geo-alt display-4 text-warning mb-2"></i>
<h6 class="card-title">收货地址</h6>
<small class="text-muted">管理收货地址</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 重新设计的图片预览模态框 -->
<div class="modal fade image-preview-modal" id="imagePreviewModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title">
<i class="bi bi-image text-primary"></i> 头像预览
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="preview-container">
<div class="preview-image-wrapper">
<img src="" alt="预览图片" class="preview-image" id="previewImage">
</div>
<div class="preview-info">
<div class="mb-2">
<i class="bi bi-info-circle text-primary"></i>
<strong>图片信息</strong>
</div>
<div class="preview-stats">
<div class="stat-item">
<div class="stat-value" id="imageWidth">-</div>
<div class="stat-label">宽度(px)</div>
</div>
<div class="stat-item">
<div class="stat-value" id="imageHeight">-</div>
<div class="stat-label">高度(px)</div>
</div>
<div class="stat-item">
<div class="stat-value" id="imageSize">-</div>
<div class="stat-label">大小</div>
</div>
<div class="stat-item">
<div class="stat-value" id="imageType">-</div>
<div class="stat-label">格式</div>
</div>
</div>
</div>
<div class="mt-3">
<small class="text-muted">
<i class="bi bi-check-circle text-success"></i>
图片将被自动调整为合适的头像尺寸
</small>
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> 取消
</button>
<button type="button" class="btn btn-primary" onclick="confirmUpload()">
<i class="bi bi-upload"></i> 确认上传
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/profile.js') }}"></script>
{% endblock %}

View File

@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}用户注册 - 太白购物商城{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/register.css') }}">
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow">
<div class="card-header text-center">
<h4><i class="bi bi-person-plus"></i> 用户注册</h4>
</div>
<div class="card-body">
<form method="POST" id="registerForm">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
{% if form.username.errors %}
<div class="invalid-feedback">
{% for error in form.username.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">用户名只能包含字母、数字和下划线3-20个字符</div>
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
<div class="input-group">
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""), id="emailInput") }}
<button type="button" class="btn btn-outline-primary" id="sendEmailCodeBtn">
<span id="btnText">发送验证码</span>
</button>
</div>
{% if form.email.errors %}
<div class="invalid-feedback d-block">
{% for error in form.email.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.email_code.label(class="form-label") }}
{{ form.email_code(class="form-control" + (" is-invalid" if form.email_code.errors else ""), placeholder="请输入6位数字验证码") }}
{% if form.email_code.errors %}
<div class="invalid-feedback">
{% for error in form.email_code.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.phone.label(class="form-label") }}
{{ form.phone(class="form-control" + (" is-invalid" if form.phone.errors else "")) }}
{% if form.phone.errors %}
<div class="invalid-feedback">
{% for error in form.phone.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else ""), id="passwordInput") }}
{% if form.password.errors %}
<div class="invalid-feedback">
{% for error in form.password.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div class="form-text">密码必须包含至少一个字母和一个数字6-20个字符</div>
</div>
<div class="mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else ""), id="confirmPasswordInput") }}
{% if form.confirm_password.errors %}
<div class="invalid-feedback">
{% for error in form.confirm_password.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
<div id="passwordMatchMessage" class="form-text"></div>
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-success") }}
</div>
</form>
<hr>
<div class="text-center">
<p class="mb-0">已有账户? <a href="{{ url_for('auth.login') }}">立即登录</a></p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/register.js') }}"></script>
{% endblock %}

0
app/utils/__init__.py Normal file
View File

0
app/utils/auth.py Normal file
View File

274
app/utils/cos_client.py Normal file
View File

@ -0,0 +1,274 @@
"""
腾讯云COS客户端工具
"""
import sys
import os
import uuid
import logging
from datetime import datetime
from qcloud_cos import CosConfig, CosS3Client
from qcloud_cos.cos_exception import CosClientError, CosServiceError
from config.cos_config import COSConfig
# 配置日志
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
logger = logging.getLogger(__name__)
class COSClient:
"""腾讯云COS客户端"""
def __init__(self):
"""初始化COS客户端"""
try:
# 配置COS
config = CosConfig(
Region=COSConfig.REGION,
SecretId=COSConfig.SECRET_ID,
SecretKey=COSConfig.SECRET_KEY,
Token=None, # 临时密钥需要传入Token永久密钥不需要
Scheme='https' # 指定使用 http/https 协议来访问COS默认为https
)
# 创建客户端
self.client = CosS3Client(config)
self.bucket = COSConfig.BUCKET_NAME
logger.info("COS客户端初始化成功")
except Exception as e:
logger.error(f"COS客户端初始化失败: {str(e)}")
raise
def generate_file_key(self, folder_type, original_filename):
"""
生成文件存储路径
Args:
folder_type: 文件夹类型 (avatar, product, review, temp)
original_filename: 原始文件名
Returns:
str: 生成的文件路径
"""
# 获取文件扩展名
file_ext = original_filename.rsplit('.', 1)[1].lower() if '.' in original_filename else ''
# 生成唯一文件名
unique_filename = f"{uuid.uuid4().hex}.{file_ext}" if file_ext else uuid.uuid4().hex
# 按日期分组
date_folder = datetime.now().strftime('%Y/%m/%d')
# 获取存储路径前缀
folder_prefix = COSConfig.UPLOAD_FOLDERS.get(folder_type, COSConfig.UPLOAD_FOLDERS['temp'])
# 组合完整路径
file_key = f"{folder_prefix}{date_folder}/{unique_filename}"
return file_key
def upload_file(self, file_obj, folder_type='temp', original_filename=None):
"""
上传文件到COS
Args:
file_obj: 文件对象或文件路径
folder_type: 文件夹类型
original_filename: 原始文件名
Returns:
dict: 上传结果 {'success': bool, 'file_key': str, 'url': str, 'error': str}
"""
try:
# 生成文件路径
if original_filename is None:
if hasattr(file_obj, 'filename'):
original_filename = file_obj.filename
else:
original_filename = 'unknown'
file_key = self.generate_file_key(folder_type, original_filename)
# 上传文件
if hasattr(file_obj, 'read'):
# 文件对象
response = self.client.put_object(
Bucket=self.bucket,
Body=file_obj,
Key=file_key,
StorageClass='STANDARD',
EnableMD5=False
)
else:
# 文件路径
response = self.client.put_object_from_local_file(
Bucket=self.bucket,
LocalFilePath=file_obj,
Key=file_key,
EnableMD5=False
)
# 生成访问URL
file_url = COSConfig.get_full_url(file_key)
logger.info(f"文件上传成功: {file_key}")
return {
'success': True,
'file_key': file_key,
'url': file_url,
'etag': response['ETag'],
'error': None
}
except CosClientError as e:
error_msg = f"COS客户端错误: {str(e)}"
logger.error(error_msg)
return {
'success': False,
'file_key': None,
'url': None,
'error': error_msg
}
except CosServiceError as e:
error_msg = f"COS服务错误: {e.get_error_code()} - {e.get_error_msg()}"
logger.error(error_msg)
return {
'success': False,
'file_key': None,
'url': None,
'error': error_msg
}
except Exception as e:
error_msg = f"上传失败: {str(e)}"
logger.error(error_msg)
return {
'success': False,
'file_key': None,
'url': None,
'error': error_msg
}
def delete_file(self, file_key):
"""
删除COS中的文件
Args:
file_key: 文件路径
Returns:
dict: 删除结果
"""
try:
response = self.client.delete_object(
Bucket=self.bucket,
Key=file_key
)
logger.info(f"文件删除成功: {file_key}")
return {
'success': True,
'error': None
}
except Exception as e:
error_msg = f"删除文件失败: {str(e)}"
logger.error(error_msg)
return {
'success': False,
'error': error_msg
}
def get_file_url(self, file_key, expires=3600):
"""
获取文件访问URL用于私有文件
Args:
file_key: 文件路径
expires: 过期时间
Returns:
str: 预签名URL
"""
try:
response = self.client.get_presigned_download_url(
Bucket=self.bucket,
Key=file_key,
Expired=expires
)
return response
except Exception as e:
logger.error(f"生成预签名URL失败: {str(e)}")
return None
def list_files(self, prefix='', max_keys=100):
"""
列出存储桶中的文件
Args:
prefix: 文件路径前缀
max_keys: 最大返回数量
Returns:
list: 文件列表
"""
try:
response = self.client.list_objects(
Bucket=self.bucket,
Prefix=prefix,
MaxKeys=max_keys
)
files = []
if 'Contents' in response:
for obj in response['Contents']:
files.append({
'key': obj['Key'],
'size': obj['Size'],
'last_modified': obj['LastModified'],
'url': COSConfig.get_full_url(obj['Key'])
})
return files
except Exception as e:
logger.error(f"列出文件失败: {str(e)}")
return []
def test_connection(self):
"""
测试COS连接
Returns:
dict: 测试结果
"""
try:
# 尝试列出存储桶
response = self.client.list_objects(
Bucket=self.bucket,
MaxKeys=1
)
return {
'success': True,
'message': 'COS连接测试成功',
'bucket': self.bucket,
'region': COSConfig.REGION
}
except Exception as e:
return {
'success': False,
'message': f'COS连接测试失败: {str(e)}',
'bucket': self.bucket,
'region': COSConfig.REGION
}
# 创建全局COS客户端实例
cos_client = COSClient()

0
app/utils/cos_upload.py Normal file
View File

36
app/utils/database.py Normal file
View File

@ -0,0 +1,36 @@
"""
数据库工具模块
"""
from flask_sqlalchemy import SQLAlchemy
import sys
# 创建数据库实例
db = SQLAlchemy()
def init_db(app):
"""初始化数据库"""
db.init_app(app)
try:
with app.app_context():
# 测试数据库连接
result = db.session.execute(db.text('SELECT 1'))
print("✅ 数据库连接成功")
# 由于表已存在,我们只需要确保模型与数据库同步
# 不需要重新创建表
print("✅ 数据库初始化完成")
except Exception as e:
print(f"❌ 数据库初始化失败: {e}")
print("请检查数据库配置和网络连接")
# 在开发环境中不退出,允许继续运行
print("⚠️ 继续运行,但可能会有数据库相关问题")
def test_connection():
"""测试数据库连接"""
try:
result = db.session.execute(db.text('SELECT 1'))
return True, "数据库连接正常"
except Exception as e:
return False, f"数据库连接失败: {str(e)}"

312
app/utils/decorators.py Normal file
View File

@ -0,0 +1,312 @@
"""
装饰器工具模块
提供登录验证权限控制等装饰器功能
"""
from functools import wraps
from flask import session, redirect, url_for, flash, request, jsonify, g
from app.models.user import User
def login_required(f):
"""
登录验证装饰器
用法:
@app.route('/profile')
@login_required
def profile():
return render_template('profile.html')
功能:
- 检查用户是否已登录
- 未登录用户重定向到登录页面
- 支持AJAX请求返回JSON响应
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# 检查session中是否有用户ID
if 'user_id' not in session:
# 如果是AJAX请求返回JSON响应
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': False,
'message': '请先登录',
'code': 'LOGIN_REQUIRED',
'redirect': url_for('auth.login')
}), 401
# 普通HTTP请求重定向到登录页
flash('请先登录后再访问该页面', 'warning')
# 保存用户想要访问的页面,登录后可以重定向回来
session['next_url'] = request.url
return redirect(url_for('auth.login'))
# 将当前用户信息加载到g对象中方便在视图函数中使用
try:
g.current_user = User.query.get(session['user_id'])
if not g.current_user or g.current_user.status != 1:
# 用户不存在或被禁用清除session
session.pop('user_id', None)
flash('账号状态异常,请重新登录', 'error')
return redirect(url_for('auth.login'))
except Exception as e:
# 数据库查询出错清除session
session.pop('user_id', None)
flash('登录状态异常,请重新登录', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""
管理员权限验证装饰器
"""
@wraps(f)
def decorated_function(*args, **kwargs):
from app.models.admin import AdminUser
# 检查session中是否有管理员ID
if 'admin_id' not in session:
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': False,
'message': '需要管理员权限',
'code': 'ADMIN_REQUIRED',
'redirect': url_for('admin.login')
}), 403
flash('需要管理员权限才能访问', 'error')
return redirect(url_for('admin.login'))
# 加载管理员信息到g对象
try:
g.current_admin = AdminUser.query.get(session['admin_id'])
if not g.current_admin or g.current_admin.status != 1:
# 管理员不存在或被禁用清除session
session.pop('admin_id', None)
flash('管理员账号状态异常,请重新登录', 'error')
return redirect(url_for('admin.login'))
except Exception as e:
# 数据库查询出错清除session
session.pop('admin_id', None)
flash('登录状态异常,请重新登录', 'error')
return redirect(url_for('admin.login'))
return f(*args, **kwargs)
return decorated_function
def json_required(f):
"""
JSON请求验证装饰器
用法:
@app.route('/api/upload', methods=['POST'])
@json_required
def api_upload():
data = request.get_json()
return jsonify({'success': True})
功能:
- 确保请求是JSON格式
- 非JSON请求返回错误响应
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not request.is_json:
return jsonify({
'success': False,
'message': '请求必须是JSON格式',
'code': 'JSON_REQUIRED'
}), 400
return f(*args, **kwargs)
return decorated_function
def validate_file_upload(allowed_extensions=None, max_size=None):
"""
文件上传验证装饰器
用法:
@app.route('/upload')
@validate_file_upload(allowed_extensions={'jpg', 'png'}, max_size=2*1024*1024)
def upload_file():
file = request.files['file']
return jsonify({'success': True})
参数:
allowed_extensions: 允许的文件扩展名集合
max_size: 最大文件大小字节
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# 检查是否有文件上传
if 'file' not in request.files:
return jsonify({
'success': False,
'message': '没有选择文件',
'code': 'NO_FILE'
}), 400
file = request.files['file']
# 检查文件名
if file.filename == '':
return jsonify({
'success': False,
'message': '没有选择文件',
'code': 'NO_FILE'
}), 400
# 检查文件扩展名
if allowed_extensions:
file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''
if file_ext not in allowed_extensions:
return jsonify({
'success': False,
'message': f'不支持的文件格式,只支持: {", ".join(allowed_extensions)}',
'code': 'INVALID_FILE_TYPE'
}), 400
# 检查文件大小
if max_size:
# 获取文件大小
file.seek(0, 2) # 移动到文件末尾
file_size = file.tell()
file.seek(0) # 重置文件指针
if file_size > max_size:
size_mb = max_size / 1024 / 1024
return jsonify({
'success': False,
'message': f'文件大小超过限制,最大允许 {size_mb:.1f}MB',
'code': 'FILE_TOO_LARGE'
}), 400
return f(*args, **kwargs)
return decorated_function
return decorator
def rate_limit(max_requests=10, per_seconds=60):
"""
简单的请求频率限制装饰器
用法:
@app.route('/api/send-code')
@rate_limit(max_requests=5, per_seconds=300) # 5分钟内最多5次请求
def send_verification_code():
return jsonify({'success': True})
参数:
max_requests: 最大请求次数
per_seconds: 时间窗口
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# 这里可以实现基于IP或用户的请求频率限制
# 简单实现可以使用session或内存缓存
# 生产环境建议使用Redis
# 获取客户端标识IP地址或用户ID
client_id = request.remote_addr
if 'user_id' in session:
client_id = f"user_{session['user_id']}"
# 这里应该实现真正的频率限制逻辑
# 暂时跳过,返回原函数
return f(*args, **kwargs)
return decorated_function
return decorator
def log_operation(action, resource_type=None, resource_id=None):
"""
操作日志记录装饰器
用法:
@app.route('/admin/users/<int:user_id>', methods=['DELETE'])
@admin_required
@log_operation('删除用户', 'user')
def delete_user(user_id):
# 删除用户逻辑
return jsonify({'success': True})
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
from app.models.operation_log import OperationLog
# 执行原函数
result = f(*args, **kwargs)
# 记录操作日志
try:
user_id = None
user_type = 1 # 默认普通用户
# 检查是否是管理员操作
if 'admin_id' in session:
user_id = session['admin_id']
user_type = 2
elif 'user_id' in session:
user_id = session['user_id']
user_type = 1
# 获取资源ID如果在URL参数中
actual_resource_id = resource_id
if resource_type and not actual_resource_id:
# 尝试从URL参数中获取资源ID
for key, value in kwargs.items():
if key.endswith('_id'):
actual_resource_id = value
break
# 准备请求数据
request_data = {}
if request.method in ['POST', 'PUT', 'PATCH']:
if request.is_json:
request_data = request.get_json() or {}
else:
request_data = request.form.to_dict()
# 记录日志
OperationLog.create_log(
user_id=user_id,
user_type=user_type,
action=action,
resource_type=resource_type,
resource_id=actual_resource_id,
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent'),
request_data=request_data if request_data else None
)
except Exception as e:
# 日志记录失败不影响主要功能
print(f"记录操作日志失败: {str(e)}")
return result
return decorated_function
return decorator

View File

@ -0,0 +1,75 @@
from flask import current_app
from flask_mail import Mail, Message
from threading import Thread
mail = Mail()
def send_async_email(app, msg):
"""异步发送邮件"""
with app.app_context():
try:
mail.send(msg)
except Exception as e:
print(f"邮件发送失败: {e}")
def send_email(to, subject, template, **kwargs):
"""发送邮件"""
app = current_app._get_current_object()
msg = Message(
subject=subject,
recipients=[to],
html=template,
sender=current_app.config['MAIL_DEFAULT_SENDER']
)
# 异步发送
thr = Thread(target=send_async_email, args=[app, msg])
thr.start()
return thr
def send_verification_email(email, code, code_type):
"""发送验证码邮件"""
type_map = {
1: '注册',
2: '登录',
3: '找回密码'
}
subject = f'【太白购物】{type_map.get(code_type, "验证")}验证码'
html_template = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>验证码邮件</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h2 style="color: #007bff;">太白购物平台</h2>
</div>
<div style="background: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px;">
<h3>您好</h3>
<p>您正在进行<strong>{type_map.get(code_type, "验证")}</strong>操作验证码为</p>
<div style="text-align: center; margin: 20px 0;">
<span style="font-size: 24px; font-weight: bold; color: #007bff; background: #e9ecef; padding: 10px 20px; border-radius: 5px; letter-spacing: 2px;">{code}</span>
</div>
<p style="color: #666;">验证码有效期为10分钟请及时使用</p>
<p style="color: #666;">如果这不是您的操作请忽略此邮件</p>
</div>
<div style="text-align: center; color: #666; font-size: 12px;">
<p>此邮件由系统自动发送请勿回复</p>
<p>© 2024 太白购物平台 版权所有</p>
</div>
</div>
</body>
</html>
"""
return send_email(email, subject, html_template)

428
app/utils/file_upload.py Normal file
View File

@ -0,0 +1,428 @@
"""
文件上传处理工具
"""
import os
import magic
from PIL import Image
from io import BytesIO
from werkzeug.utils import secure_filename
from config.cos_config import COSConfig
from .cos_client import cos_client
class FileUploadHandler:
"""文件上传处理器"""
@staticmethod
def validate_file(file_obj, file_type='image'):
"""
验证文件
Args:
file_obj: 文件对象
file_type: 文件类型 (image, file)
Returns:
dict: 验证结果
"""
if not file_obj or not file_obj.filename:
return {'valid': False, 'error': '请选择文件'}
# 检查文件扩展名
filename = secure_filename(file_obj.filename)
if '.' not in filename:
return {'valid': False, 'error': '文件格式不正确'}
file_ext = filename.rsplit('.', 1)[1].lower()
if file_type == 'image':
allowed_extensions = COSConfig.ALLOWED_IMAGE_EXTENSIONS
max_size = COSConfig.MAX_IMAGE_SIZE
else:
allowed_extensions = COSConfig.ALLOWED_FILE_EXTENSIONS
max_size = COSConfig.MAX_FILE_SIZE
if file_ext not in allowed_extensions:
return {
'valid': False,
'error': f'不支持的文件格式,支持格式: {", ".join(allowed_extensions)}'
}
# 检查文件大小
file_obj.seek(0, 2) # 移动到文件末尾
file_size = file_obj.tell()
file_obj.seek(0) # 重置文件指针
if file_size > max_size:
max_size_mb = max_size / (1024 * 1024)
return {'valid': False, 'error': f'文件大小不能超过 {max_size_mb:.1f}MB'}
# 验证文件内容类型(防止恶意文件)
try:
file_content = file_obj.read(1024) # 读取前1KB用于检测
file_obj.seek(0) # 重置文件指针
mime_type = magic.from_buffer(file_content, mime=True)
if file_type == 'image' and not mime_type.startswith('image/'):
return {'valid': False, 'error': '文件内容不是有效的图片格式'}
except Exception:
# 如果magic检测失败继续处理某些环境可能没有libmagic
pass
return {'valid': True, 'filename': filename, 'size': file_size}
@staticmethod
def process_image(file_obj, max_width=1200, max_height=1200, quality=None):
"""
处理图片压缩调整尺寸
Args:
file_obj: 图片文件对象
max_width: 最大宽度
max_height: 最大高度
quality: 压缩质量
Returns:
BytesIO: 处理后的图片数据
"""
try:
# 打开图片
image = Image.open(file_obj)
# 转换RGBA到RGB处理PNG透明背景
if image.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', image.size, (255, 255, 255))
if image.mode == 'P':
image = image.convert('RGBA')
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
image = background
# 调整图片尺寸
if image.width > max_width or image.height > max_height:
image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
# 保存处理后的图片
output = BytesIO()
image.save(
output,
format='JPEG',
quality=quality or COSConfig.IMAGE_QUALITY,
optimize=True
)
output.seek(0)
return output
except Exception as e:
raise Exception(f"图片处理失败: {str(e)}")
@staticmethod
def upload_image(file_obj, folder_type='temp', process_image=True):
"""
上传图片到COS
Args:
file_obj: 图片文件对象
folder_type: 存储文件夹类型
process_image: 是否处理图片
Returns:
dict: 上传结果
"""
# 验证文件
validation = FileUploadHandler.validate_file(file_obj, 'image')
if not validation['valid']:
return {
'success': False,
'error': validation['error'],
'url': None,
'file_key': None
}
try:
# 处理图片
if process_image:
processed_file = FileUploadHandler.process_image(file_obj)
upload_file = processed_file
else:
file_obj.seek(0)
upload_file = file_obj
# 上传到COS
result = cos_client.upload_file(
upload_file,
folder_type,
validation['filename']
)
return result
except Exception as e:
return {
'success': False,
'error': f"上传失败: {str(e)}",
'url': None,
'file_key': None
}
@staticmethod
def upload_file(file_obj, folder_type='temp'):
"""
上传普通文件到COS
Args:
file_obj: 文件对象
folder_type: 存储文件夹类型
Returns:
dict: 上传结果
"""
# 验证文件
validation = FileUploadHandler.validate_file(file_obj, 'file')
if not validation['valid']:
return {
'success': False,
'error': validation['error'],
'url': None,
'file_key': None
}
try:
file_obj.seek(0)
# 上传到COS
result = cos_client.upload_file(
file_obj,
folder_type,
validation['filename']
)
return result
except Exception as e:
return {
'success': False,
'error': f"上传失败: {str(e)}",
'url': None,
'file_key': None
}
# 创建全局文件上传处理器实例
file_upload_handler = FileUploadHandler()
"""
文件上传处理工具
"""
import os
import magic
from PIL import Image
from io import BytesIO
from werkzeug.utils import secure_filename
from config.cos_config import COSConfig
from .cos_client import cos_client
class FileUploadHandler:
"""文件上传处理器"""
@staticmethod
def validate_file(file_obj, file_type='image'):
"""
验证文件
Args:
file_obj: 文件对象
file_type: 文件类型 (image, file)
Returns:
dict: 验证结果
"""
if not file_obj or not file_obj.filename:
return {'valid': False, 'error': '请选择文件'}
# 检查文件扩展名
filename = secure_filename(file_obj.filename)
if '.' not in filename:
return {'valid': False, 'error': '文件格式不正确'}
file_ext = filename.rsplit('.', 1)[1].lower()
if file_type == 'image':
allowed_extensions = COSConfig.ALLOWED_IMAGE_EXTENSIONS
max_size = COSConfig.MAX_IMAGE_SIZE
else:
allowed_extensions = COSConfig.ALLOWED_FILE_EXTENSIONS
max_size = COSConfig.MAX_FILE_SIZE
if file_ext not in allowed_extensions:
return {
'valid': False,
'error': f'不支持的文件格式,支持格式: {", ".join(allowed_extensions)}'
}
# 检查文件大小
file_obj.seek(0, 2) # 移动到文件末尾
file_size = file_obj.tell()
file_obj.seek(0) # 重置文件指针
if file_size > max_size:
max_size_mb = max_size / (1024 * 1024)
return {'valid': False, 'error': f'文件大小不能超过 {max_size_mb:.1f}MB'}
# 验证文件内容类型(防止恶意文件)
try:
file_content = file_obj.read(1024) # 读取前1KB用于检测
file_obj.seek(0) # 重置文件指针
mime_type = magic.from_buffer(file_content, mime=True)
if file_type == 'image' and not mime_type.startswith('image/'):
return {'valid': False, 'error': '文件内容不是有效的图片格式'}
except Exception:
# 如果magic检测失败继续处理某些环境可能没有libmagic
pass
return {'valid': True, 'filename': filename, 'size': file_size}
@staticmethod
def process_image(file_obj, max_width=1200, max_height=1200, quality=None):
"""
处理图片压缩调整尺寸
Args:
file_obj: 图片文件对象
max_width: 最大宽度
max_height: 最大高度
quality: 压缩质量
Returns:
BytesIO: 处理后的图片数据
"""
try:
# 打开图片
image = Image.open(file_obj)
# 转换RGBA到RGB处理PNG透明背景
if image.mode in ('RGBA', 'LA', 'P'):
background = Image.new('RGB', image.size, (255, 255, 255))
if image.mode == 'P':
image = image.convert('RGBA')
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
image = background
# 调整图片尺寸
if image.width > max_width or image.height > max_height:
image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
# 保存处理后的图片
output = BytesIO()
image.save(
output,
format='JPEG',
quality=quality or COSConfig.IMAGE_QUALITY,
optimize=True
)
output.seek(0)
return output
except Exception as e:
raise Exception(f"图片处理失败: {str(e)}")
@staticmethod
def upload_image(file_obj, folder_type='temp', process_image=True):
"""
上传图片到COS
Args:
file_obj: 图片文件对象
folder_type: 存储文件夹类型
process_image: 是否处理图片
Returns:
dict: 上传结果
"""
# 验证文件
validation = FileUploadHandler.validate_file(file_obj, 'image')
if not validation['valid']:
return {
'success': False,
'error': validation['error'],
'url': None,
'file_key': None
}
try:
# 处理图片
if process_image:
processed_file = FileUploadHandler.process_image(file_obj)
upload_file = processed_file
else:
file_obj.seek(0)
upload_file = file_obj
# 上传到COS
result = cos_client.upload_file(
upload_file,
folder_type,
validation['filename']
)
return result
except Exception as e:
return {
'success': False,
'error': f"上传失败: {str(e)}",
'url': None,
'file_key': None
}
@staticmethod
def upload_file(file_obj, folder_type='temp'):
"""
上传普通文件到COS
Args:
file_obj: 文件对象
folder_type: 存储文件夹类型
Returns:
dict: 上传结果
"""
# 验证文件
validation = FileUploadHandler.validate_file(file_obj, 'file')
if not validation['valid']:
return {
'success': False,
'error': validation['error'],
'url': None,
'file_key': None
}
try:
file_obj.seek(0)
# 上传到COS
result = cos_client.upload_file(
file_obj,
folder_type,
validation['filename']
)
return result
except Exception as e:
return {
'success': False,
'error': f"上传失败: {str(e)}",
'url': None,
'file_key': None
}
# 创建全局文件上传处理器实例
file_upload_handler = FileUploadHandler()

0
app/utils/helpers.py Normal file
View File

0
app/utils/sms.py Normal file
View File

0
app/utils/wechat_pay.py Normal file
View File

0
app/views/__init__.py Normal file
View File

220
app/views/address.py Normal file
View File

@ -0,0 +1,220 @@
"""
地址管理视图
"""
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for, flash
from app.models.address import UserAddress
from app.models.user import User
from app.forms import AddressForm
from app.utils.decorators import login_required
from config.database import db
address_bp = Blueprint('address', __name__, url_prefix='/address')
@address_bp.route('/')
@login_required
def index():
"""地址管理页面"""
user_id = session['user_id']
addresses = UserAddress.get_user_addresses(user_id)
return render_template('user/addresses.html', addresses=addresses)
@address_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add():
"""添加地址"""
form = AddressForm()
if request.method == 'POST':
# 手动验证必填字段
if not all([
form.receiver_name.data,
form.receiver_phone.data,
form.province.data,
form.city.data,
form.district.data,
form.detail_address.data
]):
flash('请填写所有必填信息', 'error')
return render_template('user/address_form.html', form=form, action='add')
# 验证手机号格式
import re
if not re.match(r'^1[3-9]\d{9}$', form.receiver_phone.data):
flash('请输入有效的手机号', 'error')
return render_template('user/address_form.html', form=form, action='add')
try:
user_id = session['user_id']
# 如果是第一个地址或设为默认,处理默认地址
if form.is_default.data or not UserAddress.query.filter_by(user_id=user_id).first():
UserAddress.query.filter_by(user_id=user_id).update({'is_default': 0})
is_default = 1
else:
is_default = 0
address = UserAddress(
user_id=user_id,
receiver_name=form.receiver_name.data.strip(),
receiver_phone=form.receiver_phone.data.strip(),
province=form.province.data.strip(),
city=form.city.data.strip(),
district=form.district.data.strip(),
detail_address=form.detail_address.data.strip(),
postal_code=form.postal_code.data.strip() if form.postal_code.data else None,
is_default=is_default
)
db.session.add(address)
db.session.commit()
flash('地址添加成功', 'success')
return redirect(url_for('address.index'))
except Exception as e:
db.session.rollback()
flash(f'添加失败: {str(e)}', 'error')
return render_template('user/address_form.html', form=form, action='add')
@address_bp.route('/edit/<int:address_id>', methods=['GET', 'POST'])
@login_required
def edit(address_id):
"""编辑地址"""
user_id = session['user_id']
address = UserAddress.query.filter_by(id=address_id, user_id=user_id).first_or_404()
form = AddressForm()
if request.method == 'GET':
# 预填充表单数据
form.receiver_name.data = address.receiver_name
form.receiver_phone.data = address.receiver_phone
form.province.data = address.province
form.city.data = address.city
form.district.data = address.district
form.detail_address.data = address.detail_address
form.postal_code.data = address.postal_code
form.is_default.data = bool(address.is_default)
elif request.method == 'POST':
# 手动验证必填字段
if not all([
form.receiver_name.data,
form.receiver_phone.data,
form.province.data,
form.city.data,
form.district.data,
form.detail_address.data
]):
flash('请填写所有必填信息', 'error')
return render_template('user/address_form.html', form=form, action='edit', address=address)
# 验证手机号格式
import re
if not re.match(r'^1[3-9]\d{9}$', form.receiver_phone.data):
flash('请输入有效的手机号', 'error')
return render_template('user/address_form.html', form=form, action='edit', address=address)
try:
# 如果设为默认地址,先取消其他默认地址
if form.is_default.data and not address.is_default:
UserAddress.query.filter_by(user_id=user_id).update({'is_default': 0})
address.is_default = 1
elif not form.is_default.data and address.is_default:
# 如果取消当前默认地址,需要检查是否还有其他地址
other_addresses = UserAddress.query.filter(
UserAddress.user_id == user_id,
UserAddress.id != address_id
).first()
if other_addresses:
address.is_default = 0
else:
flash('至少需要保留一个默认地址', 'warning')
return render_template('user/address_form.html', form=form, action='edit', address=address)
# 更新地址信息
address.receiver_name = form.receiver_name.data.strip()
address.receiver_phone = form.receiver_phone.data.strip()
address.province = form.province.data.strip()
address.city = form.city.data.strip()
address.district = form.district.data.strip()
address.detail_address = form.detail_address.data.strip()
address.postal_code = form.postal_code.data.strip() if form.postal_code.data else None
db.session.commit()
flash('地址更新成功', 'success')
return redirect(url_for('address.index'))
except Exception as e:
db.session.rollback()
flash(f'更新失败: {str(e)}', 'error')
return render_template('user/address_form.html', form=form, action='edit', address=address)
@address_bp.route('/delete/<int:address_id>', methods=['POST'])
@login_required
def delete(address_id):
"""删除地址"""
try:
user_id = session['user_id']
address = UserAddress.query.filter_by(id=address_id, user_id=user_id).first()
if not address:
return jsonify({'success': False, 'message': '地址不存在'})
# 检查是否是唯一地址
address_count = UserAddress.query.filter_by(user_id=user_id).count()
if address_count <= 1:
return jsonify({'success': False, 'message': '至少需要保留一个地址'})
# 如果删除的是默认地址,需要设置新的默认地址
if address.is_default:
other_address = UserAddress.query.filter(
UserAddress.user_id == user_id,
UserAddress.id != address_id
).first()
if other_address:
other_address.is_default = 1
db.session.delete(address)
db.session.commit()
return jsonify({'success': True, 'message': '地址删除成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'删除失败: {str(e)}'})
@address_bp.route('/set_default/<int:address_id>', methods=['POST'])
@login_required
def set_default(address_id):
"""设置默认地址"""
try:
user_id = session['user_id']
success = UserAddress.set_default_address(user_id, address_id)
if success:
return jsonify({'success': True, 'message': '默认地址设置成功'})
else:
return jsonify({'success': False, 'message': '地址不存在'})
except Exception as e:
return jsonify({'success': False, 'message': f'设置失败: {str(e)}'})
@address_bp.route('/api/list')
@login_required
def api_list():
"""获取用户地址列表API"""
user_id = session['user_id']
addresses = UserAddress.get_user_addresses(user_id)
return jsonify({
'success': True,
'addresses': [addr.to_dict() for addr in addresses]
})

249
app/views/admin.py Normal file
View File

@ -0,0 +1,249 @@
"""
管理员视图
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify, g
from werkzeug.security import generate_password_hash
from app.models.admin import AdminUser
from app.models.user import User
from app.models.operation_log import OperationLog
from app.utils.decorators import admin_required, log_operation
from config.database import db
from datetime import datetime, timedelta
from sqlalchemy import func
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@admin_bp.route('/login', methods=['GET', 'POST'])
def login():
"""管理员登录"""
if request.method == 'POST':
username = request.form.get('username', '').strip()
password = request.form.get('password', '').strip()
if not username or not password:
flash('请输入用户名和密码', 'error')
return render_template('admin/login.html')
# 查找管理员
admin = AdminUser.query.filter_by(username=username).first()
if not admin or not admin.check_password(password):
flash('用户名或密码错误', 'error')
return render_template('admin/login.html')
if admin.status != 1:
flash('账号已被禁用,请联系系统管理员', 'error')
return render_template('admin/login.html')
# 登录成功
session['admin_id'] = admin.id
session['admin_username'] = admin.username
# 更新最后登录时间
admin.update_last_login()
# 记录登录日志
try:
OperationLog.create_log(
user_id=admin.id,
user_type=2,
action='管理员登录',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
except Exception as e:
print(f"记录登录日志失败: {str(e)}")
flash('登录成功', 'success')
return redirect(url_for('admin.dashboard'))
return render_template('admin/login.html')
@admin_bp.route('/logout')
@admin_required
@log_operation('管理员登出')
def logout():
"""管理员登出"""
session.pop('admin_id', None)
session.pop('admin_username', None)
flash('已安全退出', 'info')
return redirect(url_for('admin.login'))
@admin_bp.route('/dashboard')
@admin_required
def dashboard():
"""管理员仪表板"""
try:
# 获取统计数据
stats = {
'total_users': User.query.count(),
'active_users': User.query.filter_by(status=1).count(),
'total_admins': AdminUser.query.count(),
'recent_logs_count': OperationLog.query.filter(
OperationLog.created_at >= datetime.now() - timedelta(days=7)
).count()
}
# 获取最近的操作日志
recent_logs = OperationLog.query.order_by(
OperationLog.created_at.desc()
).limit(10).all()
# 用户注册趋势最近7天
user_trend = []
for i in range(6, -1, -1):
date = datetime.now() - timedelta(days=i)
date_start = date.replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start + timedelta(days=1)
count = User.query.filter(
User.created_at >= date_start,
User.created_at < date_end
).count()
user_trend.append({
'date': date.strftime('%m-%d'),
'count': count
})
return render_template('admin/dashboard.html',
stats=stats,
recent_logs=recent_logs,
user_trend=user_trend)
except Exception as e:
flash(f'加载仪表板数据失败: {str(e)}', 'error')
return render_template('admin/dashboard.html',
stats={},
recent_logs=[],
user_trend=[])
@admin_bp.route('/profile')
@admin_required
def profile():
"""管理员个人资料"""
return render_template('admin/profile.html', admin=g.current_admin)
@admin_bp.route('/profile/edit', methods=['POST'])
@admin_required
@log_operation('修改管理员资料')
def edit_profile():
"""编辑管理员个人资料"""
try:
real_name = request.form.get('real_name', '').strip()
email = request.form.get('email', '').strip()
phone = request.form.get('phone', '').strip()
# 更新信息
if real_name:
g.current_admin.real_name = real_name
if email:
g.current_admin.email = email
if phone:
g.current_admin.phone = phone
db.session.commit()
flash('个人资料更新成功', 'success')
except Exception as e:
db.session.rollback()
flash(f'更新失败: {str(e)}', 'error')
return redirect(url_for('admin.profile'))
@admin_bp.route('/change-password', methods=['POST'])
@admin_required
@log_operation('修改管理员密码')
def change_password():
"""修改管理员密码"""
try:
current_password = request.form.get('current_password', '').strip()
new_password = request.form.get('new_password', '').strip()
confirm_password = request.form.get('confirm_password', '').strip()
# 验证当前密码
if not g.current_admin.check_password(current_password):
flash('当前密码错误', 'error')
return redirect(url_for('admin.profile'))
# 验证新密码
if len(new_password) < 6:
flash('新密码长度至少6位', 'error')
return redirect(url_for('admin.profile'))
if new_password != confirm_password:
flash('新密码和确认密码不一致', 'error')
return redirect(url_for('admin.profile'))
# 更新密码
g.current_admin.set_password(new_password)
db.session.commit()
flash('密码修改成功', 'success')
except Exception as e:
db.session.rollback()
flash(f'密码修改失败: {str(e)}', 'error')
return redirect(url_for('admin.profile'))
@admin_bp.route('/users')
@admin_required
def users():
"""用户管理"""
page = request.args.get('page', 1, type=int)
per_page = 20
query = User.query.order_by(User.created_at.desc())
# 搜索功能
search = request.args.get('search', '').strip()
if search:
query = query.filter(
db.or_(
User.username.like(f'%{search}%'),
User.email.like(f'%{search}%'),
User.phone.like(f'%{search}%'),
User.nickname.like(f'%{search}%')
)
)
# 状态筛选
status = request.args.get('status', '', type=str)
if status:
query = query.filter(User.status == int(status))
users = query.paginate(page=page, per_page=per_page, error_out=False)
return render_template('admin/users.html', users=users, search=search, status=status)
@admin_bp.route('/logs')
@admin_required
def logs():
"""操作日志"""
page = request.args.get('page', 1, type=int)
per_page = 50
query = OperationLog.query.order_by(OperationLog.created_at.desc())
# 用户类型筛选
user_type = request.args.get('user_type', '', type=str)
if user_type:
query = query.filter(OperationLog.user_type == int(user_type))
# 操作类型筛选
action = request.args.get('action', '').strip()
if action:
query = query.filter(OperationLog.action.like(f'%{action}%'))
logs = query.paginate(page=page, per_page=per_page, error_out=False)
return render_template('admin/logs.html', logs=logs, user_type=user_type, action=action)

141
app/views/auth.py Normal file
View File

@ -0,0 +1,141 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify
from app.forms import LoginForm, RegisterForm
from app.models.user import User
from app.models.verification import EmailVerification
from app.utils.email_service import send_verification_email
from config.database import db
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""用户登录"""
if 'user_id' in session:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
username = form.username.data
password = form.password.data
# 支持用户名、手机号、邮箱登录
user = User.query.filter(
(User.username == username) |
(User.phone == username) |
(User.email == username)
).first()
if user and user.check_password(password):
if user.status == 0:
flash('账户已被禁用,请联系管理员', 'error')
return render_template('user/login.html', form=form)
# 登录成功设置session
session['user_id'] = user.id
session['username'] = user.username
session['nickname'] = user.nickname or user.username
session.permanent = form.remember_me.data
flash(f'欢迎回来,{user.nickname or user.username}', 'success')
# 获取登录前的页面
next_page = request.args.get('next')
if next_page:
return redirect(next_page)
return redirect(url_for('main.index'))
else:
flash('用户名或密码错误', 'error')
return render_template('user/login.html', form=form)
@auth_bp.route('/send_email_code', methods=['POST'])
def send_email_code():
"""发送邮箱验证码"""
try:
data = request.get_json()
email = data.get('email')
code_type = data.get('type', 1) # 默认为注册类型
if not email:
return jsonify({'success': False, 'message': '邮箱地址不能为空'})
# 检查邮箱格式
import re
email_pattern = r'^[^\s@]+@[^\s@]+\.[^\s@]+$'
if not re.match(email_pattern, email):
return jsonify({'success': False, 'message': '邮箱格式不正确'})
# 如果是注册,检查邮箱是否已被注册
if code_type == 1:
existing_user = User.query.filter_by(email=email).first()
if existing_user:
return jsonify({'success': False, 'message': '该邮箱已被注册'})
# 检查是否频繁发送1分钟内只能发送一次
from datetime import datetime, timedelta
recent_code = EmailVerification.query.filter_by(
email=email,
type=code_type
).filter(
EmailVerification.created_at > datetime.utcnow() - timedelta(minutes=1)
).first()
if recent_code:
return jsonify({'success': False, 'message': '发送过于频繁,请稍后再试'})
# 创建验证码
verification = EmailVerification.create_verification(email, code_type)
# 发送邮件
send_verification_email(email, verification.code, code_type)
return jsonify({'success': True, 'message': '验证码已发送'})
except Exception as e:
print(f"发送邮箱验证码错误: {e}")
return jsonify({'success': False, 'message': '发送失败,请重试'})
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
"""用户注册"""
if 'user_id' in session:
return redirect(url_for('main.index'))
form = RegisterForm()
if form.validate_on_submit():
try:
# 验证邮箱验证码
if not EmailVerification.verify_code(form.email.data, form.email_code.data, 1):
flash('邮箱验证码错误或已过期', 'error')
return render_template('user/register.html', form=form)
user = User(
username=form.username.data,
email=form.email.data,
phone=form.phone.data,
nickname=form.username.data
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('注册成功!请登录', 'success')
return redirect(url_for('auth.login'))
except Exception as e:
db.session.rollback()
flash('注册失败,请重试', 'error')
print(f"注册错误: {e}")
return render_template('user/register.html', form=form)
@auth_bp.route('/logout')
def logout():
"""用户登出"""
session.clear()
flash('您已成功登出', 'info')
return redirect(url_for('main.index'))

245
app/views/cart.py Normal file
View File

@ -0,0 +1,245 @@
"""
购物车视图
"""
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for, flash
from app.models.cart import Cart
from app.models.product import Product, ProductInventory
from app.models.user import User
from app.utils.decorators import login_required
from config.database import db
cart_bp = Blueprint('cart', __name__, url_prefix='/cart')
@cart_bp.route('/')
@login_required
def index():
"""购物车页面"""
user_id = session['user_id']
cart_items = Cart.get_user_cart(user_id)
# 计算总价和可用商品数量
total_price = 0
available_count = 0
for item in cart_items:
if item.is_available():
total_price += item.get_total_price()
available_count += 1
return render_template('cart/index.html',
cart_items=cart_items,
total_price=total_price,
available_count=available_count)
@cart_bp.route('/add', methods=['POST'])
@login_required
def add():
"""添加商品到购物车"""
try:
user_id = session['user_id']
product_id = request.json.get('product_id')
sku_code = request.json.get('sku_code')
spec_combination = request.json.get('spec_combination', '')
quantity = request.json.get('quantity', 1)
# 验证参数
if not product_id or quantity <= 0:
return jsonify({'success': False, 'message': '参数错误'})
# 检查商品是否存在且上架
product = Product.query.filter_by(id=product_id, status=1).first()
if not product:
return jsonify({'success': False, 'message': '商品不存在或已下架'})
# 检查库存
if sku_code:
sku_info = ProductInventory.query.filter_by(sku_code=sku_code).first()
if not sku_info:
return jsonify({'success': False, 'message': 'SKU不存在'})
if sku_info.stock < quantity:
return jsonify({'success': False, 'message': f'库存不足,仅剩{sku_info.stock}'})
else:
# 如果没有指定SKU检查默认库存
default_sku = ProductInventory.query.filter_by(
product_id=product_id,
is_default=1
).first()
if default_sku and default_sku.stock < quantity:
return jsonify({'success': False, 'message': f'库存不足,仅剩{default_sku.stock}'})
# 添加到购物车
Cart.add_to_cart(
user_id=user_id,
product_id=product_id,
sku_code=sku_code,
spec_combination=spec_combination,
quantity=quantity
)
# 获取购物车数量
cart_count = Cart.get_cart_count(user_id)
return jsonify({
'success': True,
'message': '已添加到购物车',
'cart_count': cart_count
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'添加失败: {str(e)}'})
@cart_bp.route('/update', methods=['POST'])
@login_required
def update():
"""更新购物车商品数量"""
try:
user_id = session['user_id']
cart_id = request.json.get('cart_id')
quantity = request.json.get('quantity')
if not cart_id or quantity is None or quantity < 0:
return jsonify({'success': False, 'message': '参数错误'})
# 获取购物车项目
cart_item = Cart.query.filter_by(id=cart_id, user_id=user_id).first()
if not cart_item:
return jsonify({'success': False, 'message': '购物车项目不存在'})
if quantity == 0:
# 删除商品
db.session.delete(cart_item)
else:
# 检查库存
if cart_item.get_stock() < quantity:
return jsonify({
'success': False,
'message': f'库存不足,仅剩{cart_item.get_stock()}'
})
# 更新数量
cart_item.quantity = quantity
cart_item.updated_at = db.func.now()
db.session.commit()
# 返回更新后的信息
cart_count = Cart.get_cart_count(user_id)
total_price = Cart.get_cart_total(user_id)
return jsonify({
'success': True,
'message': '更新成功',
'cart_count': cart_count,
'total_price': total_price,
'item_total': cart_item.get_total_price() if quantity > 0 else 0
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'更新失败: {str(e)}'})
@cart_bp.route('/remove', methods=['POST'])
@login_required
def remove():
"""删除购物车商品"""
try:
user_id = session['user_id']
cart_id = request.json.get('cart_id')
if not cart_id:
return jsonify({'success': False, 'message': '参数错误'})
# 获取购物车项目
cart_item = Cart.query.filter_by(id=cart_id, user_id=user_id).first()
if not cart_item:
return jsonify({'success': False, 'message': '购物车项目不存在'})
db.session.delete(cart_item)
db.session.commit()
# 返回更新后的信息
cart_count = Cart.get_cart_count(user_id)
total_price = Cart.get_cart_total(user_id)
return jsonify({
'success': True,
'message': '删除成功',
'cart_count': cart_count,
'total_price': total_price
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'删除失败: {str(e)}'})
@cart_bp.route('/clear', methods=['POST'])
@login_required
def clear():
"""清空购物车"""
try:
user_id = session['user_id']
Cart.query.filter_by(user_id=user_id).delete()
db.session.commit()
return jsonify({
'success': True,
'message': '购物车已清空'
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'清空失败: {str(e)}'})
@cart_bp.route('/count')
@login_required
def count():
"""获取购物车商品数量"""
user_id = session['user_id']
cart_count = Cart.get_cart_count(user_id)
return jsonify({'cart_count': cart_count})
@cart_bp.route('/checkout')
@login_required
def checkout():
"""去结算"""
user_id = session['user_id']
selected_items = request.args.getlist('items')
if not selected_items:
flash('请选择要购买的商品', 'error')
return redirect(url_for('cart.index'))
# 获取选中的购物车项目
cart_items = Cart.query.filter(
Cart.id.in_(selected_items),
Cart.user_id == user_id
).all()
if not cart_items:
flash('选中的商品不存在', 'error')
return redirect(url_for('cart.index'))
# 检查商品可用性
unavailable_items = []
for item in cart_items:
if not item.is_available():
unavailable_items.append(item.product.name)
if unavailable_items:
flash(f'以下商品库存不足或已下架:{", ".join(unavailable_items)}', 'error')
return redirect(url_for('cart.index'))
# 跳转到订单结算页面
items_param = '&'.join([f'items={item_id}' for item_id in selected_items])
return redirect(url_for('order.checkout') + '?' + items_param)

186
app/views/main.py Normal file
View File

@ -0,0 +1,186 @@
"""
主页面视图
"""
from flask import Blueprint, render_template, session, current_app, request, redirect, url_for
from app.models.user import User
from app.models.product import Product, Category
from sqlalchemy import func
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""首页"""
user = None
if 'user_id' in session:
try:
user = User.query.get(session['user_id'])
if user and user.status != 1:
# 用户被禁用清除session
session.pop('user_id', None)
user = None
except Exception as e:
current_app.logger.error(f"获取用户信息失败: {str(e)}")
session.pop('user_id', None)
user = None
# 获取热门商品按销量排序取前8个
hot_products = Product.query.filter_by(status=1)\
.order_by(Product.sales_count.desc())\
.limit(8).all()
# 获取最新商品按创建时间排序取前8个
new_products = Product.query.filter_by(status=1)\
.order_by(Product.created_at.desc())\
.limit(8).all()
# 获取活跃的顶级分类(用于导航)
top_categories = Category.query.filter_by(is_active=1, parent_id=0)\
.order_by(Category.sort_order)\
.limit(6).all()
return render_template('index.html',
user=user,
hot_products=hot_products,
new_products=new_products,
top_categories=top_categories)
@main_bp.route('/products')
def product_list():
"""商品列表页面"""
page = request.args.get('page', 1, type=int)
per_page = 20
# 基础查询:只显示上架商品
query = Product.query.filter_by(status=1)
# 分类筛选
category_id = request.args.get('category_id', type=int)
if category_id:
# 获取该分类及其所有子分类的商品
category = Category.query.get_or_404(category_id)
if category.level == 1: # 一级分类,查找所有子分类
subcategory_ids = [c.id for c in Category.query.filter_by(parent_id=category_id).all()]
subcategory_ids.append(category_id)
query = query.filter(Product.category_id.in_(subcategory_ids))
else:
query = query.filter_by(category_id=category_id)
# 搜索功能
search = request.args.get('search', '').strip()
if search:
query = query.filter(Product.name.like(f'%{search}%'))
# 价格筛选
min_price = request.args.get('min_price', type=float)
max_price = request.args.get('max_price', type=float)
if min_price is not None:
query = query.filter(Product.price >= min_price)
if max_price is not None:
query = query.filter(Product.price <= max_price)
# 排序
sort = request.args.get('sort', 'default')
if sort == 'price_asc':
query = query.order_by(Product.price.asc())
elif sort == 'price_desc':
query = query.order_by(Product.price.desc())
elif sort == 'sales':
query = query.order_by(Product.sales_count.desc())
elif sort == 'newest':
query = query.order_by(Product.created_at.desc())
else: # default
query = query.order_by(Product.created_at.desc())
# 分页
products = query.paginate(page=page, per_page=per_page, error_out=False)
# 获取所有分类用于侧边栏
categories = Category.query.filter_by(is_active=1, parent_id=0)\
.order_by(Category.sort_order).all()
# 当前分类信息
current_category = None
if category_id:
current_category = Category.query.get(category_id)
return render_template('product/list.html',
products=products,
categories=categories,
current_category=current_category,
search=search,
category_id=category_id,
sort=sort,
min_price=min_price,
max_price=max_price)
@main_bp.route('/products/<int:product_id>')
def product_detail(product_id):
"""商品详情页面"""
product = Product.query.filter_by(id=product_id, status=1).first_or_404()
# 增加浏览量
try:
product.view_count += 1
from config.database import db
db.session.commit()
except Exception as e:
current_app.logger.error(f"更新浏览量失败: {str(e)}")
# 获取商品图片(按排序)
images = product.images
if images:
images = sorted(images, key=lambda x: x.sort_order)
# 获取商品库存信息并转换为字典
inventory_list = product.inventory
inventory_data = []
if inventory_list:
for inventory in inventory_list:
inventory_data.append({
'id': inventory.id,
'sku_code': inventory.sku_code,
'spec_combination': inventory.spec_combination,
'price_adjustment': float(inventory.price_adjustment) if inventory.price_adjustment else 0,
'stock': inventory.stock,
'warning_stock': inventory.warning_stock,
'is_default': inventory.is_default,
'status': inventory.status,
'final_price': inventory.get_final_price()
})
# 获取推荐商品(同分类的其他商品)
recommended_products = Product.query.filter(
Product.category_id == product.category_id,
Product.id != product.id,
Product.status == 1
).order_by(Product.sales_count.desc()).limit(4).all()
return render_template('product/detail.html',
product=product,
images=images,
inventory_list=inventory_list,
inventory_data=inventory_data,
recommended_products=recommended_products)
@main_bp.route('/category/<int:category_id>')
def category_products(category_id):
"""分类商品页面(重定向到商品列表)"""
return redirect(url_for('main.product_list', category_id=category_id))
@main_bp.route('/search')
def search():
"""搜索页面(重定向到商品列表)"""
search_query = request.args.get('q', '').strip()
return redirect(url_for('main.product_list', search=search_query))
@main_bp.route('/about')
def about():
"""关于我们"""
return render_template('about.html')

340
app/views/order.py Normal file
View File

@ -0,0 +1,340 @@
"""
订单视图
"""
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for, flash, g
from app.models.order import Order, OrderItem
from app.models.cart import Cart
from app.models.address import UserAddress
from app.models.product import ProductInventory
from app.models.payment import Payment
from app.forms import CheckoutForm
from app.utils.decorators import login_required
from config.database import db
import json
order_bp = Blueprint('order', __name__, url_prefix='/order')
@order_bp.route('/checkout')
@login_required
def checkout():
"""订单结算页面"""
user_id = session['user_id']
selected_items = request.args.getlist('items')
if not selected_items:
flash('请选择要购买的商品', 'error')
return redirect(url_for('cart.index'))
# 获取选中的购物车项目
cart_items = Cart.query.filter(
Cart.id.in_(selected_items),
Cart.user_id == user_id
).all()
if not cart_items:
flash('选中的商品不存在', 'error')
return redirect(url_for('cart.index'))
# 检查商品可用性和库存
unavailable_items = []
total_amount = 0
for item in cart_items:
if not item.is_available():
unavailable_items.append(item.product.name)
else:
total_amount += item.get_total_price()
if unavailable_items:
flash(f'以下商品库存不足或已下架:{", ".join(unavailable_items)}', 'error')
return redirect(url_for('cart.index'))
# 获取用户地址
addresses = UserAddress.get_user_addresses(user_id)
if not addresses:
flash('请先添加收货地址', 'warning')
return redirect(url_for('address.add'))
# 计算运费
shipping_fee = 0 # 默认免运费
# 创建表单并设置地址选项
form = CheckoutForm()
form.address_id.choices = [(addr.id, f"{addr.receiver_name} - {addr.get_full_address()}")
for addr in addresses]
# 设置默认地址
default_address = UserAddress.get_default_address(user_id)
if default_address:
form.address_id.data = default_address.id
return render_template('order/checkout.html',
cart_items=cart_items,
addresses=addresses,
form=form,
total_amount=total_amount,
shipping_fee=shipping_fee,
final_amount=total_amount + shipping_fee)
@order_bp.route('/create', methods=['POST'])
@login_required
def create():
"""创建订单"""
try:
user_id = session['user_id']
data = request.get_json()
selected_items = data.get('selected_items', [])
address_id = data.get('address_id')
shipping_method = data.get('shipping_method', 'standard')
payment_method = data.get('payment_method', 'wechat')
remark = data.get('remark', '')
if not selected_items or not address_id:
return jsonify({'success': False, 'message': '参数错误'})
# 获取购物车商品
cart_items = Cart.query.filter(
Cart.id.in_(selected_items),
Cart.user_id == user_id
).all()
if not cart_items:
return jsonify({'success': False, 'message': '购物车商品不存在'})
# 验证地址
address = UserAddress.query.filter_by(id=address_id, user_id=user_id).first()
if not address:
return jsonify({'success': False, 'message': '收货地址不存在'})
# 再次检查库存和计算总价
total_amount = 0
order_items_data = []
for cart_item in cart_items:
if not cart_item.is_available():
return jsonify({
'success': False,
'message': f'商品"{cart_item.product.name}"库存不足或已下架'
})
# 检查库存是否足够
current_stock = cart_item.get_stock()
if current_stock < cart_item.quantity:
return jsonify({
'success': False,
'message': f'商品"{cart_item.product.name}"库存不足,仅剩{current_stock}'
})
item_total = cart_item.get_total_price()
total_amount += item_total
order_items_data.append({
'product_id': cart_item.product_id,
'sku_code': cart_item.sku_code,
'product_name': cart_item.product.name,
'product_image': cart_item.product.main_image,
'spec_combination': cart_item.spec_combination,
'price': cart_item.get_price(),
'quantity': cart_item.quantity,
'total_price': item_total
})
# 计算运费
shipping_fee_map = {
'standard': 0,
'express': 10,
'same_day': 20
}
shipping_fee = shipping_fee_map.get(shipping_method, 0)
actual_amount = total_amount + shipping_fee
# 创建订单
order = Order(
user_id=user_id,
order_sn=Order.generate_order_sn(),
total_amount=total_amount,
actual_amount=actual_amount,
shipping_fee=shipping_fee,
payment_method=payment_method,
shipping_method=shipping_method,
remark=remark
)
# 设置收货人信息
order.set_receiver_info({
'receiver_name': address.receiver_name,
'receiver_phone': address.receiver_phone,
'province': address.province,
'city': address.city,
'district': address.district,
'detail_address': address.detail_address,
'postal_code': address.postal_code,
'full_address': address.get_full_address()
})
db.session.add(order)
db.session.flush() # 获取订单ID
# 创建订单商品明细
for item_data in order_items_data:
order_item = OrderItem(
order_id=order.id,
**item_data
)
db.session.add(order_item)
# 扣减库存
for cart_item in cart_items:
if cart_item.sku_code:
sku_info = ProductInventory.query.filter_by(sku_code=cart_item.sku_code).first()
if sku_info:
sku_info.stock -= cart_item.quantity
# 增加销量
cart_item.product.sales_count += cart_item.quantity
# 删除购物车商品
for cart_item in cart_items:
db.session.delete(cart_item)
# 创建支付记录
payment = Payment(
order_id=order.id,
payment_sn=Payment.generate_payment_sn(),
payment_method=payment_method,
amount=actual_amount
)
db.session.add(payment)
db.session.commit()
return jsonify({
'success': True,
'message': '订单创建成功',
'order_id': order.id,
'order_sn': order.order_sn,
'payment_sn': payment.payment_sn
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'创建订单失败: {str(e)}'})
@order_bp.route('/list')
@login_required
def list():
"""订单列表"""
user_id = session['user_id']
status = request.args.get('status', type=int)
page = request.args.get('page', 1, type=int)
per_page = 10
query = Order.query.filter_by(user_id=user_id)
if status:
query = query.filter_by(status=status)
orders = query.order_by(Order.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('user/orders.html', orders=orders, current_status=status)
@order_bp.route('/detail/<int:order_id>')
@login_required
def detail(order_id):
"""订单详情"""
user_id = session['user_id']
order = Order.query.filter_by(id=order_id, user_id=user_id).first_or_404()
return render_template('order/detail.html', order=order)
@order_bp.route('/cancel/<int:order_id>', methods=['POST'])
@login_required
def cancel(order_id):
"""取消订单"""
try:
user_id = session['user_id']
order = Order.query.filter_by(id=order_id, user_id=user_id).first()
if not order:
return jsonify({'success': False, 'message': '订单不存在'})
if not order.can_cancel():
return jsonify({'success': False, 'message': '订单状态不允许取消'})
# 更新订单状态
order.status = Order.STATUS_CANCELLED
# 恢复库存
for item in order.order_items:
if item.sku_code:
sku_info = ProductInventory.query.filter_by(sku_code=item.sku_code).first()
if sku_info:
sku_info.stock += item.quantity
# 减少销量
if item.product:
item.product.sales_count = max(0, item.product.sales_count - item.quantity)
db.session.commit()
return jsonify({'success': True, 'message': '订单已取消'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'取消失败: {str(e)}'})
@order_bp.route('/confirm_receipt/<int:order_id>', methods=['POST'])
@login_required
def confirm_receipt(order_id):
"""确认收货"""
try:
user_id = session['user_id']
order = Order.query.filter_by(id=order_id, user_id=user_id).first()
if not order:
return jsonify({'success': False, 'message': '订单不存在'})
if not order.can_confirm_receipt():
return jsonify({'success': False, 'message': '订单状态不允许确认收货'})
# 更新订单状态
order.status = Order.STATUS_PENDING_REVIEW
order.received_at = db.func.now()
db.session.commit()
return jsonify({'success': True, 'message': '确认收货成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'确认收货失败: {str(e)}'})
@order_bp.route('/pay/<payment_sn>')
@login_required
def pay(payment_sn):
"""支付页面"""
user_id = session['user_id']
payment = Payment.query.filter_by(payment_sn=payment_sn).first_or_404()
order = payment.order
# 验证订单所有权
if order.user_id != user_id:
flash('订单不存在', 'error')
return redirect(url_for('order.list'))
# 检查是否可以支付
if not order.can_pay():
flash('订单不可支付', 'error')
return redirect(url_for('order.detail', order_id=order.id))
return render_template('order/pay.html', order=order, payment=payment)

212
app/views/payment.py Normal file
View File

@ -0,0 +1,212 @@
"""
支付视图
"""
from flask import Blueprint, render_template, request, jsonify, session, redirect, url_for, flash
from app.models.payment import Payment
from app.models.order import Order
from app.utils.decorators import login_required
from config.database import db
from datetime import datetime
payment_bp = Blueprint('payment', __name__, url_prefix='/payment')
@payment_bp.route('/process', methods=['POST'])
@login_required
def process():
"""处理支付请求"""
try:
user_id = session['user_id']
payment_sn = request.json.get('payment_sn')
payment_method = request.json.get('payment_method')
if not payment_sn:
return jsonify({'success': False, 'message': '支付流水号不能为空'})
# 获取支付记录
payment = Payment.query.filter_by(payment_sn=payment_sn).first()
if not payment:
return jsonify({'success': False, 'message': '支付记录不存在'})
order = payment.order
if order.user_id != user_id:
return jsonify({'success': False, 'message': '订单不存在'})
if not order.can_pay():
return jsonify({'success': False, 'message': '订单不可支付'})
# 根据支付方式处理
if payment_method == 'wechat':
# 微信支付
result = process_wechat_pay(payment)
elif payment_method == 'alipay':
# 支付宝支付
result = process_alipay(payment)
elif payment_method == 'bank':
# 银行卡支付
result = process_bank_pay(payment)
else:
return jsonify({'success': False, 'message': '不支持的支付方式'})
return jsonify(result)
except Exception as e:
return jsonify({'success': False, 'message': f'支付处理失败: {str(e)}'})
def process_wechat_pay(payment):
"""处理微信支付"""
# TODO: 接入真实的微信支付API
# 目前返回模拟的支付二维码
# 模拟生成支付二维码数据
qr_code_url = f"weixin://wxpay/bizpayurl?pr={payment.payment_sn}"
return {
'success': True,
'payment_type': 'qrcode',
'qr_code_url': qr_code_url,
'payment_sn': payment.payment_sn,
'amount': float(payment.amount),
'message': '请使用微信扫码支付'
}
def process_alipay(payment):
"""处理支付宝支付"""
# TODO: 接入真实的支付宝API
# 目前返回模拟的跳转链接
pay_url = f"https://mapi.alipay.com/gateway.do?service=create_direct_pay_by_user&payment_sn={payment.payment_sn}"
return {
'success': True,
'payment_type': 'redirect',
'pay_url': pay_url,
'payment_sn': payment.payment_sn,
'amount': float(payment.amount),
'message': '正在跳转到支付宝...'
}
def process_bank_pay(payment):
"""处理银行卡支付"""
# TODO: 接入银行支付网关
# 目前返回模拟的网银链接
bank_url = f"https://pay.bank.com/pay?order={payment.payment_sn}"
return {
'success': True,
'payment_type': 'redirect',
'pay_url': bank_url,
'payment_sn': payment.payment_sn,
'amount': float(payment.amount),
'message': '正在跳转到网银...'
}
@payment_bp.route('/callback/wechat', methods=['POST'])
def wechat_callback():
"""微信支付回调"""
try:
# TODO: 验证微信支付回调签名
# 目前模拟处理
callback_data = request.get_data()
# 解析回调数据,获取支付结果
# 模拟成功的回调处理
return handle_payment_success(request.form.get('payment_sn'), 'wechat_success_' + str(datetime.now().timestamp()))
except Exception as e:
return f"FAIL: {str(e)}"
@payment_bp.route('/callback/alipay', methods=['POST'])
def alipay_callback():
"""支付宝支付回调"""
try:
# TODO: 验证支付宝回调签名
# 目前模拟处理
return handle_payment_success(request.form.get('payment_sn'), 'alipay_success_' + str(datetime.now().timestamp()))
except Exception as e:
return f"FAIL: {str(e)}"
def handle_payment_success(payment_sn, third_party_sn):
"""处理支付成功"""
try:
payment = Payment.query.filter_by(payment_sn=payment_sn).first()
if not payment:
return "FAIL: Payment not found"
if payment.status == Payment.STATUS_SUCCESS:
return "SUCCESS" # 已经处理过的支付
# 更新支付状态
payment.status = Payment.STATUS_SUCCESS
payment.third_party_sn = third_party_sn
payment.paid_at = datetime.utcnow()
# 更新订单状态
order = payment.order
order.status = Order.STATUS_PENDING_SHIPMENT
db.session.commit()
return "SUCCESS"
except Exception as e:
db.session.rollback()
return f"FAIL: {str(e)}"
@payment_bp.route('/check_status/<payment_sn>')
@login_required
def check_status(payment_sn):
"""检查支付状态"""
try:
user_id = session['user_id']
payment = Payment.query.filter_by(payment_sn=payment_sn).first()
if not payment or payment.order.user_id != user_id:
return jsonify({'success': False, 'message': '支付记录不存在'})
return jsonify({
'success': True,
'status': payment.status,
'status_text': payment.get_status_text(),
'paid_at': payment.paid_at.isoformat() if payment.paid_at else None
})
except Exception as e:
return jsonify({'success': False, 'message': f'查询失败: {str(e)}'})
@payment_bp.route('/simulate_success/<payment_sn>', methods=['POST'])
@login_required
def simulate_success(payment_sn):
"""模拟支付成功(开发测试用)"""
try:
user_id = session['user_id']
payment = Payment.query.filter_by(payment_sn=payment_sn).first()
if not payment or payment.order.user_id != user_id:
return jsonify({'success': False, 'message': '支付记录不存在'})
if payment.status == Payment.STATUS_SUCCESS:
return jsonify({'success': False, 'message': '订单已支付'})
# 模拟支付成功
result = handle_payment_success(payment_sn, f'SIMULATE_{datetime.now().timestamp()}')
if result == "SUCCESS":
return jsonify({'success': True, 'message': '支付成功'})
else:
return jsonify({'success': False, 'message': result})
except Exception as e:
return jsonify({'success': False, 'message': f'模拟支付失败: {str(e)}'})

667
app/views/product.py Normal file
View File

@ -0,0 +1,667 @@
"""
商品管理视图
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, g
from werkzeug.utils import secure_filename
from app.models.product import Product, Category, ProductImage, SpecName, SpecValue, ProductInventory
from app.models.admin import AdminUser
from app.utils.decorators import admin_required, log_operation
from app.utils.cos_client import cos_client
from config.database import db
from sqlalchemy import func
import time
import uuid
import json
product_bp = Blueprint('product', __name__, url_prefix='/admin/products')
@product_bp.route('/')
@admin_required
def index():
"""商品列表"""
page = request.args.get('page', 1, type=int)
per_page = 20
query = Product.query.order_by(Product.created_at.desc())
# 搜索功能
search = request.args.get('search', '').strip()
if search:
query = query.filter(Product.name.like(f'%{search}%'))
# 分类筛选
category_id = request.args.get('category_id', '', type=str)
if category_id:
query = query.filter(Product.category_id == int(category_id))
# 状态筛选
status = request.args.get('status', '', type=str)
if status:
query = query.filter(Product.status == int(status))
products = query.paginate(page=page, per_page=per_page, error_out=False)
# 获取所有分类用于筛选
categories = Category.query.filter_by(is_active=1).order_by(Category.sort_order).all()
return render_template('admin/products.html',
products=products,
categories=categories,
search=search,
category_id=category_id,
status=status)
@product_bp.route('/add')
@admin_required
def add():
"""添加商品页面"""
categories = Category.query.filter_by(is_active=1).order_by(Category.sort_order).all()
spec_names = SpecName.query.order_by(SpecName.sort_order).all()
return render_template('admin/product_form.html',
product=None,
categories=categories,
spec_names=spec_names)
@product_bp.route('/edit/<int:product_id>')
@admin_required
def edit(product_id):
"""编辑商品页面"""
product = Product.query.get_or_404(product_id)
categories = Category.query.filter_by(is_active=1).order_by(Category.sort_order).all()
spec_names = SpecName.query.order_by(SpecName.sort_order).all()
return render_template('admin/product_form.html',
product=product,
categories=categories,
spec_names=spec_names)
@product_bp.route('/save', methods=['POST'])
@admin_required
@log_operation('保存商品信息', 'product')
def save():
"""保存商品信息"""
try:
product_id = request.form.get('product_id', type=int)
# 基本信息
name = request.form.get('name', '').strip()
category_id = request.form.get('category_id', type=int)
brand = request.form.get('brand', '').strip()
price = request.form.get('price', type=float)
original_price = request.form.get('original_price', type=float)
description = request.form.get('description', '').strip()
weight = request.form.get('weight', type=float)
status = request.form.get('status', 1, type=int)
# 验证必填字段
if not name or not category_id or not price:
flash('请填写完整的商品基本信息', 'error')
return redirect(request.referrer)
# 创建或更新商品
if product_id:
product = Product.query.get_or_404(product_id)
else:
product = Product()
product.name = name
product.category_id = category_id
product.brand = brand
product.price = price
product.original_price = original_price
product.description = description
product.weight = weight
product.status = status
# 处理库存类型
inventory_type = request.form.get('inventory_type', 'single')
if inventory_type == 'single':
product.has_specs = 0
else:
product.has_specs = 1
if not product_id:
db.session.add(product)
db.session.flush() # 获取product.id
# 处理库存信息
if inventory_type == 'single':
# 单规格处理
single_stock = request.form.get('single_stock', 0, type=int)
warning_stock = request.form.get('warning_stock', 10, type=int)
# 删除现有库存记录(如果是编辑模式)
if product_id:
ProductInventory.query.filter_by(product_id=product.id).delete()
# 创建单个SKU
sku_code = f"{product.name[:3].upper()}-DEFAULT-{product.id}"
inventory = ProductInventory(
product_id=product.id,
sku_code=sku_code,
spec_combination=None,
price_adjustment=0,
stock=single_stock,
warning_stock=warning_stock,
is_default=1,
status=1
)
db.session.add(inventory)
else:
# 多规格处理
sku_codes = request.form.getlist('sku_codes[]')
spec_combinations = request.form.getlist('spec_combinations[]')
price_adjustments = request.form.getlist('price_adjustments[]')
stocks = request.form.getlist('stocks[]')
warning_stocks = request.form.getlist('warning_stocks[]')
default_sku_index = request.form.get('default_sku', 0, type=int)
if not sku_codes:
flash('请至少添加一个SKU', 'error')
return redirect(request.referrer)
# 删除现有库存记录(如果是编辑模式)
if product_id:
ProductInventory.query.filter_by(product_id=product.id).delete()
# 创建多个SKU
for i, sku_code in enumerate(sku_codes):
try:
spec_combination = json.loads(spec_combinations[i]) if spec_combinations[i] else None
except:
spec_combination = None
inventory = ProductInventory(
product_id=product.id,
sku_code=sku_code,
spec_combination=spec_combination,
price_adjustment=float(price_adjustments[i]) if price_adjustments[i] else 0,
stock=int(stocks[i]) if stocks[i] else 0,
warning_stock=int(warning_stocks[i]) if warning_stocks[i] else 10,
is_default=1 if i == default_sku_index else 0,
status=1
)
db.session.add(inventory)
db.session.commit()
flash('商品信息保存成功', 'success')
return redirect(url_for('product.edit', product_id=product.id))
except Exception as e:
db.session.rollback()
flash(f'保存失败: {str(e)}', 'error')
return redirect(request.referrer)
@product_bp.route('/upload-images/<int:product_id>', methods=['POST'])
@admin_required
@log_operation('上传商品图片', 'productdef index():')
def upload_images(product_id):
"""上传商品图片"""
try:
product = Product.query.get_or_404(product_id)
if 'files' not in request.files:
return jsonify({'success': False, 'message': '没有选择文件'})
files = request.files.getlist('files')
uploaded_images = []
for file in files:
if file.filename == '':
continue
# 检查文件类型
allowed_extensions = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
file_ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else ''
if file_ext not in allowed_extensions:
continue
# 上传到COS
result = cos_client.upload_file(file, 'product', file.filename)
if result['success']:
# 检查是否是第一张图片
existing_images_count = ProductImage.query.filter_by(product_id=product_id).count()
is_first_image = (existing_images_count == 0)
# 保存图片记录
image = ProductImage(
product_id=product_id,
image_url=result['url'],
sort_order=existing_images_count,
is_main=1 if is_first_image else 0 # 第一张图片自动设为主图
)
db.session.add(image)
# 如果是第一张图片,同时更新商品主图
if is_first_image:
product.main_image = result['url']
uploaded_images.append({
'id': None, # 临时ID提交后会更新
'url': result['url'],
'sort_order': image.sort_order,
'is_main': is_first_image
})
db.session.commit()
# 更新图片ID
for i, uploaded_image in enumerate(uploaded_images):
image = ProductImage.query.filter_by(
product_id=product_id,
image_url=uploaded_image['url']
).first()
if image:
uploaded_images[i]['id'] = image.id
return jsonify({
'success': True,
'message': f'成功上传 {len(uploaded_images)} 张图片',
'images': uploaded_images
})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'上传失败: {str(e)}'})
@product_bp.route('/delete-image/<int:image_id>', methods=['DELETE'])
@admin_required
@log_operation('删除商品图片', 'product_image')
def delete_image(image_id):
"""删除商品图片"""
try:
image = ProductImage.query.get_or_404(image_id)
# 从COS删除文件
if image.image_url:
file_key = image.image_url.split('/')[-4:] # 提取文件路径
file_key = '/'.join(file_key)
cos_client.delete_file(file_key)
db.session.delete(image)
db.session.commit()
return jsonify({'success': True, 'message': '图片删除成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'删除失败: {str(e)}'})
@product_bp.route('/set-main-image/<int:image_id>', methods=['POST'])
@admin_required
@log_operation('设置主图', 'product')
def set_main_image(image_id):
"""设置主图"""
try:
image = ProductImage.query.get_or_404(image_id)
product = image.product
# 清除当前主图
ProductImage.query.filter_by(product_id=product.id, is_main=1).update({'is_main': 0})
# 设置新主图
image.is_main = 1
product.main_image = image.image_url
db.session.commit()
return jsonify({'success': True, 'message': '主图设置成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'设置失败: {str(e)}'})
@product_bp.route('/sort-images/<int:product_id>', methods=['POST'])
@admin_required
@log_operation('排序商品图片', 'product')
def sort_images(product_id):
"""图片排序"""
try:
image_ids = request.json.get('image_ids', [])
for index, image_id in enumerate(image_ids):
ProductImage.query.filter_by(id=image_id).update({'sort_order': index})
db.session.commit()
return jsonify({'success': True, 'message': '排序保存成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'排序失败: {str(e)}'})
@product_bp.route('/delete/<int:product_id>', methods=['DELETE'])
@admin_required
@log_operation('删除商品', 'product')
def delete(product_id):
"""删除商品"""
try:
product = Product.query.get_or_404(product_id)
# 删除商品图片
for image in product.images:
if image.image_url:
file_key = image.image_url.split('/')[-4:]
file_key = '/'.join(file_key)
cos_client.delete_file(file_key)
# 删除商品记录
db.session.delete(product)
db.session.commit()
return jsonify({'success': True, 'message': '商品删除成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'删除失败: {str(e)}'})
# 分类管理相关路由
@product_bp.route('/categories')
@admin_required
def categories():
"""分类管理"""
categories = Category.query.order_by(Category.sort_order).all()
return render_template('admin/categories.html', categories=categories)
@product_bp.route('/categories/save', methods=['POST'])
@admin_required
@log_operation('保存商品分类', 'category')
def save_category():
"""保存分类"""
try:
category_id = request.form.get('category_id', type=int)
name = request.form.get('name', '').strip()
parent_id = request.form.get('parent_id', 0, type=int)
sort_order = request.form.get('sort_order', 0, type=int)
is_active = request.form.get('is_active', 1, type=int)
if not name:
flash('分类名称不能为空', 'error')
return redirect(url_for('product.categories'))
# 检查分类名称是否重复
existing = Category.query.filter_by(name=name, parent_id=parent_id).first()
if existing and (not category_id or existing.id != category_id):
flash('同一层级下分类名称不能重复', 'error')
return redirect(url_for('product.categories'))
if category_id:
category = Category.query.get_or_404(category_id)
# 防止将分类设为自己的子分类
if parent_id == category_id:
flash('不能将分类设为自己的子分类', 'error')
return redirect(url_for('product.categories'))
# 防止循环引用
if parent_id != 0:
parent = Category.query.get(parent_id)
temp_parent = parent
while temp_parent and temp_parent.parent_id != 0:
if temp_parent.parent_id == category_id:
flash('不能创建循环引用的分类层级', 'error')
return redirect(url_for('product.categories'))
temp_parent = Category.query.get(temp_parent.parent_id)
else:
category = Category()
category.name = name
category.parent_id = parent_id
category.sort_order = sort_order
category.is_active = is_active
# 设置层级
if parent_id == 0:
category.level = 1
else:
parent = Category.query.get(parent_id)
if parent:
category.level = parent.level + 1
if category.level > 3:
flash('分类层级不能超过3级', 'error')
return redirect(url_for('product.categories'))
else:
category.level = 1
# 处理图标上传
if 'icon' in request.files:
icon_file = request.files['icon']
if icon_file and icon_file.filename:
# 删除旧图标
if category.icon_url:
old_file_key = category.icon_url.split('/')[-4:]
old_file_key = '/'.join(old_file_key)
cos_client.delete_file(old_file_key)
# 上传新图标
result = cos_client.upload_file(icon_file, 'category', icon_file.filename)
if result['success']:
category.icon_url = result['url']
else:
flash(f'图标上传失败: {result["error"]}', 'error')
return redirect(url_for('product.categories'))
if not category_id:
db.session.add(category)
db.session.commit()
flash('分类保存成功', 'success')
except Exception as e:
db.session.rollback()
flash(f'保存失败: {str(e)}', 'error')
return redirect(url_for('product.categories'))
@product_bp.route('/categories/<int:category_id>', methods=['GET'])
@admin_required
def get_category(category_id):
"""获取分类详情"""
try:
category = Category.query.get_or_404(category_id)
return jsonify({
'success': True,
'category': category.to_dict()
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@product_bp.route('/categories/<int:category_id>', methods=['DELETE'])
@admin_required
@log_operation('删除商品分类', 'category')
def delete_category(category_id):
"""删除分类"""
try:
category = Category.query.get_or_404(category_id)
# 检查是否有子分类
children = Category.query.filter_by(parent_id=category_id).count()
if children > 0:
return jsonify({'success': False, 'message': '该分类下还有子分类,无法删除'})
# 检查是否有商品使用此分类
products = Product.query.filter_by(category_id=category_id).count()
if products > 0:
return jsonify({'success': False, 'message': f'该分类下还有 {products} 个商品,无法删除'})
# 删除分类图标
if category.icon_url:
file_key = category.icon_url.split('/')[-4:]
file_key = '/'.join(file_key)
cos_client.delete_file(file_key)
db.session.delete(category)
db.session.commit()
return jsonify({'success': True, 'message': '分类删除成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'删除失败: {str(e)}'})
@product_bp.route('/categories/sort', methods=['POST'])
@admin_required
@log_operation('分类排序', 'category')
def sort_categories():
"""分类排序"""
try:
category_orders = request.json.get('orders', [])
for item in category_orders:
category_id = item.get('id')
sort_order = item.get('sort_order')
Category.query.filter_by(id=category_id).update({'sort_order': sort_order})
db.session.commit()
return jsonify({'success': True, 'message': '排序保存成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'排序失败: {str(e)}'})
# 库存管理相关路由
@product_bp.route('/inventory/<int:product_id>')
@admin_required
def get_inventory(product_id):
"""获取商品库存详情"""
try:
product = Product.query.get_or_404(product_id)
inventory_list = ProductInventory.query.filter_by(product_id=product_id) \
.order_by(ProductInventory.is_default.desc(), ProductInventory.id).all()
inventory_data = []
for inventory in inventory_list:
inventory_data.append({
'id': inventory.id,
'sku_code': inventory.sku_code,
'spec_combination': inventory.spec_combination,
'stock': inventory.stock,
'warning_stock': inventory.warning_stock,
'price_adjustment': float(inventory.price_adjustment) if inventory.price_adjustment else 0,
'is_default': inventory.is_default,
'status': inventory.status,
'final_price': inventory.get_final_price()
})
return jsonify({
'success': True,
'inventory': inventory_data,
'product_name': product.name
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@product_bp.route('/inventory/update', methods=['POST'])
@admin_required
@log_operation('更新库存', 'inventory')
def update_inventory():
"""批量更新库存"""
try:
inventory_data = request.json.get('inventory_list', [])
for item in inventory_data:
inventory_id = item.get('id')
new_stock = item.get('stock')
if inventory_id and new_stock is not None:
inventory = ProductInventory.query.get(inventory_id)
if inventory:
old_stock = inventory.stock
inventory.stock = new_stock
# 记录库存变更日志
from app.models.product import InventoryLog
InventoryLog.create_log(
product_id=inventory.product_id,
sku_code=inventory.sku_code,
change_type=3, # 调整
change_quantity=new_stock - old_stock,
before_stock=old_stock,
after_stock=new_stock,
remark='管理员手动调整'
)
db.session.commit()
return jsonify({'success': True, 'message': '库存更新成功'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'message': f'更新失败: {str(e)}'})
@product_bp.route('/inventory/log/<int:product_id>')
@admin_required
def inventory_log(product_id):
"""查看库存变更日志"""
product = Product.query.get_or_404(product_id)
page = request.args.get('page', 1, type=int)
per_page = 20
from app.models.product import InventoryLog
logs = InventoryLog.query.filter_by(product_id=product_id) \
.order_by(InventoryLog.created_at.desc()) \
.paginate(page=page, per_page=per_page, error_out=False)
return render_template('admin/inventory_log.html',
product=product,
logs=logs)
@product_bp.route('/generate-sku-code', methods=['POST'])
@admin_required
def generate_sku_code():
"""生成SKU编码"""
try:
product_name = request.json.get('product_name', '')
spec_combination = request.json.get('spec_combination', {})
# 生成SKU编码逻辑
short_name = product_name[:3].upper() if product_name else 'PRD'
spec_code = ''.join([v[:2].upper() for v in spec_combination.values()]) if spec_combination else 'DEFAULT'
timestamp = str(int(time.time()))[-4:]
sku_code = f"{short_name}-{spec_code}-{timestamp}"
return jsonify({'success': True, 'sku_code': sku_code})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})
@product_bp.route('/check-sku-code', methods=['POST'])
@admin_required
def check_sku_code():
"""检查SKU编码是否重复"""
try:
sku_code = request.json.get('sku_code', '')
product_id = request.json.get('product_id', None)
query = ProductInventory.query.filter_by(sku_code=sku_code)
if product_id:
query = query.filter(ProductInventory.product_id != product_id)
exists = query.first() is not None
return jsonify({
'success': True,
'exists': exists,
'message': 'SKU编码已存在' if exists else 'SKU编码可用'
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)})

185
app/views/upload.py Normal file
View File

@ -0,0 +1,185 @@
"""
文件上传视图
"""
from flask import Blueprint, request, jsonify, session, current_app
from werkzeug.utils import secure_filename
from app.utils.decorators import login_required
from app.models.user import User
from app.utils.cos_client import cos_client
from config.database import db
from config.cos_config import COSConfig
import os
upload_bp = Blueprint('upload', __name__)
@upload_bp.route('/avatar', methods=['POST'])
@login_required
def upload_avatar():
"""
上传用户头像
"""
try:
# 检查是否有文件
if 'avatar' not in request.files:
return jsonify({
'success': False,
'message': '没有选择文件'
}), 400
file = request.files['avatar']
# 检查文件名
if file.filename == '':
return jsonify({
'success': False,
'message': '没有选择文件'
}), 400
# 验证文件类型
if not allowed_file(file.filename, COSConfig.ALLOWED_IMAGE_EXTENSIONS):
return jsonify({
'success': False,
'message': f'不支持的文件格式,只支持: {", ".join(COSConfig.ALLOWED_IMAGE_EXTENSIONS)}'
}), 400
# 验证文件大小
file.seek(0, 2) # 移动到文件末尾
file_size = file.tell()
file.seek(0) # 重置文件指针
if file_size > COSConfig.MAX_IMAGE_SIZE:
size_mb = COSConfig.MAX_IMAGE_SIZE / 1024 / 1024
return jsonify({
'success': False,
'message': f'文件大小超过限制,最大允许 {size_mb:.1f}MB'
}), 400
# 获取当前用户
user = User.query.get(session['user_id'])
if not user:
return jsonify({
'success': False,
'message': '用户不存在'
}), 404
# 上传到COS
upload_result = cos_client.upload_file(
file_obj=file,
folder_type='avatar',
original_filename=file.filename
)
if not upload_result['success']:
current_app.logger.error(f"COS上传失败: {upload_result['error']}")
return jsonify({
'success': False,
'message': '文件上传失败,请重试'
}), 500
# 删除旧头像(如果存在)
if user.avatar_url:
try:
# 从URL中提取文件路径
old_file_key = extract_file_key_from_url(user.avatar_url)
if old_file_key:
cos_client.delete_file(old_file_key)
current_app.logger.info(f"删除旧头像: {old_file_key}")
except Exception as e:
current_app.logger.warning(f"删除旧头像失败: {str(e)}")
# 更新用户头像URL
user.avatar_url = upload_result['url']
db.session.commit()
current_app.logger.info(f"用户 {user.username} 头像上传成功: {upload_result['file_key']}")
return jsonify({
'success': True,
'message': '头像上传成功',
'avatar_url': upload_result['url'],
'file_key': upload_result['file_key']
})
except Exception as e:
current_app.logger.error(f"头像上传异常: {str(e)}")
db.session.rollback()
return jsonify({
'success': False,
'message': '服务器内部错误'
}), 500
def allowed_file(filename, allowed_extensions):
"""
检查文件扩展名是否允许
"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in allowed_extensions
def extract_file_key_from_url(url):
"""
从COS URL中提取文件路径
"""
try:
if not url:
return None
# 移除域名部分,只保留文件路径
if COSConfig.BUCKET_DOMAIN in url:
return url.split(COSConfig.BUCKET_DOMAIN + '/')[-1]
return None
except Exception:
return None
@upload_bp.route('/test', methods=['GET', 'POST'])
@login_required
def test_upload():
"""
测试上传功能
"""
if request.method == 'GET':
return '''
<!DOCTYPE html>
<html>
<head>
<title>测试上传</title>
<meta charset="utf-8">
</head>
<body>
<h2>测试文件上传</h2>
<form method="post" enctype="multipart/form-data">
<input type="file" name="test_file" accept="image/*" required>
<button type="submit">上传测试</button>
</form>
</body>
</html>
'''
# POST 请求处理
if 'test_file' not in request.files:
return '没有文件'
file = request.files['test_file']
if file.filename == '':
return '没有选择文件'
# 上传到COS
result = cos_client.upload_file(
file_obj=file,
folder_type='temp',
original_filename=file.filename
)
if result['success']:
return f'''
<h2>上传成功</h2>
<p>文件路径: {result['file_key']}</p>
<p>访问URL: <a href="{result['url']}" target="_blank">{result['url']}</a></p>
<img src="{result['url']}" style="max-width: 300px;">
'''
else:
return f'上传失败: {result["error"]}'

37
app/views/user.py Normal file
View File

@ -0,0 +1,37 @@
from flask import Blueprint, render_template, session, redirect, url_for, flash
from app.models.user import User
user_bp = Blueprint('user', __name__, url_prefix='/user')
def login_required(f):
"""登录验证装饰器"""
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('请先登录', 'warning')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
decorated_function.__name__ = f.__name__
return decorated_function
@user_bp.route('/profile')
@login_required
def profile():
"""用户个人中心"""
user = User.query.get(session['user_id'])
if not user:
session.clear()
flash('用户不存在,请重新登录', 'error')
return redirect(url_for('auth.login'))
return render_template('user/profile.html', user=user)
@user_bp.route('/orders')
@login_required
def orders():
"""用户订单 - 重定向到订单列表"""
return redirect(url_for('order.list'))

29
check_avatar.py Normal file
View File

@ -0,0 +1,29 @@
"""
检查用户头像URL
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from app import create_app
from app.models.user import User
app = create_app()
with app.app_context():
# 查看所有用户的头像信息
users = User.query.all()
print("=" * 60)
print("用户头像信息检查")
print("=" * 60)
for user in users:
print(f"用户: {user.username}")
print(f"头像URL: {user.avatar_url}")
print(f"完整URL: {user.avatar_url if user.avatar_url else '无头像'}")
print("-" * 40)
"""
检查用户头像URL
"""

Some files were not shown because too many files have changed in this diff Show More