const { API } = require('../../utils/constants'); const { request } = require('../../utils/http'); const { formatDateTime } = require('../../utils/format'); const auth = require('../../utils/auth'); // 中文数字映射(项目一、二、三…) const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; /** * 将 projects 数组转换为按项目分组的结构 * 每个项目下展开所有 groups,附带 selected 状态 */ function getEventStatus(detail) { if (!detail || typeof detail !== 'object') return null; const v = detail.eventStatus != null ? detail.eventStatus : detail.EventStatus; if (v == null || v === '') return null; const n = Number(v); return Number.isNaN(n) ? null : n; } /** 仅 1=报名中 可进入报名页;与后端 EventStatus 枚举一致 */ function toastForBlockedEventStatus(es) { if (es === 0) return '赛事未开始'; if (es === 2) return '报名已结束'; if (es === 3) return '比赛进行中,暂不可报名'; if (es === 4) return '赛事已结束'; return '暂不可报名'; } function buildProjectGroups(projects) { return projects.map((p, idx) => { const groups = Array.isArray(p.groups) ? p.groups : []; return { projectId: p.id || p.Id || idx, projectName: p.name || p.Name || '-', index: CN_NUM[idx] || String(idx + 1), fee: p.fee != null ? p.fee : 0, groups: groups.map((g) => ({ groupId: g.groupId || g.GroupId || g.id, groupName: g.groupName || g.GroupName || '-', ageRange: g.ageRange || g.AgeRange || '', gender: g.gender || g.Gender || '', fee: g.fee != null ? g.fee : (p.fee != null ? p.fee : 0), selected: false, ageRequirement: g.ageRequirement || g.AgeRequirement || '', genderRequirement: g.genderRequirement != null ? g.genderRequirement : g.GenderRequirement != null ? g.GenderRequirement : '', genderRequirementText: genderRequirementText(g.genderRequirement != null ? g.genderRequirement : g.GenderRequirement) })) }; }); } /** 计算已选组别的总价 */ function calcTotalPrice(projectGroups) { let total = 0; projectGroups.forEach((p) => { p.groups.forEach((g) => { if (g.selected) total += Number(g.fee) || 0; }); }); return total; } function toNonNegInt(v) { const n = Number(v); if (Number.isNaN(n)) return null; const i = Math.floor(n); return i >= 0 ? i : null; } function genderRequirementText(v) { if (v === undefined || v === null || v === '') return '不限'; const s = String(v).trim(); if (s === '0' || s === '男' || s.toUpperCase() === 'M') return '男'; if (s === '1' || s === '女' || s.toUpperCase() === 'F') return '女'; if (s === '2' || s === '不限' || s === '全部') return '不限'; return s; } function normalizeGenderToNum(v) { if (v === undefined || v === null || v === '') return null; const s = String(v).trim(); if (s === '0' || s === '男' || s.toUpperCase() === 'M') return 0; if (s === '1' || s === '女' || s.toUpperCase() === 'F') return 1; if (s === '2' || s === '不限' || s === '全部') return 2; const n = Number(s); if (!Number.isNaN(n) && (n === 0 || n === 1 || n === 2)) return n; return null; } function parseYmdDate(s) { if (!s) return null; const str = String(s).trim(); const m = str.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); if ([y, mo, d].some((x) => Number.isNaN(x))) return null; const dt = new Date(y, mo - 1, d); return Number.isNaN(dt.getTime()) ? null : dt; } function parseYmdDateAny(text) { if (!text) return null; const s = String(text); const m = s.match(/(\d{4})-(\d{2})-(\d{2})/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); if ([y, mo, d].some((x) => Number.isNaN(x))) return null; const dt = new Date(y, mo - 1, d); return Number.isNaN(dt.getTime()) ? null : dt; } function calcAgeYears(birthDateStr, atDateStr) { const b = parseYmdDate(birthDateStr); if (!b) return null; const at = parseYmdDate(atDateStr) || new Date(); let age = at.getFullYear() - b.getFullYear(); const m = at.getMonth() - b.getMonth(); if (m < 0 || (m === 0 && at.getDate() < b.getDate())) age -= 1; return age >= 0 ? age : null; } function parseBirthDateRangeText(text) { // e.g. "2021-03-31 至 2026-04-29" / "2021-03-31~2026-04-29" if (!text) return null; const s = String(text); const ms = s.match(/(\d{4}-\d{2}-\d{2})/g); if (!ms || ms.length < 2) return null; const a = parseYmdDate(ms[0]); const b = parseYmdDate(ms[1]); if (!a || !b) return null; const start = a <= b ? a : b; const end = a <= b ? b : a; return { start, end, startText: ms[0], endText: ms[1] }; } function parseAgeYearsRangeText(text) { // 兼容旧格式:如 "6-12岁" / "6~12" if (!text) return null; const s = String(text); const nums = s.match(/\d+/g); if (!nums || nums.length === 0) return null; const a = Number(nums[0]); if (Number.isNaN(a)) return null; if (nums.length === 1) return { min: a, max: a }; const b = Number(nums[1]); if (Number.isNaN(b)) return { min: a, max: a }; return { min: Math.min(a, b), max: Math.max(a, b) }; } function isIndividualProject(p) { const t = (p && (p.typeName || p.TypeName)) || ''; const s = String(t); // 只显示个人项目:个人速度、个人花样等 return s.includes('个人'); } function projectTypeOf(rawProject) { const t = (rawProject && (rawProject.typeName || rawProject.TypeName)) || ''; const s = String(t); if (s.includes('团队')) return 'team'; if (s.includes('个人')) return 'individual'; return 'unknown'; } function countSelectedByType(projectGroups, rawProjects) { const map = new Map(); (rawProjects || []).forEach((p) => { const pid = Number(p.id != null ? p.id : p.Id); if (!Number.isNaN(pid)) map.set(pid, projectTypeOf(p)); }); let individual = 0; let team = 0; let unknown = 0; projectGroups.forEach((pg) => { const pid = Number(pg.projectId); const type = map.get(pid) || 'unknown'; pg.groups.forEach((g) => { if (!g.selected) return; if (type === 'individual') individual += 1; else if (type === 'team') team += 1; else unknown += 1; }); }); return { individual, team, unknown }; } function buildCalculateAmountItems(projectGroups) { const map = new Map(); // projectId -> personCount projectGroups.forEach((p) => { const pid = Number(p.projectId); if (Number.isNaN(pid)) return; let cnt = 0; p.groups.forEach((g) => { if (g.selected) cnt += 1; }); if (cnt > 0) map.set(pid, (map.get(pid) || 0) + cnt); }); return Array.from(map.entries()).map(([projectId, personCount]) => ({ projectId, personCount })); } Page({ data: { eventId: null, loggedIn: false, eventDetail: null, projects: [], projectGroups: [], // 分组后的展示数据 couponCode: '', totalPrice: 0, originalPrice: 0, discountPrice: 0, usedCouponId: null, submitting: false, openidLoading: false, myAuth: null }, _calcTimer: null, _calcSeq: 0, onLoad(options) { const eventId = Number(options.eventId); this.setData({ eventId }); this.fetchDetail(eventId); }, onShow() { const accessToken = wx.getStorageSync('accessToken') || ''; const loggedIn = Boolean(accessToken); this.setData({ loggedIn }); if (loggedIn) this._ensureMyAuth(); }, onBack() { if (getCurrentPages().length > 1) { wx.navigateBack(); } else { wx.switchTab({ url: '/pages/home/home' }); } }, fetchDetail(eventId) { if (!eventId) return; request({ path: API.eventDetail(eventId), method: 'GET', needAuth: false }) .then((detail) => { const es = getEventStatus(detail); console.log( 'es', es); if (es !== null && es !== 1) { wx.showToast({ title: toastForBlockedEventStatus(es), icon: 'none' }); setTimeout(() => { if (getCurrentPages().length > 1) { wx.navigateBack(); } else { wx.switchTab({ url: '/pages/home/home' }); } }, 1600); return; } const rawProjects = Array.isArray(detail.projects) ? detail.projects : []; const projects = rawProjects.filter(isIndividualProject); const projectGroups = buildProjectGroups(projects); this.setData({ eventDetail: detail, projects, projectGroups, totalPrice: 0 }); }) .catch(() => { wx.showToast({ title: '加载失败', icon: 'none' }); }); }, _ensureMyAuth() { // 认证信息用于年龄/性别限制校验;没有认证则引导去个人中心 return auth .fetchMyAuth() .then((res) => { const d = res && typeof res === 'object' ? res : null; const birthDate = d ? (d.birthDate || d.birthday || '') : ''; if (!birthDate) { wx.showModal({ title: '需先完成认证', content: '报名个人项目需要先完成身份认证(用于校验年龄与性别限制)。', confirmText: '去个人中心', cancelText: '取消', success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/mine/mine' }); } }); this.setData({ myAuth: null }); return null; } this.setData({ myAuth: d }); return d; }) .catch(() => { wx.showModal({ title: '获取认证信息失败', content: '请稍后重试,或前往个人中心完成认证。', confirmText: '去个人中心', cancelText: '取消', success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/mine/mine' }); } }); this.setData({ myAuth: null }); return null; }); }, onGoMineLogin() { wx.switchTab({ url: '/pages/mine/mine' }); }, _showOpenidResult(openid) { wx.showModal({ title: 'openid', content: String(openid), confirmText: '复制', success: (r) => { if (r.confirm) { wx.setClipboardData({ data: String(openid) }); } } }); }, /** 优先读本地登录缓存;没有则 wx.login + 后端换 openid */ onFetchOpenId() { if (this.data.openidLoading) return; const app = getApp(); const cached = (app && app.globalData && app.globalData.openid) || wx.getStorageSync('openid') || ''; if (cached) { this._showOpenidResult(cached); return; } this.setData({ openidLoading: true }); wx.showLoading({ title: '获取中', mask: true }); wx.login({ success: (loginRes) => { const code = loginRes.code; if (!code) { wx.hideLoading(); this.setData({ openidLoading: false }); wx.showToast({ title: '未获取到 code', icon: 'none' }); return; } request({ path: API.wechatOpenidByJsCode, method: 'POST', needAuth: false, data: { code }, contentType: 'application/json' }) .then((data) => { const oid = data && typeof data === 'object' ? data.openid || data.OpenId || data.openId || '' : ''; const openid = typeof oid === 'string' ? oid : ''; if (openid && app && typeof app.setAuth === 'function') { app.setAuth({ openid }); } wx.hideLoading(); this.setData({ openidLoading: false }); if (openid) { this._showOpenidResult(openid); } else { wx.showToast({ title: '接口未返回 openid', icon: 'none' }); } }) .catch(() => { wx.hideLoading(); this.setData({ openidLoading: false }); wx.showModal({ title: '换取 openid 失败', content: `请确认后端接口路径:${API.wechatOpenidByJsCode}\n\n可临时复制 code 给服务端调试:\n${code}`, confirmText: '复制 code', cancelText: '关闭', success: (r) => { if (r.confirm) wx.setClipboardData({ data: code }); } }); }); }, fail: () => { wx.hideLoading(); this.setData({ openidLoading: false }); wx.showToast({ title: 'wx.login 失败', icon: 'none' }); } }); }, /** 点击组别标签切换选中状态 */ onGroupTap(e) { const { projectId, groupId } = e.currentTarget.dataset; if (!this.data.myAuth) { wx.showToast({ title: '请先完成身份认证', icon: 'none' }); wx.switchTab({ url: '/pages/mine/mine' }); return; } // 年龄/性别限制校验 const my = this.data.myAuth || {}; const birthDate = my.birthDate || my.birthday || ''; const myGender = normalizeGenderToNum(my.gender != null ? my.gender : my.sex); const eventStart = (this.data.eventDetail && this.data.eventDetail.matchStartTime) || ''; const clickedProject = this.data.projectGroups.find((p) => p.projectId === projectId); const clickedGroup = clickedProject ? clickedProject.groups.find((g) => g.groupId === groupId) : null; if (clickedGroup) { const reqGender = normalizeGenderToNum(clickedGroup.genderRequirement); if (reqGender != null && reqGender !== 2 && myGender != null && reqGender !== myGender) { wx.showToast({ title: `该组别仅限${genderRequirementText(reqGender)}报名`, icon: 'none' }); return; } // 年龄限制:优先支持“出生日期范围”,兼容旧“岁数范围” const birthRange = parseBirthDateRangeText(clickedGroup.ageRequirement); if (birthRange) { const bd = parseYmdDateAny(birthDate); if (!bd) { wx.showToast({ title: '无法解析出生日期,请先完善认证信息', icon: 'none' }); return; } if (bd < birthRange.start || bd > birthRange.end) { wx.showToast({ title: `出生日期需在${birthRange.startText}至${birthRange.endText}`, icon: 'none' }); return; } } else { const range = parseAgeYearsRangeText(clickedGroup.ageRequirement); if (range) { const age = calcAgeYears(birthDate, eventStart); if (age == null) { wx.showToast({ title: '无法解析出生日期,请先完善认证信息', icon: 'none' }); return; } if (age < range.min || age > range.max) { wx.showToast({ title: `年龄需在${range.min}-${range.max}岁`, icon: 'none' }); return; } } } } const nextProjectGroups = this.data.projectGroups.map((p) => { if (p.projectId !== projectId) return p; return { ...p, groups: p.groups.map((g) => { if (g.groupId !== groupId) return g; return { ...g, selected: !g.selected }; }) }; }); const detail = this.data.eventDetail || {}; const individualLimit = toNonNegInt(detail.individualProjectLimit); const teamLimit = toNonNegInt(detail.teamProjectLimit); const { individual, team } = countSelectedByType(nextProjectGroups, this.data.projects); if (individualLimit != null && individual > individualLimit) { wx.showToast({ title: `个人项目最多选择${individualLimit}个`, icon: 'none' }); return; } if (teamLimit != null && team > teamLimit) { wx.showToast({ title: `团队项目最多选择${teamLimit}个`, icon: 'none' }); return; } this.setData({ projectGroups: nextProjectGroups }, () => { this._scheduleRecalcAmount(); }); }, _scheduleRecalcAmount() { if (this._calcTimer) { clearTimeout(this._calcTimer); this._calcTimer = null; } this._calcTimer = setTimeout(() => { this._calcTimer = null; this._recalcAmountNow(); }, 250); }, _recalcAmountNow() { const items = buildCalculateAmountItems(this.data.projectGroups); if (!items.length) { this.setData({ totalPrice: 0, originalPrice: 0, discountPrice: 0, usedCouponId: null }); return; } const seq = (this._calcSeq += 1); request({ path: API.calculateAmount, method: 'POST', needAuth: true, data: { eventId: Number(this.data.eventId), registrationMode: 1, items }, contentType: 'application/json' }) .then((res) => { if (seq !== this._calcSeq) return; let totalAmount = null; let originalPrice = 0; let discountPrice = 0; if (typeof res === 'number') { totalAmount = res; originalPrice = res; } else if (typeof res === 'string') { const n = Number(res); totalAmount = Number.isNaN(n) ? null : n; originalPrice = totalAmount || 0; } else if (res && typeof res === 'object') { // 从统一的 CalculateAmountOutput 中获取数据 totalAmount = res.totalAmount != null ? Number(res.totalAmount) : (res.amount != null ? Number(res.amount) : (res.payAmount != null ? Number(res.payAmount) : null)); originalPrice = res.originalAmount != null ? Number(res.originalAmount) : totalAmount || 0; discountPrice = res.discountAmount != null ? Number(res.discountAmount) : 0; } if (totalAmount == null) { // fallback:仍然展示前端计算的费用,避免空白 const fallbackPrice = calcTotalPrice(this.data.projectGroups); this.setData({ totalPrice: fallbackPrice, originalPrice: fallbackPrice, discountPrice: 0 }); } else { this.setData({ totalPrice: totalAmount, originalPrice: originalPrice || totalAmount, discountPrice: discountPrice || 0 }); } }) .catch(() => { if (seq !== this._calcSeq) return; const fallbackPrice = calcTotalPrice(this.data.projectGroups); this.setData({ totalPrice: fallbackPrice, originalPrice: fallbackPrice, discountPrice: 0 }); }); }, onCouponInput(e) { this.setData({ couponCode: (e.detail.value || '').trim() }); }, /** * 验证并应用优惠券 */ async onValidateCoupon() { const couponCode = this.data.couponCode; if (!couponCode) { wx.showToast({ title: '请输入优惠券兑换码', icon: 'none' }); return; } wx.showLoading({ title: '验证中', mask: true }); try { const res = await request({ path: API.couponValidate, method: 'POST', needAuth: true, data: { eventId: Number(this.data.eventId), couponCode: couponCode, originalAmount: this.data.originalPrice || this.data.totalPrice }, contentType: 'application/json' }); if (res && res.isValid) { this.setData({ usedCouponId: res.couponId, totalPrice: res.finalAmount, discountPrice: res.discountAmount || 0, originalPrice: res.originalAmount || this.data.originalPrice }); wx.showToast({ title: '优惠券应用成功', icon: 'success' }); } else { wx.showToast({ title: res.invalidReason || '优惠券无效', icon: 'none' }); } } catch (error) { wx.showToast({ title: error && error.message ? error.message : '验证失败,请稍后重试', icon: 'none' }); } finally { wx.hideLoading(); } }, /** 收集已选的 projectId + groupId */ _getSelectedItems() { const items = []; this.data.projectGroups.forEach((p) => { p.groups.forEach((g) => { if (g.selected) { items.push({ projectId: Number(p.projectId), groupId: Number(g.groupId) }); } }); }); return items; }, _validateAndGetPayload(paymentMethod) { if (!this.data.loggedIn) { wx.showToast({ title: '请先登录', icon: 'none' }); return null; } const items = this._getSelectedItems(); if (items.length === 0) { wx.showToast({ title: '请至少选择一个组别', icon: 'none' }); return null; } const detail = this.data.eventDetail || {}; const individualLimit = toNonNegInt(detail.individualProjectLimit); const teamLimit = toNonNegInt(detail.teamProjectLimit); const { individual, team, unknown } = countSelectedByType(this.data.projectGroups, this.data.projects); if (unknown > 0 && (individualLimit != null || teamLimit != null)) { // typeName 识别不出来时,不阻断提交,但提醒后端应补齐 typeName console.warn('存在未识别项目类型的选项,无法严格执行个人/团队限报', { unknown }); } if (individualLimit != null && individual > individualLimit) { wx.showToast({ title: `个人项目最多选择${individualLimit}个`, icon: 'none' }); return null; } if (teamLimit != null && team > teamLimit) { wx.showToast({ title: `团队项目最多选择${teamLimit}个`, icon: 'none' }); return null; } return { eventId: Number(this.data.eventId), registrationMode: 1, paymentMethod, // 1=线下 2=线上 couponCode: this.data.couponCode || null, items }; }, /** 线下支付 */ onPayOffline() { const payload = this._validateAndGetPayload(1); if (!payload) return; this._submitApply(payload); }, /** 线上支付 */ async onPayOnline() { const payload = this._validateAndGetPayload(2); if (!payload) return; try { // 1. 获取 openid const openid = await this._getOpenId(); if (!openid) { wx.showToast({ title: '获取 openid 失败', icon: 'none' }); return; } // 2. 提交报名 this.setData({ submitting: true }); const registrationId = await this._submitApplyForPayment(payload); // 3. 选择支付方式并获取支付参数 await this._selectPaymentMethod(registrationId, openid, 2); // 4. 获取支付信息 const paymentInfo = await this._getPaymentInfo(registrationId, openid); // 5. 调起微信支付 await this._invokeWeChatPay(paymentInfo.wechatPayParams, registrationId); } catch (error) { console.error('支付流程异常:', error); wx.showToast({ title: error && error.message ? error.message : '支付失败,请稍后重试', icon: 'none' }); } finally { this.setData({ submitting: false }); } }, /** 获取 openid */ async _getOpenId() { const app = getApp(); const cached = (app && app.globalData && app.globalData.openid) || wx.getStorageSync('openid') || ''; if (cached) { return cached; } return new Promise((resolve, reject) => { wx.login({ success: (loginRes) => { const code = loginRes.code; if (!code) { reject(new Error('未获取到 code')); return; } request({ path: API.wechatOpenidByJsCode, method: 'POST', needAuth: false, data: { code }, contentType: 'application/json' }) .then((data) => { const oid = data && typeof data === 'object' ? data.openId || data.OpenId || data.openid || '' : ''; const openid = typeof oid === 'string' ? oid : ''; if (openid && app && typeof app.setAuth === 'function') { app.setAuth({ openid }); } if (openid) { resolve(openid); } else { reject(new Error('接口未返回 openid')); } }) .catch((err) => { reject(err); }); }, fail: () => { reject(new Error('wx.login 失败')); } }); }); }, /** 提交报名(用于支付流程) */ _submitApplyForPayment(payload) { return request({ path: API.apply, method: 'POST', needAuth: true, data: payload, contentType: 'application/json' }); }, /** 选择支付方式 */ _selectPaymentMethod(registrationId, openId, paymentMethod) { return request({ path: '/api/eventclient/select-payment-method', method: 'POST', needAuth: true, data: { registrationId, openId, paymentMethod }, contentType: 'application/json' }); }, /** 获取支付信息 */ _getPaymentInfo(registrationId, openid) { return request({ path: '/api/eventclient/payment-info', method: 'POST', needAuth: true, data: { registrationId, openId: openid, couponId: this.data.usedCouponId }, contentType: 'application/json' }); }, /** 调起微信支付 */ _invokeWeChatPay(wechatPayParams, registrationId) { return new Promise((resolve, reject) => { wx.requestPayment({ ...wechatPayParams, success: async () => { try { // 支付成功,同步支付状态 await request({ path: `/api/eventclient/sync-payment/${registrationId}`, method: 'POST', needAuth: true, data: {}, contentType: 'application/json' }); wx.showToast({ title: '支付成功', icon: 'success' }); setTimeout(() => { wx.switchTab({ url: '/pages/mine/mine' }); }, 1500); resolve(); } catch (err) { console.error('同步支付状态失败:', err); wx.showModal({ title: '提示', content: '支付成功,但同步状态失败,请稍后在订单列表中查看', showCancel: false, success: () => { wx.switchTab({ url: '/pages/mine/mine' }); } }); resolve(); } }, fail: (err) => { console.error('微信支付失败:', err); if (err.errMsg && err.errMsg.includes('cancel')) { wx.showToast({ title: '支付已取消', icon: 'none' }); } else { wx.showToast({ title: err.errMsg || '支付失败,请稍后重试', icon: 'none' }); } reject(err); } }); }); }, _submitApply(payload) { this.setData({ submitting: true }); request({ path: API.apply, method: 'POST', needAuth: true, data: payload, contentType: 'application/json' }) .then(() => { wx.showToast({ title: '报名成功', icon: 'success' }); setTimeout(() => wx.switchTab({ url: '/pages/mine/mine' }), 800); }) .catch((err) => { wx.showToast({ title: err && err.message ? err.message : '提交失败', icon: 'none' }); }) .finally(() => this.setData({ submitting: false })); } });