2025-07-19 14:15:59 +08:00

399 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

document.addEventListener('DOMContentLoaded', () => {
/* ========== 通用工具 ========== */
const getCookie = k =>
document.cookie
.split(';')
.map(c => c.trim())
.find(c => c.startsWith(k + '='))?.split('=')[1] || null;
const $ = id => document.getElementById(id);
/* ----------- 0. DOM 快捷引用 ----------- */
const currentPrice = $('currentPrice');
const originalPrice = $('originalPrice');
const dropPrice = $('dropPrice');
const soldBox = $('soldBox');
const groupTitle = $('groupTitle');
const userList = $('userList');
const singlePriceSpan = $('singlePrice');
const groupPriceSpan = $('groupPrice');
const btnSingle = $('btnSingle');
const btnGroup = $('btnGroup');
/* =====================================================
* 1. 取接口数据并渲染
* =================================================== */
const CFG_API = '/api/v1/gbm/index/query_group_buy_market_config';
// 登录检查
const loginToken = getCookie('loginToken');
if (!loginToken) { location.href = 'login.html'; return; }
const username = loginToken;
// 保存活动 id开团 / 参团时要用)
let activityId = null;
const POST_BODY = {
userId : username,
source : 's01',
channel: 'c01',
goodsId: '9890001'
};
fetch(CFG_API, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(POST_BODY)
})
.then(r => r.json())
.then(({ code, info, data }) => {
if (code !== '0000' || !data) { console.error(info); return; }
activityId = data.activityId;
renderGoods(data.goods);
renderStatistic(data.teamStatistic);
renderTeams(data.teamList, data.goods?.payPrice);
})
.catch(console.error);
/* ------------- 渲染商品信息 ------------- */
function renderGoods(g = {}) {
const { originalPrice: op = 0, payPrice = 0, deductionPrice = 0 } = g;
currentPrice.textContent = payPrice;
originalPrice.textContent = op;
dropPrice.textContent = `直降 ¥${deductionPrice}`;
singlePriceSpan.textContent = `${op}`;
groupPriceSpan.textContent = `${payPrice}`;
btnSingle.dataset.price = op;
btnGroup.dataset.price = payPrice;
}
/* ------------- 渲染统计信息 ------------- */
function renderStatistic(stat = {}) {
const { allTeamUserCount = 0 } = stat;
groupTitle.textContent = `${allTeamUserCount}人在抢,参与可立即拼成`;
soldBox.textContent = `${allTeamUserCount}人再抢`;
}
/* ------------- 渲染拼团列表 ------------- */
function renderTeams(list = [], groupPrice = 0) {
if (!list?.length) {
groupTitle.textContent = '小伙伴,赶紧去开团吧,做村里最靓的仔。';
return;
}
userList.innerHTML = '';
list.forEach(t => userList.appendChild(makeItem(t, groupPrice)));
initUserMarquee(3); // 显示 3 条
initCountdown();
}
function obfuscateUserId(userId) {
if (userId.length < 4) {
if (userId.length <= 1) {
return userId;
}
const first = userId.charAt(0);
const last = userId.charAt(userId.length - 1);
const middle = '*'.repeat(userId.length - 2);
return first + middle + last;
} else {
const start = userId.slice(0, 2);
const end = userId.slice(-1);
const middle = '*'.repeat(userId.length - 4);
return start + middle + end;
}
}
// 把 teamId / activityId 写到 user-info 的 dataset 上,便于点击时读取
function makeItem(team, price) {
const { userId, targetCount, lockCount, validTimeCountdown } = team;
// ① 先对 userId 脱敏
const maskedId = obfuscateUserId(userId);
// ② 继续原本逻辑
const leftNum = Math.max(targetCount - lockCount, 0);
const timeText = validTimeCountdown || '00:00:00';
const div = document.createElement('div');
div.className = 'user-item';
div.innerHTML = `
<div class="user-avatar"><i class="fas fa-user"></i></div>
<div class="user-info">
<div class="user-name">${maskedId}</div>
<div class="user-status">
仅剩 ${leftNum} 人成团
<span class="countdown">${timeText}</span>
</div>
</div>
<button class="buy-btn" data-price="${price}">参与拼团</button>
`;
return div;
}
/* =====================================================
* 2. 拼单列表纵向轮播
* =================================================== */
function initUserMarquee(visibleCount = 3, interval = 3000, duration = 500) {
const box = document.querySelector('.group-users');
const listEl = userList;
let originals = Array.from(listEl.children);
const total = originals.length;
if (total === 0) return;
// 如果原本就 <= 可见数,直接定高,不滚
if (total <= visibleCount) {
const h0 = originals[0].offsetHeight;
box.style.height = (h0 * visibleCount) + 'px';
return;
}
// 1. 复制整份加入末尾
originals.forEach(item => listEl.appendChild(item.cloneNode(true)));
// 2. 重新测量单条高度(此时 DOM 完整)
const itemH = listEl.children[0].offsetHeight;
// 3. 设定窗口高度
box.style.height = (itemH * visibleCount) + 'px';
// 4. 状态
let index = 0;
let ticking = false;
function step() {
index++;
listEl.style.transition = `transform ${duration}ms ease`;
listEl.style.transform = `translateY(-${index * itemH}px)`;
}
listEl.addEventListener('transitionend', () => {
ticking = false;
// 5. 到达复制段的“第一帧”index === total就无缝重置
if (index === total) {
listEl.style.transition = 'none';
listEl.style.transform = 'translateY(0)';
index = 0;
// 强制 reflow 再恢复 transition
void listEl.offsetHeight;
listEl.style.transition = `transform ${duration}ms ease`;
}
});
const timer = setInterval(() => {
if (!ticking) {
ticking = true;
step();
}
}, interval);
// 可选:鼠标悬停暂停
listEl.addEventListener('mouseenter', () => clearInterval(timer));
}
/* =====================================================
* 3. 倒计时
* =================================================== */
let countdownData = [];
function initCountdown() {
const els = document.querySelectorAll('.countdown');
countdownData = Array.from(els).map(el => ({
el,
remain: toSec(el.textContent.trim())
}));
setInterval(tick, 1000);
}
const toSec = t => {
if (!t.includes(':')) return 0;
const [h='00', m='00', s='00'] = t.split(':');
return +h*3600 + +m*60 + +s;
};
const fmt = n => String(n).padStart(2,'0');
const format = s => `${fmt(s/3600|0)}:${fmt((s%3600)/60|0)}:${fmt(s%60)}`;
function tick() {
countdownData.forEach(c=>{
if (c.remain > 0) {
c.remain--;
c.el.textContent = format(c.remain);
if(!c.remain) expire(c.el);
}
});
}
function expire(el){
el.textContent = '00:00:00';
const item = el.closest('.user-item');
item?.classList.add('expired');
item?.querySelector('.buy-btn')?.setAttribute('disabled','disabled');
}
/* =====================================================
* 4. 支付相关
* =================================================== */
const CREATE_PAY_API = `/api/v1/alipay/create_pay_order`;
// 4.1 支付确认弹窗
function showPaymentConfirm(price){
if(document.querySelector('.payment-overlay')) return;
const tpl = document.getElementById('tpl-payment');
const overlay = tpl.content.firstElementChild.cloneNode(true);
overlay.querySelector('#priceText').textContent = `${price}`;
overlay.querySelector('.copyable').onclick = function(){
navigator.clipboard.writeText(this.dataset.copy)
.then(()=>alert('买家账号已复制到剪贴板'));
};
overlay.querySelector('.cancel-btn').onclick = ()=>{
document.querySelectorAll('form').forEach(f=>f.remove());
overlay.remove();
};
overlay.querySelector('.confirm-btn').onclick = ()=>{
document.querySelector('form')?.submit();
overlay.remove();
};
overlay.addEventListener('click',e=>{
if(e.target===overlay) overlay.querySelector('.cancel-btn').click();
});
document.body.appendChild(overlay);
}
/* ---------- 4.2 单独购买 ---------- */
btnSingle.addEventListener('click', () => {
if (!getCookie('loginToken')) { location.href = 'login.html'; return; }
fetch(CREATE_PAY_API, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
userId : username,
productId : POST_BODY.goodsId,
marketType: 0
})
})
.then(r=>r.json())
.then(json=>{
if (json.code!=='0000') return alert(json.info||'下单失败');
document.querySelectorAll('form').forEach(f=>f.remove());
document.body.insertAdjacentHTML('beforeend', json.data);
showPaymentConfirm(btnSingle.dataset.price);
})
.catch(console.error);
});
/* ---------- 4.3 开团购买 ---------- */
btnGroup.addEventListener('click', () => {
if (!getCookie('loginToken')) { location.href = 'login.html'; return; }
fetch(CREATE_PAY_API, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
userId : username,
productId : POST_BODY.goodsId,
marketType: 1,
activityId: activityId
})
})
.then(r=>r.json())
.then(json=>{
if (json.code!=='0000') return alert(json.info||'下单失败');
document.querySelectorAll('form').forEach(f=>f.remove());
document.body.insertAdjacentHTML('beforeend', json.data);
showPaymentConfirm(btnGroup.dataset.price);
})
.catch(console.error);
});
/* ---------- 4.4 参与拼团( buy-btn ---------- */
document.body.addEventListener('click', e => {
const joinBtn = e.target.closest('.buy-btn');
if (!joinBtn) return;
if (!getCookie('loginToken')) { location.href = 'login.html'; return; }
// 获取 teamId、activityId
const userInfo = joinBtn.closest('.user-item')?.querySelector('.user-info');
const teamId = userInfo?.dataset.teamid;
const actId = userInfo?.dataset.activityid || activityId;
if (!teamId) { return alert('拼团信息已失效,请刷新页面'); }
fetch(CREATE_PAY_API, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
userId : username,
productId : POST_BODY.goodsId,
teamId : teamId,
activityId: actId,
marketType: 1 // 参团
})
})
.then(r=>r.json())
.then(json=>{
if (json.code!=='0000') return alert(json.info||'参团失败');
document.querySelectorAll('form').forEach(f=>f.remove());
document.body.insertAdjacentHTML('beforeend', json.data);
showPaymentConfirm(joinBtn.dataset.price);
})
.catch(console.error);
});
/* =====================================================
* 5. 顶部横向轮播(原逻辑保留)
* =================================================== */
const wrapper = document.querySelector('.swiper-wrapper');
const slides = [...wrapper.children];
const pagination = document.querySelector('.swiper-pagination');
const count = slides.length;
let current = 0, startX = 0, dragging = false, timer;
for (let i = 0; i < count; i++) {
const dot = document.createElement('div');
dot.className = 'swiper-dot' + (i===0?' active':'');
dot.onclick = () => goTo(i);
pagination.appendChild(dot);
}
const dots = pagination.children;
const goTo = i => {
current = (i+count)%count;
wrapper.style.transition = 'transform .3s ease';
wrapper.style.transform = `translateX(-${current*100}%)`;
[...dots].forEach((d,j)=>d.classList.toggle('active',j===current));
};
const auto = () => { timer=setInterval(()=>goTo(current+1),3000); };
const stop = () => clearInterval(timer);
auto();
const getX = e => e.touches?e.touches[0].clientX:e.clientX;
wrapper.addEventListener('pointerdown', e=>{
stop(); dragging=true; startX=getX(e); wrapper.style.transition='none';
});
wrapper.addEventListener('pointermove', e=>{
if(!dragging) return;
const diff=getX(e)-startX;
wrapper.style.transform=`translateX(calc(${-current*100}% + ${diff}px))`;
});
wrapper.addEventListener('pointerup',endSwipe);
wrapper.addEventListener('pointercancel',endSwipe);
wrapper.addEventListener('pointerleave',endSwipe);
function endSwipe(e){
if(!dragging) return; dragging=false;
const diff=getX(e)-startX;
const limit=wrapper.offsetWidth*0.15;
if(diff> limit) goTo(current-1);
else if(diff<-limit) goTo(current+1);
else goTo(current);
auto();
}
});