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:
parent
83999995f5
commit
27313f414a
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
// ========== 用户信息 ==========
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user