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:
2026-10-08 22:45:00 +08:00
parent 56559e5c3b
commit 7efa99fe18
20 changed files with 191 additions and 486 deletions
+3
View File
@@ -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},
+1
View File
@@ -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},
+3 -1
View File
@@ -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)
+1 -1
View File
@@ -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 内完成,此处不重复。
-1
View File
@@ -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",
+1
View File
@@ -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' }),
),
)
})
})
+17 -68
View File
@@ -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 }),
})
})
},
+7 -12
View File
@@ -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>
<!-- ======================================================
助力面板(底部弹出)
====================================================== -->
+1 -11
View File
@@ -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":{}}
-108
View File
@@ -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()
},
})
-40
View File
@@ -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>
-25
View File
@@ -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; }
+84
View File
@@ -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',
})
})
})
+6 -2
View File
@@ -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' })
}
},
})
},
-22
View File
@@ -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) =>
-1
View File
@@ -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' },
],
},
-1
View File
@@ -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' } },
-192
View File
@@ -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>