feat(abtest): 扩展 A/B 测试至首页弹窗、商品按钮、签到页三个场景
注册 homepage_popup / product_button / signin_reward 三个实验变量, 后端在订单支付、积分兑换、签到成功时自动上报转化事件; 小程序三页面对接 abAssign 动态文案,首页弹窗新增 POST /api/mp/ab/event 端点供前端触发 popup_action 转化事件(事件名由注册表校验,防注入)。
This commit is contained in:
@@ -289,6 +289,11 @@ func main() {
|
||||
WithV8(v8Engine).
|
||||
WithABTest(abtestSvc).
|
||||
WithAlerter(drawboxAlerter)
|
||||
// A/B 测试引擎注入:订单支付/积分兑换/签到 成功后上报转化事件。
|
||||
orderSvc.ABTest = abtestSvc
|
||||
MallService.ABTest = abtestSvc
|
||||
checkinSvc.ABTest = abtestSvc
|
||||
signinLotSvc.ABTest = abtestSvc
|
||||
// 命运盒子任务中心(Task13):独立服务,供签到/邀请服务按可选依赖挂钩调用。
|
||||
drawboxTaskSvc := drawbox.NewTaskService(db, drawboxCfgSvc, ledger)
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
|
||||
// ABTestHandler 小程序端 A/B 测试分组分配处理器。
|
||||
//
|
||||
// 仅暴露一个只读接口:拉取当前用户在指定变量下的分组配置。
|
||||
// 转化事件由后端服务在业务链路中自行记录(如 drawbox draw → abtest.RecordEvent),
|
||||
// 不开放给小程序端主动上报,避免刷量污染统计。
|
||||
// 提供分组分配(Assign)与前端触发型转化事件上报(RecordConversion)。
|
||||
// RecordConversion 仅接受注册表中已定义的变量,事件名取自注册表而非客户端传入,
|
||||
// 防止任意事件名注入。
|
||||
type ABTestHandler struct {
|
||||
Svc *abtest.Service
|
||||
// DB 用于组装分流主体(openid / 新老用户口径)。nil 时降级为仅按 uid+IP 分流。
|
||||
@@ -63,6 +63,42 @@ func (h *ABTestHandler) Assign(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RecordConversion 前端触发型转化事件上报。
|
||||
//
|
||||
// POST /api/mp/ab/event
|
||||
// Body: {"variable": "homepage_popup"}
|
||||
//
|
||||
// 事件名从注册表取(EventName 字段),客户端只指定变量,不可自定义事件名。
|
||||
// 仅用于无自然后端触发点的 UI 交互(如弹窗按钮点击)。
|
||||
func (h *ABTestHandler) RecordConversion(c *gin.Context) {
|
||||
if h.Svc == nil {
|
||||
response.OK(c, nil)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Variable string `json:"variable"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Variable == "" {
|
||||
response.Fail(c, response.CodeBadRequest, "missing variable")
|
||||
return
|
||||
}
|
||||
spec := abtest.FindVariable(req.Variable)
|
||||
if spec == nil || spec.EventName == "" {
|
||||
response.Fail(c, response.CodeBadRequest, "unknown variable or no event defined")
|
||||
return
|
||||
}
|
||||
uid := c.GetUint64("uid")
|
||||
if uid == 0 {
|
||||
response.Fail(c, response.CodeUnauthorized, "login required")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.RecordEvent(c.Request.Context(), uid, spec.Key, spec.EventName); err != nil {
|
||||
response.FailErr(c, response.CodeInternal, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// buildSubject 从 JWT、用户表与请求上下文组装分流主体。
|
||||
// 用户行查询失败时相关维度留空(openid 分桶/新老定向自然不命中),不影响 user_id 分桶。
|
||||
func (h *ABTestHandler) buildSubject(c *gin.Context) abtest.Subject {
|
||||
|
||||
@@ -446,6 +446,7 @@ func New(mode string, d Deps) *gin.Engine {
|
||||
// 未注入引擎(Deps.MpABTest == nil)时不挂载,前端调用将 404 → 走默认逻辑。
|
||||
if d.MpABTest != nil {
|
||||
mpG.GET("/ab/assign", d.MpABTest.Assign)
|
||||
mpG.POST("/ab/event", d.MpABTest.RecordConversion)
|
||||
}
|
||||
|
||||
adminG.GET("/cockpit/overview", middleware.RequirePerm(d.DB, "cockpit:read"), d.AdminSprint6.Cockpit)
|
||||
|
||||
@@ -49,8 +49,13 @@ type VariableSpec struct {
|
||||
Params []ParamSpec `json:"params"`
|
||||
}
|
||||
|
||||
// variables 注册表本体。第一版聚焦命运盒子链路——它是当前唯一具备
|
||||
// 转化事件闭环(drawbox draw → ab_events)的业务点。
|
||||
// variables 注册表本体。
|
||||
//
|
||||
// 已注册变量:
|
||||
// - drawbox:命运盒子页面文案(转化事件 draw)
|
||||
// - homepage_popup:首页弹窗文案(转化事件 popup_action)
|
||||
// - product_button:商品详情页按钮文案(转化事件 purchase)
|
||||
// - signin_reward:签到页奖励展示(转化事件 checkin)
|
||||
var variables = []VariableSpec{
|
||||
{
|
||||
Key: "drawbox",
|
||||
@@ -65,6 +70,43 @@ var variables = []VariableSpec{
|
||||
{Key: "show_footer_rule", Label: "展示底部玩法说明", Type: ParamBoolean, Default: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "homepage_popup",
|
||||
Label: "首页弹窗文案",
|
||||
Description: "首页弹窗的标题、描述与按钮文案。转化事件:用户点击弹窗按钮进入活动(popup_action)。",
|
||||
ConsumedBy: "小程序 pages/index/index",
|
||||
EventName: "popup_action",
|
||||
Params: []ParamSpec{
|
||||
{Key: "title", Label: "弹窗标题", Type: ParamString, Default: "今日好运推荐", MaxLength: 12},
|
||||
{Key: "description", Label: "弹窗描述", Type: ParamString, Default: "精选好物等你来抽!", MaxLength: 30},
|
||||
{Key: "button_text", Label: "按钮文案", Type: ParamString, Default: "立即参与", MaxLength: 8, Placeholder: "如 去看看 / 马上抽"},
|
||||
{Key: "show_popup", Label: "是否展示弹窗", Type: ParamBoolean, Default: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "product_button",
|
||||
Label: "商品详情页按钮",
|
||||
Description: "商品详情页购买/兑换按钮与紧迫感文案。转化事件:用户完成购买或积分兑换(purchase)。",
|
||||
ConsumedBy: "小程序 pages/mall/detail/index",
|
||||
EventName: "purchase",
|
||||
Params: []ParamSpec{
|
||||
{Key: "buy_button_text", Label: "现金购买按钮文案", Type: ParamString, Default: "立即购买", MaxLength: 8, Placeholder: "如 马上抢 / 立即下单"},
|
||||
{Key: "exchange_button_text", Label: "积分兑换按钮文案", Type: ParamString, Default: "积分兑换", MaxLength: 8, Placeholder: "如 兑换好礼 / 立即兑换"},
|
||||
{Key: "urgency_text", Label: "紧迫感提示文案", Type: ParamString, Default: "", MaxLength: 20, Placeholder: "如 限时特惠 / 仅剩XX件(空串不展示)"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "signin_reward",
|
||||
Label: "签到页奖励展示",
|
||||
Description: "签到页奖励标题与连续签到描述文案。转化事件:用户完成一次签到(checkin)。",
|
||||
ConsumedBy: "小程序 pages/signin-lottery/detail/index",
|
||||
EventName: "checkin",
|
||||
Params: []ParamSpec{
|
||||
{Key: "reward_title", Label: "奖励标题", Type: ParamString, Default: "签到领积分", MaxLength: 10, Placeholder: "如 每日签到 / 打卡赢好礼"},
|
||||
{Key: "streak_description", Label: "连续签到描述", Type: ParamString, Default: "连续签到奖励更多", MaxLength: 20},
|
||||
{Key: "show_streak_bonus", Label: "展示连续签到加成", Type: ParamBoolean, Default: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Variables 返回注册表全量变量定义(管理后台下拉与动态表单数据源)。
|
||||
|
||||
@@ -49,6 +49,10 @@ type Service struct {
|
||||
Ledger *points.Ledger
|
||||
// Risk 前置风控判定器(可选);积分/商城兑换前调用,命中拦截返回错误。nil 时放行。
|
||||
Risk RiskChecker
|
||||
// ABTest A/B 测试引擎(可选);积分兑换成功后上报 "purchase" 转化事件,nil 时跳过。
|
||||
ABTest interface {
|
||||
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建商城服务
|
||||
@@ -705,6 +709,10 @@ func (s *Service) ExchangeProduct(ctx context.Context, mpUserID, productID, addr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A/B 测试:积分兑换成功后上报 "purchase" 转化事件(商品详情页按钮实验)。
|
||||
if s.ABTest != nil {
|
||||
_ = s.ABTest.RecordEvent(context.Background(), mpUserID, "product_button", "purchase")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,10 @@ type Service struct {
|
||||
Promoter PromoterCommissionRecorder
|
||||
// PointsRecharger 积分充值入账器(V11.0 §6.9,可选);支付成功后积分入账,nil 时不处理 points_recharge 订单。
|
||||
PointsRecharger PointsRecharger
|
||||
// ABTest A/B 测试引擎(可选);支付成功后上报 "purchase" 转化事件,nil 时跳过。
|
||||
ABTest interface {
|
||||
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
|
||||
}
|
||||
}
|
||||
|
||||
// PromoterCommissionRecorder 推广员佣金记录接口(由 service/promoter.Service 实现)。
|
||||
@@ -557,6 +561,10 @@ func (s *Service) markPaid(orderNo, transactionID string, expectedAmountFen int6
|
||||
return err
|
||||
}
|
||||
s.publishOrderPaid(paidOrder)
|
||||
// A/B 测试:支付成功后上报 "purchase" 转化事件(商品详情页按钮实验)。
|
||||
if s.ABTest != nil && paidOrder != nil {
|
||||
_ = s.ABTest.RecordEvent(context.Background(), paidOrder.MpUserID, "product_button", "purchase")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -599,6 +607,10 @@ func (s *Service) ManualMarkPaid(orderID uint64) error {
|
||||
return err
|
||||
}
|
||||
s.publishOrderPaid(paidOrder)
|
||||
// A/B 测试:人工标记支付成功后上报 "purchase" 转化事件。
|
||||
if s.ABTest != nil && paidOrder != nil {
|
||||
_ = s.ABTest.RecordEvent(context.Background(), paidOrder.MpUserID, "product_button", "purchase")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ type CheckinService struct {
|
||||
// 选用直调而非 EventBus 的理由:避免引入新事件名 + 可选依赖空时静默跳过,
|
||||
// 失败仅记日志、不阻塞签到主流程(参考下方 Checkin 的调用注释)。
|
||||
DrawboxGranter DrawboxTaskGranter
|
||||
// ABTest A/B 测试引擎(可选);签到成功后上报 "checkin" 转化事件,nil 时跳过。
|
||||
ABTest interface {
|
||||
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
|
||||
}
|
||||
}
|
||||
|
||||
// DrawboxTaskGranter 任务中心发放抽象接口,由 drawbox.TaskService 实现。
|
||||
@@ -150,6 +154,10 @@ func (s *CheckinService) Checkin(ctx context.Context, userID uint64) (*CheckinRe
|
||||
}
|
||||
}
|
||||
}
|
||||
// A/B 测试:签到成功后上报 "checkin" 转化事件(签到页奖励展示实验)。
|
||||
if s.ABTest != nil {
|
||||
_ = s.ABTest.RecordEvent(context.Background(), userID, "signin_reward", "checkin")
|
||||
}
|
||||
return &CheckinResult{
|
||||
ContinuousDays: streak,
|
||||
Reward: reward,
|
||||
|
||||
@@ -85,6 +85,10 @@ func (s *Service) Checkin(ctx context.Context, lotteryID, userID uint64) (*Check
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// A/B 测试:签到成功后上报 "checkin" 转化事件(签到页奖励展示实验)。
|
||||
if s.ABTest != nil {
|
||||
_ = s.ABTest.RecordEvent(context.Background(), userID, "signin_reward", "checkin")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package signin_lottery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -27,6 +28,10 @@ type Service struct {
|
||||
// Bus 积分事件总线(可空):开奖生成中奖记录后发布 prize.won / prize.await_address。
|
||||
// 为 nil 时跳过发布(保持向后兼容,便于测试)。
|
||||
Bus *points.EventBus
|
||||
// ABTest A/B 测试引擎(可选);签到成功后上报 "checkin" 转化事件,nil 时跳过。
|
||||
ABTest interface {
|
||||
RecordEvent(ctx context.Context, userID uint64, variable, eventName string) error
|
||||
}
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, ledger *points.Ledger) *Service {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { daily666Api } from '../../utils/daily666-api'
|
||||
import { walletApi } from '../../utils/wallet-api'
|
||||
import { assistApi } from '../../utils/assist-api'
|
||||
import { formatDrawTimeText, fenToYuan } from '../../utils/format'
|
||||
import { abAssign, recordConversion } from '../../utils/ab-api'
|
||||
|
||||
interface MpUser {
|
||||
id: number
|
||||
@@ -121,6 +122,11 @@ Page({
|
||||
{ key: 'welfare', label: '逛福利', icon: '🎁', undone: false, path: '/pages/tasks/index/index' },
|
||||
{ key: 'invite', label: '邀请', icon: '👥', undone: false, path: '/pages/tasks/index/index' },
|
||||
] as { key: string; label: string; icon: string; undone: boolean; path: string }[],
|
||||
// A/B 实验:首页弹窗文案(homepage_popup)
|
||||
popupTitle: '今日好运推荐',
|
||||
popupDescription: '精选好物等你来抽!',
|
||||
popupButtonText: '立即参与',
|
||||
showPromoPopup: false,
|
||||
},
|
||||
|
||||
// 倒计时定时器
|
||||
@@ -178,6 +184,8 @@ Page({
|
||||
} catch (e) {
|
||||
// storage 异常不影响主页加载
|
||||
}
|
||||
|
||||
this.loadAbConfig()
|
||||
},
|
||||
|
||||
// 开屏广告关闭回调;同值守卫避免重复 setData 与组件 observer 形成回环
|
||||
@@ -187,6 +195,27 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async loadAbConfig() {
|
||||
const res = await abAssign('homepage_popup')
|
||||
const p = (res && res.assigned && res.payload) || {}
|
||||
const patch: Record<string, any> = {}
|
||||
if (typeof p.title === 'string' && p.title) patch.popupTitle = p.title.slice(0, 12)
|
||||
if (typeof p.description === 'string' && p.description) patch.popupDescription = p.description.slice(0, 30)
|
||||
if (typeof p.button_text === 'string' && p.button_text) patch.popupButtonText = p.button_text.slice(0, 8)
|
||||
if (typeof p.show_popup === 'boolean') patch.showPromoPopup = p.show_popup
|
||||
if (Object.keys(patch).length) this.setData(patch)
|
||||
},
|
||||
|
||||
onClosePromoPopup() {
|
||||
this.setData({ showPromoPopup: false })
|
||||
},
|
||||
|
||||
onPopupAction() {
|
||||
this.setData({ showPromoPopup: false })
|
||||
recordConversion('homepage_popup')
|
||||
wx.navigateTo({ url: '/pages/lottery/list/index' })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
syncTabBarSelected(this, TAB_INDEX.home)
|
||||
// 助力任务承载:从助力面板跳来时按任务条件计时上报。
|
||||
|
||||
@@ -212,3 +212,13 @@
|
||||
|
||||
<!-- 开屏广告:冷启动展示一次(V11.0 §5.1:优先微信原生,自建CPC兜底) -->
|
||||
<splash-ad show="{{splashShow}}" duration="{{2}}" native-ad-unit-id="{{splashNativeAdUnitId}}" bind:close="onSplashClose" />
|
||||
|
||||
<!-- A/B 实验:首页弹窗文案(homepage_popup) -->
|
||||
<view wx:if="{{showPromoPopup}}" class="promo-popup-mask" bindtap="onClosePromoPopup">
|
||||
<view class="promo-popup-card" catchtap="">
|
||||
<view class="promo-popup-close" bindtap="onClosePromoPopup">×</view>
|
||||
<text class="promo-popup-title">{{popupTitle}}</text>
|
||||
<text class="promo-popup-desc">{{popupDescription}}</text>
|
||||
<button class="promo-popup-btn btn btn-primary" bindtap="onPopupAction">{{popupButtonText}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -528,3 +528,29 @@
|
||||
color: #bbb;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* A/B 实验:首页弹窗 */
|
||||
.promo-popup-mask {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.promo-popup-card {
|
||||
width: 580rpx; background: #fff; border-radius: 24rpx;
|
||||
padding: 60rpx 40rpx 40rpx; text-align: center; position: relative;
|
||||
}
|
||||
.promo-popup-close {
|
||||
position: absolute; top: 16rpx; right: 24rpx;
|
||||
font-size: 48rpx; color: #999; line-height: 1;
|
||||
}
|
||||
.promo-popup-title {
|
||||
display: block; font-size: 40rpx; font-weight: 700; color: #333;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.promo-popup-desc {
|
||||
display: block; font-size: 28rpx; color: #666;
|
||||
margin-bottom: 40rpx; line-height: 1.5;
|
||||
}
|
||||
.promo-popup-btn {
|
||||
width: 80% !important; margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { addressApi } from '../../../utils/address-api'
|
||||
import { pointsApi } from '../../../utils/points-api'
|
||||
import { requestPayment } from '../../../utils/pay'
|
||||
import { formatTime } from '../../../utils/format'
|
||||
import { abAssign } from '../../../utils/ab-api'
|
||||
|
||||
// 常见富文本标签命中即视为已是 HTML 内容
|
||||
const DETAIL_HTML_TAG_RE = /<(?:p|br|div|span|img|h[1-6]|ul|ol|li|strong|em|b|i|u|s|blockquote|table|thead|tbody|tr|td|th|a|section|figure|figcaption|hr|code|pre)\b/i
|
||||
@@ -55,6 +56,10 @@ Page({
|
||||
showOrderSheet: false,
|
||||
// 防止规格选择器关闭时误触订单弹层mask
|
||||
blockOrderSheetClose: false,
|
||||
// A/B 实验:商品详情页按钮文案(product_button)
|
||||
buyButtonText: '立即购买',
|
||||
exchangeButtonText: '积分兑换',
|
||||
urgencyText: '',
|
||||
},
|
||||
|
||||
onLoad(options: any) {
|
||||
@@ -63,6 +68,7 @@ Page({
|
||||
this.loadProduct(id)
|
||||
this.loadAddresses()
|
||||
this.loadMyPoints()
|
||||
this.loadAbConfig()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,6 +77,16 @@ Page({
|
||||
if (this.data.product) this.loadAddresses()
|
||||
},
|
||||
|
||||
async loadAbConfig() {
|
||||
const res = await abAssign('product_button')
|
||||
const p = (res && res.assigned && res.payload) || {}
|
||||
const patch: Record<string, any> = {}
|
||||
if (typeof p.buy_button_text === 'string' && p.buy_button_text) patch.buyButtonText = p.buy_button_text.slice(0, 8)
|
||||
if (typeof p.exchange_button_text === 'string' && p.exchange_button_text) patch.exchangeButtonText = p.exchange_button_text.slice(0, 8)
|
||||
if (typeof p.urgency_text === 'string') patch.urgencyText = p.urgency_text.slice(0, 20)
|
||||
if (Object.keys(patch).length) this.setData(patch)
|
||||
},
|
||||
|
||||
async loadProduct(id: number) {
|
||||
try {
|
||||
const p = await mallApi.getProductDetail(id)
|
||||
|
||||
@@ -89,10 +89,11 @@
|
||||
<button class="btn btn-primary btn-small"
|
||||
disabled="{{(selectedSku ? selectedSku.stock <= 0 : (specDefs.length > 0 ? false : product.stock <= 0)) || ordering || (payMode === 'points' && product.points_cost > myPoints)}}"
|
||||
bindtap="openOrderSheet">
|
||||
{{(selectedSku ? selectedSku.stock <= 0 : (specDefs.length > 0 ? false : product.stock <= 0)) ? '已售罄' : (payMode === 'points' ? '积分兑换' : '立即购买')}}
|
||||
{{(selectedSku ? selectedSku.stock <= 0 : (specDefs.length > 0 ? false : product.stock <= 0)) ? '已售罄' : (payMode === 'points' ? exchangeButtonText : buyButtonText)}}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{urgencyText}}" class="urgency-tip">{{urgencyText}}</view>
|
||||
|
||||
<!-- 购买确认弹层(优惠券/地址/备注在此填写) -->
|
||||
<view wx:if="{{showOrderSheet}}" class="picker-mask" bindtap="closeOrderSheet">
|
||||
|
||||
@@ -104,3 +104,5 @@
|
||||
.review-reply-content { color: var(--text); }
|
||||
.review-empty { padding-top: var(--space-sm); text-align: center; }
|
||||
.review-empty-text { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
|
||||
.urgency-tip { text-align: center; font-size: 24rpx; color: #ff4d4f; margin-top: 8rpx; font-weight: 500; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prepareShareToken, buildSharePath } from '../../../utils/share'
|
||||
import { reportShareVisit } from '../../../utils/visit'
|
||||
import { formatDrawTime } from '../../../utils/format'
|
||||
import { joinErrorMessage, JOIN_CODES } from '../../../utils/error-map'
|
||||
import { abAssign } from '../../../utils/ab-api'
|
||||
|
||||
interface DayItem {
|
||||
day: number
|
||||
@@ -77,6 +78,10 @@ Page({
|
||||
progressPercent: 0,
|
||||
navHeight: 0,
|
||||
shareToken: '',
|
||||
// A/B 实验:签到页奖励展示(signin_reward)
|
||||
rewardTitle: '签到领积分',
|
||||
streakDescription: '连续签到奖励更多',
|
||||
showStreakBonus: true,
|
||||
},
|
||||
|
||||
async prepareShare(id: number) {
|
||||
@@ -100,6 +105,8 @@ Page({
|
||||
} else {
|
||||
this.loadAll(id)
|
||||
}
|
||||
|
||||
this.loadAbConfig()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -107,6 +114,16 @@ Page({
|
||||
if (this.data.lottery) this.loadAll(this.data.id)
|
||||
},
|
||||
|
||||
async loadAbConfig() {
|
||||
const res = await abAssign('signin_reward')
|
||||
const p = (res && res.assigned && res.payload) || {}
|
||||
const patch: Record<string, any> = {}
|
||||
if (typeof p.reward_title === 'string' && p.reward_title) patch.rewardTitle = p.reward_title.slice(0, 10)
|
||||
if (typeof p.streak_description === 'string' && p.streak_description) patch.streakDescription = p.streak_description.slice(0, 20)
|
||||
if (typeof p.show_streak_bonus === 'boolean') patch.showStreakBonus = p.show_streak_bonus
|
||||
if (Object.keys(patch).length) this.setData(patch)
|
||||
},
|
||||
|
||||
async loadAll(id: number) {
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<!-- 主区:签到抽奖 -->
|
||||
<view class="section card">
|
||||
<view class="section-title">签到抽奖</view>
|
||||
<view class="section-title">{{rewardTitle}}</view>
|
||||
<view class="section-desc">截至开奖前,签到任意 {{lottery.threshold_days}} 天即可参与抽奖,每日首次签到还有额外奖励</view>
|
||||
|
||||
<!-- 7 天格子(3 列 x 2 行) + 第 7 天大格子 -->
|
||||
@@ -74,6 +74,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{showStreakBonus}}" class="streak-tip">{{streakDescription}}</view>
|
||||
<button
|
||||
class="checkin-btn btn btn-primary {{canCheckin && !checkingIn ? '' : 'btn-disabled'}}"
|
||||
bindtap="onCheckin"
|
||||
|
||||
@@ -67,3 +67,5 @@
|
||||
.empty-state .empty-icon { font-size: 100rpx; opacity: 0.6; }
|
||||
.empty-state .empty-text { font-size: var(--text-lg); color: var(--text-secondary); margin-top: var(--space-md); }
|
||||
.empty-state .empty-sub { font-size: var(--text-base); color: var(--text-disabled); margin-top: var(--space-xs); }
|
||||
|
||||
.streak-tip { text-align: center; font-size: 24rpx; color: #ff5e8a; margin-bottom: var(--space-sm); font-weight: 500; }
|
||||
|
||||
@@ -22,3 +22,9 @@ export async function abAssign(variable: string): Promise<ABAssignResult> {
|
||||
return { assigned: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** 上报前端触发型转化事件(如弹窗按钮点击)。
|
||||
* 事件名由后端注册表决定,客户端仅传 variable。失败静默,不阻断主流程。 */
|
||||
export function recordConversion(variable: string) {
|
||||
api.post('/mp/ab/event', { variable }).catch(() => {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user