xiangqinxiaochengxu/miniprogram/pages/match/match.js
taiyi a8e6edf296 更新功能修改完版本提交:
功能需求:活动分享与嘉宾匹配系统
1. 活动分享入口
每个活动配置独立的分享链接和二维码,支持扫码或点击链接进入小程序。
2. 用户绑定流程
用户通过分享链接/二维码打开小程序,填写个人信息并绑定至对应活动。
若用户曾参与过历史活动,系统自动复用其历史信息并完成绑定,无需重复填写。
绑定成功后,在活动正式开始前,用户无法查看其他参与者的信息。
3. 信息可见性规则
活动开始前:仅允许查看自己的信息,其他用户信息不可见。
活动开始后:开放本活动内异性嘉宾资料查看与匹配,其他活动的用户信息完全隔离,不可见。
资料展示:男嘉宾、女嘉宾资料分开展示,用户仅可浏览异性嘉宾信息。
4. 嘉宾选择规则
每位用户最多可选择 3 位心仪嘉宾。
确认提交后不可再新增选择,但支持修改或撤销已选嘉宾。
管理员可设置匹配截止时间,到达截止时间后系统自动锁定,禁止任何修改。
5. 匹配判定规则
双向互选:若双方恰好互选,则判定为匹配成功,双方均可查看匹配结果。
多向匹配:用户同时与多位嘉宾匹配成功,全部同时展示。
单向选择:A选择B但B未选择A,A可在"谁选了我"页面看到B。
6. 用户页面说明
异性嘉宾列表:展示所有异性嘉宾资料,支持选择/取消选择。
我的选择:展示当前已选的3位嘉宾,支持修改/撤销(截止前)。
谁选了我:展示所有选择了自己的嘉宾(无论自己是否选择了对方)。
匹配成功:展示所有双向互选的匹配对象,支持查看对方完整资料。
2026-05-17 10:23:02 +08:00

480 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.

const request = require('../../utils/request')
const GENDER_MAP = {
1: 'male',
2: 'female'
}
Page({
data: {
source: 'manual',
activityId: null,
activityTitle: '',
activityMatchText: '',
stageText: '',
selectionSummaryText: '',
candidates: [],
currentCandidate: null,
nextCandidate: null,
loading: false,
activeCandidate: null,
activeCandidatePersonalityText: '',
currentIndex: 0,
dragOffsetX: 0,
dragRotation: 0,
swipeHint: '',
swipeActive: false,
dragging: false,
cardExitClass: '',
mutualMatchVisible: false,
mutualMatchName: '',
canOperate: false,
aiMatching: false,
aiSuggestionTimeText: '',
aiSuggestionCount: 0,
currentUserGender: null
},
onLoad(options) {
this.setData({
source: options.source || 'manual',
activityId: options.activity_id ? Number(options.activity_id) : null
})
},
onShareAppMessage() {
if (this.data.source === 'activity' && this.data.activityId) {
return {
title: this.data.activityTitle || '一起看看这场活动',
path: `/pages/activity-detail/activity-detail?id=${this.data.activityId}`
}
}
return {
title: '来小程序看看活动和匹配吧',
path: '/pages/home/home'
}
},
async onShow() {
this.syncOperateState()
this.syncCurrentUserGender()
await this.refreshCurrentUserGender()
if (this.data.source === 'activity' && this.data.activityId) {
await this.loadActivityMatchState()
}
this.loadCandidates()
},
syncOperateState() {
this.setData({ canOperate: !!wx.getStorageSync('token') })
},
syncCurrentUserGender() {
const app = getApp()
const userInfo = app && app.globalData ? app.globalData.userInfo : null
const currentUserGender = userInfo && userInfo.gender != null ? Number(userInfo.gender) : null
this.setData({ currentUserGender })
},
async refreshCurrentUserGender() {
try {
const userInfo = await request({ url: '/users/me' })
const app = getApp()
if (app && app.globalData) {
app.globalData.userInfo = userInfo
}
const currentUserGender = userInfo && userInfo.gender != null ? Number(userInfo.gender) : null
this.setData({ currentUserGender })
return currentUserGender
} catch (err) {
console.warn('Refresh current user gender failed:', err)
return this.data.currentUserGender
}
},
ensureCanOperate() {
const canOperate = !!wx.getStorageSync('token')
this.setData({ canOperate })
if (!canOperate) {
wx.showToast({ title: '请先登录后再操作', icon: 'none' })
return false
}
return true
},
async loadActivityMatchState() {
try {
const state = await request({ url: `/activities/${this.data.activityId}/match-state` })
const stageTextMap = {
before_start: '活动未开始,暂不可查看嘉宾信息',
matching_open: '右滑加入我的选择,最多 3 位嘉宾',
matching_closed: '匹配已截止,仅可查看结果',
ended: '活动已结束'
}
this.setData({
activityTitle: state.activity_title || '',
stageText: stageTextMap[state.stage] || '',
selectionSummaryText: `已选 ${state.selected_count || 0}/${state.selection_limit || 3},剩余 ${state.remaining_count || 0}`,
activityMatchText: state.match_deadline
? `截止时间:${state.match_deadline}`
: '',
canOperate: !!state.can_choose
})
} catch (err) {
console.warn('Load activity match state failed:', err)
this.setData({
stageText: '',
selectionSummaryText: '',
activityMatchText: ''
})
}
},
async loadCandidates() {
this.setData({ loading: true })
try {
let candidates = []
if (this.data.source === 'activity' && this.data.activityId) {
candidates = await request({ url: `/activities/${this.data.activityId}/candidates` })
} else {
candidates = await request({ url: `/matches/candidates?source=${this.data.source}` })
}
const normalizedCandidates = this.normalizeCandidates(candidates)
this.setData({
candidates: normalizedCandidates,
currentCandidate: null,
nextCandidate: null,
activeCandidate: null,
activeCandidatePersonalityText: '',
currentIndex: 0,
dragOffsetX: 0,
dragRotation: 0,
swipeHint: '',
swipeActive: false,
cardExitClass: '',
aiSuggestionTimeText: '',
aiSuggestionCount: 0
})
this.syncDeckCandidates()
} catch (err) {
console.error('Load candidates failed:', err)
wx.showToast({ title: err.message || '候选加载失败', icon: 'none' })
this.setData({ candidates: [], currentCandidate: null, nextCandidate: null, activeCandidate: null, activeCandidatePersonalityText: '' })
} finally {
this.setData({ loading: false })
}
},
async triggerAi() {
if (!this.ensureCanOperate()) {
return
}
if (this.data.aiMatching || this.data.source === 'activity') {
return
}
this.setData({ loading: true, source: 'ai', aiMatching: true })
wx.showLoading({ title: 'AI 推荐中...' })
try {
const candidates = await request({ url: '/matches/ai-suggest', method: 'POST' })
const normalizedCandidates = this.normalizeCandidates(candidates)
const nowText = new Date().toLocaleTimeString().slice(0, 5)
this.setData({
candidates: normalizedCandidates,
currentCandidate: null,
nextCandidate: null,
activeCandidate: null,
activeCandidatePersonalityText: '',
currentIndex: 0,
dragOffsetX: 0,
dragRotation: 0,
swipeHint: '',
swipeActive: false,
cardExitClass: '',
aiSuggestionTimeText: nowText,
aiSuggestionCount: normalizedCandidates.length
})
this.syncDeckCandidates()
wx.showToast({ title: normalizedCandidates.length ? 'AI 推荐已更新' : '暂时没有新的推荐', icon: 'none' })
} catch (err) {
console.error('Trigger AI match failed:', err)
wx.showToast({ title: 'AI 匹配失败,请重试', icon: 'none' })
this.setData({ candidates: [], currentCandidate: null, nextCandidate: null, activeCandidate: null, activeCandidatePersonalityText: '' })
} finally {
wx.hideLoading()
this.setData({ loading: false, aiMatching: false })
}
},
getCurrentCandidate() {
return this.data.candidates[this.data.currentIndex] || null
},
getNextCandidate() {
return this.data.candidates[this.data.currentIndex + 1] || null
},
syncDeckCandidates() {
this.setData({
currentCandidate: this.getCurrentCandidate(),
nextCandidate: this.getNextCandidate()
})
},
normalizeCandidates(candidates) {
return (candidates || []).map((item) => {
const personalityTags = Array.isArray(item.personality_tags) ? item.personality_tags : []
const matchReasons = Array.isArray(item.match_reasons) ? item.match_reasons : []
const matchScore = Number(item.match_score)
const hasMatchScore = Number.isFinite(matchScore)
const safeMatchScore = hasMatchScore ? Math.max(0, Math.min(matchScore, 100)) : null
const matchScoreLevel = hasMatchScore ? (safeMatchScore >= 80 ? 'high' : safeMatchScore >= 60 ? 'mid' : 'low') : 'none'
return {
...item,
nicknameText: item.nickname || '匿名用户',
birthYearRangeText: item.birth_year_range || '年龄未知',
cityText: item.city || '城市未知',
personality_tags: personalityTags,
matchScoreText: hasMatchScore ? safeMatchScore.toFixed(1) : '',
matchScorePercent: hasMatchScore ? safeMatchScore.toFixed(1) : '0',
hasMatchScore,
matchScoreLevel,
matchReasonsText: matchReasons.join(' / '),
hasMatchReasons: matchReasons.length > 0,
selected: !!item.selected
}
})
},
openCard(e) {
const candidate = this.getCurrentCandidate() || this.data.candidates.find((item) => item.user_id === e.currentTarget.dataset.id)
this.setData({
activeCandidate: candidate || null,
activeCandidatePersonalityText: candidate && candidate.personality_tags ? candidate.personality_tags.join('、') : ''
})
},
closeCard() {
this.setData({ activeCandidate: null, activeCandidatePersonalityText: '' })
},
noop() {},
handleTouchStart(e) {
if (!this.ensureCanOperate()) {
return
}
if (!this.getCurrentCandidate()) {
return
}
const touch = e.touches && e.touches[0]
if (!touch) {
return
}
this.touchStartX = touch.clientX
this.touchStartY = touch.clientY
this.setData({ dragging: true, swipeActive: false, cardExitClass: '' })
},
handleTouchMove(e) {
if (!this.data.canOperate) {
return
}
if (!this.data.dragging || this.touchStartX === undefined) {
return
}
const touch = e.touches && e.touches[0]
if (!touch) {
return
}
const deltaX = touch.clientX - this.touchStartX
const deltaY = (touch.clientY || 0) - (this.touchStartY || 0)
if (Math.abs(deltaY) > 18 && Math.abs(deltaY) > Math.abs(deltaX)) {
return
}
const offsetX = Math.max(Math.min(deltaX, 320), -320)
const rotation = Math.max(Math.min(offsetX / 18, 14), -14)
const swipeHint = offsetX > 36 ? 'like' : offsetX < -36 ? 'skip' : ''
this.setData({
dragOffsetX: offsetX,
dragRotation: rotation,
swipeHint,
swipeActive: !!swipeHint
})
},
handleTouchEnd() {
if (!this.data.canOperate) {
this.resetSwipeState()
return
}
if (!this.data.dragging) {
return
}
const currentCandidate = this.getCurrentCandidate()
const offsetX = this.data.dragOffsetX
this.setData({ dragging: false })
this.touchStartX = undefined
this.touchStartY = undefined
if (!currentCandidate) {
this.resetSwipeState()
return
}
const commitThreshold = 90
if (offsetX >= commitThreshold) {
this.likeCurrentCandidate(currentCandidate.user_id)
return
}
if (offsetX <= -commitThreshold) {
this.skipCurrentCandidate(currentCandidate.user_id)
return
}
this.resetSwipeState()
},
handleTouchCancel() {
this.touchStartX = undefined
this.touchStartY = undefined
this.resetSwipeState()
},
resetSwipeState() {
this.setData({
dragOffsetX: 0,
dragRotation: 0,
swipeHint: '',
swipeActive: false,
dragging: false,
cardExitClass: ''
})
this.touchStartX = undefined
this.touchStartY = undefined
this.syncDeckCandidates()
},
playExitAnimation(direction, callback) {
const className = direction === 'like' ? 'card-exit-like' : 'card-exit-skip'
this.setData({
cardExitClass: className,
swipeHint: direction,
swipeActive: true
})
setTimeout(() => {
callback()
}, 220)
},
consumeCurrentCandidate(userId) {
const remaining = this.data.candidates.filter((item) => item.user_id !== userId)
this.setData({
candidates: remaining,
currentIndex: 0,
currentCandidate: null,
nextCandidate: null,
activeCandidate: this.data.activeCandidate && this.data.activeCandidate.user_id === userId ? null : this.data.activeCandidate,
activeCandidatePersonalityText: this.data.activeCandidate && this.data.activeCandidate.user_id === userId ? '' : this.data.activeCandidatePersonalityText
})
this.resetSwipeState()
this.syncDeckCandidates()
},
async likeCurrentCandidate(userId) {
if (!this.ensureCanOperate()) {
return
}
const currentCandidate = this.getCurrentCandidate()
if (!currentCandidate || currentCandidate.user_id !== userId) {
wx.showToast({ title: '候选不存在或已失效', icon: 'none' })
this.resetSwipeState()
return
}
this.playExitAnimation('like', async () => {
try {
let result
if (this.data.source === 'activity' && this.data.activityId) {
result = await request({
url: `/activities/${this.data.activityId}/choices`,
method: 'POST',
data: { to_user_id: userId }
})
this.setData({
selectionSummaryText: `已选 ${result.selected_count || 0}/3剩余 ${result.remaining_count || 0}`
})
} else {
result = await request({
url: '/matches/like',
method: 'POST',
data: {
to_user_id: userId,
source: this.data.source,
activity_id: this.data.activityId
}
})
}
if (result.is_mutual) {
this.setData({
mutualMatchVisible: true,
mutualMatchName: currentCandidate.nicknameText || '对方'
})
} else {
wx.showToast({ title: this.data.source === 'activity' ? '已加入我的选择' : '已发送喜欢', icon: 'success' })
}
this.consumeCurrentCandidate(userId)
} catch (err) {
console.error('Like user failed:', err)
wx.showToast({ title: err.message || '操作失败', icon: 'none' })
this.resetSwipeState()
}
})
},
skipCurrentCandidate(userId) {
if (!this.ensureCanOperate()) {
return
}
this.playExitAnimation('skip', () => {
this.consumeCurrentCandidate(userId)
})
},
async likeUser(e) {
const userId = e.currentTarget.dataset.id
this.likeCurrentCandidate(userId)
},
getAiStatusText() {
if (this.data.source === 'activity') {
return this.data.stageText || '仅展示当前活动内异性嘉宾'
}
if (this.data.aiMatching) {
return 'AI 正在推荐中'
}
if (this.data.aiSuggestionTimeText) {
return `上次更新 ${this.data.aiSuggestionTimeText}`
}
return '点击获取新的推荐'
},
skipUser(e) {
const userId = e.currentTarget.dataset.id
this.skipCurrentCandidate(userId)
},
closeMutualMatch() {
this.setData({ mutualMatchVisible: false, mutualMatchName: '' })
},
goMyMatches() {
this.closeMutualMatch()
if (this.data.source === 'activity' && this.data.activityId) {
wx.navigateTo({ url: `/pages/my-matches/my-matches?activity_id=${this.data.activityId}` })
return
}
wx.navigateTo({ url: '/pages/my-matches/my-matches' })
}
})