version_1

This commit is contained in:
superlishunqin 2025-07-14 05:06:05 +08:00
parent feeca344da
commit 170db69eb4
16 changed files with 1003 additions and 84 deletions

View File

@ -1,5 +1,6 @@
from flask import Flask
from config.database import init_db
from app.utils.email_service import mail
from config.config import Config
def create_app(config_name=None):
@ -9,6 +10,9 @@ def create_app(config_name=None):
# 初始化数据库
init_db(app)
# 初始化邮件服务
mail.init_app(app)
# 注册蓝图
from app.views.auth import auth_bp
from app.views.main import main_bp

View File

@ -7,7 +7,57 @@
}
.quantity-input {
width: 60px;
width: 50px !important;
min-width: 50px !important;
padding: 4px 8px !important;
text-align: center !important;
border-radius: 0 !important;
-moz-appearance: textfield !important;
background-color: #fff !important;
border: 1px solid #ced4da !important;
}
.quantity-input:focus {
border-color: #86b7fe !important;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25) !important;
outline: 0 !important;
}
.quantity-input::-webkit-outer-spin-button,
.quantity-input::-webkit-inner-spin-button {
-webkit-appearance: none !important;
margin: 0 !important;
}
.quantity-input::-webkit-outer-spin-button,
.quantity-input::-webkit-inner-spin-button {
-webkit-appearance: none !important;
margin: 0 !important;
}
.quantity-control {
width: 120px;
margin: 0 auto;
}
.quantity-btn {
width: 30px;
height: 30px;
padding: 0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.quantity-btn:hover:not(:disabled) {
background-color: #e9ecef;
border-color: #adb5bd;
}
.quantity-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.item-checkbox {

View File

@ -3,22 +3,239 @@
margin-bottom: 1.5rem;
}
/* 地址选择区域美化 */
.address-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.address-card {
position: relative;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid #e9ecef;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.address-card:hover {
border-color: #007bff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
transform: translateY(-2px);
}
.address-card.selected {
border-color: #007bff;
background-color: #e7f3ff;
background: linear-gradient(135deg, #e7f3ff 0%, #cce7ff 100%);
box-shadow: 0 4px 16px rgba(0, 123, 255, 0.2);
transform: translateY(-2px);
}
.address-card.selected::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #007bff, #0056b3);
}
.address-card-body {
padding: 1.25rem;
position: relative;
}
.address-header {
display: flex;
justify-content: between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.address-receiver {
flex: 1;
}
.receiver-name {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.25rem;
display: flex;
align-items: center;
}
.receiver-name i {
margin-right: 0.5rem;
color: #007bff;
}
.receiver-phone {
color: #6c757d;
font-size: 0.9rem;
margin-bottom: 0;
display: flex;
align-items: center;
}
.receiver-phone i {
margin-right: 0.5rem;
color: #28a745;
}
.address-content {
background: rgba(255,255,255,0.7);
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 0.75rem;
border-left: 3px solid #e9ecef;
transition: all 0.3s ease;
}
.address-card.selected .address-content {
border-left-color: #007bff;
background: rgba(255,255,255,0.9);
}
.address-text {
color: #495057;
margin-bottom: 0;
line-height: 1.5;
display: flex;
align-items: flex-start;
}
.address-text i {
margin-right: 0.5rem;
margin-top: 0.2rem;
color: #6c757d;
flex-shrink: 0;
}
.address-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.default-badge {
background: linear-gradient(45deg, #ff6b6b, #ee5a52);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
display: inline-flex;
align-items: center;
box-shadow: 0 2px 4px rgba(255,107,107,0.3);
}
.default-badge i {
margin-right: 0.25rem;
}
/* 自定义单选按钮样式 */
.custom-radio {
position: relative;
width: 24px;
height: 24px;
}
.custom-radio input[type="radio"] {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.custom-radio .radio-mark {
position: absolute;
top: 0;
left: 0;
width: 24px;
height: 24px;
background: #fff;
border: 2px solid #ddd;
border-radius: 50%;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.custom-radio input[type="radio"]:checked + .radio-mark {
border-color: #007bff;
background: #007bff;
box-shadow: 0 0 0 3px rgba(0,123,255,0.2);
}
.custom-radio .radio-mark::after {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
opacity: 0;
transform: scale(0);
transition: all 0.2s ease;
}
.custom-radio input[type="radio"]:checked + .radio-mark::after {
opacity: 1;
transform: scale(1);
}
/* 新增地址按钮美化 */
.add-address-btn {
background: linear-gradient(45deg, #28a745, #20c997);
border: none;
color: white;
padding: 0.5rem 1rem;
border-radius: 25px;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(40,167,69,0.3);
}
.add-address-btn:hover {
background: linear-gradient(45deg, #218838, #1e7e34);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(40,167,69,0.4);
color: white;
}
.add-address-btn i {
margin-right: 0.5rem;
}
/* 卡片头部美化 */
.checkout-section .card-header {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-bottom: 1px solid #dee2e6;
padding: 1rem 1.25rem;
}
.checkout-section .card-header h5 {
margin-bottom: 0;
color: #495057;
font-weight: 600;
}
.checkout-section .card-header i {
margin-right: 0.5rem;
color: #007bff;
font-size: 1.1rem;
}
/* 其他原有样式保持不变 */
.product-item {
padding: 1rem 0;
border-bottom: 1px solid #e9ecef;
@ -79,12 +296,13 @@
/* 响应式设计 */
@media (max-width: 768px) {
.checkout-section .col-md-4,
.checkout-section .col-md-3 {
margin-bottom: 1rem;
.address-container {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.address-card {
.checkout-section .col-md-4,
.checkout-section .col-md-3 {
margin-bottom: 1rem;
}
@ -92,6 +310,14 @@
.product-item .col-md-6 {
margin-bottom: 0.5rem;
}
.address-card-body {
padding: 1rem;
}
.receiver-name {
font-size: 1rem;
}
}
/* 动画效果 */
@ -106,27 +332,39 @@
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.alert {
animation: fadeIn 0.3s ease;
}
.address-card {
animation: slideIn 0.4s ease;
}
.address-card:nth-child(2) {
animation-delay: 0.1s;
}
.address-card:nth-child(3) {
animation-delay: 0.2s;
}
/* 按钮样式 */
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1.1rem;
}
/* 卡片头部样式 */
.card-header h5 {
margin-bottom: 0;
color: #495057;
}
.card-header i {
margin-right: 0.5rem;
color: #007bff;
}
/* 表单标签样式 */
.form-check-label {
cursor: pointer;
@ -162,3 +400,20 @@
.breadcrumb-item + .breadcrumb-item::before {
color: #6c757d;
}
/* 加载状态动画 */
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(0, 123, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 123, 255, 0);
}
}
.address-card.loading {
animation: pulse 1.5s infinite;
}

View File

@ -75,11 +75,13 @@ function updateTotalPrice() {
// 修改数量
function changeQuantity(cartId, delta) {
const input = document.querySelector(`[data-cart-id="${cartId}"]`);
const input = document.querySelector(`[data-cart-id="${cartId}"].quantity-input`);
if (!input) return;
const currentValue = parseInt(input.value);
const newValue = currentValue + delta;
if (newValue >= 1 && newValue <= parseInt(input.max)) {
if (newValue >= 1) {
updateQuantity(cartId, newValue);
}
}
@ -103,25 +105,39 @@ function updateQuantity(cartId, quantity) {
if (data.success) {
// 更新页面显示
const cartItem = document.querySelector(`[data-cart-id="${cartId}"]`);
cartItem.querySelector('.quantity-input').value = quantity;
const quantityInput = cartItem.querySelector('.quantity-input');
quantityInput.value = quantity;
cartItem.querySelector('.item-total').textContent = data.item_total.toFixed(2);
// 简单的按钮状态更新
const minusBtn = cartItem.querySelector('button[onclick*="-1"]');
const plusBtn = cartItem.querySelector('button[onclick*="1"]');
if (minusBtn) {
minusBtn.disabled = quantity <= 1;
}
// 暂时不限制最大值,避免选择器错误
if (plusBtn) {
plusBtn.disabled = false;
}
// 更新总价
updateTotalPrice();
// 更新全局购物车数量
updateCartBadge(data.cart_count);
// 显示成功消息
showSuccessMessage('数量更新成功');
} else {
alert(data.message);
// 恢复原始值
alert(data.message || '更新失败');
location.reload();
}
})
.catch(error => {
console.error('Error:', error);
alert('更新失败');
alert('网络错误,请重试');
location.reload();
});
}
@ -217,3 +233,82 @@ function checkout() {
window.location.href = `/cart/checkout?${params.toString()}`;
}
// 显示成功消息
function showSuccessMessage(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.cssText = 'top: 20px; right: 20px; z-index: 1050; min-width: 300px;';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.remove();
}
}, 3000);
}
// 更新购物车徽章
// 验证数量输入
function validateQuantityInput(input) {
// 只允许数字
input.value = input.value.replace(/[^0-9]/g, "");
// 如果为空或0显示1
if (input.value === "" || parseInt(input.value) < 1) {
input.value = "1";
}
// 检查最大值
const maxStock = parseInt(input.getAttribute("max")) || 999;
if (parseInt(input.value) > maxStock) {
input.value = maxStock.toString();
}
}
// 从输入框更新数量
function updateQuantityFromInput(cartId, input) {
let quantity = parseInt(input.value);
// 验证输入
if (isNaN(quantity) || quantity < 1) {
quantity = 1;
input.value = "1";
}
const maxStock = parseInt(input.getAttribute("max")) || 999;
if (quantity > maxStock) {
quantity = maxStock;
input.value = maxStock.toString();
}
// 更新数量
updateQuantity(cartId, quantity);
}
// 处理键盘事件
function handleQuantityKeyPress(event, cartId, input) {
// 回车键确认
if (event.key === "Enter") {
input.blur();
return;
}
// 只允许数字键
if (!/[0-9]/.test(event.key) && !["Backspace", "Delete", "Tab", "Escape", "Enter", "Home", "End", "ArrowLeft", "ArrowRight"].includes(event.key)) {
event.preventDefault();
}
}
function updateCartBadge(count) {
const badge = document.querySelector('.cart-badge');
if (badge) {
badge.textContent = count;
badge.style.display = count > 0 ? 'inline' : 'none';
}
}

View File

@ -15,65 +15,145 @@ document.addEventListener('DOMContentLoaded', function() {
if (subtotalElement) {
subtotal = parseFloat(subtotalElement.textContent.replace('¥', ''));
}
// 初始化地址卡片动画
initAddressAnimations();
});
// 选择地址
// 初始化地址卡片动画
function initAddressAnimations() {
const addressCards = document.querySelectorAll('.address-card');
addressCards.forEach((card, index) => {
card.style.animationDelay = `${index * 0.1}s`;
});
}
// 选择地址(优化版本)
function selectAddress(addressId) {
selectedAddressId = addressId;
// 添加加载状态
const clickedCard = document.querySelector(`[data-address-id="${addressId}"]`);
if (clickedCard) {
clickedCard.classList.add('loading');
}
// 延迟执行UI更新给用户视觉反馈
setTimeout(() => {
// 更新UI
document.querySelectorAll('.address-card').forEach(card => {
card.classList.remove('selected');
card.classList.remove('selected', 'loading');
});
const selectedCard = document.querySelector(`[data-address-id="${addressId}"]`);
if (selectedCard) {
selectedCard.classList.add('selected');
// 添加选中动画效果
selectedCard.style.animation = 'none';
selectedCard.offsetHeight; // 触发重排
selectedCard.style.animation = 'slideIn 0.4s ease';
}
// 更新单选按钮
const radioButton = document.querySelector(`input[value="${addressId}"]`);
if (radioButton) {
radioButton.checked = true;
// 触发单选按钮动画
const radioMark = radioButton.nextElementSibling;
if (radioMark) {
radioMark.style.transform = 'scale(1.1)';
setTimeout(() => {
radioMark.style.transform = 'scale(1)';
}, 200);
}
}
// 显示成功提示
showSelectionFeedback('地址选择成功');
}, 150);
}
// 更新运费
// 显示选择反馈
function showSelectionFeedback(message) {
// 创建临时提示元素
const feedback = document.createElement('div');
feedback.className = 'position-fixed top-0 start-50 translate-middle-x mt-3';
feedback.style.zIndex = '9999';
feedback.innerHTML = `
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle me-2"></i>
${message}
</div>
`;
document.body.appendChild(feedback);
// 3秒后自动移除
setTimeout(() => {
if (feedback.parentNode) {
feedback.remove();
}
}, 3000);
}
// 更新运费(优化版本)
function updateShippingFee() {
const shippingMethodElement = document.querySelector('input[name="shipping_method"]:checked');
if (!shippingMethodElement) return;
const shippingMethod = shippingMethodElement.value;
let fee = 0;
let description = '';
switch(shippingMethod) {
case 'express':
fee = 10;
description = '次日达服务';
break;
case 'same_day':
fee = 20;
description = '当日达服务';
break;
default:
fee = 0;
description = '标准配送';
}
// 添加动画效果更新价格
const shippingFeeElement = document.getElementById('shippingFee');
const totalAmountElement = document.getElementById('totalAmount');
if (shippingFeeElement) {
shippingFeeElement.textContent = `¥${fee.toFixed(2)}`;
}
if (shippingFeeElement && totalAmountElement) {
// 淡出效果
shippingFeeElement.style.opacity = '0.5';
totalAmountElement.style.opacity = '0.5';
if (totalAmountElement) {
setTimeout(() => {
shippingFeeElement.textContent = `¥${fee.toFixed(2)}`;
totalAmountElement.textContent = `¥${(subtotal + fee).toFixed(2)}`;
// 淡入效果
shippingFeeElement.style.opacity = '1';
totalAmountElement.style.opacity = '1';
// 显示配送方式反馈
if (fee > 0) {
showSelectionFeedback(`已选择${description} (+¥${fee})`);
} else {
showSelectionFeedback(`已选择${description} (免费)`);
}
}, 200);
}
}
// 提交订单
// 提交订单(优化版本)
function submitOrder() {
// 验证地址选择
if (!selectedAddressId) {
showAlert('请选择收货地址', 'warning');
highlightAddressSection();
return;
}
@ -84,11 +164,13 @@ function submitOrder() {
if (!shippingMethodElement) {
showAlert('请选择配送方式', 'warning');
highlightSection('shipping');
return;
}
if (!paymentMethodElement) {
showAlert('请选择支付方式', 'warning');
highlightSection('payment');
return;
}
@ -123,6 +205,10 @@ function submitOrder() {
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> 提交中...';
submitBtn.disabled = true;
submitBtn.classList.add('loading');
// 添加页面加载遮罩
showLoadingOverlay();
// 提交订单
fetch('/order/create', {
@ -139,23 +225,141 @@ function submitOrder() {
return response.json();
})
.then(data => {
hideLoadingOverlay();
if (data.success) {
showAlert('订单创建成功!正在跳转到支付页面...', 'success');
// 添加成功动画
submitBtn.innerHTML = '<i class="bi bi-check-circle"></i> 订单创建成功';
submitBtn.classList.add('btn-success');
submitBtn.classList.remove('btn-danger');
setTimeout(() => {
window.location.href = `/order/pay/${data.payment_sn}`;
}, 1500);
} else {
showAlert(data.message || '订单创建失败', 'error');
// 恢复按钮状态
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
restoreSubmitButton(submitBtn, originalText);
}
})
.catch(error => {
console.error('提交订单错误:', error);
hideLoadingOverlay();
showAlert('提交订单失败,请重试', 'error');
// 恢复按钮状态
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
restoreSubmitButton(submitBtn, originalText);
});
}
// 恢复提交按钮状态
function restoreSubmitButton(submitBtn, originalText) {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
submitBtn.classList.remove('loading', 'btn-success');
submitBtn.classList.add('btn-danger');
}
// 高亮地址选择区域
function highlightAddressSection() {
const addressSection = document.querySelector('#addressList');
if (addressSection) {
addressSection.style.border = '2px solid #dc3545';
addressSection.style.borderRadius = '8px';
addressSection.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
addressSection.style.border = '';
}, 3000);
}
}
// 高亮指定区域
function highlightSection(sectionType) {
let selector = '';
switch(sectionType) {
case 'shipping':
selector = 'input[name="shipping_method"]';
break;
case 'payment':
selector = 'input[name="payment_method"]';
break;
}
if (selector) {
const element = document.querySelector(selector);
if (element) {
const section = element.closest('.card');
if (section) {
section.style.border = '2px solid #dc3545';
section.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
section.style.border = '';
}, 3000);
}
}
}
}
// 显示加载遮罩
function showLoadingOverlay() {
const overlay = document.createElement('div');
overlay.id = 'loadingOverlay';
overlay.className = 'position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center';
overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '9999';
overlay.innerHTML = `
<div class="text-center text-white">
<div class="spinner-border mb-3" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<h5>正在创建订单...</h5>
<p>请稍候不要关闭页面</p>
</div>
`;
document.body.appendChild(overlay);
}
// 隐藏加载遮罩
function hideLoadingOverlay() {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
overlay.remove();
}
}
// 增强的提示函数
function showAlert(message, type = 'info') {
const alertClass = {
'success': 'alert-success',
'error': 'alert-danger',
'warning': 'alert-warning',
'info': 'alert-info'
}[type] || 'alert-info';
const icon = {
'success': 'bi-check-circle',
'error': 'bi-exclamation-triangle',
'warning': 'bi-exclamation-triangle',
'info': 'bi-info-circle'
}[type] || 'bi-info-circle';
const alertDiv = document.createElement('div');
alertDiv.className = `alert ${alertClass} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3`;
alertDiv.style.zIndex = '10000';
alertDiv.innerHTML = `
<i class="bi ${icon} me-2"></i>
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alertDiv);
// 5秒后自动移除
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 5000);
}

View File

@ -261,14 +261,14 @@
<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">
name="single_stock" min="0" value="{{ product.inventory[0].stock if product and product.inventory else 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">
name="warning_stock" min="0" value="{{ product.inventory[0].warning_stock if product and product.inventory else 10 }}">
</div>
</div>
</div>

View File

@ -106,8 +106,8 @@
<div class="col-md-6">
<h6>联系我们</h6>
<p class="text-muted">
<i class="bi bi-envelope"></i> service@taibai-mall.com<br>
<i class="bi bi-telephone"></i> 400-888-8888
<i class="bi bi-envelope"></i> 392437483@qq.com<br>
<i class="bi bi-telephone"></i> 17753825492
</p>
</div>
</div>

View File

@ -99,17 +99,25 @@
<!-- 数量 -->
<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"
<div class="quantity-control d-flex align-items-center justify-content-center">
<button class="btn btn-outline-secondary btn-sm quantity-btn" 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() or item.quantity <= 1 %}disabled{% endif %}>
<i class="bi bi-dash"></i>
</button>
<input type="text" class="form-control form-control-sm text-center quantity-input mx-1"
value="{{ item.quantity }}"
data-cart-id="{{ item.id }}"
data-max-stock="{{ item.get_stock() }}"
oninput="validateQuantityInput(this)"
onblur="updateQuantityFromInput({{ item.id }}, this)"
onkeydown="handleQuantityKeyPress(event, {{ item.id }}, this)"
{% if not item.is_available() %}disabled{% endif %}>
<button class="btn btn-outline-secondary btn-sm" type="button"
<button class="btn btn-outline-secondary btn-sm quantity-btn" type="button"
onclick="changeQuantity({{ item.id }}, 1)"
{% if not item.is_available() or item.quantity >= item.get_stock() %}disabled{% endif %}>+</button>
{% if not item.is_available() or item.quantity >= item.get_stock() %}disabled{% endif %}>
<i class="bi bi-plus"></i>
</button>
</div>
<div class="mt-1">
<small class="text-muted">小计:¥<span class="item-total">{{ "%.2f"|format(item.get_total_price()) }}</span></small>

View File

@ -3,6 +3,7 @@
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/checkout.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
{% endblock %}
{% block content %}
@ -20,33 +21,68 @@
<!-- 收货地址 -->
<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> 新增地址
<h5><i class="bi bi-geo-alt-fill"></i> 收货地址</h5>
<a href="{{ url_for('address.add') }}" class="btn add-address-btn">
<i class="bi bi-plus-circle"></i> 新增地址
</a>
</div>
<div class="card-body">
<div class="row" id="addressList">
<div class="address-container" id="addressList">
{% for address in addresses %}
<div class="col-md-6 mb-3">
<div class="card address-card {% if address.is_default %}selected{% endif %}"
<div class="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 class="address-card-body">
<div class="address-header">
<div class="address-receiver">
<h6 class="receiver-name">
<i class="bi bi-person-fill"></i>
{{ address.receiver_name }}
</h6>
<p class="receiver-phone">
<i class="bi bi-telephone-fill"></i>
{{ address.receiver_phone }}
</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 class="custom-radio">
<input type="radio" name="address_id" value="{{ address.id }}"
{% if address.is_default %}checked{% endif %}>
<span class="radio-mark"></span>
</div>
</div>
<div class="address-content">
<p class="address-text">
<i class="bi bi-geo-alt"></i>
{{ address.get_full_address() }}
</p>
</div>
<div class="address-footer">
{% if address.is_default %}
<span class="default-badge">
<i class="bi bi-star-fill"></i>
默认地址
</span>
{% else %}
<span></span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% if not addresses %}
<div class="col-12">
<div class="text-center py-4">
<i class="bi bi-geo-alt text-muted" style="font-size: 3rem;"></i>
<h5 class="text-muted mt-3">暂无收货地址</h5>
<p class="text-muted">请先添加收货地址,然后继续下单</p>
<a href="{{ url_for('address.add') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> 立即添加
</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@ -54,7 +90,7 @@
<!-- 商品信息 -->
<div class="card checkout-section">
<div class="card-header">
<h5><i class="bi bi-box"></i> 商品信息</h5>
<h5><i class="bi bi-box-seam"></i> 商品信息</h5>
</div>
<div class="card-body">
{% for item in cart_items %}

View File

@ -0,0 +1,49 @@
# 使用Python 3.9作为基础镜像以匹配开发环境
FROM python:3.9-slim
# 设置工作目录
WORKDIR /app
# 设置环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_APP=run.py \
FLASK_ENV=production
# 创建并配置国内镜像源
RUN echo "deb https://mirrors.ustc.edu.cn/debian/ bookworm main" > /etc/apt/sources.list && \
echo "deb https://mirrors.ustc.edu.cn/debian/ bookworm-updates main" >> /etc/apt/sources.list && \
echo "deb https://mirrors.ustc.edu.cn/debian-security/ bookworm-security main" >> /etc/apt/sources.list
# 安装系统依赖包括libmagic
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
libmagic1 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# 使用阿里云pip镜像源更稳定
RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \
pip config set install.trusted-host mirrors.aliyun.com
# 安装Python依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目文件
COPY . .
# 运行配置更新脚本
RUN python update_config.py
# 创建非root用户运行应用
RUN useradd -m appuser
RUN chown -R appuser:appuser /app
USER appuser
# 暴露自定义端口50400
EXPOSE 50400
# 启动命令
CMD ["gunicorn", "--bind", "0.0.0.0:50400", "run:app"]

72
docker/README.md Normal file
View File

@ -0,0 +1,72 @@
# 太白购物商城 Docker 部署指南
## 前置条件
确保您的服务器上已安装:
- Docker (20.10.0+)
- Docker Compose (2.0.0+)
## 部署步骤
### 1. 克隆项目到服务器
git clone <项目仓库URL> shopping-platform
cd shopping-platform
### 2. 配置数据库连接
您有两个选择:
#### 选项1使用Docker内置MySQL数据库
编辑 docker/docker-compose.yml取消以下环境变量的注释
- MYSQL_HOST=db
- MYSQL_USER=shopping_user
- MYSQL_PASSWORD=shopping_password
- MYSQL_DB=shopping_db
- MYSQL_PORT=3306
#### 选项2使用现有外部数据库
默认配置使用外部数据库(27.124.22.104)。保持docker-compose.yml中的数据库环境变量为注释状态。
### 3. 修改密钥和敏感信息
编辑 docker/docker-compose.yml更改SECRET_KEY为安全的密钥。
### 4. 构建并启动应用
cd docker
docker-compose up -d --build
### 5. 验证部署
访问 http://服务器IP:50400 确认应用是否正常运行。
## 维护命令
### 查看日志
docker-compose logs -f app
### 重启应用
docker-compose restart app
### 完全重新部署
docker-compose down
docker-compose up -d --build
### 停止所有服务
docker-compose down
## 端口说明
- 应用端口50400
- MySQL端口3366 (避免与主机MySQL冲突)
## 疑难解答
### 应用无法连接到数据库
1. 检查数据库连接配置
2. 查看应用日志docker-compose logs app
### 端口冲突
修改 docker-compose.yml 中的端口映射
### 权限问题
确保当前用户有权限访问Docker

41
docker/deploy.sh Executable file
View File

@ -0,0 +1,41 @@
#!/bin/bash
echo "🚀 开始部署太白购物商城..."
# 检查Docker是否已安装
if ! command -v docker &> /dev/null; then
echo "❌ Docker未安装。请先安装Docker。"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose未安装。请先安装Docker Compose。"
exit 1
fi
# 确保gunicorn在依赖中
grep -q "gunicorn" ../requirements.txt || echo "gunicorn==20.1.0" >> ../requirements.txt
# 构建并启动应用
echo "🔨 构建Docker镜像..."
docker-compose build
echo "🚀 启动服务..."
docker-compose up -d
# 等待几秒钟让服务启动
sleep 5
# 检查服务状态
echo "📊 检查服务状态..."
docker-compose ps
echo ""
echo "✅ 部署完成!"
echo "🌐 应用地址: http://localhost:50400"
echo ""
echo "📋 常用命令:"
echo " 查看日志: docker-compose logs -f app"
echo " 重启应用: docker-compose restart app"
echo " 停止应用: docker-compose down"
echo " 查看状态: docker-compose ps"

View File

@ -0,0 +1,56 @@
version: '3.8'
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
restart: always
ports:
- "50400:50400"
depends_on:
- db
environment:
# 应用配置
- FLASK_CONFIG=production
- SECRET_KEY=change-me-in-production
# 数据库配置 - 两种选择之一:
# 选项1使用Docker内置MySQL取消下面的注释启用
# - MYSQL_HOST=db
# - MYSQL_USER=taibai
# - MYSQL_PASSWORD=taibaishopping
# - MYSQL_DB=shopping_db
# - MYSQL_PORT=3306
# 选项2使用现有外部数据库默认
# 保持上面的选项1注释即可使用配置文件中的默认设置
volumes:
- ../logs:/app/logs
- ../app/static/uploads:/app/app/static/uploads
networks:
- app-network
db:
image: mysql:8.0
restart: always
environment:
- MYSQL_DATABASE=online_shopping
- MYSQL_USER=taibai
- MYSQL_PASSWORD=taibaishopping
- MYSQL_ROOT_PASSWORD=root_password_here
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3366:3306" # 使用3366避免与主机MySQL端口冲突
networks:
- app-network
command: --default-authentication-plugin=mysql_native_password
networks:
app-network:
driver: bridge
volumes:
mysql-data:

21
docker/test.sh Executable file
View File

@ -0,0 +1,21 @@
#!/bin/bash
echo "🧪 测试Docker部署..."
# 检查应用是否响应
echo "⏳ 等待应用启动..."
sleep 10
# 测试应用是否可访问
if curl -f http://localhost:50400 > /dev/null 2>&1; then
echo "✅ 应用运行正常!"
echo "🌐 访问地址: http://localhost:50400"
else
echo "❌ 应用无法访问,请检查日志:"
echo "docker-compose logs app"
fi
# 显示当前运行的容器
echo ""
echo "📋 当前运行的容器:"
docker-compose ps

View File

@ -11,3 +11,4 @@ Flask-Mail==0.9.1
cos-python-sdk-v5==1.9.24
Pillow==10.0.1
python-magic==0.4.27
gunicorn==20.1.0

27
update_config.py Normal file
View File

@ -0,0 +1,27 @@
import os
config_file = 'config/config.py'
with open(config_file, 'r') as f:
content = f.read()
# 修改数据库配置部分,使其支持环境变量
modified_content = content.replace(
''' # 数据库配置
MYSQL_HOST = '27.124.22.104'
MYSQL_USER = 'taibai'
MYSQL_PASSWORD = 'taibaishopping'
MYSQL_DB = 'online_shopping'
MYSQL_PORT = 3306''',
''' # 数据库配置
MYSQL_HOST = os.environ.get('MYSQL_HOST', '27.124.22.104')
MYSQL_USER = os.environ.get('MYSQL_USER', 'taibai')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'taibaishopping')
MYSQL_DB = os.environ.get('MYSQL_DB', 'online_shopping')
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', '3306'))'''
)
with open(config_file, 'w') as f:
f.write(modified_content)
print(f"✅ 成功更新 {config_file} 以支持环境变量")