306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
/**
|
|
* 商城前台 AJAX 封装
|
|
*
|
|
* 提供统一的 XHR 请求方法、购物车操作、签到、工单提交等。
|
|
* 依赖:页面中需有 <meta name="csrf" content="..."> 或全局变量 FNW_CSRF
|
|
*/
|
|
var Fnw = (function () {
|
|
'use strict';
|
|
|
|
// ─── CSRF Token ───
|
|
function getCsrfToken() {
|
|
var m = document.querySelector('meta[name="csrf"]');
|
|
return m ? m.getAttribute('content') : (window.FNW_CSRF || '');
|
|
}
|
|
|
|
// ─── 通用 XHR ───
|
|
/**
|
|
* 发送请求
|
|
* @param {Object} opts
|
|
* @param {string} opts.action - API action 名称
|
|
* @param {string} [opts.method='GET'] - HTTP 方法
|
|
* @param {Object} [opts.data] - POST 数据
|
|
* @param {function} [opts.success] - 成功回调 (res)
|
|
* @param {function} [opts.error] - 失败回调 (res)
|
|
* @param {function} [opts.complete] - 完成回调
|
|
*/
|
|
function request(opts) {
|
|
var method = (opts.method || 'GET').toUpperCase();
|
|
var url = 'api.php?action=' + encodeURIComponent(opts.action);
|
|
var xhr = new XMLHttpRequest();
|
|
xhr.open(method, url, true);
|
|
xhr.setRequestHeader('Accept', 'application/json');
|
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
|
|
|
if (method === 'POST') {
|
|
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
|
}
|
|
|
|
// CSRF header for POST
|
|
if (method === 'POST') {
|
|
xhr.setRequestHeader('X-CSRF-Token', getCsrfToken());
|
|
}
|
|
|
|
xhr.onreadystatechange = function () {
|
|
if (xhr.readyState !== 4) return;
|
|
if (opts.complete) opts.complete();
|
|
if (xhr.status === 0) { if (opts.error) opts.error({ ok: false, msg: '网络错误,请检查连接' }); return; }
|
|
var res;
|
|
try { res = JSON.parse(xhr.responseText); } catch (e) { res = { ok: false, msg: '服务器响应异常' }; }
|
|
if (res.ok) {
|
|
if (opts.success) opts.success(res);
|
|
} else {
|
|
if (opts.error) opts.error(res);
|
|
}
|
|
};
|
|
|
|
if (method === 'POST' && opts.data) {
|
|
var params = [];
|
|
// Always include csrf_token in POST body as fallback
|
|
opts.data.csrf_token = getCsrfToken();
|
|
for (var k in opts.data) {
|
|
if (opts.data.hasOwnProperty(k)) {
|
|
params.push(encodeURIComponent(k) + '=' + encodeURIComponent(opts.data[k]));
|
|
}
|
|
}
|
|
xhr.send(params.join('&'));
|
|
} else {
|
|
// Append GET params
|
|
if (opts.data && method === 'GET') {
|
|
var qs = [];
|
|
for (var k in opts.data) {
|
|
if (opts.data.hasOwnProperty(k)) {
|
|
qs.push(encodeURIComponent(k) + '=' + encodeURIComponent(opts.data[k]));
|
|
}
|
|
}
|
|
if (qs.length) url += '&' + qs.join('&');
|
|
}
|
|
xhr.send();
|
|
}
|
|
}
|
|
|
|
// ─── Toast 提示 ───
|
|
var toastTimer = null;
|
|
function showToast(msg, type) {
|
|
type = type || 'ok'; // 'ok' | 'err' | 'info'
|
|
var existing = document.getElementById('fnwToast');
|
|
if (existing) existing.remove();
|
|
|
|
var el = document.createElement('div');
|
|
el.id = 'fnwToast';
|
|
el.className = 'fnw-toast fnw-toast-' + type;
|
|
el.innerHTML = '<span class="fnw-toast-icon">' +
|
|
(type === 'ok' ? '✓' : type === 'err' ? '✗' : 'ⓘ') +
|
|
'</span><span class="fnw-toast-msg">' + msg + '</span>';
|
|
|
|
Object.assign(el.style, {
|
|
position: 'fixed',
|
|
top: '-60px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
zIndex: '9999',
|
|
padding: '12px 24px',
|
|
borderRadius: '10px',
|
|
fontSize: '.92rem',
|
|
fontWeight: '500',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,.15)',
|
|
transition: 'top .3s ease',
|
|
maxWidth: '90vw'
|
|
});
|
|
|
|
var colors = {
|
|
ok: { bg: '#f0fdf4', border: '#86efac', color: '#166534', icon: '#22c55e' },
|
|
err: { bg: '#fef2f2', border: '#fca5a5', color: '#991b1b', icon: '#ef4444' },
|
|
info: { bg: '#eff6ff', border: '#93c5fd', color: '#1e40af', icon: '#3b82f6' }
|
|
};
|
|
var c = colors[type] || colors.info;
|
|
el.style.background = c.bg;
|
|
el.style.border = '1px solid ' + c.border;
|
|
el.style.color = c.color;
|
|
el.querySelector('.fnw-toast-icon').style.color = c.icon;
|
|
|
|
document.body.appendChild(el);
|
|
|
|
// Animate in
|
|
requestAnimationFrame(function () {
|
|
el.style.top = '20px';
|
|
});
|
|
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(function () {
|
|
el.style.top = '-60px';
|
|
setTimeout(function () { el.remove(); }, 320);
|
|
}, 2800);
|
|
|
|
return el;
|
|
}
|
|
|
|
// ─── 购物车 ───
|
|
var cart = {
|
|
add: function (productId, qty, callback) {
|
|
request({
|
|
action: 'cart_add',
|
|
method: 'POST',
|
|
data: { product_id: productId, qty: qty || 1 },
|
|
success: function (res) {
|
|
showToast(res.msg || '已加入购物车', 'ok');
|
|
cart.updateBadge(res.data.cart_count);
|
|
if (callback) callback(null, res.data);
|
|
},
|
|
error: function (res) {
|
|
showToast(res.msg || '加入购物车失败', 'err');
|
|
if (callback) callback(new Error(res.msg));
|
|
}
|
|
});
|
|
},
|
|
update: function (productId, qty, callback) {
|
|
request({
|
|
action: 'cart_update',
|
|
method: 'POST',
|
|
data: { product_id: productId, qty: qty },
|
|
success: function (res) {
|
|
cart.updateBadge(res.data.cart_count);
|
|
if (callback) callback(null, res.data);
|
|
},
|
|
error: function (res) {
|
|
showToast(res.msg || '更新失败', 'err');
|
|
if (callback) callback(new Error(res.msg));
|
|
}
|
|
});
|
|
},
|
|
remove: function (productId, callback) {
|
|
request({
|
|
action: 'cart_remove',
|
|
method: 'POST',
|
|
data: { product_id: productId },
|
|
success: function (res) {
|
|
showToast('已移除', 'ok');
|
|
cart.updateBadge(res.data.cart_count);
|
|
if (callback) callback(null, res.data);
|
|
},
|
|
error: function (res) {
|
|
showToast(res.msg || '移除失败', 'err');
|
|
if (callback) callback(new Error(res.msg));
|
|
}
|
|
});
|
|
},
|
|
clear: function (callback) {
|
|
request({
|
|
action: 'cart_clear',
|
|
method: 'POST',
|
|
success: function (res) {
|
|
showToast('购物车已清空', 'ok');
|
|
cart.updateBadge(0);
|
|
if (callback) callback(null);
|
|
},
|
|
error: function (res) {
|
|
showToast(res.msg || '清空失败', 'err');
|
|
if (callback) callback(new Error(res.msg));
|
|
}
|
|
});
|
|
},
|
|
updateBadge: function (count) {
|
|
var badges = document.querySelectorAll('.badge.cart-badge');
|
|
badges.forEach(function (b) {
|
|
b.textContent = count;
|
|
b.style.display = count > 0 ? 'inline' : 'none';
|
|
});
|
|
// Also try nav-link badge
|
|
var navBadge = document.querySelector('a[href="cart"] .badge');
|
|
if (navBadge) {
|
|
navBadge.textContent = count;
|
|
navBadge.style.display = count > 0 ? 'inline' : 'none';
|
|
}
|
|
}
|
|
};
|
|
|
|
// ─── 签到 ───
|
|
var sign = {
|
|
do: function (callback) {
|
|
request({
|
|
action: 'sign_in',
|
|
method: 'POST',
|
|
success: function (res) {
|
|
showToast(res.msg || '签到成功!', 'ok');
|
|
if (callback) callback(null, res.data);
|
|
},
|
|
error: function (res) {
|
|
showToast(res.msg || '签到失败', 'err');
|
|
if (callback) callback(new Error(res.msg));
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// ─── 工单 ───
|
|
var ticket = {
|
|
submit: function (data, callback) {
|
|
request({
|
|
action: 'ticket_submit',
|
|
method: 'POST',
|
|
data: data,
|
|
success: function (res) {
|
|
showToast(res.msg || '工单提交成功!', 'ok');
|
|
if (callback) callback(null, res.data);
|
|
},
|
|
error: function (res) {
|
|
showToast(res.msg || '提交失败', 'err');
|
|
if (callback) callback(new Error(res.msg));
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// ─── 搜索(防抖)─────────────────────────────────────────────
|
|
var searchTimer = null;
|
|
var search = {
|
|
/**
|
|
* 带防抖的搜索
|
|
* @param {string} q 关键词
|
|
* @param {number} [delay=400] 防抖毫秒数
|
|
* @param {function} callback 回调 (err, results)
|
|
*/
|
|
query: function (q, delay, callback) {
|
|
if (typeof delay === 'function') { callback = delay; delay = 400; }
|
|
clearTimeout(searchTimer);
|
|
if (!q || q.length < 1) { if (callback) callback(null, []); return; }
|
|
searchTimer = setTimeout(function () {
|
|
request({
|
|
action: 'search',
|
|
data: { q: q },
|
|
success: function (res) { if (callback) callback(null, res.data); },
|
|
error: function (res) { if (callback) callback(new Error(res.msg)); }
|
|
});
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
// ─── 公开 API ───
|
|
return {
|
|
request: request,
|
|
toast: showToast,
|
|
cart: cart,
|
|
sign: sign,
|
|
ticket: ticket,
|
|
search: search,
|
|
csrf: getCsrfToken
|
|
};
|
|
})();
|
|
|
|
// ─── 页面加载后初始化:注入 CSRF meta ───
|
|
(function () {
|
|
// 如果页面还没有 csrf meta tag,尝试从表单中获取并注入
|
|
if (!document.querySelector('meta[name="csrf"]')) {
|
|
var csrfInput = document.querySelector('input[name="csrf"]');
|
|
if (csrfInput) {
|
|
var meta = document.createElement('meta');
|
|
meta.name = 'csrf';
|
|
meta.content = csrfInput.value;
|
|
document.head.appendChild(meta);
|
|
}
|
|
}
|
|
})();
|