From 1a7fd11eeecfb2985db8fa3ab007fb20bfe1d7cd Mon Sep 17 00:00:00 2001 From: Mplan Date: Tue, 28 Jul 2026 23:23:19 +0800 Subject: [PATCH 1/9] fix(frontend): adapt to updated profile APIs --- .../profile/ContributionHeatmap.vue | 88 +++++-- src/stores/auth.ts | 21 +- src/stores/profile.ts | 22 +- src/styles/tokens.css | 12 + src/types/api.ts | 28 ++- src/views/admin/AdminView.vue | 6 +- src/views/profile/ProfileSettingsView.vue | 20 +- src/views/profile/ProfileView.vue | 225 ++++++------------ 8 files changed, 237 insertions(+), 185 deletions(-) diff --git a/src/components/profile/ContributionHeatmap.vue b/src/components/profile/ContributionHeatmap.vue index d161f28..048ca80 100644 --- a/src/components/profile/ContributionHeatmap.vue +++ b/src/components/profile/ContributionHeatmap.vue @@ -22,15 +22,11 @@ const props = withDefaults(defineProps<{ cells: ContributionHeatmapCell[] totalCount: number activeDays: number - currentStreak: number - bestStreak: number activeDate?: string | null title?: string - subtitle?: string }>(), { activeDate: null, title: '上传活跃度', - subtitle: '按上传日期统计', }) const emit = defineEmits<{ @@ -154,31 +150,38 @@ const visibleCells = computed(() => { return cells }) -const maxCount = computed(() => { - return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0) +const contributionQuartiles = computed(() => { + const counts = visibleCells.value + .filter(cell => cell.inRange && cell.count > 0) + .map(cell => cell.count) + .sort((a, b) => a - b) + + if (!counts.length) return [0, 0, 0] as const + + const percentile = (value: number) => { + const index = Math.ceil(counts.length * value) - 1 + return counts[Math.max(0, index)] || 0 + } + + return [percentile(0.25), percentile(0.5), percentile(0.75)] as const }) function getLevel(count: number) { if (count <= 0) return 0 - const max = Math.max(maxCount.value, 1) - const ratio = count / max + const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value - if (ratio >= 0.75 || count >= 8) return 4 - if (ratio >= 0.5 || count >= 5) return 3 - if (ratio >= 0.25 || count >= 3) return 2 - return 1 + if (count <= firstQuartile) return 1 + if (count <= secondQuartile) return 2 + if (count <= thirdQuartile) return 3 + return 4 } function getCellClass(cell: { count: number; inRange: boolean }) { if (!cell.inRange) return 'bg-transparent border-transparent' const level = getLevel(cell.count) - if (level === 0) return 'bg-zinc-100 border-zinc-200' - if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]' - if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]' - if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]' - return 'bg-[#0f3d63] border-[#0b2f4d]' + return `oc-heatmap-cell--level-${level}` } const weeks = computed(() => { @@ -396,12 +399,53 @@ function formatMonthTooltip(monthKey: string) {
少 - - - - - + + + + + 多
+ + diff --git a/src/stores/auth.ts b/src/stores/auth.ts index b30b4ab..ef9547a 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -23,7 +23,14 @@ export const useAuthStore = defineStore('auth', () => { function applyAuth(auth: AuthResponse) { setAccessToken(auth.access_token) user.value = auth.user - profile.value = auth.profile + profile.value = { + id: auth.user.id, + username: auth.user.username, + avatar_url: auth.user.avatar_url, + role: auth.user.role, + is_disabled: auth.user.is_disabled, + created_at: auth.user.created_at, + } } function clearAuth() { @@ -58,11 +65,19 @@ export const useAuthStore = defineStore('auth', () => { } async function updateUsername(username: string) { - const updatedProfile = await apiRequest('/profiles/me', { + const updatedUser = await apiRequest('/profiles/me', { method: 'PATCH', body: { username }, }) - profile.value = updatedProfile + user.value = updatedUser + profile.value = { + id: updatedUser.id, + username: updatedUser.username, + avatar_url: updatedUser.avatar_url, + role: updatedUser.role, + is_disabled: updatedUser.is_disabled, + created_at: updatedUser.created_at, + } } async function updatePassword(password: string) { diff --git a/src/stores/profile.ts b/src/stores/profile.ts index 4162d09..f6e3646 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { defineStore } from 'pinia' import { apiRequest } from '@/lib/api' import { useAuthStore } from '@/stores/auth' -import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api' +import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api' import type { CloudType, Profile } from '@/types/database' export interface ProfileCloudItem { @@ -21,6 +21,8 @@ export interface ProfileCloudItem { is_hidden: boolean cloudTypeName: string cloudTypeRarity: CloudType['rarity'] + favoriteCount: number + isFavorited: boolean } function toProfileCloud(row: ApiCloud): ProfileCloudItem { @@ -40,6 +42,8 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem { is_hidden: row.is_hidden, cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知', cloudTypeRarity: row.cloud_type_rarity || 'common', + favoriteCount: row.favorite_count, + isFavorited: row.is_favorited, } } @@ -55,6 +59,7 @@ export const useProfileStore = defineStore('profile-page', () => { const authStore = useAuthStore() const profilesById = ref>({}) const cloudsByKey = ref>({}) + const likeStatsByUserId = ref>({}) const loadingKeys = ref>({}) const errorByKey = ref>({}) @@ -68,6 +73,10 @@ export const useProfileStore = defineStore('profile-page', () => { return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : [] } + function getLikeStats(userId: string | null) { + return userId ? likeStatsByUserId.value[userId] ?? null : null + } + function getError(userId: string | null, isOwnProfile: boolean) { return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : '' } @@ -99,9 +108,17 @@ export const useProfileStore = defineStore('profile-page', () => { const profilePromise = isOwnProfile && authStore.profile?.id === userId ? Promise.resolve(authStore.profile) : apiRequest(`/profiles/${userId}`, { auth: false }).then(toProfile) - const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)]) + const likeStatsPromise = isOwnProfile + ? apiRequest('/profiles/me/like-stats') + : Promise.resolve(null) + const [profile, cloudRows, likeStats] = await Promise.all([ + profilePromise, + fetchAllClouds(userId, isOwnProfile), + likeStatsPromise, + ]) profilesById.value[userId] = profile cloudsByKey.value[key] = cloudRows.map(toProfileCloud) + if (likeStats) likeStatsByUserId.value[userId] = likeStats } catch (error) { errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败' cloudsByKey.value[key] = [] @@ -186,6 +203,7 @@ export const useProfileStore = defineStore('profile-page', () => { return { getProfile, getClouds, + getLikeStats, getError, isLoaded, isLoading, diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 86a600d..06498d2 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -53,6 +53,18 @@ --oc-color-danger: #dc2626; --oc-color-selection: rgb(52 116 93 / 20%); + /* Contribution heatmap: no activity + GitHub-style contribution quartiles. */ + --oc-heatmap-empty: #f1f5f9; + --oc-heatmap-empty-border: #d9e3ee; + --oc-heatmap-level-1: #ddf2e6; + --oc-heatmap-level-1-border: #bce4cf; + --oc-heatmap-level-2: #8fceb0; + --oc-heatmap-level-2-border: #60b18d; + --oc-heatmap-level-3: #438f71; + --oc-heatmap-level-3-border: #34745d; + --oc-heatmap-level-4: #274c40; + --oc-heatmap-level-4-border: #213e35; + --oc-gradient-canvas: radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%), linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%); diff --git a/src/types/api.ts b/src/types/api.ts index f0532fb..377a390 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -4,6 +4,13 @@ export interface AuthUser { id: string email: string email_verified: boolean + username: string + avatar_url: string | null + role: Profile['role'] + is_disabled: boolean + cloud_count: number + public_cloud_count: number + collection_count: number created_at: string } @@ -12,12 +19,10 @@ export interface AuthResponse { token_type: 'bearer' expires_in: number user: AuthUser - profile: Profile } export interface MeResponse { user: AuthUser - profile: Profile } export interface PublicProfile { @@ -54,6 +59,24 @@ export interface ApiCloud extends Cloud { cloud_type_name: string | null cloud_type_rarity: CloudType['rarity'] | null username: string | null + favorite_count: number + is_favorited: boolean +} + +export interface LikeStatsPhoto { + cloud_id: string + image_url: string + thumbnail_url: string + favorite_count: number + captured_at: string | null + created_at: string +} + +export interface LikeStats { + user_id: string + username: string + total_likes: number + photos: LikeStatsPhoto[] } export interface ApiCollection extends UserCollection { @@ -89,5 +112,4 @@ export interface AdminStats { export interface AdminUserItem { user: AuthUser - profile: Profile } diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue index faf8501..cc6175a 100644 --- a/src/views/admin/AdminView.vue +++ b/src/views/admin/AdminView.vue @@ -234,7 +234,7 @@ async function fetchStats() { async function fetchUsers() { const result = await apiRequest>('/admin/users?page=1&page_size=100') - users.value = result.items.map(item => item.profile) + users.value = result.items.map(item => item.user) } async function fetchImages() { @@ -435,7 +435,7 @@ async function updateUserRole(user: Profile, role: Profile['role']) { method: 'PATCH', body: { role }, }) - users.value = users.value.map(item => item.id === user.id ? result.profile : item) + users.value = users.value.map(item => item.id === user.id ? result.user : item) message.success('用户角色已更新') } catch (error) { const text = getErrorMessage(error, '用户角色更新失败') @@ -461,7 +461,7 @@ async function toggleUserDisabled(user: Profile) { method: 'PATCH', body: { is_disabled: nextDisabled }, }) - users.value = users.value.map(item => item.id === user.id ? result.profile : item) + users.value = users.value.map(item => item.id === user.id ? result.user : item) message.success(nextDisabled ? '用户已禁用' : '用户已恢复') } catch (error) { const text = getErrorMessage(error, '用户状态更新失败') diff --git a/src/views/profile/ProfileSettingsView.vue b/src/views/profile/ProfileSettingsView.vue index ebf3702..e4280f3 100644 --- a/src/views/profile/ProfileSettingsView.vue +++ b/src/views/profile/ProfileSettingsView.vue @@ -1,7 +1,7 @@ - - diff --git a/src/styles/components.css b/src/styles/components.css index 90d9dc4..3e08ef2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -40,6 +40,21 @@ @apply py-10; } + .oc-page-hero__split { + @apply grid gap-6; + } + + @media (min-width: 900px) { + .oc-page-hero__split { + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr); + align-items: start; + } + + .oc-page-hero__split--stretch { + align-items: stretch; + } + } + .oc-page-content { @apply py-8; } diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue index a9cf892..632d2ed 100644 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ b/src/views/encyclopedia/EncyclopediaView.vue @@ -1,6 +1,6 @@