fix: 修改任务系统的bug

This commit is contained in:
zerosaturation 2026-04-17 17:17:32 +08:00
parent 9e4cbab9b4
commit bebdc13c80
26 changed files with 1542 additions and 584 deletions

View File

@ -1 +1,2 @@
# TopFans
密码R251Y>Y8inL_BM=W

View File

@ -53,7 +53,7 @@
```
ssh root@101.132.250.62
>n73qBnCja-,#VF+Wq
R251Y>Y8inL_BM=W
```
### 1.1 更新系统包

View File

@ -289,7 +289,8 @@ func (ctrl *TaskController) CompleteGuide(c *gin.Context) {
userID, _ := c.Get("user_id")
var req struct {
TaskKey string `json:"task_key"`
TaskKey string `json:"task_key"`
Stages []*pbTask.OnboardingStage `json:"stages"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "请求参数错误")
@ -303,6 +304,7 @@ func (ctrl *TaskController) CompleteGuide(c *gin.Context) {
logger.Logger.Info("CompleteGuide request",
zap.Int64("user_id", userID.(int64)),
zap.String("task_key", req.TaskKey),
zap.Int("stages_count", len(req.Stages)),
)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -315,6 +317,7 @@ func (ctrl *TaskController) CompleteGuide(c *gin.Context) {
resp, err := ctrl.taskMobileService.CompleteGuide(ctx, &pbTask.CompleteGuideRequest{
TaskKey: req.TaskKey,
Stages: req.Stages,
})
if err != nil {
@ -476,7 +479,9 @@ func (ctrl *TaskController) ClaimOnboardingReward(c *gin.Context) {
}
response.Success(c, map[string]interface{}{
"success": resp.Success,
"success": resp.Success,
"crystal_balance": resp.CrystalBalance,
"experience": resp.Experience,
})
}

View File

@ -596,16 +596,17 @@ func (x *ClaimAllDailyTasksResponse) GetClaimedTaskKeys() []string {
}
type OnboardingStage struct {
state protoimpl.MessageState `protogen:"open.v1"`
Stage int32 `protobuf:"varint,1,opt,name=stage,proto3" json:"stage,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
RequiredTaskKeys []string `protobuf:"bytes,3,rep,name=required_task_keys,json=requiredTaskKeys,proto3" json:"required_task_keys,omitempty"`
CrystalReward int64 `protobuf:"varint,4,opt,name=crystal_reward,json=crystalReward,proto3" json:"crystal_reward,omitempty"`
ExpReward int64 `protobuf:"varint,5,opt,name=exp_reward,json=expReward,proto3" json:"exp_reward,omitempty"`
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` // pending/completed/in_progress
IsCurrent bool `protobuf:"varint,7,opt,name=is_current,json=isCurrent,proto3" json:"is_current,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Stage int32 `protobuf:"varint,1,opt,name=stage,proto3" json:"stage,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
RequiredTaskKeys []string `protobuf:"bytes,3,rep,name=required_task_keys,json=requiredTaskKeys,proto3" json:"required_task_keys,omitempty"`
CrystalReward int64 `protobuf:"varint,4,opt,name=crystal_reward,json=crystalReward,proto3" json:"crystal_reward,omitempty"`
ExpReward int64 `protobuf:"varint,5,opt,name=exp_reward,json=expReward,proto3" json:"exp_reward,omitempty"`
Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` // pending/completed/in_progress
IsCurrent bool `protobuf:"varint,7,opt,name=is_current,json=isCurrent,proto3" json:"is_current,omitempty"`
AllTasksCompleted bool `protobuf:"varint,8,opt,name=all_tasks_completed,json=allTasksCompleted,proto3" json:"all_tasks_completed,omitempty"` // 该阶段所有任务是否完成
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *OnboardingStage) Reset() {
@ -687,9 +688,17 @@ func (x *OnboardingStage) GetIsCurrent() bool {
return false
}
func (x *OnboardingStage) GetAllTasksCompleted() bool {
if x != nil {
return x.AllTasksCompleted
}
return false
}
type CompleteGuideRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
TaskKey string `protobuf:"bytes,1,opt,name=task_key,json=taskKey,proto3" json:"task_key,omitempty"`
Stages []*OnboardingStage `protobuf:"bytes,2,rep,name=stages,proto3" json:"stages,omitempty"` // 前端传入的阶段配置,首次调用时存储
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -731,6 +740,13 @@ func (x *CompleteGuideRequest) GetTaskKey() string {
return ""
}
func (x *CompleteGuideRequest) GetStages() []*OnboardingStage {
if x != nil {
return x.Stages
}
return nil
}
type CompleteGuideResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
@ -1076,11 +1092,13 @@ func (x *ClaimOnboardingRewardRequest) GetStage() int32 {
}
type ClaimOnboardingRewardResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
Success bool `protobuf:"varint,2,opt,name=success,proto3" json:"success,omitempty"`
CrystalBalance string `protobuf:"bytes,3,opt,name=crystal_balance,json=crystalBalance,proto3" json:"crystal_balance,omitempty"` // 使用 string 避免 Dubbo int64 序列化 bug
Experience string `protobuf:"bytes,4,opt,name=experience,proto3" json:"experience,omitempty"` // 使用 string 避免 Dubbo int64 序列化 bug
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ClaimOnboardingRewardResponse) Reset() {
@ -1127,6 +1145,20 @@ func (x *ClaimOnboardingRewardResponse) GetSuccess() bool {
return false
}
func (x *ClaimOnboardingRewardResponse) GetCrystalBalance() string {
if x != nil {
return x.CrystalBalance
}
return ""
}
func (x *ClaimOnboardingRewardResponse) GetExperience() string {
if x != nil {
return x.Experience
}
return ""
}
type ExhibitionRevenueItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
@ -1910,7 +1942,7 @@ const file_task_proto_rawDesc = "" +
"\n" +
"experience\x18\x04 \x01(\x03R\n" +
"experience\x12*\n" +
"\x11claimed_task_keys\x18\x05 \x03(\tR\x0fclaimedTaskKeys\"\xe6\x01\n" +
"\x11claimed_task_keys\x18\x05 \x03(\tR\x0fclaimedTaskKeys\"\x96\x02\n" +
"\x0fOnboardingStage\x12\x14\n" +
"\x05stage\x18\x01 \x01(\x05R\x05stage\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12,\n" +
@ -1920,9 +1952,11 @@ const file_task_proto_rawDesc = "" +
"exp_reward\x18\x05 \x01(\x03R\texpReward\x12\x16\n" +
"\x06status\x18\x06 \x01(\tR\x06status\x12\x1d\n" +
"\n" +
"is_current\x18\a \x01(\bR\tisCurrent\"1\n" +
"is_current\x18\a \x01(\bR\tisCurrent\x12.\n" +
"\x13all_tasks_completed\x18\b \x01(\bR\x11allTasksCompleted\"h\n" +
"\x14CompleteGuideRequest\x12\x19\n" +
"\btask_key\x18\x01 \x01(\tR\ataskKey\"\xd6\x01\n" +
"\btask_key\x18\x01 \x01(\tR\ataskKey\x125\n" +
"\x06stages\x18\x02 \x03(\v2\x1d.topfans.task.OnboardingStageR\x06stages\"\xd6\x01\n" +
"\x15CompleteGuideResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12#\n" +
@ -1944,10 +1978,14 @@ const file_task_proto_rawDesc = "" +
"\x06status\x18\x03 \x01(\tR\x06status\x125\n" +
"\x06stages\x18\x04 \x03(\v2\x1d.topfans.task.OnboardingStageR\x06stages\"4\n" +
"\x1cClaimOnboardingRewardRequest\x12\x14\n" +
"\x05stage\x18\x01 \x01(\x05R\x05stage\"k\n" +
"\x05stage\x18\x01 \x01(\x05R\x05stage\"\xb4\x01\n" +
"\x1dClaimOnboardingRewardResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12\x18\n" +
"\asuccess\x18\x02 \x01(\bR\asuccess\"\xe2\x02\n" +
"\asuccess\x18\x02 \x01(\bR\asuccess\x12'\n" +
"\x0fcrystal_balance\x18\x03 \x01(\tR\x0ecrystalBalance\x12\x1e\n" +
"\n" +
"experience\x18\x04 \x01(\tR\n" +
"experience\"\xe2\x02\n" +
"\x15ExhibitionRevenueItem\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\x12\x17\n" +
"\astar_id\x18\x02 \x01(\x03R\x06starId\x12#\n" +
@ -2071,50 +2109,51 @@ var file_task_proto_depIdxs = []int32{
29, // 2: topfans.task.ReportEventResponse.base:type_name -> topfans.common.BaseResponse
29, // 3: topfans.task.ClaimDailyTaskResponse.base:type_name -> topfans.common.BaseResponse
29, // 4: topfans.task.ClaimAllDailyTasksResponse.base:type_name -> topfans.common.BaseResponse
29, // 5: topfans.task.CompleteGuideResponse.base:type_name -> topfans.common.BaseResponse
9, // 6: topfans.task.CompleteGuideResponse.stages:type_name -> topfans.task.OnboardingStage
29, // 7: topfans.task.GetOnboardingStatusResponse.base:type_name -> topfans.common.BaseResponse
9, // 8: topfans.task.GetOnboardingStatusResponse.stages:type_name -> topfans.task.OnboardingStage
29, // 9: topfans.task.AdvanceStageResponse.base:type_name -> topfans.common.BaseResponse
9, // 10: topfans.task.AdvanceStageResponse.stages:type_name -> topfans.task.OnboardingStage
29, // 11: topfans.task.ClaimOnboardingRewardResponse.base:type_name -> topfans.common.BaseResponse
29, // 12: topfans.task.GetExhibitionRevenueResponse.base:type_name -> topfans.common.BaseResponse
18, // 13: topfans.task.GetExhibitionRevenueResponse.items:type_name -> topfans.task.ExhibitionRevenueItem
29, // 14: topfans.task.ClaimExhibitionRevenueResponse.base:type_name -> topfans.common.BaseResponse
29, // 15: topfans.task.ClaimAllExhibitionRevenueResponse.base:type_name -> topfans.common.BaseResponse
29, // 16: topfans.task.InitUserTasksResponse.base:type_name -> topfans.common.BaseResponse
29, // 17: topfans.task.OnExhibitionCompletedResponse.base:type_name -> topfans.common.BaseResponse
1, // 18: topfans.task.TaskMobileService.GetDailyTasks:input_type -> topfans.task.GetDailyTasksRequest
3, // 19: topfans.task.TaskMobileService.ReportEvent:input_type -> topfans.task.ReportEventRequest
5, // 20: topfans.task.TaskMobileService.ClaimDailyTask:input_type -> topfans.task.ClaimDailyTaskRequest
7, // 21: topfans.task.TaskMobileService.ClaimAllDailyTasks:input_type -> topfans.task.ClaimAllDailyTasksRequest
10, // 22: topfans.task.TaskMobileService.CompleteGuide:input_type -> topfans.task.CompleteGuideRequest
12, // 23: topfans.task.TaskMobileService.GetOnboardingStatus:input_type -> topfans.task.GetOnboardingStatusRequest
14, // 24: topfans.task.TaskMobileService.AdvanceStage:input_type -> topfans.task.AdvanceStageRequest
16, // 25: topfans.task.TaskMobileService.ClaimOnboardingReward:input_type -> topfans.task.ClaimOnboardingRewardRequest
19, // 26: topfans.task.TaskMobileService.GetExhibitionRevenue:input_type -> topfans.task.GetExhibitionRevenueRequest
21, // 27: topfans.task.TaskMobileService.ClaimExhibitionRevenue:input_type -> topfans.task.ClaimExhibitionRevenueRequest
23, // 28: topfans.task.TaskMobileService.ClaimAllExhibitionRevenue:input_type -> topfans.task.ClaimAllExhibitionRevenueRequest
25, // 29: topfans.task.TaskInternalService.InitUserTasks:input_type -> topfans.task.InitUserTasksRequest
27, // 30: topfans.task.TaskInternalService.OnExhibitionCompleted:input_type -> topfans.task.OnExhibitionCompletedRequest
2, // 31: topfans.task.TaskMobileService.GetDailyTasks:output_type -> topfans.task.GetDailyTasksResponse
4, // 32: topfans.task.TaskMobileService.ReportEvent:output_type -> topfans.task.ReportEventResponse
6, // 33: topfans.task.TaskMobileService.ClaimDailyTask:output_type -> topfans.task.ClaimDailyTaskResponse
8, // 34: topfans.task.TaskMobileService.ClaimAllDailyTasks:output_type -> topfans.task.ClaimAllDailyTasksResponse
11, // 35: topfans.task.TaskMobileService.CompleteGuide:output_type -> topfans.task.CompleteGuideResponse
13, // 36: topfans.task.TaskMobileService.GetOnboardingStatus:output_type -> topfans.task.GetOnboardingStatusResponse
15, // 37: topfans.task.TaskMobileService.AdvanceStage:output_type -> topfans.task.AdvanceStageResponse
17, // 38: topfans.task.TaskMobileService.ClaimOnboardingReward:output_type -> topfans.task.ClaimOnboardingRewardResponse
20, // 39: topfans.task.TaskMobileService.GetExhibitionRevenue:output_type -> topfans.task.GetExhibitionRevenueResponse
22, // 40: topfans.task.TaskMobileService.ClaimExhibitionRevenue:output_type -> topfans.task.ClaimExhibitionRevenueResponse
24, // 41: topfans.task.TaskMobileService.ClaimAllExhibitionRevenue:output_type -> topfans.task.ClaimAllExhibitionRevenueResponse
26, // 42: topfans.task.TaskInternalService.InitUserTasks:output_type -> topfans.task.InitUserTasksResponse
28, // 43: topfans.task.TaskInternalService.OnExhibitionCompleted:output_type -> topfans.task.OnExhibitionCompletedResponse
31, // [31:44] is the sub-list for method output_type
18, // [18:31] is the sub-list for method input_type
18, // [18:18] is the sub-list for extension type_name
18, // [18:18] is the sub-list for extension extendee
0, // [0:18] is the sub-list for field type_name
9, // 5: topfans.task.CompleteGuideRequest.stages:type_name -> topfans.task.OnboardingStage
29, // 6: topfans.task.CompleteGuideResponse.base:type_name -> topfans.common.BaseResponse
9, // 7: topfans.task.CompleteGuideResponse.stages:type_name -> topfans.task.OnboardingStage
29, // 8: topfans.task.GetOnboardingStatusResponse.base:type_name -> topfans.common.BaseResponse
9, // 9: topfans.task.GetOnboardingStatusResponse.stages:type_name -> topfans.task.OnboardingStage
29, // 10: topfans.task.AdvanceStageResponse.base:type_name -> topfans.common.BaseResponse
9, // 11: topfans.task.AdvanceStageResponse.stages:type_name -> topfans.task.OnboardingStage
29, // 12: topfans.task.ClaimOnboardingRewardResponse.base:type_name -> topfans.common.BaseResponse
29, // 13: topfans.task.GetExhibitionRevenueResponse.base:type_name -> topfans.common.BaseResponse
18, // 14: topfans.task.GetExhibitionRevenueResponse.items:type_name -> topfans.task.ExhibitionRevenueItem
29, // 15: topfans.task.ClaimExhibitionRevenueResponse.base:type_name -> topfans.common.BaseResponse
29, // 16: topfans.task.ClaimAllExhibitionRevenueResponse.base:type_name -> topfans.common.BaseResponse
29, // 17: topfans.task.InitUserTasksResponse.base:type_name -> topfans.common.BaseResponse
29, // 18: topfans.task.OnExhibitionCompletedResponse.base:type_name -> topfans.common.BaseResponse
1, // 19: topfans.task.TaskMobileService.GetDailyTasks:input_type -> topfans.task.GetDailyTasksRequest
3, // 20: topfans.task.TaskMobileService.ReportEvent:input_type -> topfans.task.ReportEventRequest
5, // 21: topfans.task.TaskMobileService.ClaimDailyTask:input_type -> topfans.task.ClaimDailyTaskRequest
7, // 22: topfans.task.TaskMobileService.ClaimAllDailyTasks:input_type -> topfans.task.ClaimAllDailyTasksRequest
10, // 23: topfans.task.TaskMobileService.CompleteGuide:input_type -> topfans.task.CompleteGuideRequest
12, // 24: topfans.task.TaskMobileService.GetOnboardingStatus:input_type -> topfans.task.GetOnboardingStatusRequest
14, // 25: topfans.task.TaskMobileService.AdvanceStage:input_type -> topfans.task.AdvanceStageRequest
16, // 26: topfans.task.TaskMobileService.ClaimOnboardingReward:input_type -> topfans.task.ClaimOnboardingRewardRequest
19, // 27: topfans.task.TaskMobileService.GetExhibitionRevenue:input_type -> topfans.task.GetExhibitionRevenueRequest
21, // 28: topfans.task.TaskMobileService.ClaimExhibitionRevenue:input_type -> topfans.task.ClaimExhibitionRevenueRequest
23, // 29: topfans.task.TaskMobileService.ClaimAllExhibitionRevenue:input_type -> topfans.task.ClaimAllExhibitionRevenueRequest
25, // 30: topfans.task.TaskInternalService.InitUserTasks:input_type -> topfans.task.InitUserTasksRequest
27, // 31: topfans.task.TaskInternalService.OnExhibitionCompleted:input_type -> topfans.task.OnExhibitionCompletedRequest
2, // 32: topfans.task.TaskMobileService.GetDailyTasks:output_type -> topfans.task.GetDailyTasksResponse
4, // 33: topfans.task.TaskMobileService.ReportEvent:output_type -> topfans.task.ReportEventResponse
6, // 34: topfans.task.TaskMobileService.ClaimDailyTask:output_type -> topfans.task.ClaimDailyTaskResponse
8, // 35: topfans.task.TaskMobileService.ClaimAllDailyTasks:output_type -> topfans.task.ClaimAllDailyTasksResponse
11, // 36: topfans.task.TaskMobileService.CompleteGuide:output_type -> topfans.task.CompleteGuideResponse
13, // 37: topfans.task.TaskMobileService.GetOnboardingStatus:output_type -> topfans.task.GetOnboardingStatusResponse
15, // 38: topfans.task.TaskMobileService.AdvanceStage:output_type -> topfans.task.AdvanceStageResponse
17, // 39: topfans.task.TaskMobileService.ClaimOnboardingReward:output_type -> topfans.task.ClaimOnboardingRewardResponse
20, // 40: topfans.task.TaskMobileService.GetExhibitionRevenue:output_type -> topfans.task.GetExhibitionRevenueResponse
22, // 41: topfans.task.TaskMobileService.ClaimExhibitionRevenue:output_type -> topfans.task.ClaimExhibitionRevenueResponse
24, // 42: topfans.task.TaskMobileService.ClaimAllExhibitionRevenue:output_type -> topfans.task.ClaimAllExhibitionRevenueResponse
26, // 43: topfans.task.TaskInternalService.InitUserTasks:output_type -> topfans.task.InitUserTasksResponse
28, // 44: topfans.task.TaskInternalService.OnExhibitionCompleted:output_type -> topfans.task.OnExhibitionCompletedResponse
32, // [32:45] is the sub-list for method output_type
19, // [19:32] is the sub-list for method input_type
19, // [19:19] is the sub-list for extension type_name
19, // [19:19] is the sub-list for extension extendee
0, // [0:19] is the sub-list for field type_name
}
func init() { file_task_proto_init() }

View File

@ -77,10 +77,12 @@ message OnboardingStage {
int64 exp_reward = 5;
string status = 6; // pending/completed/in_progress
bool is_current = 7;
bool all_tasks_completed = 8; //
}
message CompleteGuideRequest {
string task_key = 1;
repeated OnboardingStage stages = 2; //
}
message CompleteGuideResponse {
@ -119,6 +121,8 @@ message ClaimOnboardingRewardRequest {
message ClaimOnboardingRewardResponse {
topfans.common.BaseResponse base = 1;
bool success = 2;
string crystal_balance = 3; // 使 string Dubbo int64 bug
string experience = 4; // 使 string Dubbo int64 bug
}
// ==================== ====================

View File

@ -118,8 +118,9 @@ func (r *rankingRepository) getHotRankingByDimension(starID int64, dimension str
Where("exhibitions.expire_at > ?", now).
Where("fan_profiles.star_id = ?", starID)
case "month":
// 本月:统计本月内的点赞数
db = db.Where("assets.id IN (SELECT asset_id FROM asset_likes WHERE star_id = ? AND created_at >= ? GROUP BY asset_id)", starID, startOfMonth)
// 本月:本月内开始的展览,按点赞数排序
db = db.Joins("INNER JOIN exhibitions ON exhibitions.asset_id = assets.id").
Where("exhibitions.start_time >= ?", startOfMonth)
case "total":
// 全部:直接使用 assets 表的 like_count无需额外条件
}
@ -140,7 +141,9 @@ func (r *rankingRepository) getHotRankingByDimension(starID int64, dimension str
Where("exhibitions.expire_at > ?", now).
Where("host_fp.star_id = ?", starID)
case "month":
countDB = countDB.Where("assets.id IN (SELECT asset_id FROM asset_likes WHERE star_id = ? AND created_at >= ? GROUP BY asset_id)", starID, startOfMonth)
// 本月:本月内开始的展览
countDB = countDB.Joins("INNER JOIN exhibitions ON exhibitions.asset_id = assets.id").
Where("exhibitions.start_time >= ?", startOfMonth)
}
if err := countDB.Count(&total).Error; err != nil {
return nil, 0, err
@ -190,8 +193,9 @@ func (r *rankingRepository) GetMyBestRanking(userID, starID int64, dimension str
Where("exhibitions.expire_at > ?", now).
Where("host_fp.star_id = ?", starID)
case "month":
// 本月:关联 asset_likes 表,筛选本月有点赞的藏品
db = db.Where("assets.id IN (SELECT asset_id FROM asset_likes WHERE star_id = ? AND created_at >= ? GROUP BY asset_id)", starID, startOfMonth)
// 本月:本月内开始的展览
db = db.Joins("INNER JOIN exhibitions ON exhibitions.asset_id = assets.id").
Where("exhibitions.start_time >= ?", startOfMonth)
}
// 获取用户在该star下点赞数最高的藏品
@ -232,7 +236,9 @@ func (r *rankingRepository) GetMyBestRanking(userID, starID int64, dimension str
Where("exhibitions.expire_at > ?", now).
Where("host_fp.star_id = ?", starID)
case "month":
rankingDB = rankingDB.Where("assets.id IN (SELECT asset_id FROM asset_likes WHERE star_id = ? AND created_at >= ? GROUP BY asset_id)", starID, startOfMonth)
// 本月:本月内开始的展览
rankingDB = rankingDB.Joins("INNER JOIN exhibitions ON exhibitions.asset_id = assets.id").
Where("exhibitions.start_time >= ?", startOfMonth)
}
if err := rankingDB.Count(&rank).Error; err != nil {

View File

@ -159,11 +159,12 @@ func (p *TaskMobileProvider) CompleteGuide(ctx context.Context, req *pb.Complete
return &pb.CompleteGuideResponse{}, nil
}
logger.Logger.Debug("CompleteGuide",
logger.Logger.Info("CompleteGuide",
zap.Int64("user_id", userID),
zap.String("task_key", req.TaskKey))
zap.String("task_key", req.TaskKey),
zap.Int("stages_count", len(req.Stages)))
return p.onboardingSvc.CompleteGuide(ctx, userID, req.TaskKey)
return p.onboardingSvc.CompleteGuide(ctx, userID, req.TaskKey, req.Stages)
}
func (p *TaskMobileProvider) GetOnboardingStatus(ctx context.Context, req *pb.GetOnboardingStatusRequest) (*pb.GetOnboardingStatusResponse, error) {
@ -202,11 +203,6 @@ func (p *TaskMobileProvider) ClaimOnboardingReward(ctx context.Context, req *pb.
return &pb.ClaimOnboardingRewardResponse{Success: false}, nil
}
logger.Logger.Debug("ClaimOnboardingReward",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int32("stage", req.Stage))
return p.onboardingSvc.ClaimOnboardingReward(ctx, userID, starID, req.Stage)
}

View File

@ -13,6 +13,7 @@ import (
type OnboardingRepository interface {
GetOnboardingStatus(userID int64, starID int64) (*model.UserOnboardingStatus, error)
GetOrCreateOnboardingStatus(userID int64, starID int64) (*model.UserOnboardingStatus, error)
GetUserOnboardingStatuses(userID int64) ([]*model.UserOnboardingStatus, error)
UpdateOnboardingStatus(status *model.UserOnboardingStatus) error
UpdateOnboardingProgress(progress *model.UserOnboardingProgress) error
GetUserOnboardingProgress(userID int64, taskKey string) (*model.UserOnboardingProgress, error)
@ -20,6 +21,8 @@ type OnboardingRepository interface {
ListActiveStageConfigs() ([]*model.OnboardingStageConfig, error)
ListUserOnboardingProgressByUser(userID int64) ([]*model.UserOnboardingProgress, error)
GetStageConfig(stage int64) (*model.OnboardingStageConfig, error)
SaveStageConfigs(configs []*model.OnboardingStageConfig) error
CountStageConfigs() (int64, error)
}
type onboardingRepository struct {
@ -65,6 +68,16 @@ func (r *onboardingRepository) GetOrCreateOnboardingStatus(userID int64, starID
return &status, nil
}
func (r *onboardingRepository) GetUserOnboardingStatuses(userID int64) ([]*model.UserOnboardingStatus, error) {
var statuses []*model.UserOnboardingStatus
err := r.db.Where("user_id = ?", userID).Order("star_id DESC").Find(&statuses).Error
if err != nil {
logger.Logger.Error("Failed to GetUserOnboardingStatuses", zap.Int64("user_id", userID), zap.Error(err))
return nil, err
}
return statuses, nil
}
func (r *onboardingRepository) UpdateOnboardingStatus(status *model.UserOnboardingStatus) error {
status.UpdatedAt = time.Now().Unix()
if err := r.db.Save(status).Error; err != nil {
@ -139,9 +152,84 @@ func (r *onboardingRepository) ListUserOnboardingProgressByUser(userID int64) ([
func (r *onboardingRepository) GetStageConfig(stage int64) (*model.OnboardingStageConfig, error) {
var config model.OnboardingStageConfig
logger.Logger.Info("GetStageConfig: querying",
zap.Int64("stage", stage))
err := r.db.Where("stage = ? AND is_active = ?", stage, true).First(&config).Error
if err != nil {
logger.Logger.Error("GetStageConfig: query failed",
zap.Int64("stage", stage),
zap.Error(err))
return nil, err
}
logger.Logger.Info("GetStageConfig: found",
zap.Int64("stage", stage),
zap.String("name", config.Name))
return &config, nil
}
func (r *onboardingRepository) SaveStageConfigs(configs []*model.OnboardingStageConfig) error {
if len(configs) == 0 {
logger.Logger.Warn("SaveStageConfigs: configs is empty, returning")
return nil
}
now := time.Now().Unix()
logger.Logger.Info("SaveStageConfigs: starting save",
zap.Int("count", len(configs)))
for _, cfg := range configs {
logger.Logger.Info("SaveStageConfigs: processing config",
zap.Int("stage", cfg.Stage),
zap.String("name", cfg.Name),
zap.Strings("required_task_keys", cfg.RequiredTaskKeys),
zap.Int64("crystal_reward", cfg.CrystalReward),
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
}
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",
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")
return nil
}
func (r *onboardingRepository) CountStageConfigs() (int64, error) {
var count int64
err := r.db.Model(&model.OnboardingStageConfig{}).Where("is_active = ?", true).Count(&count).Error
return count, err
}

View File

@ -55,13 +55,27 @@ import { useStore } from 'vuex'
const store = useStore()
//
const isActive = computed(() => store.state.guide.isActive)
const isActive = computed(() => {
const val = store.state.guide.isActive
console.log('[GuideOverlay] isActive computed:', val)
return val
})
const isComponentMode = computed(() => store.state.guide.componentMode)
const targetRect = computed(() => store.state.guide.targetRect)
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 })
//
onMounted(() => {
console.log('[GuideOverlay] mounted, isActive:', store.state.guide.isActive)
})
//
const highlightStyle = computed(() => {
const config = stepConfig.value || {}
@ -271,6 +285,7 @@ watch(() => store.state.guide.currentStep, async () => {
//
onMounted(() => {
console.log('[GuideOverlay] mounted, isActive:', store.state.guide.isActive, 'currentGuide:', store.state.guide.currentGuide?.key, 'currentStep:', store.state.guide.currentStep)
if (isActive.value) {
// DOM
setTimeout(() => {
@ -288,16 +303,20 @@ onMounted(() => {
//
let closeComponentHandled = false //
uni.$on('guide:closeComponent', () => {
console.log('[GuideOverlay] 收到 guide:closeComponent 事件, componentMode:', store.state.guide.componentMode)
// componentMode false
if (closeComponentHandled || !store.state.guide.componentMode) {
console.log('[GuideOverlay] guide:closeComponent 忽略, closeComponentHandled:', closeComponentHandled, 'componentMode:', store.state.guide.componentMode)
return
}
closeComponentHandled = true
store.commit('guide/RESET_COMPONENT_MODE')
// component action
if (store.getters['guide/hasNext']) {
console.log('[GuideOverlay] 有下一步,前进')
store.commit('guide/NEXT_STEP')
} else {
console.log('[GuideOverlay] 没有下一步,结束引导')
store.commit('guide/END_GUIDE')
}
//
@ -356,8 +375,10 @@ function handleTooltipClick() {
//
function handleHighlightClick() {
const stepConfigVal = stepConfig.value
console.log('[GuideOverlay] handleHighlightClick, stepConfig:', stepConfigVal)
if (stepConfigVal && stepConfigVal.action) {
const { action } = stepConfigVal
console.log('[GuideOverlay] action:', action)
if (action.type === 'navigate' && action.url) {
//
@ -482,6 +503,7 @@ defineExpose({
z-index: 10001;
display: flex;
align-items: center;
flex-direction: column;
}
.tooltip-content {

View File

@ -26,6 +26,15 @@
}
}
},
{
"path": "pages/starbook/items",
"style": {
"navigationStyle": "custom",
"app-plus": {
"bounce": "none"
}
}
},
{
"path": "pages/starcity/index",
"style": {

View File

@ -195,15 +195,22 @@ onMounted(() => {
uni.$on('userInfoUpdated', handleUserInfoUpdate);
uni.$on('balanceUpdated', handleBalanceUpdate);
//
const today = new Date().toISOString().split('T')[0]
// 5
// 5
const now = new Date()
const currentHour = now.getHours()
if (currentHour < 5) return // 5
const today = now.toISOString().split('T')[0]
const dailyLoginKey = `daily_login_completed_${today}`
// star_id
const starId = uni.getStorageSync('star_id')
if (starId && !uni.getStorageSync(dailyLoginKey)) {
reportEvent('daily_login', starId).then(() => {
//
//
uni.setStorageSync(dailyLoginKey, true)
const yesterday = new Date(now.getTime() - 86400000).toISOString().split('T')[0]
uni.removeStorageSync(`daily_login_completed_${yesterday}`)
}).catch(err => {
console.error('上报登录事件失败:', err)
})

View File

@ -2,31 +2,185 @@
<view class="starbook-content">
<!-- 背景图片 -->
<image class="background-image" src="/static/background/starbook.jpg" mode="aspectFill"></image>
<!-- 内容区域 -->
<view class="content-wrapper">
<!-- NFT卡片网格容器 -->
<view class="nft-grid-container">
<view
v-for="(item, index) in nftList"
:key="index"
class="nft-grid-item"
<!-- 类型Tab -->
<view class="type-tabs">
<view
class="tab-item"
:class="{ active: currentType === 'regular' }"
@click="switchType('regular')"
>
<NftCard
:cover-image="item.image"
:width="cardSize"
:height="cardSize"
:locked="item.locked"
:show-add-button="!item.locked && !item.image"
operation="place"
:custom-style="cardCustomStyle"
@add="handleAddNft"
@click="handleCardClick(item, index)"
/>
<text>普通</text>
</view>
<view
class="tab-item"
:class="{ active: currentType === 'collection' }"
@click="switchType('collection')"
>
<text>典藏</text>
</view>
<view
class="tab-item"
:class="{ active: currentType === 'activity' }"
@click="switchType('activity')"
>
<text>活动</text>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="!hasData" class="empty-container">
<text class="empty-text">暂无藏品</text>
</view>
<!-- 藏品列表 -->
<view v-else class="nft-list-container">
<!-- 普通藏品 grade 分组 -->
<view v-if="currentType === 'regular'">
<view v-for="group in regularGroups" :key="group.category" class="nft-group">
<!-- 分组标题 -->
<view class="group-header">
<text class="group-title">{{ group.category_name }} · {{ formatGrade(group.grade) }}</text>
</view>
<!-- 分组内容 -->
<view class="nft-row">
<view
v-for="(item, index) in group.items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<NftCard
:cover-image="item.cover_url_signed"
:width="cardSize"
:height="cardSize"
:locked="false"
:custom-style="cardCustomStyle"
/>
<view class="nft-info">
<text class="nft-name">{{ item.name }}</text>
<text class="nft-likes">{{ item.like_count }}</text>
</view>
</view>
<!-- 更多按钮 -->
<view v-if="group.has_more" class="nft-grid-item more-item" @click="goToMore(group)">
<NftCard
:cover-image="''"
:width="cardSize"
:height="cardSize"
:locked="false"
:show-add-button="false"
operation="none"
:custom-style="cardCustomStyle"
/>
<view class="more-overlay">
<text class="more-text">更多></text>
</view>
</view>
</view>
</view>
</view>
<!-- 典藏藏品 category 分组 -->
<view v-if="currentType === 'collection'">
<view v-for="group in collectionGroups" :key="group.category" class="nft-group">
<!-- 分组标题 -->
<view class="group-header">
<text class="group-title">{{ group.category_name }}</text>
</view>
<!-- 分组内容 -->
<view class="nft-row">
<view
v-for="(item, index) in group.items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<NftCard
:cover-image="item.cover_url_signed"
:width="cardSize"
:height="cardSize"
:locked="false"
:custom-style="cardCustomStyle"
/>
<view class="nft-info">
<text class="nft-name">{{ item.name }}</text>
<text class="nft-likes">{{ item.like_count }}</text>
</view>
</view>
<!-- 更多按钮 -->
<view v-if="group.has_more" class="nft-grid-item more-item" @click="goToMore(group)">
<NftCard
:cover-image="''"
:width="cardSize"
:height="cardSize"
:locked="false"
:show-add-button="false"
operation="none"
:custom-style="cardCustomStyle"
/>
<view class="more-overlay">
<text class="more-text">更多></text>
</view>
</view>
</view>
</view>
</view>
<!-- 活动藏品 activity_type 分组 -->
<view v-if="currentType === 'activity'">
<view v-for="group in activityGroups" :key="group.category" class="nft-group">
<!-- 分组标题 -->
<view class="group-header">
<text class="group-title">{{ group.category_name }}</text>
</view>
<!-- 分组内容 -->
<view class="nft-row">
<view
v-for="(item, index) in group.items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<NftCard
:cover-image="item.cover_url_signed"
:width="cardSize"
:height="cardSize"
:locked="false"
:custom-style="cardCustomStyle"
/>
<view class="nft-info">
<text class="nft-name">{{ item.name }}</text>
<text class="nft-likes">{{ item.like_count }}</text>
</view>
</view>
<!-- 更多按钮 -->
<view v-if="group.has_more" class="nft-grid-item more-item" @click="goToMore(group)">
<NftCard
:cover-image="''"
:width="cardSize"
:height="cardSize"
:locked="false"
:show-add-button="false"
operation="none"
:custom-style="cardCustomStyle"
/>
<view class="more-overlay">
<text class="more-text">更多></text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
@ -34,8 +188,7 @@
import { ref, computed, onMounted, onActivated, watch } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import NftCard from './NftCard.vue';
import { getMyAssetsApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
import { getStarbookHomeApi } from '@/utils/api.js';
//
const screenWidth = ref(0);
@ -43,20 +196,21 @@ const screenWidth = ref(0);
//
const loading = ref(false);
// NFT15API
const nftList = ref([]);
//
const currentType = ref('regular');
//
const starbookData = ref([]);
//
let lastLoadedAt = 0;
//
const cardSize = computed(() => {
if (screenWidth.value === 0) return 200;
// 3padding
// rpxpx1rpx = screenWidth / 750
const rpxToPx = screenWidth.value / 750;
const padding = 40 * rpxToPx; // 40rpx
const gap = 15 * rpxToPx; // 15rpx32
// = - padding - 2
const padding = 30 * rpxToPx; // 30rpx
const gap = 15 * rpxToPx; // 15rpx
const availableWidth = screenWidth.value - (padding * 2) - (gap * 2);
return Math.floor(availableWidth / 3);
});
@ -68,63 +222,57 @@ const cardCustomStyle = {
left: '0'
};
// NFT -
const handleAddNft = () => {
//
uni.navigateTo({
url: '/pages/castlove/mall'
});
// grade
const gradeMap = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' };
function formatGrade(grade) {
return `等级${gradeMap[grade] || grade}`;
}
//
const hasData = computed(() => {
return starbookData.value.length > 0;
});
//
const regularGroups = computed(() => {
const group = starbookData.value.find(g => g.type === 'regular');
if (!group) return [];
return (group.grades || []).sort((a, b) => b.grade - a.grade); // grade
});
const collectionGroups = computed(() => {
return starbookData.value.filter(g => g.type === 'collection');
});
const activityGroups = computed(() => {
return starbookData.value.filter(g => g.type === 'activity');
});
//
const switchType = (type) => {
currentType.value = type;
};
//
const loadAssetsList = async () => {
//
const loadStarbookData = async () => {
const now = Date.now();
if (now - lastLoadedAt < 1000) return; // 1
lastLoadedAt = now;
loading.value = true;
try {
const response = await getMyAssetsApi(1, 20);
if (response.code === 200 && response.data && response.data.items) {
// URL
const assetsPromises = response.data.items.map(async item => {
const realCoverUrl = await getAssetCoverRealUrl(item.cover_url);
return {
asset_id: item.asset_id,
name: item.name,
image: realCoverUrl,
cover_url: item.cover_url || '/static/nft/collection.png',
tx_hash: item.tx_hash,
like_count: item.like_count,
status: item.status,
locked: false
};
});
const assets = await Promise.all(assetsPromises);
//
assets.push({ image: '', locked: false });
// 15
const totalCards = 15;
const remainingCards = totalCards - assets.length;
for (let i = 0; i < remainingCards; i++) {
assets.push({ image: '', locked: true });
}
nftList.value = assets;
const response = await getStarbookHomeApi();
if (response.code === 200 && response.data && response.data.groups) {
starbookData.value = response.data.groups;
}
} catch (error) {
console.error('获取藏品列表失败:', error);
console.error('获取星册数据失败:', error);
uni.showToast({
title: error.message || '获取藏品列表失败',
title: error.message || '获取星册数据失败',
icon: 'none',
duration: 2000
});
// 使1 + 14
const defaultList = [{ image: '', locked: false }];
for (let i = 0; i < 14; i++) {
defaultList.push({ image: '', locked: true });
}
nftList.value = defaultList;
starbookData.value = [];
} finally {
loading.value = false;
}
@ -132,14 +280,31 @@ const loadAssetsList = async () => {
//
const handleCardClick = (item) => {
if (item.image && !item.locked && item.asset_id) {
if (item.asset_id) {
uni.navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${item.asset_id}`
});
}
};
// props
//
const goToMore = (group) => {
const params = {
type: currentType.value,
category: group.category
};
if (currentType.value === 'regular' && group.grade) {
params.grade = group.grade;
}
const queryString = Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join('&');
uni.navigateTo({
url: `/pages/starbook/items?${queryString}`
});
};
// props
const props = defineProps({
isActive: {
type: Boolean,
@ -150,25 +315,25 @@ const props = defineProps({
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
screenWidth.value = systemInfo.windowWidth;
//
loadAssetsList();
loadStarbookData();
});
// keep-alive
//
onActivated(() => {
loadAssetsList();
loadStarbookData();
});
//
//
onShow(() => {
loadAssetsList();
if (props.isActive) {
loadStarbookData();
}
});
// isActive prop tab
watch(() => props.isActive, (newValue, oldValue) => {
if (newValue && !oldValue) {
loadAssetsList();
// isActive prop
watch(() => props.isActive, (newVal) => {
if (newVal) {
loadStarbookData();
}
});
</script>
@ -188,7 +353,7 @@ watch(() => props.isActive, (newValue, oldValue) => {
background: #0d0820;
}
/* 滚动条样式 - 自动隐藏 */
/* 滚动条样式 */
.starbook-content::-webkit-scrollbar {
width: 6rpx;
}
@ -226,31 +391,133 @@ watch(() => props.isActive, (newValue, oldValue) => {
z-index: 1;
width: 100%;
min-height: 100%;
padding: 250rpx 30rpx;
padding: 224rpx 30rpx 120rpx;
box-sizing: border-box;
}
/* NFT网格容器 */
.nft-grid-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* 列间距(与计算逻辑保持一致) */
column-gap: 15rpx;
/* 行间距 */
row-gap: 10rpx;
width: 100%;
max-width: 100%;
/* 确保网格项靠上对齐 */
align-items: start;
justify-items: center;
/* 类型Tab */
.type-tabs {
display: flex;
justify-content: center;
gap: 40rpx;
margin-bottom: 30rpx;
padding: 0 20rpx;
}
.tab-item {
padding: 12rpx 30rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
border-bottom: 4rpx solid transparent;
transition: all 0.3s ease;
}
.tab-item.active {
color: #ffffff;
border-bottom-color: #ffffff;
}
/* 加载中 */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
padding-top: 200rpx;
}
.loading-text {
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
}
/* 空状态 */
.empty-container {
display: flex;
justify-content: center;
align-items: center;
padding-top: 200rpx;
}
.empty-text {
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
}
/* 藏品列表容器 */
.nft-list-container {
width: 100%;
}
/* 藏品分组 */
.nft-group {
margin-bottom: 40rpx;
}
/* 分组标题 */
.group-header {
margin-bottom: 20rpx;
}
.group-title {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
/* 藏品行(横向滚动) */
.nft-row {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
/* 藏品网格项 */
.nft-grid-item {
position: relative;
width: 100%;
/* 保持3:4比例 */
padding-top: 133.33%;
/* 确保内容靠上对齐 */
width: 210rpx;
flex-shrink: 0;
}
.nft-grid-item.more-item {
cursor: pointer;
}
/* 藏品信息 */
.nft-info {
padding: 8rpx 0;
text-align: center;
}
.nft-name {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nft-likes {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.5);
}
/* 更多覆盖层 */
.more-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
border-radius: 16rpx;
}
.more-text {
font-size: 26rpx;
color: #ffffff;
}
</style>

View File

@ -688,15 +688,16 @@ export default {
//
// API loadGallerySlots loadRandomGallery
const parseGalleryResponse = async (data) => {
// 使 currentUserUid
// API gallery_owner_id UID
if (!isViewingOthers.value && currentUserUid.value) {
visitingGalleryOwnerUid.value = currentUserUid.value;
galleryOwnerNickname.value = '';
} else {
visitingGalleryOwnerUid.value = data.gallery_owner_id;
galleryOwnerNickname.value = data.nickname || '';
}
console.log('parseGalleryResponse called', {
isViewingOthers: isViewingOthers.value,
dataGalleryOwnerId: data.gallery_owner_id,
currentUserUid: currentUserUid.value
});
// isViewingOthers
// isViewingOthers onLoad
visitingGalleryOwnerUid.value = data.gallery_owner_id;
galleryOwnerNickname.value = data.nickname || '';
galleryOwnerId.value = data.gallery_owner_id;
uni.setStorageSync('gallery_owner_id', data.gallery_owner_id);
@ -743,10 +744,16 @@ export default {
};
const loadGallerySlots = async () => {
console.log('loadGallerySlots called', {
isViewingOthers: isViewingOthers.value,
visitingGalleryOwnerUid: visitingGalleryOwnerUid.value,
currentUserUid: currentUserUid.value
});
try {
let response;
// 使 isViewingOthers onLoad isMyGallery
if (!isViewingOthers.value) {
// visitingGalleryOwnerUid currentUserUid API
// isViewingOthers API
if (visitingGalleryOwnerUid.value === currentUserUid.value) {
response = await getMyGalleriesApi();
} else {
response = await getUserGalleriesApi(visitingGalleryOwnerUid.value);
@ -1217,6 +1224,17 @@ export default {
visitingGalleryOwnerUid.value = currentUserUid.value;
isViewingOthers.value = false;
}
// guide_key
if (options && options.guide_key) {
console.log('[Guide] exhibition 收到引导跳转参数, guide_key:', options.guide_key, 'guide_step:', options.guide_step)
// 使 resumeGuide shouldShowGuide
store.dispatch('guide/resumeGuide', options.guide_key).then(res => {
console.log('[Guide] exhibition resumeGuide 结果:', res)
}).catch(err => {
console.error('[Guide] exhibition resumeGuide 失败:', err)
})
}
});
//

View File

@ -252,13 +252,13 @@
@claim-success="handleClaimSuccess" @close="showGuideListModal = false" />
<!-- 全局引导遮罩 -->
<!-- <GuideOverlay /> -->
<GuideOverlay />
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import { onShow, onLoad } from '@dcloudio/uni-app';
import { useStore } from 'vuex';
import { onReady } from "@dcloudio/uni-app";
import Header from '../components/Header.vue';
@ -1217,6 +1217,18 @@ onReady(() => {
//
});
//
onLoad((options) => {
if (options && options.guide_key) {
console.log('[Guide] profile 收到引导跳转参数, guide_key:', options.guide_key, 'guide_step:', options.guide_step)
store.dispatch('guide/resumeGuide', options.guide_key).then(res => {
console.log('[Guide] profile resumeGuide 结果:', res)
}).catch(err => {
console.error('[Guide] profile resumeGuide 失败:', err)
})
}
});
onShow(() => {
// Header
fetchUserInfo().then(() => {

View File

@ -71,13 +71,6 @@
@update:isExpanded="navExpanded = $event"
/>
<!-- 新手引导开始弹窗 -->
<GuideStartModal
:visible="showGuideStartModal"
@start="handleGuideStart"
@close="handleGuideStartModalClose"
/>
<!-- 全局引导遮罩 -->
<GuideOverlay />
</view>
@ -89,13 +82,12 @@ import { onLoad, onShow } from '@dcloudio/uni-app'
import { useStore } from 'vuex'
import Header from '../components/Header.vue'
import BottomNav from '../components/BottomNav.vue'
import GuideStartModal from '@/components/GuideStartModal.vue'
import GuideOverlay from '@/components/GuideOverlay.vue'
import RankingModal from '../components/RankingModal.vue'
import CabinItem from './components/CabinItem.vue'
import BannerCarousel from './components/BannerCarousel.vue'
import NavArrows from './components/NavArrows.vue'
import { shouldShowGuideStartModal } from '@/utils/guideConfig.js'
import { clearSubStepProgress, shouldShowGuideStartModal, resetGuide } from '@/utils/guideConfig.js'
import { IMAGE_W, IMAGE_H } from './config/cabin.js'
import { useSwipe } from './composables/useSwipe.js'
import { useCabin } from './composables/useCabin.js'
@ -110,7 +102,6 @@ const currentStarId = ref(uni.getStorageSync('star_id') || null)
// ========== UI State ==========
const navExpanded = ref(false)
const showRankingModal = ref(false)
const showGuideStartModal = ref(false)
const isDev = ref(false) //
// ========== Screen Info ==========
@ -169,7 +160,8 @@ const handleRankingVisit = (userId) => {
const handleRankingModalClose = (visible) => {
showRankingModal.value = visible
if (!visible && store.state.guide.isActive) {
// 使 componentMode isActive END_GUIDE false
if (!visible && store.state.guide.componentMode) {
uni.$emit('guide:closeComponent')
}
}
@ -200,7 +192,7 @@ const handleTabChange = (newTab) => {
'/pages/starcity/index',
'/pages/friends/index'
]
if (newTab >= 0 && newTab < routes.length) {
uni.navigateTo({
url: routes[newTab]
@ -208,14 +200,6 @@ const handleTabChange = (newTab) => {
}
}
const handleGuideStart = () => {
showGuideStartModal.value = false
}
const handleGuideStartModalClose = () => {
showGuideStartModal.value = false
}
const openDebugGrid = () => {
uni.navigateTo({
url: '/pages/square/debug-grid'
@ -295,9 +279,11 @@ onMounted(() => {
})
onShow(() => {
//
//
if (shouldShowGuideStartModal()) {
showGuideStartModal.value = true
uni.navigateTo({
url: '/pages/tasks/guide'
})
}
})
@ -316,6 +302,17 @@ onLoad((options) => {
console.log('[Guide] 调试模式已关闭')
}
}
// guide_key
if (options && options.guide_key) {
console.log('[Guide] 收到引导跳转参数, guide_key:', options.guide_key, 'guide_step:', options.guide_step)
// 使 resumeGuide shouldShowGuide
store.dispatch('guide/resumeGuide', options.guide_key).then(res => {
console.log('[Guide] resumeGuide 结果:', res)
}).catch(err => {
console.error('[Guide] resumeGuide 失败:', err)
})
}
})
//

View File

@ -1,6 +1,6 @@
<template>
<view class="page-container">
<Header :showBack="true" backIconColor="#e6e6e6" />
<Header :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" :showBack="true" backIconColor="#e6e6e6" />
<StarbookContent :isActive="true" />
<!-- 蒙层 - 导航栏展开时显示 -->

View File

@ -0,0 +1,287 @@
<template>
<view class="page-container">
<Header :showBack="true" backIconColor="#e6e6e6" :title="pageTitle" />
<!-- 加载中 -->
<view v-if="loading && page === 1" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="!hasData" class="empty-container">
<text class="empty-text">暂无藏品</text>
</view>
<!-- 藏品网格 -->
<view v-else class="nft-grid-container">
<view
v-for="item in items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<NftCard
:cover-image="item.cover_url_signed"
:width="cardSize"
:height="cardSize"
:locked="false"
:custom-style="cardCustomStyle"
/>
<view class="nft-info">
<text class="nft-name">{{ item.name }}</text>
<text class="nft-likes">{{ item.like_count }}</text>
</view>
</view>
</view>
<!-- 分页提示 -->
<view v-if="hasMore && !loading" class="load-more" @click="loadMore">
<text class="load-more-text">加载更多</text>
</view>
<!-- 加载中加载更多 -->
<view v-if="loading && page > 1" class="loading-more">
<text class="loading-more-text">加载中...</text>
</view>
<!-- 没有更多了 -->
<view v-if="!hasMore && items.length > 0" class="no-more">
<text class="no-more-text"> 没有更多了 </text>
</view>
<BottomNav :activeTab="1" />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import Header from "../components/Header.vue";
import BottomNav from "../components/BottomNav.vue";
import NftCard from "../components/NftCard.vue";
import { getStarbookItemsApi } from '@/utils/api.js';
//
const screenWidth = ref(0);
//
const type = ref('regular');
const category = ref('castlove');
const grade = ref(null);
//
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
//
const items = ref([]);
//
const loading = ref(false);
//
const cardSize = computed(() => {
if (screenWidth.value === 0) return 200;
const rpxToPx = screenWidth.value / 750;
const padding = 30 * rpxToPx; // 30rpx
const gap = 15 * rpxToPx; // 15rpx32
const availableWidth = screenWidth.value - (padding * 2) - (gap * 2);
return Math.floor(availableWidth / 3);
});
//
const cardCustomStyle = {
position: 'absolute',
top: '0',
left: '0'
};
//
const pageTitle = computed(() => {
if (type.value === 'regular') {
return `普通 · ${formatGrade(grade.value)}`;
}
return category.value;
});
//
const hasData = computed(() => {
return items.value.length > 0;
});
//
const hasMore = computed(() => {
return items.value.length < total.value;
});
// grade
const gradeMap = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' };
function formatGrade(g) {
return `等级${gradeMap[g] || g}`;
}
//
const loadData = async (append = false) => {
loading.value = true;
try {
const response = await getStarbookItemsApi(
type.value,
category.value,
grade.value,
page.value,
pageSize.value
);
if (response.code === 200 && response.data) {
if (append) {
items.value = [...items.value, ...(response.data.items || [])];
} else {
items.value = response.data.items || [];
}
total.value = response.data.total || 0;
}
} catch (error) {
console.error('获取藏品列表失败:', error);
uni.showToast({
title: error.message || '获取藏品列表失败',
icon: 'none',
duration: 2000
});
} finally {
loading.value = false;
}
};
//
const loadMore = () => {
if (!hasMore.value || loading.value) return;
page.value++;
loadData(true);
};
//
const handleCardClick = (item) => {
if (item.asset_id) {
uni.navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${item.asset_id}`
});
}
};
//
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
screenWidth.value = systemInfo.windowWidth;
//
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options || currentPage.$page?.options || {};
type.value = options.type || 'regular';
category.value = options.category || 'castlove';
grade.value = options.grade ? parseInt(options.grade) : null;
page.value = parseInt(options.page) || 1;
//
loadData();
});
//
onReachBottom(() => {
if (hasMore.value && !loading.value) {
loadMore();
}
});
</script>
<style scoped>
.page-container {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
background: #0d0820;
}
.loading-container,
.empty-container {
display: flex;
justify-content: center;
align-items: center;
padding-top: 300rpx;
}
.loading-text,
.empty-text {
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
}
/* 藏品网格容器 */
.nft-grid-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 15rpx;
row-gap: 20rpx;
padding: 250rpx 30rpx 180rpx;
width: 100%;
box-sizing: border-box;
}
.nft-grid-item {
position: relative;
width: 100%;
padding-top: 133.33%;
}
.nft-grid-item:active {
transform: scale(0.98);
}
/* 藏品信息 */
.nft-info {
padding: 10rpx 0;
text-align: center;
}
.nft-name {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nft-likes {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.5);
}
/* 加载更多 */
.load-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx;
}
.load-more-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
}
.loading-more,
.no-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx;
}
.loading-more-text,
.no-more-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
</style>

View File

@ -75,11 +75,29 @@
</view>
</view>
<!-- 一键领取按钮 -->
<view v-if="!loading && !errorMessage && hasClaimableTasks" class="claim-all-bar">
<button class="claim-all-btn" :loading="claimingAll" @click="handleClaimAll">
一键领取 ({{ claimableCount }})
</button>
<!-- 底部进度区域和一键领取 -->
<view v-if="!loading && !errorMessage" class="bottom-section">
<!-- 进度条 -->
<view class="progress-section">
<view class="progress-bar">
<view
v-for="(milestone, index) in milestones"
:key="index"
class="progress-node"
:class="{ 'active': index < completedCount }"
>
<view class="node-circle"></view>
<text class="node-label">{{ milestone }}</text>
</view>
</view>
</view>
<!-- 一键领取按钮 -->
<view v-if="hasClaimableTasks" class="claim-all-bar">
<button class="claim-all-btn" :loading="claimingAll" @click="handleClaimAll">
一键领取 ({{ claimableCount }})
</button>
</view>
</view>
</view>
</view>
@ -110,6 +128,15 @@ const starId = ref(1)
const hasClaimableTasks = computed(() => tasks.value.some(t => t.can_claim))
const claimableCount = computed(() => tasks.value.filter(t => t.can_claim).length)
//
const milestones = ref(['任务1', '任务2', '任务3', '任务4'])
//
const completedCount = computed(() => {
//
return tasks.value.filter(t => t.status === 'claimed').length
})
// visible
watch(() => props.visible, (newVal) => {
if (newVal) {
@ -485,6 +512,67 @@ const handleCloseClick = (e) => {
margin-top: 20rpx;
}
.bottom-section {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.progress-section {
width: 100%;
}
.progress-bar {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
height: 60rpx;
padding: 0 30rpx;
}
.progress-bar::before {
content: '';
position: absolute;
top: 50%;
left: 50rpx;
right: 50rpx;
height: 4rpx;
background: rgba(255, 255, 255, 0.2);
transform: translateY(-50%);
z-index: 0;
}
.progress-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.node-circle {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
border: 4rpx solid rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.progress-node.active .node-circle {
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-color: #F08399;
}
.node-label {
font-size: 22rpx;
color: rgba(230, 230, 230, 0.7);
margin-top: 8rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
text-shadow: 0 4rpx 4rpx rgba(0, 0, 0, 0.3);
}
.claim-all-btn {
width: 100%;
padding: 24rpx 0;

View File

@ -11,96 +11,103 @@
<!-- 页面标题 -->
<view class="page-title">新手引导</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="errorMessage" class="error-state">
<text class="error-text">{{ errorMessage }}</text>
<button class="retry-btn" @click="loadStatus">重试</button>
</view>
<!-- 引导内容 -->
<view v-else class="guide-content">
<!-- 当前阶段信息 -->
<view class="current-stage-card">
<view class="stage-header">
<text class="stage-label">当前阶段</text>
<text class="stage-number"> {{ currentStage }} 阶段</text>
</view>
<view class="stage-name">{{ currentStageName }}</view>
<view class="stage-reward" v-if="currentStageReward > 0">
<text class="reward-icon">💎</text>
<text class="reward-value">{{ currentStageReward }}</text>
<text class="reward-sep">|</text>
<text class="reward-icon"></text>
<text class="reward-value">{{ currentStageExp }} 经验</text>
</view>
</view>
<!-- 阶段列表 -->
<view class="stages-list">
<view v-for="stage in stages" :key="stage.stage" class="stage-item" :class="{ 'is-current': stage.is_current, 'is-completed': stage.status === 'completed' }">
<view class="stage-item-header">
<view class="stage-item-name">
<text v-if="stage.status === 'completed'" class="check-icon"></text>
<text>{{ stage.name }}</text>
</view>
<view v-if="stage.is_current" class="current-tag">进行中</view>
<!-- 引导列表 -->
<view class="guide-list">
<view
v-for="item in guideList"
:key="item.key"
class="guide-item"
:class="{ 'in-progress': item.status === 'in_progress' }"
>
<view class="guide-item-header">
<view class="guide-status-badge" :class="item.status">
{{ item.statusText }}
</view>
<text class="guide-name">{{ item.name }}</text>
</view>
<!-- 该阶段的任务列表 -->
<view class="task-list-mini">
<view v-for="taskKey in stage.required_task_keys" :key="taskKey" class="task-key-item">
<text class="task-check" :class="{ 'is-done': isTaskCompleted(stage, taskKey) }">
{{ isTaskCompleted(stage, taskKey) ? '✓' : '○' }}
</text>
<text class="task-key-name">{{ taskKey }}</text>
</view>
<view class="guide-item-body">
<text class="guide-desc">{{ item.desc }}</text>
</view>
<!-- 进度条进行中状态显示 -->
<view v-if="item.status === 'in_progress'" class="guide-progress">
<view class="progress-bar">
<view
class="progress-fill"
:style="{ width: item.progress.percentage + '%' }"
></view>
</view>
<text class="progress-text">{{ item.progress.completed }}/{{ item.progress.total }} 步骤</text>
</view>
<view class="guide-item-footer">
<!-- 未开始 -->
<view
v-if="item.buttonType === 'start'"
class="guide-btn start-btn"
@click="handleStartGuide(item.key)"
>
{{ item.buttonText }}
</view>
<!-- 进行中 -->
<view
v-else-if="item.buttonType === 'continue'"
class="guide-btn continue-btn"
@click="handleContinueGuide(item.key)"
>
{{ item.buttonText }}
</view>
<!-- 已完成可领取 -->
<view
v-else-if="item.buttonType === 'claim'"
class="guide-btn claim-btn"
@click="handleClaimReward(item.key)"
>
{{ item.buttonText }}
</view>
<!-- 已领取 -->
<view
v-else
class="guide-btn disabled-btn"
>
{{ item.buttonText }}
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-bar">
<!-- 进入下一阶段按钮 -->
<button
v-if="canAdvance"
class="advance-btn"
:loading="advancing"
@click="handleAdvance"
>
进入下一阶段
</button>
<view v-else-if="!allCurrentTasksDone" class="advance-hint">
完成当前阶段所有任务后可进入下一阶段
</view>
<!-- 领取奖励按钮 -->
<button
v-if="canClaimReward"
class="claim-btn"
:loading="claimingReward"
@click="handleClaimReward"
>
领取奖励
</button>
</view>
</view>
<!-- 下拉刷新 -->
<view v-if="!loading" class="pull-to-refresh" @click="handleRefresh">
<!-- <text class="refresh-text">{{ refreshing ? '刷新中...' : '下拉刷新' }}</text> -->
<!-- 底部统计 -->
<view class="guide-footer">
<view class="footer-stats">
已完成 {{ doneCount }}/{{ totalCount }}
</view>
<view v-if="claimableCount > 0" class="footer-claim-tip">
{{ claimableCount }} 个奖励可领取
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getOnboardingStatus, advanceStage, claimOnboardingReward } from '@/utils/task-api.js'
import { getOnboardingStatus, advanceStage, claimOnboardingReward, completeGuide } from '@/utils/task-api.js'
import {
guideConfig,
getGuideConfig,
getGuideStatusList,
getStepProgress,
hasGuideProgress,
getNextIncompleteStep,
getStepPage,
clearSubStepProgress,
resetGuide,
claimGuideReward,
markGuideDone,
isGuideDone,
isGuideRewardClaimed,
onboardingStages
} from '@/utils/guideConfig.js'
const isAndroid = ref(false)
@ -116,106 +123,222 @@ const handleBack = () => {
}
const loading = ref(true)
const refreshing = ref(false)
const errorMessage = ref('')
const currentStage = ref(0)
const currentStatus = ref('')
const stages = ref([])
const advancing = ref(false)
const claimingReward = ref(false)
const guideList = ref([])
//
const currentStageConfig = computed(() => stages.value.find(s => s.is_current))
const currentStageName = computed(() => currentStageConfig.value?.name || '新手引导')
const currentStageReward = computed(() => currentStageConfig.value?.crystal_reward || 0)
const currentStageExp = computed(() => currentStageConfig.value?.exp_reward || 0)
//
const totalCount = computed(() => guideList.value.length)
const doneCount = computed(() => guideList.value.filter(item =>
item.status === 'completed' || item.status === 'reward_claimed'
).length)
const claimableCount = computed(() => guideList.value.filter(item => item.status === 'completed').length)
//
const currentTasksDone = computed(() => {
if (!currentStageConfig.value) return false
return currentStageConfig.value.required_task_keys.every(key =>
isTaskCompleted(currentStageConfig.value, key)
)
})
//
function refreshList() {
const rawList = getGuideStatusList()
guideList.value = rawList.map(item => {
const progress = getStepProgress(item.key)
const status = calculateStatus(item.key, item.done, item.claimed)
const allCurrentTasksDone = computed(() => currentTasksDone.value)
const canAdvance = computed(() => currentStatus.value === 'in_progress' && currentTasksDone.value)
const canClaimReward = computed(() => currentStatus.value === 'in_progress' && currentTasksDone.value)
//
// 使 AllTasksCompleted
function isTaskCompleted(stage, taskKey) {
// stage.all_tasks_completed
if (stage.all_tasks_completed === true) {
return true
}
// all_tasks_completed status
if (stage.status === 'completed') {
return true
}
return false
return {
...item,
progress,
status,
statusText: getStatusText(status, progress),
buttonText: getButtonText(status),
buttonType: getButtonType(status)
}
})
}
async function loadStatus() {
function calculateStatus(key, done, claimed) {
if (claimed) return 'reward_claimed'
if (done && !claimed) return 'completed'
if (hasGuideProgress(key)) return 'in_progress'
return 'not_started'
}
function getStatusText(status, progress) {
if (status === 'in_progress') {
return `${progress.completed}/${progress.total} 步骤`
}
const map = {
not_started: '未开始',
completed: '已完成',
reward_claimed: '已领取'
}
return map[status] || '未开始'
}
function getButtonText(status) {
const map = {
not_started: '开始',
in_progress: '继续',
completed: '领取奖励',
reward_claimed: '已领取'
}
return map[status] || '开始'
}
function getButtonType(status) {
const map = {
not_started: 'start',
in_progress: 'continue',
completed: 'claim',
reward_claimed: 'claimed'
}
return map[status] || 'start'
}
//
function handleStartGuide(key) {
console.log('[guide] handleStartGuide:', key)
//
clearSubStepProgress(key)
// ""
resetGuide(key)
const targetPage = getStepPage(key, 0)
if (targetPage) {
uni.navigateTo({
url: targetPage + '?guide_key=' + key + '&guide_step=0'
})
}
}
//
function handleContinueGuide(key) {
console.log('[guide] handleContinueGuide:', key)
//
const resumeStep = getNextIncompleteStep(key)
const targetPage = getStepPage(key, resumeStep)
console.log('[guide] handleContinueGuide, resumeStep:', resumeStep, 'targetPage:', targetPage)
if (targetPage) {
uni.navigateTo({
url: targetPage + '?guide_key=' + key + '&guide_step=' + resumeStep
})
}
}
//
async function completeGuideAndSync(key) {
try {
//
markGuideDone(key)
//
await completeGuide(key, onboardingStages)
console.log('[guide] completeGuideAndSync success:', key)
} catch (err) {
console.error('[guide] completeGuideAndSync error:', err)
}
}
//
async function checkAndSyncCompletedGuides() {
const rawList = getGuideStatusList()
for (const item of rawList) {
if (item.done && !item.claimed) {
//
await completeGuideAndSync(item.key)
}
}
}
//
async function handleClaimReward(key) {
//
try {
// stage
const config = getGuideConfig(key)
if (!config) return
// onboardingStages stage
let stageNum = -1
for (const s of onboardingStages) {
if (s.required_task_keys.includes(key)) {
stageNum = s.stage
break
}
}
if (stageNum >= 0) {
const res = await claimOnboardingReward(stageNum)
// Header
if (res.data?.crystal_balance !== undefined) {
try {
const userStr = uni.getStorageSync('user')
if (userStr) {
const user = JSON.parse(userStr)
user.crystal_balance = parseInt(res.data.crystal_balance)
uni.setStorageSync('user', JSON.stringify(user))
}
} catch (e) {
console.error('更新本地存储失败:', e)
}
uni.$emit('balanceUpdated', { crystal_balance: res.data.crystal_balance, experience: res.data.experience })
}
}
//
claimGuideReward(key)
// uni.showToast({
// title: `${config.reward?.exp || 0} ${config.reward?.diamond || 0}`,
// icon: 'none'
// })
refreshList()
} catch (err) {
console.error('handleClaimReward error:', err)
uni.showToast({ title: '领取失败', icon: 'none' })
}
}
//
async function initBackend() {
try {
loading.value = true
errorMessage.value = ''
//
const res = await getOnboardingStatus()
const data = res.data || {}
currentStage.value = data.current_stage || 0
currentStatus.value = data.status || 'pending'
stages.value = data.stages || []
if (!data.stages || data.stages.length === 0) {
//
await completeGuide('init', onboardingStages)
} else {
// stage
// stages allTasksCompleted
for (const stage of data.stages) {
if (stage.allTasksCompleted && stage.required_task_keys) {
for (const taskKey of stage.required_task_keys) {
//
if (!isGuideDone(taskKey)) {
console.log('[Guide] 从后端同步任务完成状态:', taskKey)
markGuideDone(taskKey)
}
}
}
}
}
//
refreshList()
} catch (err) {
console.error('loadStatus error:', err)
errorMessage.value = err.message || '加载失败'
console.error('initBackend error:', err)
} finally {
loading.value = false
refreshing.value = false
}
}
async function handleAdvance() {
if (advancing.value) return
try {
advancing.value = true
const nextStage = currentStage.value + 1
await advanceStage(nextStage)
await loadStatus()
uni.showToast({ title: '已进入下一阶段', icon: 'success' })
} catch (err) {
console.error('handleAdvance error:', err)
uni.showToast({ title: err.message || '进入下一阶段失败', icon: 'none' })
} finally {
advancing.value = false
}
}
async function handleClaimReward() {
if (claimingReward.value) return
try {
claimingReward.value = true
await claimOnboardingReward(currentStage.value)
await loadStatus()
uni.showToast({ title: '奖励领取成功', icon: 'success' })
} catch (err) {
console.error('handleClaimReward error:', err)
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
} finally {
claimingReward.value = false
}
}
function handleRefresh() {
refreshing.value = true
loadStatus()
}
onMounted(() => {
const systemInfo = uni.getSystemInfoSync()
isAndroid.value = systemInfo.platform === 'android'
loadStatus()
initBackend().then(() => {
//
checkAndSyncCompletedGuides()
refreshList()
})
})
</script>
@ -223,7 +346,8 @@ onMounted(() => {
.guide-container {
position: relative;
min-height: 100vh;
padding-bottom: 120rpx;
padding: 20rpx;
padding-bottom: 200rpx;
}
.background-image {
@ -264,222 +388,142 @@ onMounted(() => {
padding: 20rpx 0;
}
.loading-state,
.error-state {
.guide-list {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #e0e0e0;
border-top-color: #6c5ce7;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20rpx;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text,
.error-text {
color: #999;
font-size: 28rpx;
}
.retry-btn {
margin-top: 20rpx;
padding: 16rpx 40rpx;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border-radius: 50rpx;
font-size: 28rpx;
}
.guide-content {
position: relative;
z-index: 1;
padding: 20rpx;
}
.current-stage-card {
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 30rpx;
color: #fff;
}
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.stage-label {
font-size: 28rpx;
opacity: 0.8;
}
.stage-number {
font-size: 28rpx;
font-weight: bold;
}
.stage-name {
font-size: 40rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.stage-reward {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 28rpx;
}
.reward-icon {
font-size: 28rpx;
}
.reward-value {
font-weight: bold;
}
.reward-sep {
opacity: 0.5;
margin: 0 10rpx;
}
.stages-list {
margin-bottom: 30rpx;
}
.stage-item {
.guide-item {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.stage-item.is-current {
border: 2rpx solid #6c5ce7;
.guide-item.in-progress {
border-left: 4rpx solid #4a90e2;
}
.stage-item.is-completed {
opacity: 0.7;
}
.stage-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.stage-item-name {
.guide-item-header {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 12rpx;
}
.check-icon {
color: #52c41a;
font-size: 32rpx;
}
.current-tag {
padding: 6rpx 16rpx;
background: #6c5ce7;
color: #fff;
border-radius: 20rpx;
.guide-status-badge {
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-size: 22rpx;
margin-right: 12rpx;
}
.task-list-mini {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.task-key-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background: #f5f5f5;
border-radius: 20rpx;
font-size: 24rpx;
}
.task-check {
color: #ddd;
font-size: 24rpx;
}
.task-check.is-done {
color: #52c41a;
}
.task-key-name {
color: #666;
}
.action-bar {
padding: 20rpx 0;
}
.advance-btn {
width: 100%;
padding: 24rpx 0;
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
color: #fff;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.advance-hint {
text-align: center;
.guide-status-badge.not_started {
background: #f0f0f0;
color: #999;
}
.guide-status-badge.in_progress {
background: #e6f0ff;
color: #4a90e2;
}
.guide-status-badge.completed {
background: #fff3e6;
color: #ff9500;
}
.guide-status-badge.reward_claimed {
background: #e8f5e9;
color: #4caf50;
}
.guide-name {
font-size: 30rpx;
font-weight: 500;
color: #333;
flex: 1;
}
.guide-desc {
font-size: 26rpx;
padding: 20rpx 0;
color: #666;
margin-bottom: 16rpx;
}
.guide-progress {
margin-bottom: 16rpx;
}
.progress-bar {
height: 8rpx;
background: #eee;
border-radius: 4rpx;
overflow: hidden;
margin-bottom: 8rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.progress-text {
font-size: 22rpx;
color: #999;
}
.guide-item-footer {
display: flex;
justify-content: flex-end;
}
.guide-btn {
padding: 12rpx 32rpx;
color: #fff;
border-radius: 30rpx;
font-size: 26rpx;
}
.start-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.continue-btn {
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
}
.claim-btn {
width: 100%;
padding: 24rpx 0;
background: linear-gradient(135deg, #ffd700, #ffb347);
color: #333;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
}
.pull-to-refresh {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
padding: 30rpx 0;
}
.refresh-text {
.disabled-btn {
background: #ddd;
color: #999;
font-size: 24rpx;
}
.guide-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
background: #fff;
border-top: 1rpx solid #eee;
z-index: 10;
}
.footer-stats {
font-size: 28rpx;
color: #333;
text-align: center;
margin-bottom: 8rpx;
}
.footer-claim-tip {
font-size: 26rpx;
color: #ff6b6b;
text-align: center;
}
</style>

View File

@ -39,11 +39,14 @@ const mutations = {
* @param {number} [guideConfig.startFromStep] 从指定步骤开始可选默认从保存的进度继续
*/
START_GUIDE(state, { guideConfig, startFromStep }) {
console.log('[Guide] START_GUIDE mutation called, key:', guideConfig.key, 'startFromStep:', startFromStep)
state.currentGuide = guideConfig
// 如果指定了起始步骤,从指定步骤开始;否则恢复之前保存的进度
state.currentStep = startFromStep !== undefined ? startFromStep : (getGuideCurrentStep(guideConfig.key) || 0)
console.log('[Guide] START_GUIDE, currentStep set to:', state.currentStep)
state.isActive = true
state.isNavigating = false
console.log('[Guide] START_GUIDE, isActive set to:', state.isActive)
},
/**
@ -236,6 +239,7 @@ const actions = {
}
// 获取第一个未完成的步骤
const resumeStep = getNextIncompleteStep(guideKey)
console.log('[Guide] resumeGuide:', guideKey, 'resumeStep:', resumeStep, 'guide:', guide)
commit('START_GUIDE', { guideConfig: guide, startFromStep: resumeStep })
return true
},

View File

@ -541,4 +541,26 @@ export function getActivityRankingApi(activityId, starId = null, page = 1, pageS
url: url,
method: 'GET'
})
}
// ==================== 星册相关接口 ====================
// 获取星册首页数据
export function getStarbookHomeApi() {
return request({
url: '/api/v1/starbook/home',
method: 'GET'
})
}
// 获取星册藏品列表(分页)
export function getStarbookItemsApi(type, category, grade = null, page = 1, pageSize = 20) {
let url = `/api/v1/starbook/items?type=${type}&category=${category}&page=${page}&page_size=${pageSize}`
if (grade !== null) {
url += `&grade=${grade}`
}
return request({
url: url,
method: 'GET'
})
}

View File

@ -52,6 +52,47 @@
* }
*/
// ==================== 新手引导阶段配置 ====================
/**
* 新手引导阶段配置
* 用于后端存储和双重校验
*
* 结构
* {
* stage: 0, // 阶段号
* name: '初识平台', // 阶段名称
* required_task_keys: [], // 该阶段必须完成的任务key列表
* crystal_reward: 100, // 水晶奖励
* exp_reward: 50 // 经验奖励
* }
*/
export const onboardingStages = [
{
stage: 0,
name: '初识平台',
required_task_keys: ['square_home', 'browse_exhibition'],
crystal_reward: 100,
exp_reward: 50
},
{
stage: 1,
name: '互动入门',
required_task_keys: ['follow_star', 'send_gift'],
crystal_reward: 200,
exp_reward: 100
},
{
stage: 2,
name: '社交达人',
required_task_keys: ['add_friend', 'share_content'],
crystal_reward: 300,
exp_reward: 150
}
]
// ==================== 引导配置文件 ====================
export const guideConfig = {
// 广场页面引导
square_home: {

View File

@ -43,10 +43,11 @@ export const claimAllDailyTasks = (starId) =>
/**
* 完成引导步骤
* @param {string} taskKey - 引导任务key square_home, profile_edit
* @param {Array} stages - 前端配置的阶段列表首次调用时传后续不传
* @returns {Promise}
*/
export const completeGuide = (taskKey) =>
request({ url: '/api/v1/tasks/guide/complete', method: 'POST', data: { task_key: taskKey } })
export const completeGuide = (taskKey, stages = []) =>
request({ url: '/api/v1/tasks/guide/complete', method: 'POST', data: { task_key: taskKey, stages } })
/**
* 获取引导状态

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB