fix: 引导任务修改bug

This commit is contained in:
zerosaturation 2026-04-22 17:35:43 +08:00
parent dd838fccc4
commit 6866618ff6
7 changed files with 295 additions and 69 deletions

View File

@ -78,6 +78,7 @@ message OnboardingStage {
string status = 6; // pending/completed/in_progress string status = 6; // pending/completed/in_progress
bool is_current = 7; bool is_current = 7;
bool all_tasks_completed = 8; // bool all_tasks_completed = 8; //
bool is_reward_claimed = 9; //
} }
message CompleteGuideRequest { message CompleteGuideRequest {

View File

@ -49,21 +49,22 @@ func (UserOnboardingProgress) TableName() string { return "user_onboarding_progr
// UserOnboardingStatus 引导流程状态表per-user per-star // UserOnboardingStatus 引导流程状态表per-user per-star
type UserOnboardingStatus struct { type UserOnboardingStatus struct {
ID int64 `gorm:"primaryKey;column:id;autoIncrement"` ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
UserID int64 `gorm:"column:user_id;not null;uniqueIndex:uk_user_star_onboarding"` UserID int64 `gorm:"column:user_id;not null;uniqueIndex:uk_user_star_onboarding"`
StarID int64 `gorm:"column:star_id;not null;uniqueIndex:uk_user_star_onboarding"` StarID int64 `gorm:"column:star_id;not null;uniqueIndex:uk_user_star_onboarding"`
IsOnboardingCompleted bool `gorm:"column:is_onboarding_completed;default:false"` IsOnboardingCompleted bool `gorm:"column:is_onboarding_completed;default:false"`
IsOnboardingClaimed bool `gorm:"column:is_onboarding_claimed;default:false"` IsOnboardingClaimed bool `gorm:"column:is_onboarding_claimed;default:false"`
HasFriendDisplayBonus bool `gorm:"column:has_friend_display_bonus;default:false"` HasFriendDisplayBonus bool `gorm:"column:has_friend_display_bonus;default:false"`
OnboardingCompletedAt *int64 `gorm:"column:onboarding_completed_at"` OnboardingCompletedAt *int64 `gorm:"column:onboarding_completed_at"`
OnboardingClaimedAt *int64 `gorm:"column:onboarding_claimed_at"` OnboardingClaimedAt *int64 `gorm:"column:onboarding_claimed_at"`
CreatedAt int64 `gorm:"column:created_at"` ClaimedStages []int64 `gorm:"column:claimed_stages;type:text;serializer:json"` // 已领取奖励的阶段列表
UpdatedAt int64 `gorm:"column:updated_at"` CreatedAt int64 `gorm:"column:created_at"`
CurrentStage int64 `gorm:"column:current_stage;default:0"` UpdatedAt int64 `gorm:"column:updated_at"`
Status string `gorm:"column:status;size:20;default:pending"` CurrentStage int64 `gorm:"column:current_stage;default:0"`
IsFirstLoginBonusClaimed bool `gorm:"column:is_first_login_bonus_claimed;default:false"` // 废弃字段 Status string `gorm:"column:status;size:20;default:pending"`
CompletedAt *int64 `gorm:"column:completed_at"` // 废弃字段 IsFirstLoginBonusClaimed bool `gorm:"column:is_first_login_bonus_claimed;default:false"` // 废弃字段
ClaimedAt *int64 `gorm:"column:claimed_at"` // 废弃字段 CompletedAt *int64 `gorm:"column:completed_at"` // 废弃字段
ClaimedAt *int64 `gorm:"column:claimed_at"` // 废弃字段
} }
func (UserOnboardingStatus) TableName() string { return "user_onboarding_status" } func (UserOnboardingStatus) TableName() string { return "user_onboarding_status" }

View File

@ -8,6 +8,7 @@ import (
"github.com/topfans/backend/services/taskService/model" "github.com/topfans/backend/services/taskService/model"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type OnboardingRepository interface { type OnboardingRepository interface {
@ -186,42 +187,22 @@ func (r *onboardingRepository) SaveStageConfigs(configs []*model.OnboardingStage
zap.Int64("exp_reward", cfg.ExpReward)) zap.Int64("exp_reward", cfg.ExpReward))
cfg.UpdatedAt = now cfg.UpdatedAt = now
// Use upsert via ON CONFLICT to properly handle JSON serialization
// First try to update existing record upsert := clause.OnConflict{
// Use Select to specify fields so GORM properly handles JSON serialization Columns: []clause.Column{{Name: "stage"}},
result := r.db.Model(&model.OnboardingStageConfig{}). DoUpdates: clause.AssignmentColumns([]string{
Where("stage = ?", cfg.Stage). "name", "required_task_keys", "crystal_reward", "exp_reward", "is_active", "updated_at",
Select("name", "required_task_keys", "crystal_reward", "exp_reward", "is_active", "updated_at"). }),
Updates(cfg)
if result.Error != nil {
logger.Logger.Error("SaveStageConfigs: failed to update config",
zap.Int("stage", cfg.Stage),
zap.Error(result.Error))
return result.Error
} }
if err := r.db.Clauses(upsert).Create(cfg).Error; err != nil {
logger.Logger.Info("SaveStageConfigs: update result", logger.Logger.Error("SaveStageConfigs: failed to upsert config",
zap.Int("stage", cfg.Stage),
zap.Error(err))
return err
}
logger.Logger.Info("SaveStageConfigs: upserted config",
zap.Int("stage", cfg.Stage), zap.Int("stage", cfg.Stage),
zap.Int64("rows_affected", result.RowsAffected)) zap.String("name", cfg.Name))
// If no rows affected, create new record
if result.RowsAffected == 0 {
cfg.CreatedAt = now
if err := r.db.Create(cfg).Error; err != nil {
logger.Logger.Error("SaveStageConfigs: failed to create config",
zap.Int("stage", cfg.Stage),
zap.Error(err))
return err
}
logger.Logger.Info("SaveStageConfigs: created config",
zap.Int("stage", cfg.Stage),
zap.String("name", cfg.Name))
} else {
logger.Logger.Info("SaveStageConfigs: updated config",
zap.Int("stage", cfg.Stage),
zap.String("name", cfg.Name))
}
} }
logger.Logger.Info("SaveStageConfigs: all configs saved successfully") logger.Logger.Info("SaveStageConfigs: all configs saved successfully")

View File

@ -51,6 +51,8 @@
<script setup> <script setup>
import { computed, watch, onMounted, onUnmounted } from 'vue' import { computed, watch, onMounted, onUnmounted } from 'vue'
import { useStore } from 'vuex' import { useStore } from 'vuex'
import { completeGuide, advanceStage, getOnboardingStatus } from '@/utils/task-api.js'
import { onboardingStages } from '@/utils/guideConfig.js'
const store = useStore() const store = useStore()
@ -66,10 +68,37 @@ const stepConfig = computed(() => store.getters['guide/currentStepConfig'])
const isFirst = computed(() => store.getters['guide/isFirst']) const isFirst = computed(() => store.getters['guide/isFirst'])
const isLast = computed(() => store.getters['guide/isLast']) const isLast = computed(() => store.getters['guide/isLast'])
// // isActive false
watch(() => store.state.guide.isActive, (newVal) => { watch(() => isActive.value, async (newVal, oldVal) => {
console.log('[GuideOverlay] isActive changed to:', newVal) console.log('[GuideOverlay] isActive watch:', oldVal, '->', newVal)
}, { immediate: true }) if (oldVal === true && newVal === false) {
const guideKey = store.state.guide.completedGuideKey
if (guideKey) {
try {
await completeGuide(guideKey, onboardingStages)
console.log('[GuideOverlay] guide synced to backend:', guideKey)
//
const statusRes = await getOnboardingStatus()
const stages = statusRes.data?.stages || []
const currentStageData = stages.find(s => s.is_current)
if (currentStageData && currentStageData.all_tasks_completed) {
const nextStage = (currentStageData.stage || 0) + 1
const maxStage = onboardingStages.length - 1
if (nextStage <= maxStage) {
await advanceStage(nextStage)
console.log('[GuideOverlay] advanced to stage:', nextStage)
}
}
} catch (err) {
console.error('[GuideOverlay] failed to sync guide to backend:', err)
} finally {
// completedGuideKey
store.state.guide.completedGuideKey = null
}
}
}
})
// //
onMounted(() => { onMounted(() => {

View File

@ -118,6 +118,8 @@ import {
resetGuide, resetGuide,
claimGuideReward, claimGuideReward,
markGuideDone, markGuideDone,
isGuideDone,
markGuideRewardClaimed,
onboardingStages onboardingStages
} from '@/utils/guideConfig.js' } from '@/utils/guideConfig.js'
@ -132,6 +134,7 @@ const emit = defineEmits(['close', 'updated'])
const loading = ref(true) const loading = ref(true)
const guideList = ref([]) const guideList = ref([])
const backendStages = ref([])
// //
const totalCount = computed(() => guideList.value.length) const totalCount = computed(() => guideList.value.length)
@ -261,6 +264,25 @@ function refreshList() {
} }
function calculateStatus(key, done, claimed) { function calculateStatus(key, done, claimed) {
// 使
const backendStage = backendStages.value.find(s =>
s.required_task_keys && s.required_task_keys.includes(key)
)
if (backendStage) {
// in_progress
if (backendStage.status === 'in_progress') {
return 'reward_claimed'
}
// completed
if (backendStage.status === 'completed' || backendStage.all_tasks_completed) {
return 'completed'
}
// locked
if (backendStage.status === 'locked') {
return 'not_started'
}
}
// 退
if (claimed) return 'reward_claimed' if (claimed) return 'reward_claimed'
if (done && !claimed) return 'completed' if (done && !claimed) return 'completed'
if (hasGuideProgress(key)) return 'in_progress' if (hasGuideProgress(key)) return 'in_progress'
@ -400,18 +422,29 @@ async function initBackend() {
const res = await getOnboardingStatus() const res = await getOnboardingStatus()
const data = res.data || {} const data = res.data || {}
if (!data.stages || data.stages.length === 0) { // stages
await completeGuide('init', onboardingStages) backendStages.value = data.stages || []
} else {
//
if (data.stages && data.stages.length > 0) {
for (const stage of data.stages) { for (const stage of data.stages) {
if (stage.allTasksCompleted && stage.required_task_keys) { // all_tasks_completed 线
// status === 'completed'
if ((stage.all_tasks_completed || stage.status === 'completed') && stage.required_task_keys) {
for (const taskKey of stage.required_task_keys) { for (const taskKey of stage.required_task_keys) {
if (!markGuideDone(taskKey)) { //
if (!isGuideDone(taskKey)) {
console.log('[GuideModal] 从后端同步任务完成状态:', taskKey) console.log('[GuideModal] 从后端同步任务完成状态:', taskKey)
markGuideDone(taskKey) markGuideDone(taskKey)
} }
} }
} }
// status === 'in_progress'
if (stage.status === 'in_progress' && stage.required_task_keys) {
for (const taskKey of stage.required_task_keys) {
markGuideRewardClaimed(taskKey)
}
}
} }
} }
@ -712,6 +745,9 @@ const handleClose = () => {
} }
.disabled-btn { .disabled-btn {
min-width: 120rpx;
width: 120rpx;
height: 50rpx;
background: rgba(150, 150, 150, 0.5); background: rgba(150, 150, 150, 0.5);
border: 2rpx solid rgba(150, 150, 150, 0.6); border: 2rpx solid rgba(150, 150, 150, 0.6);
box-shadow: none; box-shadow: none;

View File

@ -29,6 +29,8 @@ const state = {
pendingAction: null, pendingAction: null,
// 是否处于组件打开模式(遮罩隐藏) // 是否处于组件打开模式(遮罩隐藏)
componentMode: false, componentMode: false,
// 刚刚完成的引导 key用于 GuideOverlay 触发后端同步)
completedGuideKey: null,
} }
const mutations = { const mutations = {
@ -78,6 +80,8 @@ const mutations = {
const key = state.currentGuide.key const key = state.currentGuide.key
markGuideAsShown(key) markGuideAsShown(key)
markGuideDone(key) // 标记为已完成 markGuideDone(key) // 标记为已完成
// 保存刚完成的引导 key用于 GuideOverlay 触发后端同步)
state.completedGuideKey = key
// 重置步骤进度 // 重置步骤进度
setGuideCurrentStep(key, 0) setGuideCurrentStep(key, 0)
// 清除子步骤完成记录 // 清除子步骤完成记录
@ -85,8 +89,9 @@ const mutations = {
} }
state.currentGuide = null state.currentGuide = null
state.currentStep = 0 state.currentStep = 0
state.isActive = false
state.isNavigating = false state.isNavigating = false
// isActive 设为 false 放最后,让 GuideOverlay 的 watch 能读取 completedGuideKey
state.isActive = false
}, },
/** /**

View File

@ -70,24 +70,45 @@
export const onboardingStages = [ export const onboardingStages = [
{ {
stage: 0, stage: 0,
name: '初识平台', name: '广场首页',
required_task_keys: ['square_home', 'browse_exhibition'], required_task_keys: ['square_home'],
crystal_reward: 100, crystal_reward: 100,
exp_reward: 50 exp_reward: 50
}, },
{ {
stage: 1, stage: 1,
name: '互动入门', name: '浏览展厅',
required_task_keys: ['follow_star', 'send_gift'], required_task_keys: ['browse_exhibition'],
crystal_reward: 200, crystal_reward: 100,
exp_reward: 100 exp_reward: 50
}, },
{ {
stage: 2, stage: 2,
name: '社交达人', name: '关注明星',
required_task_keys: ['add_friend', 'share_content'], required_task_keys: ['follow_star'],
crystal_reward: 300, crystal_reward: 100,
exp_reward: 150 exp_reward: 50
},
{
stage: 3,
name: '发送礼物',
required_task_keys: ['send_gift'],
crystal_reward: 100,
exp_reward: 50
},
{
stage: 4,
name: '添加好友',
required_task_keys: ['add_friend'],
crystal_reward: 100,
exp_reward: 50
},
{
stage: 5,
name: '分享内容',
required_task_keys: ['share_content'],
crystal_reward: 100,
exp_reward: 50
} }
] ]
@ -287,6 +308,146 @@ export const guideConfig = {
buttons: ['next'] buttons: ['next']
} }
] ]
},
// 浏览展厅引导
browse_exhibition: {
key: 'browse_exhibition',
name: '浏览展厅',
desc: '了解展馆功能和展品',
reward: { exp: 10, diamond: 5 },
steps: [
{
page: '/pages/exhibition/exhibition',
mask: true,
target: '.exhibition-header',
content: '这里是展馆顶部区域,可以查看展馆信息',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
},
{
page: '/pages/exhibition/exhibition',
mask: true,
target: '.nft-cards-container',
content: '这里展示了你铸造的所有展品',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
}
]
},
// 关注明星引导
follow_star: {
key: 'follow_star',
name: '关注明星',
desc: '关注喜欢的明星获取动态',
reward: { exp: 10, diamond: 5 },
steps: [
{
page: '/pages/square/square',
mask: true,
target: '.star-list',
content: '这里可以浏览和关注你喜欢的明星',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
},
{
page: '/pages/square/square',
mask: true,
target: '.follow-btn',
content: '点击按钮关注明星,关注后能收到他们的最新动态',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
}
]
},
// 发送礼物引导
send_gift: {
key: 'send_gift',
name: '发送礼物',
desc: '为喜欢的明星送礼物支持',
reward: { exp: 10, diamond: 5 },
steps: [
{
page: '/pages/square/square',
mask: true,
target: '.gift-btn',
content: '点击这里可以为明星送上礼物',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
},
{
page: '/pages/square/square',
mask: true,
target: '.gift-item',
content: '选择喜欢的礼物并发送,让明星感受到你的支持',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
}
]
},
// 添加好友引导
add_friend: {
key: 'add_friend',
name: '添加好友',
desc: '与其他粉丝建立联系',
reward: { exp: 10, diamond: 5 },
steps: [
{
page: '/pages/square/square',
mask: true,
target: '.friend-tab',
content: '在这里可以查看好友列表',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
},
{
page: '/pages/square/square',
mask: true,
target: '.add-friend-btn',
content: '点击添加新好友,与志同道合的粉丝交流',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
}
]
},
// 分享内容引导
share_content: {
key: 'share_content',
name: '分享内容',
desc: '将喜欢的内容分享给更多人',
reward: { exp: 10, diamond: 5 },
steps: [
{
page: '/pages/square/square',
mask: true,
target: '.share-btn',
content: '点击分享按钮可以将内容分享给好友',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
},
{
page: '/pages/square/square',
mask: true,
target: '.share-platform',
content: '选择分享平台,让更多人看到这份热爱',
center: true,
customPosition: { x: 0, y: 0 },
buttons: ['next']
}
]
} }
} }
@ -496,6 +657,18 @@ export function markGuideDone(key) {
uni.setStorageSync(makeUserIdKey('guide_done', key), true) uni.setStorageSync(makeUserIdKey('guide_done', key), true)
} }
/**
* 标记引导奖励已领取
* @param {string} key 引导key
*/
export function markGuideRewardClaimed(key) {
const claimed = uni.getStorageSync(makeUserIdKey('guide_rewards_claimed', '')) || []
if (!claimed.includes(key)) {
claimed.push(key)
uni.setStorageSync(makeUserIdKey('guide_rewards_claimed', ''), claimed)
}
}
/** /**
* 领取引导奖励 * 领取引导奖励
* @param {string} key 引导key * @param {string} key 引导key