feat(profile): render level progress bar in LV box

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.
This commit is contained in:
zerosaturation 2026-07-27 18:02:59 +08:00
parent 83999995f5
commit 27313f414a
4 changed files with 210 additions and 24 deletions

View File

@ -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
}

View File

@ -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"`
}
// ========== 用户信息 ==========

View File

@ -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),
}
}

View File

@ -23,10 +23,21 @@
<view class="profile-scroll">
<!-- 右上角模块 -->
<view class="top-right-module">
<view class="level-box">
<text class="level-label">LV</text>
<text class="level-value">{{ fanLevel }}</text>
<view class="level-box" :class="{ 'level-box--max': isMaxLevel }">
<!-- 经验条底槽 + 前景填充 progressRatio 裁剪宽度 -->
<view class="level-bar-fill" :style="levelBarStyle"></view>
<view class="level-text">
<text class="level-label">LV</text>
<text class="level-value">{{ fanLevel }}</text>
<text v-if="isMaxLevel" class="level-suffix">MAX</text>
</view>
</view>
<text v-if="!isMaxLevel && hoursToNext > 0" class="level-hint">
LV {{ (fanLevel || 0) + 1 }} 还差 {{ hoursToNext }} 小时
</text>
<text v-else-if="isMaxLevel" class="level-hint level-hint--max">
已达顶级
</text>
</view>
<!-- 上半部分用户信息卡片 -->
<view class="top-section">
@ -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;