From 27313f414a6730a89273e8a8eb008385889c3259 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 27 Jul 2026 18:02:59 +0800 Subject: [PATCH] feat(profile): render level progress bar in LV box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile.vue only rendered fan level digit, hiding the fact that fans level up via accumulated exhibition hours (6h/level, cap 20), not login/task experience. Users saw "LV X" without "X more hours to next level", so upgrades felt invisible. - backend: surface exhibition_hours / next_level_hours on CurrentIdentityDTO via a new loadLevelProgress helper in user_controller (read-only, no proto regen, no service surface change). Full-level responses set next_level_hours = exhibition_hours so the client can derive progress=1. - frontend: split level-box into base track + gold fill + text overlay; width bound to progressRatio with 0.4s transition. Adds "距 LV X+1 还差 N 小时" hint and MAX indicator for full-level users. --- backend/gateway/controller/user_controller.go | 66 ++++++++- backend/gateway/dto/response_dto.go | 16 +- backend/gateway/dto/user_converter.go | 14 +- frontend/pages/profile/profile.vue | 138 ++++++++++++++++-- 4 files changed, 210 insertions(+), 24 deletions(-) diff --git a/backend/gateway/controller/user_controller.go b/backend/gateway/controller/user_controller.go index ed461e8..5855d96 100644 --- a/backend/gateway/controller/user_controller.go +++ b/backend/gateway/controller/user_controller.go @@ -2,6 +2,7 @@ package controller import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -19,10 +20,12 @@ import ( "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/jwt" "github.com/topfans/backend/pkg/logger" + "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/user" userSvcRepo "github.com/topfans/backend/services/userService/repository" userSvc "github.com/topfans/backend/services/userService/service" "go.uber.org/zap" + "gorm.io/gorm" ) // UserController 用户控制器 @@ -126,8 +129,11 @@ func (ctrl *UserController) GetCurrentUser(c *gin.Context) { star := ctrl.findStar(ctx, resp.FanProfile.StarId) + // 升级进度:累计上架时长 + 下一级阈值(满级时 nextLevelHours = exhibitionHours 表示 100%) + exhibitionHours, nextLevelHours := loadLevelProgress(userID.(int64), starID.(int64), int64(resp.FanProfile.Level)) + // 转换为 DTO 并返回 - data := dto.ToGetMeResponseDTO(resp.User, resp.FanProfile, star) + data := dto.ToGetMeResponseDTO(resp.User, resp.FanProfile, star, exhibitionHours, nextLevelHours) response.Success(c, data) } @@ -504,12 +510,17 @@ func (ctrl *UserController) SwitchIdentity(c *gin.Context) { star := ctrl.findStar(ctx, resp.FanProfile.StarId) + // 升级进度:累计上架时长 + 下一级阈值(满级时 nextLevelHours = exhibitionHours 表示 100%) + exhibitionHours, nextLevelHours := loadLevelProgress(userID.(int64), starID.(int64), int64(resp.FanProfile.Level)) + // 转换为 DTO 并返回 data := dto.ToSwitchIdentityResponseDTO( resp.AccessToken, resp.ExpiresIn, resp.FanProfile, star, + exhibitionHours, + nextLevelHours, ) response.Success(c, data) } @@ -653,3 +664,56 @@ func (ctrl *UserController) DeleteAccount(c *gin.Context) { ) response.SuccessWithMessage(c, "账号已注销", nil) } + +// loadLevelProgress 读取 (userID, starID) 的累计上架时长与下一级阈值,供前端展示升级进度。 +// +// 返回 (exhibitionHours, nextLevelHours): +// - 满级(currentLevel >= level_cap_config.max_level)时返回 (累计时长, 累计时长), +// 前端 progress=1 即可,无需下一级阈值。 +// - 任意查询失败(无记录 / DB 不可用)时安全降级为 (0, 0),让前端 progress=0,不影响主链路。 +// +// 注:gateway 共享同一 postgres 连接(database.GetDB 是进程级单例),这里只读、只附加查询, +// 不写入,不动 proto,不污染 userService。 +func loadLevelProgress(userID, starID, currentLevel int64) (int64, int64) { + db := database.GetDB() + if db == nil || userID <= 0 || starID <= 0 { + return 0, 0 + } + + // 1. 累计上架时长 + var eh models.UserExhibitionHours + exhibitionHours := int64(0) + if err := db.Select("total_exhibition_hours"). + Where("user_id = ? AND star_id = ?", userID, starID). + First(&eh).Error; err == nil { + exhibitionHours = eh.TotalExhibitionHours + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + // 真实查询错误:warn 但不阻断主链路 + logger.Logger.Warn("loadLevelProgress: read user_exhibition_hours failed", + zap.Int64("user_id", userID), + zap.Int64("star_id", starID), + zap.Error(err)) + } + + // 2. 等级上限(满级判定) + maxLevel := userSvcRepo.GetLevelCap() + if currentLevel >= int64(maxLevel) { + return exhibitionHours, exhibitionHours + } + + // 3. 下一级阈值:取 level_thresholds 中 level = currentLevel+1 的 max_exhibition_hours + var next models.LevelThreshold + if err := db.Select("max_exhibition_hours"). + Where("level = ?", currentLevel+1). + First(&next).Error; err != nil { + // 无下一级配置(理论上不该发生)→ 降级 + logger.Logger.Warn("loadLevelProgress: read level_thresholds next level failed", + zap.Int64("user_id", userID), + zap.Int64("star_id", starID), + zap.Int64("current_level", currentLevel), + zap.Error(err)) + return exhibitionHours, exhibitionHours + } + + return exhibitionHours, next.MaxExhibitionHours +} diff --git a/backend/gateway/dto/response_dto.go b/backend/gateway/dto/response_dto.go index 094beec..1738f85 100644 --- a/backend/gateway/dto/response_dto.go +++ b/backend/gateway/dto/response_dto.go @@ -12,12 +12,16 @@ type FanIdentityDTO struct { // CurrentIdentityDTO 当前身份详情 type CurrentIdentityDTO struct { - StarId int64 `json:"star_id"` // 明星ID - IdentityID string `json:"identity_id"` // "wyb" - IdentityName string `json:"identity_name"` // "王一博" - Tag string `json:"tag"` // "小摩托" - Level int32 `json:"level"` - CrystalBalance int64 `json:"crystal_balance"` + StarId int64 `json:"star_id"` // 明星ID + IdentityID string `json:"identity_id"` // "wyb" + IdentityName string `json:"identity_name"` // "王一博" + Tag string `json:"tag"` // "小摩托" + Level int32 `json:"level"` + CrystalBalance int64 `json:"crystal_balance"` + // 升级进度:累计上架时长(实时)与下一级所需小时数 + // 满级(>= GetLevelCap)时 NextLevelHours = ExhibitionHours 表示已达 100% + ExhibitionHours int64 `json:"exhibition_hours"` + NextLevelHours int64 `json:"next_level_hours"` } // ========== 用户信息 ========== diff --git a/backend/gateway/dto/user_converter.go b/backend/gateway/dto/user_converter.go index 0289de5..621790c 100644 --- a/backend/gateway/dto/user_converter.go +++ b/backend/gateway/dto/user_converter.go @@ -17,7 +17,9 @@ func ToFanIdentityDTO(star *pb.Star) FanIdentityDTO { } // ToCurrentIdentityDTO 转换为当前身份详情 -func ToCurrentIdentityDTO(profile *pb.FanProfile, star *pb.Star) CurrentIdentityDTO { +// exhibitionHours / nextLevelHours 由调用方传入(gateway 内查 user_exhibition_hours + level_thresholds); +// 满级时建议将 nextLevelHours = exhibitionHours,让前端 progress = 1。 +func ToCurrentIdentityDTO(profile *pb.FanProfile, star *pb.Star, exhibitionHours, nextLevelHours int64) CurrentIdentityDTO { return CurrentIdentityDTO{ StarId: star.StarId, IdentityID: star.IdentityId, // "wyb" @@ -25,6 +27,8 @@ func ToCurrentIdentityDTO(profile *pb.FanProfile, star *pb.Star) CurrentIdentity Tag: star.Tag, // "小摩托" Level: profile.Level, CrystalBalance: profile.CrystalBalance, + ExhibitionHours: exhibitionHours, + NextLevelHours: nextLevelHours, } } @@ -100,11 +104,12 @@ func ToLoginResponseDTO( } // ToGetMeResponseDTO 转换为 GetMe 响应 -func ToGetMeResponseDTO(user *pb.User, profile *pb.FanProfile, star *pb.Star) GetMeResponseDTO { +// exhibitionHours / nextLevelHours 由调用方传入,透传到 CurrentIdentity(用于前端展示升级进度) +func ToGetMeResponseDTO(user *pb.User, profile *pb.FanProfile, star *pb.Star, exhibitionHours, nextLevelHours int64) GetMeResponseDTO { dto := GetMeResponseDTO{ UID: user.Id, Nickname: profile.Nickname, - CurrentIdentity: ToCurrentIdentityDTO(profile, star), + CurrentIdentity: ToCurrentIdentityDTO(profile, star, exhibitionHours, nextLevelHours), } // 头像URL(优先使用粉丝档案中的头像,没有则使用用户头像) @@ -229,11 +234,12 @@ func ToSwitchIdentityResponseDTO( expiresIn int64, profile *pb.FanProfile, star *pb.Star, + exhibitionHours, nextLevelHours int64, ) SwitchIdentityResponseDTO { return SwitchIdentityResponseDTO{ AccessToken: accessToken, ExpiresIn: expiresIn, - CurrentIdentity: ToCurrentIdentityDTO(profile, star), + CurrentIdentity: ToCurrentIdentityDTO(profile, star, exhibitionHours, nextLevelHours), } } diff --git a/frontend/pages/profile/profile.vue b/frontend/pages/profile/profile.vue index 35398ab..f407582 100644 --- a/frontend/pages/profile/profile.vue +++ b/frontend/pages/profile/profile.vue @@ -23,10 +23,21 @@ - - LV - {{ fanLevel }} + + + + + LV + {{ fanLevel }} + MAX + + + 距 LV {{ (fanLevel || 0) + 1 }} 还差 {{ hoursToNext }} 小时 + + + 已达顶级 + @@ -630,6 +641,11 @@ const fanLevel = ref(0); const fanTag = ref(""); const loading = ref(false); +// 升级进度:累计上架时长 + 下一级所需小时数 +// 后端 level system: 累计 hours 决定等级;阈值每 6h 一级,满级 max_level(后端 level_cap_config) +const exhibitionHours = ref(0); +const nextLevelHours = ref(0); + // 修改昵称弹窗 const showNicknameModal = ref(false); const newNickname = ref(""); @@ -716,6 +732,40 @@ const updateBgOverlay = (newOverlayImage) => { replaceableBgOverlay.value = newOverlayImage; }; +// 满级判定:level_cap_config.max_level 默认 20;后端在满级时 nextLevelHours == exhibitionHours +const MAX_LEVEL = 20; +const isMaxLevel = computed( + () => (fanLevel.value || 0) >= MAX_LEVEL || + (nextLevelHours.value > 0 && nextLevelHours.value === exhibitionHours.value && (fanLevel.value || 0) > 0) +); + +// 0~1 进度比;满级或无下一级阈值时返回 1 +const progressRatio = computed(() => { + if (isMaxLevel.value) return 1; + const target = nextLevelHours.value; + if (!target || target <= 0) return 0; + const cur = exhibitionHours.value || 0; + return Math.max(0, Math.min(1, cur / target)); +}); + +// 经验条前景填充:宽度按 progressRatio 裁剪;满级时铺满。 +// 注意:uniapp / 小程序端 `:style` 不支持 `width: %` 的 calc,这里用百分比字符串。 +const levelBarStyle = computed(() => { + const p = progressRatio.value; + const widthPct = Math.round(p * 100); + return { + width: `${widthPct}%`, + }; +}); + +// 距离下一级还差多少小时(满级时返回 0) +const hoursToNext = computed(() => { + if (isMaxLevel.value) return 0; + const target = nextLevelHours.value || 0; + if (target <= 0) return 0; + return Math.max(0, target - (exhibitionHours.value || 0)); +}); + // 显示手机号(根据showMobile状态显示脱敏或未脱敏) const displayMobile = computed(() => { console.log( @@ -792,6 +842,9 @@ const fetchUserInfo = async (forceRefresh = false) => { : oldCachedUser?.fan_identity || null, fan_level: apiData.current_identity?.level || 0, crystal_balance: apiData.current_identity?.crystal_balance || 0, + // 升级进度字段(后端在 current_identity 内一并透出) + exhibition_hours: apiData.current_identity?.exhibition_hours ?? 0, + next_level_hours: apiData.current_identity?.next_level_hours ?? 0, // 保留旧缓存中的其他字段(如果API没有返回) blockchain_address: apiData.blockchain_address || oldCachedUser?.blockchain_address || "", @@ -822,6 +875,8 @@ const fetchUserInfo = async (forceRefresh = false) => { userAvatarUrl.value = userForCache.avatar_url || ""; mobile.value = userForCache.mobile || uni.getStorageSync("login_mobile") || ""; + exhibitionHours.value = userForCache.exhibition_hours || 0; + nextLevelHours.value = userForCache.next_level_hours || 0; } } catch (error) { console.error("获取用户信息失败:", error); @@ -839,6 +894,8 @@ const fetchUserInfo = async (forceRefresh = false) => { userAvatarUrl.value = cachedUser.avatar_url || ""; mobile.value = cachedUser.mobile || uni.getStorageSync("login_mobile") || ""; + exhibitionHours.value = cachedUser.exhibition_hours || 0; + nextLevelHours.value = cachedUser.next_level_hours || 0; } catch (e) { console.error("解析缓存用户信息失败:", e); } @@ -1817,39 +1874,94 @@ onShow(() => { .top-right-module { position: fixed; width: 313.6rpx; - height: 73.6rpx; top: -16rpx; right: 0; + z-index: 4; + display: flex; + flex-direction: column; + align-items: center; +} + +/* LV 经验条胶囊:底槽 + 前景填充 + 文字叠层 */ +.level-box { + position: relative; + width: 313.6rpx; + height: 73.6rpx; border-top-right-radius: 35.5rpx; border-top-left-radius: 7rpx; border-bottom-left-radius: 35.5rpx; border-bottom-right-radius: 6rpx; + overflow: hidden; + /* 底槽色:紫粉渐变(项目原色) */ background: linear-gradient(135deg, #9e90ff 0%, #ffcad3 100%); - z-index: 4; - display: flex; - align-items: center; - justify-content: center; + /* 轻微内阴影让填充条与底槽分层更明显 */ + box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.18); } -.level-box { +/* 满级特殊样式:金色底 + 满填充 */ +.level-box--max { + background: linear-gradient(135deg, #d49a3c 0%, #f6c860 100%); +} + +/* 经验条前景填充(按比例从左往右铺) */ +.level-bar-fill { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 0; /* 由 :style 动态赋值 width */ + background: linear-gradient(90deg, #ffd680 0%, #f6a73d 100%); + box-shadow: 0 0 12rpx rgba(246, 167, 61, 0.55); + transition: width 0.4s ease; +} + +/* 文字层(绝对定位浮在填充条之上) */ +.level-text { + position: relative; + z-index: 1; display: flex; - align-items: flex-end; + align-items: baseline; + justify-content: center; + width: 100%; + height: 100%; + line-height: 73.6rpx; } .level-label { color: #fffabd; font-size: 24rpx; - margin-right: 8rpx; + margin-right: 6rpx; + text-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.35); } .level-value { - color: #fff; + color: #fffabd; font-size: 34rpx; font-weight: bold; - color: #fffabd; text-shadow: -1px 1px 4px #ce090984; } +.level-suffix { + color: #fffabd; + font-size: 22rpx; + margin-left: 6rpx; + letter-spacing: 1rpx; + text-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.35); +} + +/* 距下一级提示 */ +.level-hint { + margin-top: 8rpx; + font-size: 22rpx; + color: #fff9e7; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +.level-hint--max { + color: #ffd680; + font-weight: bold; +} + /* 上半部分:用户信息区域 */ .top-section { position: relative;