fix(all): F30 沟通记录核对整改——①详情页上滑切换改 wx.navigateTo 入栈(原 setData 原地重载致永远退不回上一个抽奖,栈满降级 redirectTo;遮罩转场与 _resetStateForSwitch 随之删除);切换队列空时不再只 toast 改直跳手气广场;队列补过滤当前页 id 防环绕切到自己;清理 onPageTouchEnd 死条件 ②转盘残留边界:wheel-area 补口令输入框(原口令框随 action-area 对 wheel 隐藏致无处可输)、渲染条件加 !max_group_num 防绕过成团、onWheelDraw 补登录前置校验 ③user/edit chooseLocation fail 不再静默吞错(拒绝授权 toast 提示,主动取消不打扰),补 4 用例 ④contact 二维码 image 补 show-menu-by-longpress 对齐「长按识别」文案 ⑤web 端删除幽灵「充值配置」页(F27 已删小程序充值入口,该开关无消费者):views/config/recharge 目录+路由+菜单+config.ts 类型与方法;历史订单类型标签与后端接口保留 ⑥删孤儿页 pages/lottery/draw(无入口且无条件调 instant);后端 ErrNotInstantLottery 进码表 2126「该活动不是即抽即中活动」(eligibility 与 DrawInstant 两处内联错误统一为 sentinel,命中不再 500)⑦验证:go build+vet 零错误,lottery/response 包测试绿,miniprogram tsc 零报错+vitest 433/433,web vue-tsc 零报错
This commit is contained in:
@@ -81,6 +81,8 @@ const (
|
||||
LotteryJoinCodeRegionUnavailable = 2124
|
||||
// LotteryJoinCodeQuestionnaireNotBound 问卷门槛配置异常(未绑定/问卷失效,用户无法自救)
|
||||
LotteryJoinCodeQuestionnaireNotBound = 2125
|
||||
// LotteryJoinCodeNotInstantLottery 非即抽即中活动禁止走即抽即中入口
|
||||
LotteryJoinCodeNotInstantLottery = 2126
|
||||
)
|
||||
|
||||
// 组团抽奖错误码(2130 段)
|
||||
@@ -174,6 +176,7 @@ var lotteryJoinCodeTable = map[error]lotteryJoinCodeEntry{
|
||||
lottery.ErrNotInRoster: {LotteryJoinCodeNotInRoster, "您不在年会名单内,无法参与", true},
|
||||
lottery.ErrRosterAlreadyUsed: {LotteryJoinCodeRosterAlreadyUsed, "该名单标识已被使用", false},
|
||||
lottery.ErrInstantJoinNotAllowed: {LotteryJoinCodeInstantNotAllowed, "即抽即中活动请直接抽奖", false},
|
||||
lottery.ErrNotInstantLottery: {LotteryJoinCodeNotInstantLottery, "该活动不是即抽即中活动", false},
|
||||
lottery.ErrNeedShare: {LotteryJoinCodeNeedShare, "请先分享本抽奖再参与", false},
|
||||
lottery.ErrNotGroupLottery: {LotteryJoinCodeNotGroupLottery, "该活动不是组团抽奖", false},
|
||||
lottery.ErrGroupNotFound: {LotteryJoinCodeGroupNotFound, "团不存在或团码错误", false},
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestLotteryJoinCode_Mapping(t *testing.T) {
|
||||
{lottery.ErrNotInRoster, LotteryJoinCodeNotInRoster},
|
||||
{lottery.ErrRosterAlreadyUsed, LotteryJoinCodeRosterAlreadyUsed},
|
||||
{lottery.ErrInstantJoinNotAllowed, LotteryJoinCodeInstantNotAllowed},
|
||||
{lottery.ErrNotInstantLottery, LotteryJoinCodeNotInstantLottery},
|
||||
{lottery.ErrNeedShare, LotteryJoinCodeNeedShare},
|
||||
{lottery.ErrNotGroupLottery, LotteryJoinCodeNotGroupLottery},
|
||||
{lottery.ErrGroupNotFound, LotteryJoinCodeGroupNotFound},
|
||||
|
||||
@@ -19,6 +19,8 @@ var (
|
||||
ErrAlreadyDrawn = errors.New("draw: lottery already drawn")
|
||||
ErrAlreadyDrawnStage = errors.New("draw: stage already drawn")
|
||||
ErrNotReady = errors.New("draw: lottery not ready for drawing")
|
||||
// ErrNotInstantLottery 即抽即中入口仅允许即抽即中类型活动(时间/人数制活动禁止调 instant)
|
||||
ErrNotInstantLottery = errors.New("draw: not instant lottery")
|
||||
)
|
||||
|
||||
// DrawEngine 开奖引擎
|
||||
@@ -456,7 +458,7 @@ func (e *DrawEngine) DrawInstant(db *gorm.DB, lotteryID uint64, mpUserID uint64)
|
||||
return nil, err
|
||||
}
|
||||
if lottery.DrawType != model.DrawTypeInstant {
|
||||
return nil, errors.New("draw: not instant lottery")
|
||||
return nil, ErrNotInstantLottery
|
||||
}
|
||||
if !lottery.CanJoin() {
|
||||
return nil, fmt.Errorf("draw: lottery status is %s", lottery.Status)
|
||||
|
||||
@@ -69,7 +69,7 @@ func (s *Service) checkJoinEligibility(entry joinEntryKind, in JoinLotteryInput)
|
||||
|
||||
// 即抽即中入口:先校验活动类型,再校验可参与状态(保持原顺序)。
|
||||
if entry == entryInstant && lottery.DrawType != model.DrawTypeInstant {
|
||||
return nil, errors.New("draw: not instant lottery")
|
||||
return nil, ErrNotInstantLottery
|
||||
}
|
||||
|
||||
// 组团入口的 CanJoin 已在 loadGroupLottery 内完成,此处不重复。
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"pages/address/index",
|
||||
"pages/address/edit/index",
|
||||
"pages/lottery/prize-detail/index",
|
||||
"pages/lottery/draw/index",
|
||||
"pages/lottery/participants/index",
|
||||
"pages/wallet/index/index",
|
||||
"pages/wallet/withdraw/index",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class="qr-image"
|
||||
src="{{qrCodeUrl}}"
|
||||
mode="aspectFit"
|
||||
show-menu-by-longpress="{{true}}"
|
||||
wx:if="{{qrCodeUrl}}"
|
||||
/>
|
||||
<view class="qr-placeholder" wx:else>
|
||||
|
||||
@@ -59,11 +59,13 @@ import { promptLotterySubscribe } from '../../../utils/subscribe'
|
||||
|
||||
const showToast = vi.fn()
|
||||
const navigateTo = vi.fn()
|
||||
const redirectTo = vi.fn()
|
||||
const setNavigationBarTitle = vi.fn()
|
||||
const showShareMenu = vi.fn()
|
||||
;(global as any).wx = {
|
||||
showToast,
|
||||
navigateTo,
|
||||
redirectTo,
|
||||
setNavigationBarTitle,
|
||||
showShareMenu,
|
||||
showModal: vi.fn(),
|
||||
@@ -131,6 +133,7 @@ beforeEach(() => {
|
||||
loggedInApp()
|
||||
showToast.mockReset()
|
||||
navigateTo.mockReset()
|
||||
redirectTo.mockReset()
|
||||
setNavigationBarTitle.mockReset()
|
||||
showShareMenu.mockReset()
|
||||
vi.mocked(lotteryApi.detail).mockReset()
|
||||
@@ -359,6 +362,16 @@ describe('大转盘玩法', () => {
|
||||
expect(inst.data.wheelTargetIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('onWheelDraw:未登录 toast 拦截且不发起请求', async () => {
|
||||
guestApp()
|
||||
mockDefaultApis()
|
||||
const inst = createPageInstance(pageOptions, { id: 1, wheelSectors: sectors })
|
||||
await inst.onWheelDraw()
|
||||
expect(showToast).toHaveBeenCalledWith({ title: '请先登录', icon: 'none' })
|
||||
expect(lotteryApi.instant).not.toHaveBeenCalled()
|
||||
expect(lotteryApi.join).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onWheelResult:中奖扇区展示恭喜文案并 toast', () => {
|
||||
const inst = createPageInstance(pageOptions, { id: 1, wheelSectors: sectors, drawing: true })
|
||||
inst.onWheelResult({ detail: { index: 0 } })
|
||||
@@ -550,3 +563,57 @@ describe('助力进度点', () => {
|
||||
expect(inst.data.rightDots).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 上滑切换「下一个抽奖」(navigateTo 入栈式跳转改造)。
|
||||
* 注意:切换队列为页面模块级共享状态,用例顺序有依赖——
|
||||
* 「空队列」用例必须排在所有会向队列注入项的用例之前。
|
||||
*/
|
||||
describe('上滑切换下一个抽奖', () => {
|
||||
it('队列为空:直接跳手气广场列表(不再只 toast)', () => {
|
||||
mockDefaultApis()
|
||||
const inst = createPageInstance(pageOptions, { id: 1 })
|
||||
inst.switchToNext()
|
||||
expect(navigateTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/pages/lottery/list/index' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('队列非空:navigateTo 入栈新详情页(可退回上一个抽奖)', async () => {
|
||||
mockDefaultApis()
|
||||
vi.mocked(lotteryApi.list).mockResolvedValue({
|
||||
total: 1,
|
||||
list: [{ id: 2, title: '下一个抽奖', cover_image: '' }],
|
||||
})
|
||||
const inst = createPageInstance(pageOptions)
|
||||
await loadPage(inst)
|
||||
await new Promise((r) => setTimeout(r, 0)) // 等预取队列拼接完成
|
||||
inst.switchToNext()
|
||||
await vi.waitFor(() =>
|
||||
expect(navigateTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/pages/lottery/detail/index?id=2' }),
|
||||
),
|
||||
)
|
||||
// 原地重载已移除:不再回写当前页 id/loading
|
||||
expect(inst.data.id).toBe(1)
|
||||
expect(inst.data.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('navigateTo 失败(页面栈满):降级 redirectTo 同一详情页', async () => {
|
||||
mockDefaultApis()
|
||||
vi.mocked(lotteryApi.list).mockResolvedValue({
|
||||
total: 1,
|
||||
list: [{ id: 3, title: '再下一个', cover_image: '' }],
|
||||
})
|
||||
const inst = createPageInstance(pageOptions)
|
||||
await loadPage(inst)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
navigateTo.mockImplementation((opts: any) => opts && opts.fail && opts.fail())
|
||||
inst.switchToNext()
|
||||
await vi.waitFor(() =>
|
||||
expect(redirectTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: '/pages/lottery/detail/index?id=3' }),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -118,10 +118,6 @@ Page({
|
||||
settlementPaying: false,
|
||||
|
||||
// ---- 上滑切换 ----
|
||||
/** 切换遮罩状态:'' | 'in'(滑入盖住) | 'out'(淡出) */
|
||||
switching: '' as '' | 'in' | 'out',
|
||||
/** 即将切换到的下一个抽奖(遮罩展示用) */
|
||||
switchNext: null as NextItem | null,
|
||||
/** 切换队列是否已耗尽(无可切换项) */
|
||||
nextEmpty: false,
|
||||
|
||||
@@ -517,7 +513,6 @@ Page({
|
||||
if (
|
||||
!_touch.triggered &&
|
||||
dy < -60 && // 向上滑动超过阈值
|
||||
Math.abs(dy) > 0 &&
|
||||
Math.abs(dx) / Math.abs(dy) < 0.6 && // 接近垂直方向
|
||||
this._isAtBottom() // 仅在页面已到底(或短内容无滚动空间)时触发
|
||||
) {
|
||||
@@ -833,6 +828,11 @@ Page({
|
||||
|
||||
// 用户点击转盘中心"开始抽奖":调后端即时抽奖接口,拿到结果后旋转转盘到中奖扇区。
|
||||
async onWheelDraw() {
|
||||
const app = getApp<any>()
|
||||
if (!app.globalData.user) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.data.drawing) return
|
||||
const preBlock = this._checkJoinPre()
|
||||
if (preBlock) {
|
||||
@@ -850,9 +850,8 @@ Page({
|
||||
// (仅动画效果,不代表中奖,时间制统一开奖),转动结束再展示参与结果;
|
||||
// 原先 join 完直接弹"参与成功"而转盘纹丝不动,用户误以为功能坏了。
|
||||
if (this.data.lottery && this.data.lottery.draw_type !== 'instant') {
|
||||
const app0 = getApp<any>()
|
||||
const payload0: any = { password: this.data.password }
|
||||
const gs0 = app0.globalData.groupShare
|
||||
const gs0 = app.globalData.groupShare
|
||||
if (gs0 && gs0.encryptedData && gs0.iv) {
|
||||
payload0.group_data = gs0.encryptedData
|
||||
payload0.group_iv = gs0.iv
|
||||
@@ -868,7 +867,6 @@ Page({
|
||||
return
|
||||
}
|
||||
// 即抽即中活动:调后端即时抽奖接口,拿到结果后旋转转盘到中奖扇区。
|
||||
const app = getApp<any>()
|
||||
const payload: any = { password: this.data.password }
|
||||
const gs = app.globalData.groupShare
|
||||
if (gs && gs.encryptedData && gs.iv) {
|
||||
@@ -1053,7 +1051,6 @@ Page({
|
||||
|
||||
/** 底部提示栏内的上滑手势:直接切换(不依赖是否到底) */
|
||||
onSwitchTouchEnd(e: WechatMiniprogram.TouchEvent) {
|
||||
if (this.data.switching) return
|
||||
const t = e.changedTouches[0]
|
||||
const dy = t.clientY - _touch.startY
|
||||
if (dy < -30) this.switchToNext()
|
||||
@@ -1074,9 +1071,12 @@ Page({
|
||||
|
||||
/** 上滑触发:切换到下一个抽奖 */
|
||||
switchToNext() {
|
||||
if (this.data.switching) return
|
||||
// 队列模块级共享(navigateTo 后新页面实例继承旧队列),先剔除当前页自身,避免环绕后切换到自己
|
||||
_nextQueue = _nextQueue.filter((l) => l.id !== this.data.id)
|
||||
_nextIndex = Math.min(_nextIndex, _nextQueue.length)
|
||||
if (_nextQueue.length === 0) {
|
||||
wx.showToast({ title: '暂无更多抽奖', icon: 'none' })
|
||||
// 无可切换项:直达手气广场列表,避免"滑了没反应"
|
||||
this.goMoreLuck()
|
||||
return
|
||||
}
|
||||
// 游标越界:尝试加载下一页,仍不足则循环回开头
|
||||
@@ -1095,63 +1095,12 @@ Page({
|
||||
|
||||
tryAdvance().then((next) => {
|
||||
if (!next) return
|
||||
// 1) 显示切换遮罩(从底部滑入盖住页面)
|
||||
this.setData({ switching: 'in', switchNext: next })
|
||||
|
||||
// 2) 等遮罩盖住后重置状态、滚动到顶、加载新详情
|
||||
setTimeout(() => {
|
||||
this._resetStateForSwitch()
|
||||
this.setData({ id: next.id, password: '', loading: true })
|
||||
wx.pageScrollTo({ scrollTop: 0, duration: 0 })
|
||||
_reachedBottom = false
|
||||
_scrollTop = 0
|
||||
|
||||
Promise.all([
|
||||
this.loadDetail(next.id),
|
||||
this.loadWinners(next.id),
|
||||
this.loadParticipants(next.id),
|
||||
this._prefetchShareToken(next.id),
|
||||
]).then(() => {
|
||||
// 接近队列末尾时预取下一页
|
||||
if (_nextIndex + 3 >= _nextQueue.length && _nextPage * 20 < _nextTotal) {
|
||||
_nextPage++
|
||||
this._prefetchNextList()
|
||||
}
|
||||
this._measureContent()
|
||||
// 3) 遮罩淡出
|
||||
this.setData({ switching: 'out' })
|
||||
setTimeout(() => this.setData({ switching: '', switchNext: null }), 240)
|
||||
}).catch(() => {
|
||||
this.setData({ switching: '', switchNext: null, loading: false })
|
||||
wx.showToast({ title: '切换失败', icon: 'none' })
|
||||
})
|
||||
}, 280)
|
||||
})
|
||||
},
|
||||
|
||||
/** 切换前重置所有页面状态(避免上一个抽奖的残留) */
|
||||
_resetStateForSwitch() {
|
||||
this._clearCountdown()
|
||||
this.setData({
|
||||
joined: false,
|
||||
myCode: '',
|
||||
password: '',
|
||||
isFull: false,
|
||||
joinCount: 0,
|
||||
maxPerUser: 0,
|
||||
remainJoins: 0,
|
||||
canJoinMore: false,
|
||||
cfg: {},
|
||||
drawTimeText: '',
|
||||
participants: [],
|
||||
assistStatus: null,
|
||||
assistTasks: [],
|
||||
showAssistPanel: false,
|
||||
assistCountdown: 0,
|
||||
leftDots: [],
|
||||
rightDots: [],
|
||||
adsEnded: [],
|
||||
isCreator: false,
|
||||
const url = `/pages/lottery/detail/index?id=${next.id}`
|
||||
// 入栈式跳转:页面栈增长,可退回上一个抽奖;栈满 10 层时 navigateTo 失败,降级 redirectTo
|
||||
wx.navigateTo({
|
||||
url,
|
||||
fail: () => wx.redirectTo({ url }),
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -122,10 +122,15 @@
|
||||
<!-- ======================================================
|
||||
原有参与按钮区域(当非助力模式时使用)
|
||||
====================================================== -->
|
||||
<!-- 大转盘玩法(play_template=wheel):转盘组件替代普通参与按钮 -->
|
||||
<view class="wheel-area" wx:if="{{lottery.play_template==='wheel' && (lottery.status==='active'||lottery.status==='approved')}}">
|
||||
<!-- 大转盘玩法(play_template=wheel):转盘组件替代普通参与按钮;组团活动不渲染转盘(避免绕过成团机制) -->
|
||||
<view class="wheel-area" wx:if="{{lottery.play_template==='wheel' && !lottery.max_group_num && (lottery.status==='active'||lottery.status==='approved')}}">
|
||||
<wheel sectors="{{wheelSectors}}" spinning="{{drawing}}" target-index="{{wheelTargetIndex}}" bind:draw="onWheelDraw" bind:result="onWheelResult" />
|
||||
<view class="wheel-result" wx:if="{{wheelResultText}}">{{wheelResultText}}</view>
|
||||
<!-- 口令输入(口令抽奖且未参与时展示;action-area 对 wheel 玩法隐藏,否则无处可输) -->
|
||||
<view wx:if="{{lottery.is_password_only && !joined}}" class="password-box">
|
||||
<text class="label">参与口令</text>
|
||||
<input placeholder="输入口令" value="{{password}}" bindinput="onPasswordInput"/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-area" wx:if="{{lottery.play_template!=='wheel' && !lottery.max_group_num && !(cfg.enable_assist && isLogin && joined)}}">
|
||||
@@ -302,16 +307,6 @@
|
||||
<text wx:else class="more-luck-link" catchtap="goMoreLuck">更多试试手气 ›</text>
|
||||
</view>
|
||||
|
||||
<!-- 上滑切换遮罩(转场动画) -->
|
||||
<view class="switch-mask {{switching}}" wx:if="{{switching}}">
|
||||
<image class="switch-cover" src="{{switchNext.cover_image||'/images/gift.png'}}" mode="aspectFill" lazy-load/>
|
||||
<view class="switch-info">
|
||||
<text class="switch-label">下一个抽奖</text>
|
||||
<text class="switch-title">{{switchNext.title}}</text>
|
||||
</view>
|
||||
<view class="switch-loading">加载中…</view>
|
||||
</view>
|
||||
|
||||
<!-- ======================================================
|
||||
助力面板(底部弹出)
|
||||
====================================================== -->
|
||||
|
||||
@@ -186,14 +186,4 @@ button.share-guide-share::after { border: none; }
|
||||
}
|
||||
|
||||
/* F9:「更多试试手气」可点入口(替代原"暂无更多抽奖"死文案) */
|
||||
.more-luck-link { color: var(--primary); font-weight: 600; }
|
||||
|
||||
/* 上滑切换遮罩(转场) */
|
||||
.switch-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 300; transform: translateY(100%); background: var(--bg-card); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-md); transition: transform .26s ease; }
|
||||
.switch-mask.in { transform: translateY(0); }
|
||||
.switch-mask.out { transform: translateY(0); opacity: 0; transition: opacity .22s ease; }
|
||||
.switch-cover { width: 280rpx; height: 280rpx; border-radius: 24rpx; background: var(--divider); }
|
||||
.switch-info { text-align: center; }
|
||||
.switch-label { display: block; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.switch-title { display: block; font-size: var(--text-lg); color: var(--title); font-weight: 600; margin-top: var(--space-xs); }
|
||||
.switch-loading { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.more-luck-link { color: var(--primary); font-weight: 600; }
|
||||
@@ -1 +0,0 @@
|
||||
{"navigationBarTitleText":"幸运抽奖","usingComponents":{}}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { lotteryApi } from '../../../utils/lottery-api'
|
||||
import type { MpUser, AppOption } from '../../../utils/auth'
|
||||
import { buildRegionExtra } from '../../../utils/location'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
id: 0,
|
||||
password: '',
|
||||
prizes: [] as any[],
|
||||
lottery: null as any,
|
||||
animating: false,
|
||||
resultIndex: -1,
|
||||
showResult: false,
|
||||
winner: null as any,
|
||||
thanksIndex: -1,
|
||||
rotation: 0,
|
||||
isLogin: false,
|
||||
},
|
||||
onLoad(options: any) {
|
||||
const app = getApp<AppOption>()
|
||||
this.setData({ isLogin: !!app.globalData.user })
|
||||
app.loginReady.then(() => {
|
||||
this.setData({ isLogin: true })
|
||||
const id = Number(options.id)
|
||||
// 口令抽奖可从入口页透传口令(即抽即中后端同样校验口令)
|
||||
const password = options.password ? decodeURIComponent(options.password) : ''
|
||||
this.setData({ id, password })
|
||||
this.loadDetail(id)
|
||||
}).catch(() => {
|
||||
this.setData({ isLogin: false })
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
})
|
||||
},
|
||||
async loadDetail(id: number) {
|
||||
try {
|
||||
const res: any = await lotteryApi.detail(id)
|
||||
// 过滤掉谢谢参与,只留有效奖品做转盘
|
||||
const displayPrizes = res.prizes || []
|
||||
const thanksIdx = displayPrizes.findIndex((p: any) => p.type === 'thanks')
|
||||
this.setData({
|
||||
lottery: res,
|
||||
prizes: displayPrizes,
|
||||
thanksIndex: thanksIdx >= 0 ? thanksIdx : -1,
|
||||
})
|
||||
} catch (e: any) {
|
||||
wx.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
async startDraw() {
|
||||
const app = getApp<AppOption>()
|
||||
if (!app.globalData.user) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this.data.animating) return
|
||||
this.setData({ animating: true, showResult: false, winner: null })
|
||||
try {
|
||||
// 区域限制抽奖:携带定位经纬度供后端校验(与详情页参与一致)
|
||||
const payload: any = { password: this.data.password }
|
||||
const regionExtra = await buildRegionExtra(this.data.lottery?.config)
|
||||
if (regionExtra) payload.extra = regionExtra
|
||||
const res: any = await lotteryApi.instant(this.data.id, payload)
|
||||
const winner = res.winner
|
||||
// 计算旋转角度
|
||||
const prizeCount = this.data.prizes.length
|
||||
const segmentAngle = 360 / prizeCount
|
||||
let targetIndex = 0
|
||||
if (winner && winner.prize_id) {
|
||||
targetIndex = this.data.prizes.findIndex((p: any) => p.id === winner.prize_id)
|
||||
if (targetIndex < 0) targetIndex = 0
|
||||
} else {
|
||||
// 未中奖,转到谢谢参与或随机位置
|
||||
targetIndex = this.data.thanksIndex >= 0 ? this.data.thanksIndex : Math.floor(Math.random() * prizeCount)
|
||||
}
|
||||
// 计算目标旋转角度:在当前角度基础上累加(absolute 赋值相同结果时 transition 无变化),
|
||||
// 先凑整到目标格,再多转 5 圈,保证二次抽奖同结果也有完整动画
|
||||
const currentMod = ((this.data.rotation % 360) + 360) % 360
|
||||
const targetMod = 360 - targetIndex * segmentAngle - segmentAngle / 2
|
||||
const delta = ((targetMod - currentMod) % 360 + 360) % 360
|
||||
const targetRotation = this.data.rotation + 360 * 5 + delta
|
||||
this.setData({ rotation: targetRotation })
|
||||
// 动画结束后显示结果
|
||||
setTimeout(() => {
|
||||
this.setData({
|
||||
animating: false,
|
||||
showResult: true,
|
||||
winner: winner,
|
||||
resultIndex: targetIndex,
|
||||
})
|
||||
}, 3000)
|
||||
} catch (e: any) {
|
||||
this.setData({ animating: false })
|
||||
wx.showToast({ title: e.message || '抽奖失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
closeResult() {
|
||||
this.setData({ showResult: false })
|
||||
},
|
||||
goPrizeDetail() {
|
||||
if (this.data.winner && this.data.winner.id) {
|
||||
wx.navigateTo({ url: `/pages/lottery/prize-detail/index?id=${this.data.winner.id}` })
|
||||
}
|
||||
this.setData({ showResult: false })
|
||||
},
|
||||
goBack() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
<view class="page">
|
||||
<view class="draw-header">
|
||||
<text class="draw-title">{{lottery.title||'幸运大转盘'}}</text>
|
||||
<text class="draw-sub" wx:if="{{lottery}}">已有 {{lottery.current_count}} 人参与</text>
|
||||
</view>
|
||||
|
||||
<view class="wheel-container">
|
||||
<view class="wheel" style="transform: rotate({{rotation}}deg); transition: transform 3s cubic-bezier(0.25, 0.1, 0.25, 1);">
|
||||
<view class="wheel-item" wx:for="{{prizes}}" wx:key="id"
|
||||
style="transform: rotate({{index * 360 / prizes.length}}deg);">
|
||||
<view class="prize-segment" style="background: {{index % 2 === 0 ? '#ff6b6b' : '#ffd93d'}};">
|
||||
<text class="prize-name-seg">{{item.name}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="wheel-pointer">▼</view>
|
||||
</view>
|
||||
|
||||
<button class="draw-btn btn {{animating?'disabled':''}}" bindtap="startDraw">
|
||||
{{animating?'抽奖中...':'点击抽奖'}}
|
||||
</button>
|
||||
|
||||
<!-- 结果弹窗 -->
|
||||
<view class="result-mask" wx:if="{{showResult}}" bindtap="closeResult">
|
||||
<view class="result-box" catchtap="preventBubble">
|
||||
<block wx:if="{{winner}}">
|
||||
<view class="result-icon">🎉</view>
|
||||
<text class="result-title">恭喜中奖!</text>
|
||||
<text class="result-prize">{{winner.prize_name}}</text>
|
||||
<button class="result-btn primary" bindtap="goPrizeDetail">查看奖品</button>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="result-icon">😢</view>
|
||||
<text class="result-title">很遗憾</text>
|
||||
<text class="result-prize">未中奖,再试一次吧</text>
|
||||
<button class="result-btn" bindtap="closeResult">知道了</button>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -1,25 +0,0 @@
|
||||
.page { background: linear-gradient(135deg, #ff6b6b 0%, #ffd93d 100%); padding: var(--space-xl); display: flex; flex-direction: column; align-items: center; }
|
||||
.draw-header { text-align: center; margin-bottom: var(--space-lg); }
|
||||
.draw-title { font-size: var(--text-2xl); font-weight: 600; color: #fff; display: block; text-shadow: 0 2rpx 8rpx rgba(0,0,0,0.2); }
|
||||
.draw-sub { font-size: var(--text-base); color: rgba(255,255,255,0.9); margin-top: var(--space-xs); display: block; }
|
||||
|
||||
/* Wheel — core visual kept */
|
||||
.wheel-container { position: relative; width: 600rpx; height: 600rpx; margin: var(--space-lg) 0; }
|
||||
.wheel { width: 100%; height: 100%; border-radius: 50%; position: relative; overflow: hidden; border: 8rpx solid #fff; box-shadow: 0 8rpx 32rpx rgba(0,0,0,0.2); }
|
||||
.wheel-item { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
.prize-segment { position: absolute; top: 0; left: 50%; width: 50%; height: 50%; transform-origin: 0% 100%; clip-path: polygon(0 0, 100% 0, 50% 100%); display: flex; align-items: center; justify-content: center; }
|
||||
.prize-name-seg { position: absolute; top: 20%; left: 30%; font-size: var(--text-xs); color: #fff; font-weight: 500; transform: rotate(90deg); transform-origin: center; white-space: nowrap; text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.3); }
|
||||
.wheel-pointer { position: absolute; top: -20rpx; left: 50%; transform: translateX(-50%); font-size: 48rpx; color: var(--primary); z-index: 10; text-shadow: 0 2rpx 8rpx rgba(0,0,0,0.3); }
|
||||
|
||||
.draw-btn { width: 400rpx; height: 100rpx; line-height: 100rpx; background: #fff; color: var(--primary); font-size: var(--text-lg); border-radius: 50rpx; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.2); border: none; }
|
||||
.draw-btn.disabled { opacity: 0.6; }
|
||||
|
||||
/* Result modal */
|
||||
.result-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.result-box { background: var(--bg-card); border-radius: 24rpx; padding: var(--space-xxl) 80rpx; text-align: center; animation: popIn 0.3s ease; }
|
||||
@keyframes popIn { from { transform: scale(0.5); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
.result-icon { font-size: 80rpx; margin-bottom: var(--space-md); }
|
||||
.result-title { font-size: var(--text-2xl); font-weight: 600; color: var(--title); display: block; margin-bottom: var(--space-md); }
|
||||
.result-prize { font-size: var(--text-lg); color: var(--primary); display: block; margin-bottom: var(--space-xl); }
|
||||
.result-btn { width: 300rpx; height: 80rpx; line-height: 80rpx; font-size: var(--text-base); border-radius: 40rpx; }
|
||||
.result-btn.primary { background: var(--primary); color: #fff; }
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'
|
||||
import {
|
||||
installPageCapture,
|
||||
loadPageModule,
|
||||
createPageInstance,
|
||||
} from '../../../testkit/page-harness'
|
||||
|
||||
/**
|
||||
* pages/user/edit 资料编辑页测试:
|
||||
* chooseLocation 选点成功回填、失败 toast 区分——
|
||||
* 用户主动取消(errMsg 含 cancel)不打扰,其余失败提示检查定位授权。
|
||||
*/
|
||||
installPageCapture()
|
||||
|
||||
vi.mock('../../../utils/upload', () => ({ uploadImage: vi.fn() }))
|
||||
vi.mock('../../../utils/user-api', () => ({ userApi: { updateMe: vi.fn() } }))
|
||||
|
||||
const showToast = vi.fn()
|
||||
const chooseLocation = vi.fn()
|
||||
;(global as any).wx = { showToast, chooseLocation }
|
||||
|
||||
let app: any
|
||||
;(global as any).getApp = () => app
|
||||
|
||||
let pageOptions: any
|
||||
beforeAll(async () => {
|
||||
pageOptions = await loadPageModule(() => import('./index'))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
showToast.mockReset()
|
||||
chooseLocation.mockReset()
|
||||
app = {
|
||||
globalData: { user: null },
|
||||
loginReady: Promise.resolve(null),
|
||||
}
|
||||
})
|
||||
|
||||
describe('选择所在位置', () => {
|
||||
it('选点成功时回填位置名称', () => {
|
||||
chooseLocation.mockImplementation(({ success }: any) =>
|
||||
success({ name: '市民中心', address: '深圳市福田区' }),
|
||||
)
|
||||
const inst = createPageInstance(pageOptions)
|
||||
inst.chooseLocation()
|
||||
|
||||
expect(inst.data.location).toBe('市民中心')
|
||||
expect(showToast).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('选点成功且名称为空时回填详细地址', () => {
|
||||
chooseLocation.mockImplementation(({ success }: any) =>
|
||||
success({ name: '', address: '深圳市福田区' }),
|
||||
)
|
||||
const inst = createPageInstance(pageOptions)
|
||||
inst.chooseLocation()
|
||||
|
||||
expect(inst.data.location).toBe('深圳市福田区')
|
||||
})
|
||||
|
||||
it('用户主动取消选点(errMsg 含 cancel)时不提示', () => {
|
||||
chooseLocation.mockImplementation(({ fail }: any) =>
|
||||
fail({ errMsg: 'chooseLocation:fail cancel' }),
|
||||
)
|
||||
const inst = createPageInstance(pageOptions)
|
||||
inst.chooseLocation()
|
||||
|
||||
expect(showToast).not.toHaveBeenCalled()
|
||||
expect(inst.data.location).toBe('')
|
||||
})
|
||||
|
||||
it('定位授权被拒等非取消失败时提示检查定位授权', () => {
|
||||
chooseLocation.mockImplementation(({ fail }: any) =>
|
||||
fail({ errMsg: 'chooseLocation:fail auth deny' }),
|
||||
)
|
||||
const inst = createPageInstance(pageOptions)
|
||||
inst.chooseLocation()
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith({
|
||||
title: '无法获取位置,请检查定位授权',
|
||||
icon: 'none',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -48,11 +48,15 @@ Page({
|
||||
this.setData({ nickname: e.detail.value })
|
||||
},
|
||||
|
||||
/** 选择所在位置 */
|
||||
/** 选择所在位置(用户主动取消选点不提示) */
|
||||
chooseLocation() {
|
||||
wx.chooseLocation({
|
||||
success: (res) => this.setData({ location: res.name || res.address }),
|
||||
fail: () => {},
|
||||
fail: (err) => {
|
||||
if (!err.errMsg.includes('cancel')) {
|
||||
wx.showToast({ title: '无法获取位置,请检查定位授权', icon: 'none' })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -85,22 +85,6 @@ export interface WalletConfig {
|
||||
require_phone: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 充值配置 — 字段对齐后端 `/admin/config/recharge`。
|
||||
* enabled 控制小程序端充值入口显隐;tiers 为可配置的充值档位列表。
|
||||
*/
|
||||
export interface RechargeTierConfig {
|
||||
amount_fen: number
|
||||
amount_yuan: string
|
||||
points: number
|
||||
bonus: number
|
||||
}
|
||||
|
||||
export interface RechargeConfig {
|
||||
enabled: boolean
|
||||
tiers: RechargeTierConfig[]
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
listAssistRules: () =>
|
||||
http.get<any, AssistRulesResponse>('/admin/config/assist-rules'),
|
||||
@@ -164,12 +148,6 @@ export const configApi = {
|
||||
require_phone: requirePhone,
|
||||
}),
|
||||
|
||||
getRechargeConfig: () =>
|
||||
http.get<any, RechargeConfig>('/admin/config/recharge'),
|
||||
|
||||
updateRechargeConfig: (data: { enabled: boolean; tiers: { amount_fen: number; points: number }[] }) =>
|
||||
http.put('/admin/config/recharge', data),
|
||||
|
||||
getDynamicConfig: () => http.get<any, DynamicConfig>('/admin/config/dynamic'),
|
||||
|
||||
updateDynamicConfig: (data: DynamicConfig) =>
|
||||
|
||||
@@ -70,7 +70,6 @@ const menu = [
|
||||
{ label: '签到卡片文案', key: '/config/checkin-card' },
|
||||
{ label: '免责说明配置', key: '/config/disclaimer' },
|
||||
{ label: '联系客服配置', key: '/config/contact-info' },
|
||||
{ label: '充值配置', key: '/config/recharge' },
|
||||
{ label: '动态功能设置', key: '/config/dynamic' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -34,7 +34,6 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'config/checkin-card', component: () => import('../views/config/checkin-card/index.vue'), meta: { title: '签到卡片文案配置' } },
|
||||
{ path: 'config/disclaimer', component: () => import('../views/config/disclaimer/index.vue'), meta: { title: '免责说明配置' } },
|
||||
{ path: 'config/withdraw-disclaimer', component: () => import('../views/config/withdraw-disclaimer/index.vue'), meta: { title: '提现规则配置' } },
|
||||
{ path: 'config/recharge', component: () => import('../views/config/recharge/index.vue'), meta: { title: '充值配置' } },
|
||||
{ path: 'config/wallet', component: () => import('../views/config/wallet/index.vue'), meta: { title: '提现设置' } },
|
||||
{ path: 'config/dynamic', component: () => import('../views/config/dynamic/index.vue'), meta: { title: '动态功能设置' } },
|
||||
{ path: 'faq', redirect: { name: 'faq-modules' } },
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<template>
|
||||
<n-card title="充值配置" :bordered="false" style="max-width: 900px">
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
label-placement="top"
|
||||
require-mark-placement="right-hanging"
|
||||
>
|
||||
<n-form-item label="充值入口开关" path="enabled">
|
||||
<n-switch v-model:value="form.enabled">
|
||||
<template #checked>已开启</template>
|
||||
<template #unchecked>已关闭</template>
|
||||
</n-switch>
|
||||
<span style="margin-left: 12px; color: #999">
|
||||
关闭后小程序端隐藏"好运币充值"入口,后端同时拒绝新的充值订单。
|
||||
</span>
|
||||
</n-form-item>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-form-item label="充值档位">
|
||||
<n-space vertical style="width: 100%">
|
||||
<n-card
|
||||
v-for="(tier, index) in form.tiers"
|
||||
:key="index"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
style="width: 100%"
|
||||
>
|
||||
<n-space align="center" :wrap="false">
|
||||
<n-input-number
|
||||
v-model:value="tier.amountYuan"
|
||||
:min="1"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
placeholder="金额"
|
||||
style="width: 160px"
|
||||
>
|
||||
<template #prefix>¥</template>
|
||||
</n-input-number>
|
||||
<span>=</span>
|
||||
<n-input-number
|
||||
v-model:value="tier.points"
|
||||
:min="1"
|
||||
:step="100"
|
||||
placeholder="好运币"
|
||||
style="width: 180px"
|
||||
/>
|
||||
<span style="white-space: nowrap">好运币</span>
|
||||
<n-tag v-if="tierBonus(tier) !== null" type="success" size="small">
|
||||
送 {{ tierBonus(tier) }}
|
||||
</n-tag>
|
||||
<n-button
|
||||
quaternary
|
||||
type="error"
|
||||
size="small"
|
||||
:disabled="form.tiers.length <= 1"
|
||||
@click="removeTier(index)"
|
||||
>
|
||||
删除
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<n-button dashed @click="addTier" style="width: 100%">+ 添加档位</n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button :disabled="saving" @click="load">重置</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDivider,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInputNumber,
|
||||
NSpace,
|
||||
NSwitch,
|
||||
NTag,
|
||||
useMessage,
|
||||
type FormInst,
|
||||
type FormRules,
|
||||
} from 'naive-ui'
|
||||
import { configApi } from '../../../api/config'
|
||||
|
||||
const msg = useMessage()
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInst | null>(null)
|
||||
|
||||
interface TierForm {
|
||||
amountYuan: number | null
|
||||
points: number | null
|
||||
}
|
||||
|
||||
const form = ref<{ enabled: boolean; tiers: TierForm[] }>({
|
||||
enabled: false,
|
||||
tiers: [],
|
||||
})
|
||||
|
||||
const formRules: FormRules = {}
|
||||
|
||||
// 赠送基准:价格最低的有效档位,其单价即基准汇率(每元应得好运币数)
|
||||
const baseTier = computed<TierForm | null>(() => {
|
||||
let base: TierForm | null = null
|
||||
for (const t of form.value.tiers) {
|
||||
if (t.amountYuan == null || t.points == null || t.amountYuan <= 0) continue
|
||||
if (!base || t.amountYuan < base.amountYuan!) base = t
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
// 赠送好运币数按基准汇率折算,超出部分即为赠送;无赠送返回 null
|
||||
function tierBonus(tier: TierForm): number | null {
|
||||
const base = baseTier.value
|
||||
if (!base || tier.amountYuan == null || tier.points == null) return null
|
||||
const expected = Math.round((tier.amountYuan * base.points!) / base.amountYuan!)
|
||||
const bonus = Math.round(tier.points) - expected
|
||||
return bonus > 0 ? bonus : null
|
||||
}
|
||||
|
||||
function addTier() {
|
||||
form.value.tiers.push({ amountYuan: null, points: null })
|
||||
}
|
||||
|
||||
function removeTier(index: number) {
|
||||
form.value.tiers.splice(index, 1)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await configApi.getRechargeConfig()
|
||||
form.value.enabled = data?.enabled ?? false
|
||||
form.value.tiers = (data?.tiers ?? []).map((t) => ({
|
||||
amountYuan: t.amount_fen / 100,
|
||||
points: t.points,
|
||||
}))
|
||||
if (form.value.tiers.length === 0) {
|
||||
form.value.tiers.push({ amountYuan: null, points: null })
|
||||
}
|
||||
} catch (e: any) {
|
||||
msg.error(e?.message || '加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const validTiers = form.value.tiers.filter((t) => t.amountYuan != null && t.points != null)
|
||||
if (validTiers.length === 0) {
|
||||
msg.warning('至少需要一个充值档位')
|
||||
return
|
||||
}
|
||||
for (const t of validTiers) {
|
||||
if (t.amountYuan! < 1) {
|
||||
msg.warning('充值金额最低 1 元')
|
||||
return
|
||||
}
|
||||
if (t.points! < 1) {
|
||||
msg.warning('好运币数量最低 1')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await configApi.updateRechargeConfig({
|
||||
enabled: form.value.enabled,
|
||||
tiers: validTiers.map((t) => ({
|
||||
amount_fen: Math.round(t.amountYuan! * 100),
|
||||
points: t.points!,
|
||||
})),
|
||||
})
|
||||
msg.success('保存成功')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
msg.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user