356 lines
14 KiB
HTML
356 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html xmlns:th="http://www.thymeleaf.org">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>商品详情</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||
<style>
|
||
:root { --primary: #0f172a; --accent: #3b82f6; --bg-gray: #f8fafc; }
|
||
body { background: #fff; padding-bottom: 100px; font-family: -apple-system, sans-serif; }
|
||
|
||
/* 顶部导航 Header */
|
||
.nav-header {
|
||
position: fixed; top: 0; left: 0; right: 0; height: 50px;
|
||
background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 0 16px; z-index: 1000; box-shadow: 0 1px 0 rgba(0,0,0,0.05);
|
||
}
|
||
.nav-title { font-size: 16px; font-weight: 600; color: var(--primary); }
|
||
.btn-back { border: none; background: transparent; font-size: 20px; color: var(--primary); padding: 0; }
|
||
|
||
/* 顶部大图 */
|
||
.hero-img {
|
||
width: 100%; height: 320px; object-fit: cover; background: #f1f5f9;
|
||
margin-top: 50px; /* 避开Header */
|
||
}
|
||
|
||
/* 内容容器 - 上浮圆角 */
|
||
.content-box {
|
||
position: relative; top: -24px; border-radius: 24px 24px 0 0;
|
||
background: #fff; padding: 24px 20px; margin-bottom: -24px;
|
||
}
|
||
|
||
.prod-title { font-size: 22px; font-weight: 800; color: var(--primary); margin-bottom: 8px; }
|
||
.prod-desc { font-size: 13px; color: #64748b; line-height: 1.6; margin-bottom: 24px; }
|
||
|
||
/* 选项组 */
|
||
.opt-group { margin-bottom: 24px; }
|
||
.opt-title { font-size: 14px; font-weight: 700; color: var(--primary); margin-bottom: 12px; }
|
||
|
||
/* 扁平化 Chips */
|
||
.chips-wrap { display: flex; flex-wrap: wrap; gap: 10px; }
|
||
.chip-input { display: none; } /* Hide default checkbox/radio */
|
||
.chip-label {
|
||
padding: 8px 16px; border-radius: 8px; background: var(--bg-gray);
|
||
font-size: 13px; color: #475569; transition: all 0.2s; border: 1px solid transparent;
|
||
cursor: pointer;
|
||
}
|
||
/* 选中态 */
|
||
.chip-input:checked + .chip-label {
|
||
background: #eff6ff; color: var(--accent); border-color: var(--accent); font-weight: 600;
|
||
}
|
||
|
||
/* 数量步进器 */
|
||
.stepper {
|
||
display: inline-flex; align-items: center; background: var(--bg-gray); border-radius: 8px; padding: 4px;
|
||
}
|
||
.step-btn {
|
||
width: 32px; height: 32px; border: none; background: #fff; border-radius: 6px;
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.05); color: var(--primary); font-size: 18px; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.step-val { width: 40px; text-align: center; font-size: 15px; font-weight: 600; background: transparent; border: none; }
|
||
|
||
/* 底部固定栏 */
|
||
.bottom-bar {
|
||
position: fixed; bottom: 0; left: 0; right: 0;
|
||
background: #fff; padding: 12px 20px 30px; /* Adapt to safe area */
|
||
box-shadow: 0 -4px 16px rgba(0,0,0,0.04);
|
||
display: flex; justify-content: space-between; align-items: center; z-index: 100;
|
||
}
|
||
.price-area { display: flex; flex-direction: column; }
|
||
.price-label { font-size: 11px; color: #94a3b8; }
|
||
.price-val { font-size: 24px; font-weight: 800; color: var(--primary); }
|
||
.btn-add {
|
||
background: var(--primary); color: #fff; border: none; padding: 14px 40px;
|
||
border-radius: 12px; font-weight: 600; font-size: 16px;
|
||
}
|
||
.btn-add:active { transform: scale(0.98); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 顶部导航 -->
|
||
<div class="nav-header">
|
||
<button class="btn-back" onclick="history.back()"><i class="bi bi-arrow-left"></i></button>
|
||
<span class="nav-title">商品详情</span>
|
||
<div style="width: 24px;"></div> <!-- 占位符确保标题居中 -->
|
||
</div>
|
||
|
||
<img id="prodImg" src="" class="hero-img">
|
||
|
||
<div class="content-box">
|
||
<h1 class="prod-title" id="prodName">Loading...</h1>
|
||
<p class="prod-desc" id="prodDesc"></p>
|
||
|
||
<form id="addForm">
|
||
<input type="hidden" id="pid">
|
||
|
||
<!-- 动态渲染选项 -->
|
||
<div id="dynamicOptions"></div>
|
||
|
||
<div class="opt-group">
|
||
<div class="opt-title">数量</div>
|
||
<div class="stepper">
|
||
<button type="button" class="step-btn" onclick="changeQty(-1)">-</button>
|
||
<input type="number" class="step-val" id="qty" value="1" readonly>
|
||
<button type="button" class="step-btn" onclick="changeQty(1)">+</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="bottom-bar">
|
||
<div class="price-area">
|
||
<span class="price-label">总计金额</span>
|
||
<span class="price-val" id="totalPrice">¥0.00</span>
|
||
</div>
|
||
<button class="btn-add" onclick="submitCart()">加入购物袋</button>
|
||
</div>
|
||
|
||
<script src="/static/js/ui-enhancements.js"></script>
|
||
<script src="/static/js/cookie-utils.js"></script>
|
||
<script>
|
||
let basePrice = 0;
|
||
let productData = null;
|
||
let currentProductId = null;
|
||
let isLoggedIn = false;
|
||
|
||
// 检查用户是否登录
|
||
async function checkLoginStatus() {
|
||
try {
|
||
const res = await UIEnhancements.enhancedFetch('/cart', { credentials: 'include' });
|
||
isLoggedIn = res.data.code === 200;
|
||
} catch(e) {
|
||
isLoggedIn = false;
|
||
}
|
||
return isLoggedIn;
|
||
}
|
||
|
||
// 记录浏览行为
|
||
async function recordView(productId) {
|
||
const loggedIn = await checkLoginStatus();
|
||
if (loggedIn) {
|
||
// 已登录:调用后端接口记录(后端会自动记录)
|
||
// 这里不需要额外调用,因为getProductDetail已经记录了
|
||
} else {
|
||
// 未登录:存储到cookies
|
||
let viewHistory = CookieUtils.getJSON('viewHistory') || [];
|
||
// 检查是否已存在该商品的浏览记录
|
||
const exists = viewHistory.some(v => v.productId === productId);
|
||
if (!exists) {
|
||
viewHistory.push({
|
||
productId: productId,
|
||
timestamp: Date.now()
|
||
});
|
||
// 限制浏览历史数量,避免cookies过大(最多保存50条)
|
||
if (viewHistory.length > 50) {
|
||
viewHistory = viewHistory.slice(-50);
|
||
}
|
||
CookieUtils.setJSON('viewHistory', viewHistory, 7); // 7天过期
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
(async () => {
|
||
const id = new URLSearchParams(location.search).get('id');
|
||
if(!id) return alert('商品不存在');
|
||
currentProductId = parseInt(id);
|
||
|
||
try {
|
||
const res = await UIEnhancements.enhancedFetch(`/products/${id}`, { credentials: 'include' });
|
||
const json = res.data;
|
||
if(json.code === 200) {
|
||
productData = json.data;
|
||
render(productData);
|
||
// 记录浏览行为
|
||
await recordView(currentProductId);
|
||
} else {
|
||
alert(json.message);
|
||
}
|
||
} catch(e) { console.error(e); }
|
||
})();
|
||
|
||
function render(data) {
|
||
const p = data.product;
|
||
basePrice = p.basePrice;
|
||
|
||
document.getElementById('pid').value = p.id;
|
||
document.getElementById('prodImg').src = p.image || '/static/images/default.jpg';
|
||
document.getElementById('prodName').innerText = p.name;
|
||
document.getElementById('prodDesc').innerText = p.description || '';
|
||
|
||
let html = '';
|
||
|
||
// 1. 规格 (Radio)
|
||
if (data.specs && data.specs.length) {
|
||
html += `<div class="opt-group"><div class="opt-title">规格</div><div class="chips-wrap">`;
|
||
data.specs.forEach((s, i) => {
|
||
html += `
|
||
<input type="radio" name="spec" id="sp_${s.id}" value="${s.id}" class="chip-input"
|
||
data-price="${s.priceAdjust}" ${i===0?'checked':''} onchange="calcPrice()">
|
||
<label for="sp_${s.id}" class="chip-label">${s.specName}</label>
|
||
`;
|
||
});
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
// 2. 甜度 (Radio)
|
||
if (data.customs?.sweetness?.length) {
|
||
html += `<div class="opt-group"><div class="opt-title">甜度</div><div class="chips-wrap">`;
|
||
data.customs.sweetness.forEach((o, i) => {
|
||
html += `
|
||
<input type="radio" name="sweetness" id="sw_${i}" value="${o.optionValue}" class="chip-input" ${i===0?'checked':''}>
|
||
<label for="sw_${i}" class="chip-label">${o.optionValue}</label>
|
||
`;
|
||
});
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
// 3. 冰度 (Radio)
|
||
if (data.customs?.ice?.length) {
|
||
html += `<div class="opt-group"><div class="opt-title">温度</div><div class="chips-wrap">`;
|
||
data.customs.ice.forEach((o, i) => {
|
||
html += `
|
||
<input type="radio" name="ice" id="ic_${i}" value="${o.optionValue}" class="chip-input" ${i===0?'checked':''}>
|
||
<label for="ic_${i}" class="chip-label">${o.optionValue}</label>
|
||
`;
|
||
});
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
// 4. 配料 (Checkbox)
|
||
if (data.toppings && data.toppings.length) {
|
||
html += `<div class="opt-group"><div class="opt-title">加料</div><div class="chips-wrap">`;
|
||
data.toppings.forEach(t => {
|
||
html += `
|
||
<input type="checkbox" name="topping" id="tp_${t.id}" value="${t.id}" class="chip-input"
|
||
data-price="${t.price}" onchange="calcPrice()">
|
||
<label for="tp_${t.id}" class="chip-label">${t.toppingName} (+¥${t.price})</label>
|
||
`;
|
||
});
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
document.getElementById('dynamicOptions').innerHTML = html;
|
||
calcPrice();
|
||
}
|
||
|
||
function changeQty(d) {
|
||
const input = document.getElementById('qty');
|
||
let v = parseInt(input.value) + d;
|
||
if(v < 1) v = 1;
|
||
input.value = v;
|
||
calcPrice();
|
||
}
|
||
|
||
function calcPrice() {
|
||
let total = parseFloat(basePrice);
|
||
|
||
// 规格加价
|
||
const spec = document.querySelector('input[name="spec"]:checked');
|
||
if (spec) total += parseFloat(spec.dataset.price || 0);
|
||
|
||
// 配料加价
|
||
document.querySelectorAll('input[name="topping"]:checked').forEach(el => {
|
||
total += parseFloat(el.dataset.price || 0);
|
||
});
|
||
|
||
const qty = parseInt(document.getElementById('qty').value);
|
||
document.getElementById('totalPrice').innerText = '¥' + (total * qty).toFixed(2);
|
||
}
|
||
|
||
async function submitCart() {
|
||
const btn = document.querySelector('.btn-add');
|
||
btn.innerText = '添加中...';
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
const payload = {
|
||
productId: parseInt(document.getElementById('pid').value),
|
||
specId: document.querySelector('input[name="spec"]:checked')?.value ?
|
||
parseInt(document.querySelector('input[name="spec"]:checked').value) : null,
|
||
customSweetness: document.querySelector('input[name="sweetness"]:checked')?.value || null,
|
||
customIce: document.querySelector('input[name="ice"]:checked')?.value || null,
|
||
quantity: parseInt(document.getElementById('qty').value),
|
||
toppings: JSON.stringify(
|
||
Array.from(document.querySelectorAll('input[name="topping"]:checked')).map(el => parseInt(el.value))
|
||
)
|
||
};
|
||
|
||
const loggedIn = await checkLoginStatus();
|
||
|
||
if (loggedIn) {
|
||
// 已登录:调用后端接口
|
||
const res = await fetch('/cart/add', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(payload),
|
||
credentials: 'include'
|
||
});
|
||
const json = await res.json();
|
||
|
||
if(json.code === 200) {
|
||
window.location.href = '/cart.html';
|
||
} else {
|
||
alert(json.message);
|
||
btn.disabled = false;
|
||
btn.innerText = '加入购物袋';
|
||
}
|
||
} else {
|
||
// 未登录:存储到cookies
|
||
let cartItems = CookieUtils.getJSON('cartItems') || [];
|
||
|
||
// 检查是否已存在相同配置的商品
|
||
const existingIndex = cartItems.findIndex(item =>
|
||
item.productId === payload.productId &&
|
||
item.specId === payload.specId &&
|
||
item.customSweetness === payload.customSweetness &&
|
||
item.customIce === payload.customIce &&
|
||
item.toppings === payload.toppings
|
||
);
|
||
|
||
if (existingIndex >= 0) {
|
||
// 合并数量
|
||
cartItems[existingIndex].quantity += payload.quantity;
|
||
} else {
|
||
// 添加新商品
|
||
cartItems.push(payload);
|
||
}
|
||
|
||
// 限制购物车数量,避免cookies过大(最多保存30个商品)
|
||
if (cartItems.length > 30) {
|
||
cartItems = cartItems.slice(-30);
|
||
}
|
||
|
||
CookieUtils.setJSON('cartItems', cartItems, 7); // 7天过期
|
||
|
||
// 提示并跳转
|
||
if (confirm('商品已添加到购物袋,是否前往购物袋?')) {
|
||
window.location.href = '/cart.html';
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.innerText = '加入购物袋';
|
||
}
|
||
}
|
||
} catch(e) {
|
||
console.error(e);
|
||
alert('添加失败');
|
||
btn.disabled = false;
|
||
btn.innerText = '加入购物袋';
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |