fix: перенёс уровни в бд и пофиксид их отображение на фронте
Backend CI / build-and-test (push) Successful in 52s
Frontend CI / build-and-check (push) Failing after 5m15s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 16s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m0s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 32s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 13s

This commit is contained in:
2026-05-18 02:28:05 +03:00
parent 302e01d705
commit 811b6ef51a
27 changed files with 1526 additions and 51 deletions
-3
View File
@@ -38,6 +38,3 @@ EMAIL_SMTP_USERNAME=
EMAIL_SMTP_PASSWORD=
EMAIL_SMTP_FROM_ADDRESS=no-reply@universe.local
EMAIL_SMTP_FROM_NAME=UniVerse
# Gamification
GAMIFICATION_XP_THRESHOLDS=[0, 100, 300, 600, 1000, 1500, 2500, 4000]
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
@@ -18,6 +17,7 @@ public class GamificationServiceTests
public async Task CheckAndAwardAchievementsAsync_AwardsModernConditionsOnce()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User
@@ -78,6 +78,7 @@ public class GamificationServiceTests
public async Task CheckAndAwardAchievementsAsync_CountsConsecutiveIsoWeeksAcrossYears()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local" });
@@ -98,6 +99,38 @@ public class GamificationServiceTests
Assert.True(await db.UserAchievements.AnyAsync(ua => ua.UserId == 1 && ua.AchievementId == 1001));
}
[Theory]
[InlineData(0, 1)]
[InlineData(99, 1)]
[InlineData(100, 2)]
[InlineData(299, 2)]
[InlineData(300, 3)]
public async Task CalculateLevelAsync_UsesDatabaseThresholds(int xp, int expectedLevel)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var level = await service.CalculateLevelAsync(xp);
Assert.Equal(expectedLevel, level);
}
[Theory]
[InlineData(120, 100, 300)]
[InlineData(350, 300, null)]
public async Task GetLevelProgressAsync_ReturnsCurrentAndNextThresholds(int xp, int currentLevelXp, int? nextLevelXp)
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
var service = CreateService(db);
var progress = await service.GetLevelProgressAsync(xp);
Assert.Equal(currentLevelXp, progress.CurrentLevelXp);
Assert.Equal(nextLevelXp, progress.NextLevelXp);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
@@ -119,16 +152,16 @@ public class GamificationServiceTests
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Gamification:XpThresholds:0"] = "0",
["Gamification:XpThresholds:1"] = "100",
["Gamification:XpThresholds:2"] = "300"
})
.Build();
return new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
}
return new GamificationService(db, configuration, notifications, NullLogger<GamificationService>.Instance);
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
private static Achievement Achievement(int id, string name, string condition, int coinReward) => new()
@@ -156,7 +156,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
stub.GetByIdAsync(Arg.Any<int>()).Returns(userDto);
stub.UpdateProfileAsync(Arg.Any<int>(), Arg.Any<UpdateUserRequest>()).Returns(userDto);
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0));
stub.GetStatsAsync(Arg.Any<int>()).Returns(new UserStatsDto(0, 0, 0, 0, 0, 1, 0, 0, 100));
stub.GetAllAsync(Arg.Any<UserFilterRequest>()).Returns(pagedUsers);
stub.SetRolesAsync(Arg.Any<int>(), Arg.Any<IReadOnlyCollection<UserRole>>()).Returns(Task.CompletedTask);
stub.SetActiveAsync(Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
@@ -274,7 +274,8 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
stub.AwardCoinsAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>()).Returns(Task.CompletedTask);
stub.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
stub.CalculateLevel(Arg.Any<int>()).Returns(1);
stub.CalculateLevelAsync(Arg.Any<int>()).Returns(Task.FromResult(1));
stub.GetLevelProgressAsync(Arg.Any<int>()).Returns(Task.FromResult(new LevelProgressDto(0, 100)));
return stub;
}
@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
using UniVerse.Domain.Entities;
using UniVerse.Infrastructure.Data;
using UniVerse.Infrastructure.Services;
using Xunit;
namespace UniVerse.Api.Tests.Users;
public class UserServiceTests
{
[Fact]
public async Task GetStatsAsync_ReturnsLevelProgressThresholds()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 120 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(2, stats.Level);
Assert.Equal(100, stats.CurrentLevelXp);
Assert.Equal(300, stats.NextLevelXp);
}
[Fact]
public async Task GetStatsAsync_ReturnsNullNextLevelAtMaxConfiguredLevel()
{
await using var db = CreateDbContext();
SeedLevelThresholds(db);
db.Users.Add(new User { Id = 1, Email = "student@test.local", Xp = 350 });
await db.SaveChangesAsync();
var service = CreateService(db);
var stats = await service.GetStatsAsync(1);
Assert.Equal(3, stats.Level);
Assert.Equal(300, stats.CurrentLevelXp);
Assert.Null(stats.NextLevelXp);
}
private static AppDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase($"UserServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private static UserService CreateService(AppDbContext db)
{
var notifications = Substitute.For<INotificationService>();
notifications.CreateUserNotificationAsync(
Arg.Any<int>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>())
.Returns(callInfo => new UserNotificationDto(1, callInfo.ArgAt<string>(1), callInfo.ArgAt<string>(2), callInfo.ArgAt<string>(3), false, DateTime.UtcNow));
notifications.SendAsync(Arg.Any<NotificationMessage>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var gamification = new GamificationService(db, notifications, NullLogger<GamificationService>.Instance);
return new UserService(db, gamification);
}
private static void SeedLevelThresholds(AppDbContext db)
{
db.LevelThresholds.AddRange(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 });
db.SaveChanges();
}
}
-3
View File
@@ -22,9 +22,6 @@
"BaseUrl": "https://schedule.rdcenter.ru",
"ApiKey": ""
},
"Gamification": {
"XpThresholds": [0, 100, 300, 600, 1000, 1500, 2500, 4000]
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
+9
View File
@@ -5624,6 +5624,15 @@
"achievementsCount": {
"type": "integer",
"format": "int32"
},
"currentLevelXp": {
"type": "integer",
"format": "int32"
},
"nextLevelXp": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
@@ -11,3 +11,8 @@ public record CoinTransactionDto(
string? Description,
DateTime CreatedAt
);
public record LevelProgressDto(
int CurrentLevelXp,
int? NextLevelXp
);
@@ -22,7 +22,9 @@ public record UserStatsDto(
int Xp,
int Coins,
int Level,
int AchievementsCount
int AchievementsCount,
int CurrentLevelXp,
int? NextLevelXp
);
public record UpdateUserRequest(
@@ -10,7 +10,8 @@ public interface IGamificationService
Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
int? reviewId = null, int? achievementId = null, string? description = null);
Task CheckAndAwardAchievementsAsync(int userId);
int CalculateLevel(int xp);
Task<int> CalculateLevelAsync(int xp);
Task<LevelProgressDto> GetLevelProgressAsync(int xp);
Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId);
Task<PagedResult<CoinTransactionDto>> GetTransactionsAsync(int userId, PaginationRequest pagination);
}
@@ -0,0 +1,7 @@
namespace UniVerse.Domain.Entities;
public class LevelThreshold
{
public int Level { get; set; }
public int RequiredXp { get; set; }
}
@@ -23,6 +23,7 @@ public class AppDbContext : DbContext
public DbSet<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
public DbSet<LevelThreshold> LevelThresholds { get; set; } = null!;
public DbSet<UserNotification> UserNotifications { get; set; } = null!;
public DbSet<RefreshToken> RefreshTokens { get; set; } = null!;
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using UniVerse.Domain.Entities;
namespace UniVerse.Infrastructure.Data.Configurations;
public class LevelThresholdConfiguration : IEntityTypeConfiguration<LevelThreshold>
{
public void Configure(EntityTypeBuilder<LevelThreshold> builder)
{
builder.ToTable("level_thresholds", table =>
{
table.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
builder.HasKey(t => t.Level);
builder.Property(t => t.Level).HasColumnName("level").ValueGeneratedNever();
builder.Property(t => t.RequiredXp).HasColumnName("required_xp").IsRequired();
builder.HasIndex(t => t.RequiredXp).IsUnique();
builder.HasData(
new LevelThreshold { Level = 1, RequiredXp = 0 },
new LevelThreshold { Level = 2, RequiredXp = 100 },
new LevelThreshold { Level = 3, RequiredXp = 300 },
new LevelThreshold { Level = 4, RequiredXp = 600 },
new LevelThreshold { Level = 5, RequiredXp = 1000 },
new LevelThreshold { Level = 6, RequiredXp = 1500 },
new LevelThreshold { Level = 7, RequiredXp = 2500 },
new LevelThreshold { Level = 8, RequiredXp = 4000 }
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class LevelThresholds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "level_thresholds",
columns: table => new
{
level = table.Column<int>(type: "integer", nullable: false),
required_xp = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_level_thresholds", x => x.level);
table.CheckConstraint("CK_level_thresholds_level_positive", "level > 0");
table.CheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
migrationBuilder.InsertData(
table: "level_thresholds",
columns: new[] { "level", "required_xp" },
values: new object[,]
{
{ 1, 0 },
{ 2, 100 },
{ 3, 300 },
{ 4, 600 },
{ 5, 1000 },
{ 6, 1500 },
{ 7, 2500 },
{ 8, 4000 }
});
migrationBuilder.CreateIndex(
name: "IX_level_thresholds_required_xp",
table: "level_thresholds",
column: "required_xp",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "level_thresholds");
}
}
}
@@ -334,6 +334,71 @@ namespace UniVerse.Infrastructure.Migrations
b.ToTable("lecture_enrollments", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.LevelThreshold", b =>
{
b.Property<int>("Level")
.HasColumnType("integer")
.HasColumnName("level");
b.Property<int>("RequiredXp")
.HasColumnType("integer")
.HasColumnName("required_xp");
b.HasKey("Level");
b.HasIndex("RequiredXp")
.IsUnique();
b.ToTable("level_thresholds", null, t =>
{
t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0");
t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0");
});
b.HasData(
new
{
Level = 1,
RequiredXp = 0
},
new
{
Level = 2,
RequiredXp = 100
},
new
{
Level = 3,
RequiredXp = 300
},
new
{
Level = 4,
RequiredXp = 600
},
new
{
Level = 5,
RequiredXp = 1000
},
new
{
Level = 6,
RequiredXp = 1500
},
new
{
Level = 7,
RequiredXp = 2500
},
new
{
Level = 8,
RequiredXp = 4000
});
});
modelBuilder.Entity("UniVerse.Domain.Entities.Location", b =>
{
b.Property<int>("Id")
@@ -221,7 +221,7 @@ public class AuthService : IAuthService
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == userId)
?? throw new NotFoundException("User", userId);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
private async Task TrySendLoginNotificationAsync(User user, string? ipAddress)
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Globalization;
using UniVerse.Application.DTOs.Achievements;
@@ -17,17 +16,16 @@ namespace UniVerse.Infrastructure.Services;
public class GamificationService : IGamificationService
{
private readonly AppDbContext _db;
private readonly IConfiguration _config;
private readonly INotificationService _notifications;
private readonly ILogger<GamificationService> _logger;
private List<LevelThreshold>? _levelThresholds;
public GamificationService(
AppDbContext db,
IConfiguration config,
INotificationService notifications,
ILogger<GamificationService> logger)
{
_db = db; _config = config; _notifications = notifications; _logger = logger;
_db = db; _notifications = notifications; _logger = logger;
}
public async Task AwardCoinsAsync(int userId, int amount, CoinTransactionType type,
@@ -83,7 +81,7 @@ public class GamificationService : IGamificationService
"attendance_streak_weeks" => attendanceStreakWeeks >= value,
"attended_registered" => attended >= value,
"coins_earned" => earnedCoins >= value,
"level_reached" => CalculateLevel(user.Xp) >= value,
"level_reached" => await CalculateLevelAsync(user.Xp) >= value,
"profile_completed" => profileCompleted && value <= 1,
"first_activity" => firstActivity && value <= 1,
_ => false
@@ -184,13 +182,47 @@ public class GamificationService : IGamificationService
return DateOnly.FromDateTime(ISOWeek.ToDateTime(isoYear, isoWeek, DayOfWeek.Monday));
}
public int CalculateLevel(int xp)
public async Task<int> CalculateLevelAsync(int xp)
{
var thresholds = _config.GetSection("Gamification:XpThresholds").Get<int[]>()
?? [0, 100, 300, 600, 1000, 1500, 2500, 4000];
for (int i = thresholds.Length - 1; i >= 0; i--)
if (xp >= thresholds[i]) return i + 1;
return 1;
var thresholds = await GetLevelThresholdsAsync();
return thresholds
.Where(t => xp >= t.RequiredXp)
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.LastOrDefault()?.Level ?? thresholds[0].Level;
}
public async Task<LevelProgressDto> GetLevelProgressAsync(int xp)
{
var thresholds = await GetLevelThresholdsAsync();
var current = thresholds
.Where(t => xp >= t.RequiredXp)
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.LastOrDefault() ?? thresholds[0];
var next = thresholds
.Where(t => t.RequiredXp > current.RequiredXp)
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.FirstOrDefault();
return new LevelProgressDto(current.RequiredXp, next?.RequiredXp);
}
private async Task<List<LevelThreshold>> GetLevelThresholdsAsync()
{
if (_levelThresholds is { Count: > 0 }) return _levelThresholds;
_levelThresholds = await _db.LevelThresholds
.AsNoTracking()
.OrderBy(t => t.RequiredXp)
.ThenBy(t => t.Level)
.ToListAsync();
if (_levelThresholds.Count == 0)
_levelThresholds.Add(new LevelThreshold { Level = 1, RequiredXp = 0 });
return _levelThresholds;
}
public async Task<List<UserAchievementDto>> GetUserAchievementsAsync(int userId) =>
@@ -26,7 +26,7 @@ public class UserService : IUserService
.Include(u => u.Roles)
.FirstOrDefaultAsync(u => u.Id == id)
?? throw new NotFoundException("User", id);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
public async Task<UserDto> UpdateProfileAsync(int id, UpdateUserRequest request)
@@ -42,7 +42,7 @@ public class UserService : IUserService
await _db.SaveChangesAsync();
await _gamification.CheckAndAwardAchievementsAsync(id);
return user.ToDto(_gamification.CalculateLevel(user.Xp));
return user.ToDto(await _gamification.CalculateLevelAsync(user.Xp));
}
public async Task<UserStatsDto> GetStatsAsync(int id)
@@ -55,9 +55,13 @@ public class UserService : IUserService
var reviews = await _db.Reviews.CountAsync(r => r.UserId == id);
var achievements = await _db.UserAchievements.CountAsync(ua => ua.UserId == id);
var level = await _gamification.CalculateLevelAsync(user.Xp);
var levelProgress = await _gamification.GetLevelProgressAsync(user.Xp);
return new UserStatsDto(
totalLectures, attended, reviews,
user.Xp, user.Coins, _gamification.CalculateLevel(user.Xp), achievements
user.Xp, user.Coins, level, achievements,
levelProgress.CurrentLevelXp, levelProgress.NextLevelXp
);
}
@@ -94,7 +98,10 @@ public class UserService : IUserService
.Take(filter.PageSize)
.ToListAsync();
var items = users.Select(u => u.ToDto(_gamification.CalculateLevel(u.Xp))).ToList();
var items = new List<UserDto>(users.Count);
foreach (var user in users)
items.Add(user.ToDto(await _gamification.CalculateLevelAsync(user.Xp)));
return PagedResult<UserDto>.Create(items, total, filter.Page, filter.PageSize);
}
-2
View File
@@ -41,8 +41,6 @@ services:
- Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local}
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
- Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]}
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE:-universe};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
networks:
- backend
-3
View File
@@ -42,9 +42,6 @@ services:
- Email:Smtp:Password=${EMAIL_SMTP_PASSWORD}
- Email:Smtp:FromAddress=${EMAIL_SMTP_FROM_ADDRESS:-no-reply@universe.local}
- Email:Smtp:FromName=${EMAIL_SMTP_FROM_NAME:-UniVerse}
- Gamification:XpThresholds=${GAMIFICATION_XP_THRESHOLDS:-[0, 100, 300, 600, 1000, 1500, 2500, 4000]}
- ConnectionStrings:DefaultConnection=Host=db;Port=5432;Database=${POSTGRES_DATABASE};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}
db:
+2
View File
@@ -45,6 +45,8 @@ export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): U
coins: stats?.coins ?? ('coins' in user ? user.coins : 0),
level: stats?.level ?? ('level' in user ? user.level : 1),
xp: stats?.xp ?? ('xp' in user ? user.xp : 0),
currentLevelXp: stats?.currentLevelXp ?? 0,
nextLevelXp: stats?.nextLevelXp,
lecturesAttended: stats?.attendedLectures ?? 0,
hoursLearned: stats ? Math.round(stats.attendedLectures * 1.5 * 10) / 10 : 0,
achievements: stats ? Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)) : [],
+2
View File
@@ -60,6 +60,8 @@ export interface UserStatsDto {
coins: number
level: number
achievementsCount: number
currentLevelXp: number
nextLevelXp?: number | null
}
export interface LectureDto {
+3 -2
View File
@@ -4,6 +4,7 @@ defineProps<{
max?: number
label?: string
color?: string
text?: string
}>()
</script>
@@ -14,12 +15,12 @@ defineProps<{
<div
class="progress-fill"
:style="{
width: `${Math.min(100, (value / (max ?? 100)) * 100)}%`,
width: `${Math.max(0, Math.min(100, (value / (max && max > 0 ? max : 100)) * 100))}%`,
background: color ?? 'var(--gradient-progress-success)'
}"
/>
</div>
<div class="progress-text">{{ value }} / {{ max ?? 100 }}</div>
<div class="progress-text">{{ text ?? `${value} / ${max ?? 100}` }}</div>
</div>
</template>
+2
View File
@@ -36,6 +36,8 @@ export const useUserStore = defineStore('user', () => {
coins: stats.coins,
level: stats.level,
xp: stats.xp,
currentLevelXp: stats.currentLevelXp,
nextLevelXp: stats.nextLevelXp,
lecturesAttended: stats.attendedLectures,
hoursLearned: Math.round(stats.attendedLectures * 1.5 * 10) / 10,
achievements: Array.from({ length: stats.achievementsCount }, (_, index) => String(index + 1)),
+2
View File
@@ -14,6 +14,8 @@ export interface User {
coins: number
level: number
xp?: number
currentLevelXp?: number
nextLevelXp?: number | null
lecturesAttended?: number
hoursLearned?: number
achievements?: string[]
+22 -5
View File
@@ -33,8 +33,25 @@ const recommended = computed(() =>
)
const achievements = computed(() => userStore.achievements.filter(a => a.unlocked).slice(0, 3))
const reminders = computed(() => userStore.notifications.slice(0, 3))
const xpToNext = 200
const xpProgress = computed(() => user.value.xp ?? 120)
const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value)
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1)
const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
return Math.min(Math.max(userXp.value - currentLevelXp.value, 0), levelProgressMax.value)
})
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Прогресс до уровня ${user.value.level + 1}` : 'Максимальный уровень'
)
const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
)
onMounted(async () => {
await Promise.all([
@@ -99,10 +116,10 @@ onMounted(async () => {
<GlassCard>
<div class="xp-section">
<div class="xp-header">
<span class="xp-label">Прогресс до уровня {{ user.level + 1 }}</span>
<span class="xp-val">{{ xpProgress }} / {{ xpToNext }} XP</span>
<span class="xp-label">{{ levelProgressLabel }}</span>
<span class="xp-val">{{ levelProgressText }}</span>
</div>
<ProgressBar :value="xpProgress" :max="xpToNext" />
<ProgressBar :value="levelProgress" :max="levelProgressMax" :text="levelProgressText" />
</div>
</GlassCard>
+22 -3
View File
@@ -24,6 +24,25 @@ const userYearLine = computed(() => {
const year = user.value.year ?? 0
return Number.isFinite(year) && year > 0 ? `${year} курс` : ''
})
const currentLevelXp = computed(() => user.value.currentLevelXp ?? 0)
const nextLevelXp = computed(() => user.value.nextLevelXp)
const userXp = computed(() => user.value.xp ?? 0)
const hasLevelProgress = computed(() => nextLevelXp.value !== undefined)
const hasNextLevel = computed(() => typeof nextLevelXp.value === 'number' && nextLevelXp.value > currentLevelXp.value)
const levelProgressMax = computed(() => hasNextLevel.value ? nextLevelXp.value! - currentLevelXp.value : 1)
const levelProgress = computed(() => {
if (!hasLevelProgress.value) return 0
if (!hasNextLevel.value) return 1
return Math.min(Math.max(userXp.value - currentLevelXp.value, 0), levelProgressMax.value)
})
const levelProgressLabel = computed(() =>
!hasLevelProgress.value
? `Уровень ${user.value.level}`
: hasNextLevel.value ? `Уровень ${user.value.level}` : `Уровень ${user.value.level} · максимум`
)
const levelProgressText = computed(() =>
hasNextLevel.value ? `${levelProgress.value} / ${levelProgressMax.value} XP` : `${userXp.value} XP`
)
const interestTags = ref([
{ label: '#ML', active: true },
{ label: '#ИИ', active: true },
@@ -66,10 +85,10 @@ onMounted(() => {
</div>
<div class="level">
<div class="level-header">
<span>Уровень {{ user.level }}</span>
<span>{{ user.xp }} / 200 XP</span>
<span>{{ levelProgressLabel }}</span>
<span>{{ levelProgressText }}</span>
</div>
<ProgressBar :value="user.xp ?? 0" :max="200" />
<ProgressBar :value="levelProgress" :max="levelProgressMax" :text="levelProgressText" />
</div>
<div class="tags">
<div class="section-title">Интересы</div>