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