r117/public/app.js · 约 38302 字符
1/* 甜趣蛋糕 H5 · 前端逻辑(在线走真实 PHP API,离线走 SEED + localStorage 兜底) */2(function () {3 'use strict';4 const $ = (s, r) => (r || document).querySelector(s);5 const view = $('#view'), topbar = $('#topbar'), tabbar = $('#tabbar');6 let member = SEED.member;7 let cur = { view: 'home', pid: 0, selSize: '', selFlavor: '', lastFilter: {} };89 /* ---------- 工具 ---------- */10 function toast(msg) {11 const t = $('#toast'); t.textContent = msg; t.classList.add('show');12 clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), 1600);13 }14 function money(n) { return '¥' + (Math.round(n * 100) / 100); }15 function esc(s) { return (s == null ? '' : String(s)).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }16 function openSheet(html) { $('#sheet').innerHTML = html; $('#mask').classList.add('show'); }17 function closeSheet() { $('#mask').classList.remove('show'); }18 $('#mask').addEventListener('click', e => { if (e.target.id === 'mask') closeSheet(); });1920 function setTop(title, back) {21 let h = '';22 if (back) h += '<div class="back" onclick="SC.back()">‹</div>';23 if (title) h += '<div class="title">' + esc(title) + '</div>';24 else h += '<div class="title"></div>';25 h += '<div class="act" onclick="SC.home()">🏠</div>';26 topbar.innerHTML = h;27 SC._back = back || null;28 }2930 /* ---------- API:在线优先,离线 SEED 兜底 ---------- */31 async function api(action, payload, method) {32 method = method || 'GET';33 try {34 const opt = { method, headers: { 'Content-Type': 'application/json' } };35 let url = 'index.php?c=api&a=' + action;36 if (method === 'GET' && payload) {37 url += '&' + new URLSearchParams(payload).toString();38 } else if (payload) {39 opt.body = JSON.stringify(payload);40 }41 const r = await fetch(url, opt);42 if (!r.ok) throw 0;43 const j = await r.json();44 if (j && j.ok) return j;45 throw 0;46 } catch (e) {47 return seedApi(action, payload || {}, method);48 }49 }5051 /* ---------- 离线 SEED / localStorage 兜底 ---------- */52 const LS = {53 get(k, d) { try { return JSON.parse(localStorage.getItem(k)) || d; } catch (e) { return d; } },54 set(k, v) { localStorage.setItem(k, JSON.stringify(v)); }55 };56 function ensureDemo() {57 if (!localStorage.getItem('sc_orders')) {58 LS.set('sc_orders', [{59 id: 1001, order_no: '2026081500123000101', member_id: 2, contact_name: '阿慕', phone: '13800000002',60 address: '西安市雁塔区科技路 18 号', delivery_type: 'delivery', delivery_time: '昨天下午6点', card_msg: '生日快乐!',61 remark: '', coupon_id: 0, coupon_amount: 0, points_used: 0, total_amount: 336, pay_amount: 336,62 status: 'done', created_at: '2026-08-15 18:02:11',63 items: [{ name: '芒果千层慕斯', size: '8寸', flavor: '原味', price: 158, qty: 1, subtotal: 158 },64 { name: '海盐芝士蛋糕', size: '8寸', flavor: '原味', price: 178, qty: 1, subtotal: 178 }]65 }]);66 }67 if (!localStorage.getItem('sc_mycoupons')) {68 LS.set('sc_mycoupons', [{ id: 11, coupon_id: 2, name: '满199减30', desc: '满199减30', status: 'unused', type: 'full_reduce' },69 { id: 12, coupon_id: 5, name: '无门槛5元券', desc: '无门槛¥5', status: 'unused', type: 'no_threshold' }]);70 }71 }72 function seedApi(action, p, method) {73 method = method || 'GET';74 const S = SEED;75 switch (action) {76 case 'banners': return { ok: 1, data: S.banners };77 case 'categories': return { ok: 1, scenes: S.scenes, kinds: S.kinds, sizes: S.sizes };78 case 'products': {79 let list = S.products.slice();80 if (p.kind) list = list.filter(x => x.kind_id == p.kind);81 if (p.scene) list = list.filter(x => (x.scenes || '').split('|').includes(p.scene));82 if (p.kw) { const k = (p.kw || '').toLowerCase(); list = list.filter(x => (x => x.name.toLowerCase().includes(k) || (x.tags || '').toLowerCase().includes(k))(x)); }83 if (p.tag) list = list.filter(x => x[p.tag] == 1);84 if (p.sort === 'sales') list.sort((a, b) => b.sales - a.sales);85 else if (p.sort === 'price_asc') list.sort((a, b) => a.price - b.price);86 else if (p.sort === 'price_desc') list.sort((a, b) => b.price - a.price);87 else if (p.sort === 'rating') list.sort((a, b) => b.rating - a.rating);88 else list.sort((a, b) => (b.is_hot - a.is_hot) || (b.sales - a.sales));89 return { ok: 1, data: list };90 }91 case 'product_detail': {92 const pr = S.products.find(x => x.id == p.id);93 if (!pr) return { ok: 0 };94 return { ok: 1, product: pr, skus: pr.skus, reviews: S.reviews.filter(r => r.product_id == p.id) };95 }96 case 'search': {97 const k = (p.kw || '').toLowerCase();98 const list = S.products.filter(x => x.name.toLowerCase().includes(k) || (x.tags || '').toLowerCase().includes(k) || (x.scenes || '').toLowerCase().includes(k));99 return { ok: 1, data: list };100 }101 case 'recommend': return { ok: 1, data: S.products.filter(x => x.is_recommend || x.is_hot).slice(0, 8) };102 case 'member_info': return { ok: 1, member: member, coupons_count: LS.get('sc_mycoupons', []).filter(c => c.status === 'unused').length };103 case 'coupon_list': return { ok: 1, data: S.coupons };104 case 'settings': return { ok: 1, data: S.settings };105 case 'cart_list': {106 const cart = LS.get('sc_cart', []);107 let total = 0; const data = cart.map(c => { c.subtotal = Math.round(c.price * c.qty * 100) / 100; total += c.subtotal; return c; });108 return { ok: 1, data, total: Math.round(total * 100) / 100 };109 }110 case 'cart_add': {111 const cart = LS.get('sc_cart', []);112 const ex = cart.find(c => c.product_id == p.product_id && c.size == p.size && c.flavor == p.flavor);113 if (ex) ex.qty += p.qty; else cart.push({ product_id: p.product_id, size: p.size, flavor: p.flavor, price: p.price, qty: p.qty, name: p.name, images: p.images });114 LS.set('sc_cart', cart); return { ok: 1, msg: '已加入购物车' };115 }116 case 'cart_update': {117 const cart = LS.get('sc_cart', []); const it = cart.find(c => c.product_id == p.product_id && c.size == p.size && c.flavor == p.flavor); if (it) it.qty = p.qty; LS.set('sc_cart', cart); return { ok: 1 };118 }119 case 'cart_remove': {120 let cart = LS.get('sc_cart', []); cart = cart.filter(c => !(c.product_id == p.product_id && c.size == p.size && c.flavor == p.flavor)); LS.set('sc_cart', cart); return { ok: 1 };121 }122 case 'coupon_receive': {123 const mine = LS.get('sc_mycoupons', []); const c = S.coupons.find(x => x.id == p.coupon_id);124 if (mine.find(m => m.coupon_id == p.coupon_id)) return { ok: 0, msg: '您已领取过该券' };125 mine.push({ id: Date.now(), coupon_id: c.id, name: c.name, desc: c.desc, type: c.type, status: 'unused' });126 LS.set('sc_mycoupons', mine); return { ok: 1, msg: '领取成功' };127 }128 case 'coupon_my': return { ok: 1, data: LS.get('sc_mycoupons', []) };129 case 'order_create': {130 ensureDemo();131 const orders = LS.get('sc_orders', []);132 const oid = Date.now(); const no = '2026' + oid.toString().slice(-10);133 orders.unshift({ id: oid, order_no: no, member_id: 2, contact_name: p.contact_name, phone: p.phone, address: p.address,134 delivery_type: p.delivery_type, delivery_time: p.delivery_time, card_msg: p.card_msg, remark: p.remark, coupon_id: 0, coupon_amount: 0, points_used: 0,135 total_amount: p.total_amount, pay_amount: p.pay_amount, status: 'paid', created_at: new Date().toISOString().slice(0, 19).replace('T', ' '), items: p.items });136 LS.set('sc_orders', orders); LS.set('sc_cart', []); return { ok: 1, order_id: oid, order_no: no, pay_amount: p.pay_amount };137 }138 case 'order_list': ensureDemo(); return { ok: 1, data: LS.get('sc_orders', []) };139 case 'order_detail': ensureDemo(); { const o = LS.get('sc_orders', []).find(x => x.id == p.id); return o ? { ok: 1, order: o } : { ok: 0 }; }140 case 'order_cancel': { const o = LS.get('sc_orders', []); const it = o.find(x => x.id == p.id); if (it) it.status = 'canceled'; LS.set('sc_orders', o); return { ok: 1 }; }141 case 'order_review': { const o = LS.get('sc_orders', []); const it = o.find(x => x.id == p.order_id); if (it) it.status = 'done'; LS.set('sc_orders', o); return { ok: 1 }; }142 case 'custom_submit': return { ok: 1, msg: '定制需求已提交,客服将尽快与您确认' };143 default: return { ok: 0, msg: 'offline' };144 }145 }146147 /* ---------- 购物车操作 ---------- */148 async function addToCart(product, size, flavor, qty) {149 const sku = product.skus.find(s => s.size === size && s.flavor === flavor) || product.skus[0];150 const price = sku.price;151 const r = await api('cart_add', { member_id: member.id, product_id: product.id, sku_id: sku.id || 0, size, flavor, price, qty, name: product.name, images: product.images }, 'POST');152 if (r.ok) { toast('已加入购物车 🛒'); updateBadge(); }153 else toast(r.msg || '添加失败');154 }155 async function updateBadge() {156 const r = await api('cart_list', { member_id: member.id });157 const n = r.ok ? r.data.length : 0;158 const b = $('#cartBadge'); if (b) { b.textContent = n; b.style.display = n ? 'inline-block' : 'none'; }159 }160161 /* ---------- Tab / 路由 ---------- */162 function renderTabbar() {163 const tabs = [['home', '🏠', '首页'], ['category', '🍰', '分类'], ['cart', '🛒', '购物车'], ['member', '👤', '我的']];164 tabbar.innerHTML = tabs.map(t => '<div class="tab ' + (cur.view === t[0] ? 'on' : '') + '" onclick="SC.go(\'' + t[0] + '\')"><span class="ic">' + t[1] + '</span>' + t[2] + (t[0] === 'cart' ? '<span class="badge" id="cartBadge" style="display:none">0</span>' : '') + '</div>').join('');165 }166 const SC = {167 home() { render('home'); },168 back() { if (SC._back) SC._back(); else render('home'); },169 go(v) { if (['home', 'category', 'cart', 'member'].includes(v)) render(v); else location.hash = '#' + v; }170 };171172 async function render(v) {173 if (v) cur.view = v.replace(/:.*/, '');174 renderTabbar();175 if (cur.view === 'home') return renderHome();176 if (cur.view === 'category') return renderCategory();177 if (cur.view === 'detail') return renderDetail();178 if (cur.view === 'cart') return renderCart();179 if (cur.view === 'checkout') return renderCheckout();180 if (cur.view === 'orders') return renderOrders();181 if (cur.view === 'order') return renderOrderDetail();182 if (cur.view === 'member') return renderMember();183 if (cur.view === 'coupons') return renderCoupons();184 if (cur.view === 'custom') return renderCustom();185 if (cur.view === 'search') return renderSearch();186 if (cur.view === 'help') return renderHelp();187 }188189 const STATUS = { pending: ['待支付', 'st-pending'], paid: ['已支付·待制作', 'st-paid'], making: ['制作中', 'st-making'], delivering: ['配送中', 'st-delivering'], done: ['已完成', 'st-done'], canceled: ['已取消', 'st-canceled'], refund: ['退款', 'st-refund'] };190191 /* ---------- 首页 ---------- */192 async function renderHome() {193 setTop('甜趣蛋糕', null);194 const [b, p, rec] = await Promise.all([api('banners'), api('products'), api('recommend')]);195 const banners = b.ok ? b.data : [];196 const scenes = SEED.scenes;197 const hot = p.ok ? p.data.filter(x => x.is_hot).slice(0, 4) : [];198 const newp = p.ok ? p.data.filter(x => x.is_new).slice(0, 4) : [];199 const recs = rec.ok ? rec.data : [];200 view.innerHTML =201 '<div class="searchbox" onclick="SC.go(\'search\')">🔍 <input placeholder="搜索蛋糕 / 场景 / 口味" readonly></div>' +202 '<div class="banner"><div class="slide" id="bslide"><div class="emo" id="bemo">🎂</div><div><div class="t" id="bt"></div><div class="s" id="bs"></div></div></div></div>' +203 '<div class="dots" id="bdots"></div>' +204 '<div class="quick">' + scenes.map((s, i) => '<div class="q" onclick="SC.go(\'category\')"><div class="emo">' + ['🎂','💍','🎉','☕','🧒','💕','🎊','🎁'][i] + '</div><div class="l">' + s + '</div></div>').join('') + '</div>' +205 sec('热销榜单', '全部', 'category') + grid(hot) +206 sec('新品上市', '全部', 'category') + grid(newp) +207 sec('为你推荐', '', null) + grid(recs) +208 '<div class="note">🎂 新鲜手工现做 · 提前 ' + SEED.settings.delivery_advance_hours + ' 小时预约 · 满' + SEED.settings.free_delivery_amount + '元免配送费</div>';209 let bi = 0;210 function showB() { const x = banners[bi % banners.length]; $('#bemo').textContent = x.image; $('#bt').textContent = x.title; $('#bs').textContent = '点击查看对应专区'; }211 if (banners.length) {212 showB();213 const dots = $('#bdots'); dots.innerHTML = banners.map((_, i) => '<span class="' + (i === 0 ? 'on' : '') + '"></span>').join('');214 setInterval(() => { bi++; showB(); [...dots.children].forEach((d, i) => d.classList.toggle('on', i === bi % banners.length)); }, 3000);215 }216 }217 function sec(t, more, goto) { return '<div class="sec-h"><div class="t">' + t + '</div>' + (more ? '<div class="more" onclick="SC.go(\'' + goto + '\')">' + more + ' ›</div>' : '<div></div>') + '</div>'; }218 function grid(list) {219 if (!list || !list.length) return '<div class="empty"><span class="e">🍰</span>暂无商品</div>';220 return '<div class="grid">' + list.map(p => {221 const tag = p.is_new ? '<span class="tag new">新品</span>' : (p.is_hot ? '<span class="tag hot">热销</span>' : '');222 return '<div class="pcard" onclick="SC.go(\'detail:' + p.id + '\')"><div class="ph">' + tag + (p.images[0] || '🎂') + '</div>' +223 '<div class="body"><div class="nm">' + esc(p.name) + '</div>' +224 '<div class="meta">已售' + p.sales + ' · ' + p.rating + '★</div>' +225 '<span class="price">' + money(p.price) + (p.original_price ? '<small>' + money(p.original_price) + '</small>' : '') + '</span>' +226 '<button class="add" onclick="event.stopPropagation();SC.quickAdd(' + p.id + ')">+</button></div></div>';227 }).join('') + '</div>';228 }229 SC.quickAdd = async function (id) {230 const r = await api('product_detail', { id });231 if (!r.ok) return toast('商品不存在');232 addToCart(r.product, r.skus[0].size, r.skus[0].flavor, 1);233 };234235 /* ---------- 分类 ---------- */236 async function renderCategory() {237 setTop('全部分类', () => render('home'));238 const [c, p] = await Promise.all([api('categories'), api('products', cur.lastFilter)]);239 const cats = c.ok ? c : { scenes: SEED.scenes, kinds: SEED.kinds, sizes: SEED.sizes };240 const f = cur.lastFilter;241 view.innerHTML =242 '<div class="fbar" id="fkind">' + '<div class="c ' + (!f.kind ? 'on' : '') + '" data-k="0">全部</div>' + cats.kinds.map(k => '<div class="c ' + (f.kind == k.id ? 'on' : '') + '" data-k="' + k.id + '">' + k.name + '</div>').join('') + '</div>' +243 '<div class="fbar" id="fscene">' + cats.scenes.map(s => '<div class="c ' + (f.scene === s ? 'on' : '') + '" data-s="' + esc(s) + '">' + s + '</div>').join('') + '</div>' +244 '<div class="sortbar" id="fsort"><div class="c ' + (!f.sort ? 'on' : '') + '" data-o="">综合</div><div class="c ' + (f.sort === 'sales' ? 'on' : '') + '" data-o="sales">销量</div><div class="c ' + (f.sort === 'price_asc' ? 'on' : '') + '" data-o="price_asc">价格↑</div><div class="c ' + (f.sort === 'price_desc' ? 'on' : '') + '" data-o="price_desc">价格↓</div><div class="c ' + (f.sort === 'rating' ? 'on' : '') + '" data-o="rating">好评</div></div>' +245 '<div id="plist"></div>';246 const reload = async () => {247 const r = await api('products', cur.lastFilter);248 $('#plist').innerHTML = grid(r.ok ? r.data : []);249 };250 $('#fkind').onclick = e => { const t = e.target.closest('.c'); if (!t) return; cur.lastFilter.kind = +t.dataset.k || 0; renderCategory(); };251 $('#fscene').onclick = e => { const t = e.target.closest('.c'); if (!t) return; cur.lastFilter.scene = cur.lastFilter.scene === t.dataset.s ? '' : t.dataset.s; renderCategory(); };252 $('#fsort').onclick = e => { const t = e.target.closest('.c'); if (!t) return; cur.lastFilter.sort = t.dataset.o; renderCategory(); };253 reload();254 }255256 /* ---------- 详情 ---------- */257 async function renderDetail() {258 const r = await api('product_detail', { id: cur.pid });259 if (!r.ok) { view.innerHTML = '<div class="empty"><span class="e">🚫</span>商品不存在</div>'; return; }260 const pr = r.product, skus = r.skus, revs = r.reviews || [];261 cur.selSize = skus[0].size; cur.selFlavor = skus[0].flavor;262 setTop(pr.name, () => render('home'));263 const sizes = [...new Set(skus.map(s => s.size))];264 const flavors = [...new Set(skus.map(s => s.flavor))];265 const curSku = () => skus.find(s => s.size === cur.selSize && s.flavor === cur.selFlavor) || skus[0];266 view.innerHTML =267 '<div class="dph">' + (pr.images[0] || '🎂') + '</div>' +268 '<div class="dname">' + esc(pr.name) + '</div>' +269 '<div class="dmeta">已售 ' + pr.sales + ' · ' + pr.rating + '★ · 库存 ' + pr.stock + '</div>' +270 '<div class="dprice">' + money(curSku().price) + (pr.original_price ? '<small>' + money(pr.original_price) + '</small>' : '') + (pr.is_new ? ' <span class="muted" style="font-size:12px">新品</span>' : '') + '</div>' +271 '<div class="attr"><div class="row"><div class="lab">尺寸</div><div class="opts" id="oSz">' + sizes.map(s => '<div class="opt ' + (s === cur.selSize ? 'on' : '') + '" data-s="' + esc(s) + '">' + s + '</div>').join('') + '</div></div>' +272 '<div class="row"><div class="lab">口味</div><div class="opts" id="oFl">' + flavors.map(f => '<div class="opt ' + (f === cur.selFlavor ? 'on' : '') + '" data-f="' + esc(f) + '">' + f + '</div>').join('') + '</div></div>' +273 '<div class="row"><div class="lab">数量</div><div class="qty"><button onclick="SC.chg(-1)">−</button><span id="qnum">1</span><button onclick="SC.chg(1)">+</button></div></div></div>' +274 '<div class="desc"><h4>商品介绍</h4>' + esc(pr.description) +275 '<h4>配料表</h4>' + esc(pr.ingredients) + '<h4>过敏原提示</h4>' + esc(pr.allergen) +276 '<h4>保质期</h4>' + esc(pr.shelf_life) + '<h4>食用建议</h4>' + esc(pr.advice) + '</div>' +277 (revs.length ? '<div class="sec-h mt12"><div class="t">用户评价 (' + revs.length + ')</div></div>' + revs.map(rv => '<div class="field" style="box-shadow:none;border:1px solid var(--line)"><div class="flex between"><b>' + esc(rv.username) + '</b><span class="brand">' + '★'.repeat(rv.rating) + '</span></div><div class="mt8">' + esc(rv.content) + '</div></div>').join('') : '') +278 '<div style="height:70px"></div>' +279 '<div class="bar2"><div class="price" id="bprice">' + money(curSku().price) + '</div>' +280 '<button class="btn ghost" onclick="SC.buyNow()">立即购买</button>' +281 '<button class="btn pri" onclick="SC.addCart()">加入购物车</button></div>';282 const refresh = () => { const sk = curSku(); $('#bprice').textContent = money(sk.price); };283 $('#oSz').onclick = e => { const t = e.target.closest('.opt'); if (!t) return; cur.selSize = t.dataset.s; [...$('#oSz').children].forEach(o => o.classList.toggle('on', o === t)); refresh(); };284 $('#oFl').onclick = e => { const t = e.target.closest('.opt'); if (!t) return; cur.selFlavor = t.dataset.f; [...$('#oFl').children].forEach(o => o.classList.toggle('on', o === t)); refresh(); };285 SC._qty = 1;286 SC.chg = d => { SC._qty = Math.max(1, SC._qty + d); $('#qnum').textContent = SC._qty; };287 SC.addCart = () => addToCart(pr, cur.selSize, cur.selFlavor, SC._qty);288 SC.buyNow = async () => { await addToCart(pr, cur.selSize, cur.selFlavor, SC._qty); render('cart'); };289 window._curProduct = pr;290 }291292 /* ---------- 购物车 ---------- */293 async function renderCart() {294 setTop('购物车', () => render('home'));295 const r = await api('cart_list', { member_id: member.id });296 const list = r.ok ? r.data : [];297 if (!list.length) { view.innerHTML = '<div class="empty"><span class="e">🛒</span>购物车还是空的<br>去挑一款喜欢的蛋糕吧</div>'; return; }298 view.innerHTML = '<div id="clist"></div>' +299 '<div class="bar2"><div class="price" id="ctotal">合计 ' + money(r.total) + '</div><button class="btn pri" onclick="SC.checkout()">去结算</button></div>';300 const draw = () => {301 $('#clist').innerHTML = list.map(c => '<div class="citem" data-p="' + c.product_id + '" data-s="' + esc(c.size) + '" data-f="' + esc(c.flavor) + '">' +302 '<div class="ph">' + (c.images && c.images[0] || '🎂') + '</div>' +303 '<div class="info"><div class="nm">' + esc(c.name) + '</div><div class="sp">' + esc(c.size) + ' · ' + esc(c.flavor) + '</div>' +304 '<div class="pr">' + money(c.price) + ' <span class="qty" style="float:right"><button onclick="SC.cqty(' + c.product_id + ',\'' + esc(c.size) + '\',\'' + esc(c.flavor) + '\',-1)">−</button><span>' + c.qty + '</span><button onclick="SC.cqty(' + c.product_id + ',\'' + esc(c.size) + '\',\'' + esc(c.flavor) + '\',1)">+</button></span></div></div>' +305 '<div onclick="SC.cdel(' + c.product_id + ',\'' + esc(c.size) + '\',\'' + esc(c.flavor) + '\')" style="color:#bbb;font-size:20px;cursor:pointer">🗑</div></div>').join('');306 };307 draw();308 SC.cqty = async (pid, sz, fl, d) => {309 const it = list.find(c => c.product_id == pid && c.size === sz && c.flavor === fl);310 const nq = Math.max(1, it.qty + d);311 await api('cart_update', { product_id: pid, size: sz, flavor: fl, qty: nq }, 'POST');312 it.qty = nq; const t = list.reduce((a, c) => a + c.price * c.qty, 0); $('#ctotal').textContent = '合计 ' + money(t); draw();313 };314 SC.cdel = async (pid, sz, fl) => { await api('cart_remove', { product_id: pid, size: sz, flavor: fl }, 'POST'); renderCart(); updateBadge(); };315 SC.checkout = () => render('checkout');316 }317318 /* ---------- 结算 ---------- */319 async function renderCheckout() {320 setTop('确认订单', () => render('cart'));321 const [cart, set] = await Promise.all([api('cart_list', { member_id: member.id }), api('settings')]);322 const list = cart.ok ? cart.data : [];323 if (!list.length) { view.innerHTML = '<div class="empty"><span class="e">🧾</span>购物车为空</div>'; return; }324 const set0 = set.ok ? set.data : SEED.settings;325 const fee = list.reduce((a, c) => a + c.price * c.qty, 0) < (+set0.free_delivery_amount) ? (+set0.delivery_fee) : 0;326 const total = list.reduce((a, c) => a + c.price * c.qty, 0) + fee;327 cur._fee = fee; cur._total = total; cur._items = list;328 view.innerHTML =329 '<div class="field"><label>收货人</label><input id="cn" value="' + esc(member.username) + '"></div>' +330 '<div class="field"><label>手机号</label><input id="cp" value="' + esc(member.phone) + '"></div>' +331 '<div class="field"><label>配送地址</label><input id="ca" value="' + esc(set0.address) + '"></div>' +332 '<div class="field"><label>配送方式</label><select id="dt"><option value="delivery">同城配送(' + (+set0.delivery_fee) + '元,满' + set0.free_delivery_amount + '免)</option><option value="pickup">到店自提(免配送费)</option></select></div>' +333 '<div class="field"><label>期望送达时间</label><input id="dtime" placeholder="如:明天下午3点"></div>' +334 '<div class="field"><label>贺卡留言</label><textarea id="card" placeholder="写下想说的话…"></textarea></div>' +335 '<div class="field"><label>订单备注</label><input id="remark" placeholder="忌口 / 特殊要求"></div>' +336 list.map(c => '<div class="citem"><div class="ph">' + (c.images && c.images[0] || '🎂') + '</div><div class="info"><div class="nm">' + esc(c.name) + '</div><div class="sp">' + esc(c.size) + ' · ' + esc(c.flavor) + ' × ' + c.qty + '</div><div class="pr">' + money(c.price * c.qty) + '</div></div></div>').join('') +337 '<div class="note">配送费 ' + money(fee) + ' · 合计 <b class="brand">' + money(total) + '</b>(演示环境支付为模拟)</div>' +338 '<button class="btn pri block" onclick="SC.submitOrder()">提交订单 ' + money(total) + '</button>';339 }340 SC.submitOrder = async function () {341 const items = cur._items.map(c => ({ product_id: c.product_id, sku_id: c.sku_id || 0, qty: c.qty }));342 const r = await api('order_create', {343 member_id: member.id, contact_name: $('#cn').value, phone: $('#cp').value, address: $('#ca').value,344 delivery_type: $('#dt').value, delivery_time: $('#dtime').value, card_msg: $('#card').value, remark: $('#remark').value,345 items, total_amount: cur._total, pay_amount: cur._total346 }, 'POST');347 if (r.ok) { toast('下单成功 🎉'); updateBadge(); openSheet('<div class="sheet"><h3>🎉 支付成功(演示)</h3><p class="muted">订单号 ' + r.order_no + '<br>实付 ' + money(r.pay_amount) + '</p><button class="btn pri block mt12" onclick="closeSheet();SC.go(\'orders\')">查看我的订单</button></div>'); }348 else toast(r.msg || '下单失败');349 };350351 /* ---------- 订单列表 ---------- */352 async function renderOrders() {353 setTop('我的订单', () => render('member'));354 const r = await api('order_list', { member_id: member.id });355 const list = r.ok ? r.data : [];356 if (!list.length) { view.innerHTML = '<div class="empty"><span class="e">📦</span>还没有订单</div>'; return; }357 view.innerHTML = list.map(o => {358 const st = STATUS[o.status] || ['', ''];359 return '<div class="ord" onclick="SC.go(\'order:' + o.id + '\')"><div class="h"><span>订单号 ' + o.order_no + '</span><span class="st ' + st[1] + '">' + st[0] + '</span></div>' +360 o.items.map(it => '<div class="it"><div class="ph">🎂</div><div>' + esc(it.name) + ' <span class="muted">' + esc(it.size) + '·' + esc(it.flavor) + ' ×' + it.qty + '</span></div></div>').join('') +361 '<div class="total">实付 <b>' + money(o.pay_amount) + '</b></div></div>';362 }).join('');363 }364 async function renderOrderDetail() {365 const r = await api('order_detail', { id: cur.oid });366 if (!r.ok) { view.innerHTML = '<div class="empty"><span class="e">🚫</span>订单不存在</div>'; return; }367 const o = r.order; const st = STATUS[o.status] || ['', ''];368 setTop('订单详情', () => render('orders'));369 const steps = ['paid', 'making', 'delivering', 'done'];370 const idx = steps.indexOf(o.status);371 const prog = idx >= 0 ? '<div class="prog">' + [['🧾', '已支付'], ['👩🍳', '制作中'], ['🚚', '配送中'], ['✅', '已完成']].map((s, i) => '<div class="p ' + (i <= idx ? 'on' : '') + '"><div class="dot">' + s[0] + '</div><div class="l">' + s[1] + '</div></div>').join('') + '</div>' : '';372 view.innerHTML = '<div class="ord" style="cursor:default"><div class="h"><span>订单号 ' + o.order_no + '</span><span class="st ' + st[1] + '">' + st[0] + '</span></div>' +373 o.items.map(it => '<div class="it"><div class="ph">🎂</div><div>' + esc(it.name) + ' <span class="muted">' + esc(it.size) + '·' + esc(it.flavor) + ' ×' + it.qty + '</span></div><div class="pr">' + money(it.price * it.qty) + '</div></div>').join('') + '</div>' +374 prog +375 '<div class="field"><label>收货人</label><div>' + esc(o.contact_name) + ' · ' + esc(o.phone) + '</div></div>' +376 '<div class="field"><label>地址</label><div>' + esc(o.address) + '</div></div>' +377 (o.delivery_time ? '<div class="field"><label>期望送达</label><div>' + esc(o.delivery_time) + '</div></div>' : '') +378 (o.card_msg ? '<div class="field"><label>贺卡留言</label><div>' + esc(o.card_msg) + '</div></div>' : '') +379 '<div class="field"><label>实付金额</label><div class="brand bold" style="font-size:16px">' + money(o.pay_amount) + '</div></div>' +380 (o.status === 'paid' || o.status === 'making' ? '<button class="btn ghost block" onclick="SC.cancelOrder(' + o.id + ')">取消订单</button>' : '') +381 (o.status === 'done' ? '<button class="btn pri block" onclick="SC.reviewOrder(' + o.id + ',' + (o.items[0] ? o.items[0].product_id : 0) + ')">评价晒单</button>' : '');382 SC.cancelOrder = async id => { await api('order_cancel', { id }, 'POST'); toast('已取消'); render('orders'); };383 SC.reviewOrder = async (oid, pid) => {384 openSheet('<div class="sheet"><span class="close" onclick="closeSheet()">×</span><h3>评价晒单</h3><div id="rstars" class="flex" style="gap:6px;font-size:24px;margin:10px 0">★★★☆☆</div><textarea id="rcontent" placeholder="说说蛋糕怎么样~"></textarea><button class="btn pri block mt12" onclick="SC.doReview(' + oid + ',' + pid + ')">提交评价</button></div>');385 const stars = $('#rstars'); let rating = 3;386 const paint = () => stars.textContent = '★'.repeat(rating) + '☆'.repeat(5 - rating);387 stars.onclick = () => { rating = Math.min(5, rating + 1); paint(); };388 SC.doReview = async (oid, pid) => { await api('order_review', { order_id: oid, product_id: pid, member_id: member.id, rating, content: $('#rcontent').value }, 'POST'); closeSheet(); toast('感谢评价 ❤'); render('orders'); };389 };390 }391392 /* ---------- 会员中心 ---------- */393 async function renderMember() {394 setTop('个人中心', () => render('home'));395 const [m, c] = await Promise.all([api('member_info', { member_id: member.id }), api('coupon_my', { member_id: member.id })]);396 const mem = m.ok ? m.member : member;397 const myC = c.ok ? c.data : [];398 view.innerHTML =399 '<div class="mhero"><div class="nm">' + esc(mem.username) + ' <span class="lv">' + esc(mem.level_name) + '</span></div>' +400 '<div class="row"><div class="c"><div class="n">' + mem.points + '</div><div class="l">积分</div></div>' +401 '<div class="c"><div class="n">' + money(mem.balance) + '</div><div class="l">储值余额</div></div>' +402 '<div class="c"><div class="n">' + mem.growth + '</div><div class="l">成长值</div></div>' +403 '<div class="c"><div class="n">' + myC.filter(x => x.status === 'unused').length + '</div><div class="l">优惠券</div></div></div></div>' +404 '<div class="mlist mt12">' +405 '<div class="it" onclick="SC.go(\'orders\')"><span class="e">📦</span><span class="t">我的订单</span><span class="r">›</span></div>' +406 '<div class="it" onclick="SC.go(\'coupons\')"><span class="e">🎟️</span><span class="t">我的优惠券</span><span class="r">' + myC.filter(x => x.status === 'unused').length + ' 张 ›</span></div>' +407 '<div class="it" onclick="SC.go(\'custom\')"><span class="e">🎨</span><span class="t">定制蛋糕</span><span class="r">›</span></div>' +408 '<div class="it" onclick="SC.go(\'help\')"><span class="e">ℹ️</span><span class="t">帮助与关于</span><span class="r">›</span></div>' +409 '</div>' +410 '<div class="note mt12">🎂 会员日福利 · 积分抵现(100 积分=1 元,单笔最高抵 10%)· 生日专属券 · 免费升磅</div>';411 }412413 /* ---------- 优惠券 ---------- */414 async function renderCoupons() {415 setTop('优惠券', () => render('member'));416 const [list, mine] = await Promise.all([api('coupon_list'), api('coupon_my', { member_id: member.id })]);417 const cs = list.ok ? list.data : [];418 const my = mine.ok ? mine.data : [];419 view.innerHTML = sec('领券中心', '', null) +420 cs.map(c => '<div class="coupon"><div class="l"><div class="v">' + (c.type === 'discount' ? (c.discount * 10) : '¥' + c.value) + (c.type === 'discount' ? '<small>折</small>' : '') + '</div><div style="font-size:10px;opacity:.9">' + (c.type === 'newcomer' ? '新人' : c.type === 'birthday' ? '生日' : '券') + '</div></div>' +421 '<div class="r"><div class="nm">' + esc(c.name) + '</div><div class="cond">' + c.desc + ' · 有效期' + c.valid_days + '天</div></div>' +422 '<div class="get"><button onclick="SC.recv(' + c.id + ')">领取</button></div></div>').join('') +423 sec('我的优惠券', '', null) +424 (my.length ? my.map(c => '<div class="coupon ' + (c.status !== 'unused' ? 'used' : '') + '"><div class="l"><div class="v">' + (c.desc || '').replace('满', '').replace('减', '¥') + '</div></div><div class="r"><div class="nm">' + esc(c.name) + '</div><div class="cond">' + (c.status === 'unused' ? '未使用' : c.status === 'used' ? '已使用' : '已过期') + '</div></div></div>').join('') : '<div class="muted" style="padding:10px">还没有领取优惠券</div>');425 SC.recv = async function (id) { const r = await api('coupon_receive', { member_id: member.id, coupon_id: id }, 'POST'); toast(r.msg || (r.ok ? '领取成功' : '失败')); if (r.ok) renderCoupons(); };426 }427428 /* ---------- 定制蛋糕 ---------- */429 async function renderCustom() {430 setTop('定制蛋糕', () => render('member'));431 view.innerHTML =432 '<div class="note">🎨 上传参考图、写下祝福语与造型,专属甜品师为您 1 对 1 设计</div>' +433 '<div class="field"><label>参考图(可上传一张)</label><div class="upbox" id="upbox">点击上传图片</div><input type="file" id="upfile" accept="image/*" style="display:none"></div>' +434 '<div class="field"><label>祝福语 / 文字内容</label><input id="ctext" placeholder="如:宝贝生日快乐"></div>' +435 '<div class="field"><label>造型偏好</label><div class="chips" id="cshape">' + ['城堡', '小动物', '鲜花', '卡通', '字母', '其它'].map(s => '<div class="c" data-s="' + s + '">' + s + '</div>').join('') + '</div></div>' +436 '<div class="field"><label>尺寸</label><div class="chips" id="csize">' + SEED.sizes.map(s => '<div class="c" data-s="' + esc(s) + '">' + s + '</div>').join('') + '</div></div>' +437 '<div class="field"><label>特殊要求</label><textarea id="cnote" placeholder="奶油颜色、忌口、配送时间…"></textarea></div>' +438 '<button class="btn pri block" onclick="SC.submitCustom()">提交定制需求</button>';439 let img = '', shape = '', size = '8寸';440 $('#upbox').onclick = () => $('#upfile').click();441 $('#upfile').onchange = e => { const f = e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = ev => { img = ev.target.result; $('#upbox').innerHTML = '<img src="' + img + '">'; }; rd.readAsDataURL(f); };442 $('#cshape').onclick = e => { const t = e.target.closest('.c'); if (!t) return; shape = t.dataset.s; [...e.currentTarget.children].forEach(o => o.classList.toggle('on', o === t)); };443 $('#csize').onclick = e => { const t = e.target.closest('.c'); if (!t) return; size = t.dataset.s; [...e.currentTarget.children].forEach(o => o.classList.toggle('on', o === t)); };444 SC.submitCustom = async () => {445 const r = await api('custom_submit', { member_id: member.id, image: img, text: $('#ctext').value, shape, size, note: $('#cnote').value }, 'POST');446 if (r.ok) { toast(r.msg); render('member'); } else toast(r.msg || '提交失败');447 };448 }449450 /* ---------- 搜索 ---------- */451 async function renderSearch() {452 setTop('搜索', () => render('home'));453 view.innerHTML = '<div class="searchbox" style="background:#fff;border:1px solid var(--line)"><span style="color:#999">🔍</span><input id="skw" placeholder="搜索蛋糕 / 场景 / 口味" style="color:#333"></div>' +454 '<div class="sec-h mt12"><div class="t">热门搜索</div></div><div class="chips">' + ['生日蛋糕', '草莓', '慕斯', '芝士', '无糖', '儿童', '巧克力'].map(s => '<div class="c" onclick="SC.doSearch(\'' + s + '\')">' + s + '</div>').join('') + '</div>' +455 '<div id="sres"></div>';456 const doS = async kw => { const r = await api('search', { kw }); $('#sres').innerHTML = sec('搜索结果', '', null) + grid(r.ok ? r.data : []); };457 $('#skw').addEventListener('input', e => { if (e.target.value) doS(e.target.value); });458 SC.doSearch = doS;459 }460461 /* ---------- 帮助 ---------- */462 async function renderHelp() {463 setTop('帮助与关于', () => render('member'));464 const s = SEED.settings;465 view.innerHTML =466 '<div class="mhero" style="background:linear-gradient(135deg,#ffb6c8,#ff8fab)"><div class="nm">' + esc(s.shop_name) + '</div><div class="row"><div class="c"><div class="n">' + esc(s.phone) + '</div><div class="l">客服电话</div></div><div class="c"><div class="n">' + esc(s.open_time) + '</div><div class="l">营业时间</div></div></div></div>' +467 '<div class="field"><label>门店地址</label><div>' + esc(s.address) + '</div></div>' +468 '<div class="field"><label>配送说明</label><div>' + esc(s.notice) + '</div></div>' +469 '<div class="mlist"><div class="it"><span class="e">🚚</span><span class="t">配送范围</span><span class="r">门店 ' + s.delivery_range_km + ' 公里内</span></div>' +470 '<div class="it"><span class="e">💰</span><span class="t">配送费</span><span class="r">' + s.delivery_fee + ' 元(满' + s.free_delivery_amount + '免)</span></div>' +471 '<div class="it"><span class="e">⏰</span><span class="t">提前预约</span><span class="r">' + s.delivery_advance_hours + ' 小时</span></div>' +472 '<div class="it"><span class="e">↩️</span><span class="t">售后退换</span><span class="r">破损/口味不符可退</span></div></div>' +473 '<div class="note mt12">本模板为「蛋糕店 PHP + H5 全栈演示」:前端 H5 + 后端 PHP MVC + SQLite,下载解压后 <code>php -S</code> 即可运行。演示数据仅供学习参考。</div>';474 }475476 /* ---------- 启动 ---------- */477 function route() {478 const h = location.hash.replace('#', '');479 if (h.startsWith('detail:')) { cur.view = 'detail'; cur.pid = +h.split(':')[1]; }480 else if (h.startsWith('order:')) { cur.view = 'order'; cur.oid = +h.split(':')[1]; }481 else if (h) cur.view = h;482 else cur.view = 'home';483 render();484 }485 window.SC = SC;486 window.addEventListener('hashchange', route);487 ensureDemo();488 updateBadge();489 route();490})();491