/**
* 商城前台 AJAX 封装
*
* 提供统一的 XHR 请求方法、购物车操作、签到、工单提交等。
* 依赖:页面中需有 或全局变量 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 = '' +
(type === 'ok' ? '✓' : type === 'err' ? '✗' : 'ⓘ') +
'' + msg + '';
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);
}
}
})();