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
bool is_current = 7;
bool all_tasks_completed = 8; //
bool is_reward_claimed = 9; //
}
message CompleteGuideRequest {

View File

@ -57,6 +57,7 @@ type UserOnboardingStatus struct {
HasFriendDisplayBonus bool `gorm:"column:has_friend_display_bonus;default:false"`
OnboardingCompletedAt *int64 `gorm:"column:onboarding_completed_at"`
OnboardingClaimedAt *int64 `gorm:"column:onboarding_claimed_at"`
ClaimedStages []int64 `gorm:"column:claimed_stages;type:text;serializer:json"` // 已领取奖励的阶段列表
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
CurrentStage int64 `gorm:"column:current_stage;default:0"`

View File

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

View File

@ -51,6 +51,8 @@
<script setup>
import { computed, watch, onMounted, onUnmounted } from 'vue'
import { useStore } from 'vuex'
import { completeGuide, advanceStage, getOnboardingStatus } from '@/utils/task-api.js'
import { onboardingStages } from '@/utils/guideConfig.js'
const store = useStore()
@ -66,10 +68,37 @@ const stepConfig = computed(() => store.getters['guide/currentStepConfig'])
const isFirst = computed(() => store.getters['guide/isFirst'])
const isLast = computed(() => store.getters['guide/isLast'])
//
watch(() => store.state.guide.isActive, (newVal) => {
console.log('[GuideOverlay] isActive changed to:', newVal)
}, { immediate: true })
// isActive false
watch(() => isActive.value, async (newVal, oldVal) => {
console.log('[GuideOverlay] isActive watch:', oldVal, '->', newVal)
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(() => {

View File

@ -118,6 +118,8 @@ import {
resetGuide,
claimGuideReward,
markGuideDone,
isGuideDone,
markGuideRewardClaimed,
onboardingStages
} from '@/utils/guideConfig.js'
@ -132,6 +134,7 @@ const emit = defineEmits(['close', 'updated'])
const loading = ref(true)
const guideList = ref([])
const backendStages = ref([])
//
const totalCount = computed(() => guideList.value.length)
@ -261,6 +264,25 @@ function refreshList() {
}
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 (done && !claimed) return 'completed'
if (hasGuideProgress(key)) return 'in_progress'
@ -400,18 +422,29 @@ async function initBackend() {
const res = await getOnboardingStatus()
const data = res.data || {}
if (!data.stages || data.stages.length === 0) {
await completeGuide('init', onboardingStages)
} else {
// stages
backendStages.value = data.stages || []
//
if (data.stages && data.stages.length > 0) {
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) {
if (!markGuideDone(taskKey)) {
//
if (!isGuideDone(taskKey)) {
console.log('[GuideModal] 从后端同步任务完成状态:', 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 {
min-width: 120rpx;
width: 120rpx;
height: 50rpx;
background: rgba(150, 150, 150, 0.5);
border: 2rpx solid rgba(150, 150, 150, 0.6);
box-shadow: none;

View File

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

View File

@ -70,24 +70,45 @@
export const onboardingStages = [
{
stage: 0,
name: '初识平台',
required_task_keys: ['square_home', 'browse_exhibition'],
name: '广场首页',
required_task_keys: ['square_home'],
crystal_reward: 100,
exp_reward: 50
},
{
stage: 1,
name: '互动入门',
required_task_keys: ['follow_star', 'send_gift'],
crystal_reward: 200,
exp_reward: 100
name: '浏览展厅',
required_task_keys: ['browse_exhibition'],
crystal_reward: 100,
exp_reward: 50
},
{
stage: 2,
name: '社交达人',
required_task_keys: ['add_friend', 'share_content'],
crystal_reward: 300,
exp_reward: 150
name: '关注明星',
required_task_keys: ['follow_star'],
crystal_reward: 100,
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']
}
]
},
// 浏览展厅引导
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)
}
/**
* 标记引导奖励已领取
* @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