feat: enhance profile heatmap and caching

This commit is contained in:
2026-05-22 00:55:23 +08:00
parent f35baf4a67
commit 7cdf07447c
3 changed files with 1106 additions and 3 deletions
+589 -3
View File
@@ -1,9 +1,595 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NEmpty, NProgress, NSkeleton, NTag } from 'naive-ui'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database'
interface TimelineGroup {
key: string
label: string
items: ProfileCloudItem[]
}
const route = useRoute()
const authStore = useAuthStore()
const encyclopediaStore = useEncyclopediaStore()
const profileStore = useProfileStore()
const selectedCloud = ref<ProfileCloudItem | null>(null)
const selectedUploadDate = ref<string | null>(null)
const timelineSectionRef = ref<HTMLElement | null>(null)
const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
const statusMeta = {
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
pending: { label: '待审核', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
const viewedUserId = computed(() => {
const routeUserId = typeof route.params.id === 'string' ? route.params.id : null
return routeUserId || authStore.user?.id || null
})
const isOwnProfile = computed(() => {
return !!viewedUserId.value && viewedUserId.value === authStore.user?.id
})
const loading = computed(() => profileStore.isLoading(viewedUserId.value, isOwnProfile.value))
const errorMsg = computed(() => {
if (!viewedUserId.value) return '未找到要查看的用户。'
return profileStore.getError(viewedUserId.value, isOwnProfile.value)
})
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
})
const profileSubtitle = computed(() => {
if (isOwnProfile.value) {
return '回看你的云图记录、收集进度和拍摄节奏。'
}
return '公开展示的云图记录会按时间顺序陈列在这里。'
})
const totalShots = computed(() => clouds.value.length)
const approvedShots = computed(() => {
return clouds.value.filter(item => item.status === 'approved').length
})
const pendingShots = computed(() => {
return clouds.value.filter(item => item.status === 'pending').length
})
const shootingDays = computed(() => {
return new Set(
clouds.value.map(item => toDayKey(item.captured_at || item.created_at)),
).size
})
const uploadDayCounts = computed(() => {
const counts = new Map<string, number>()
for (const item of clouds.value) {
const key = toDayKey(item.created_at)
counts.set(key, (counts.get(key) || 0) + 1)
}
return counts
})
const mostCommonCloudType = computed(() => {
const counts = new Map<string, number>()
for (const item of clouds.value) {
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
}
let winner = '还没有记录'
let maxCount = 0
for (const [name, count] of counts.entries()) {
if (count > maxCount) {
winner = name
maxCount = count
}
}
return {
name: winner,
count: maxCount,
}
})
const filteredClouds = computed(() => {
if (!selectedUploadDate.value) return clouds.value
return clouds.value.filter(item => toDayKey(item.created_at) === selectedUploadDate.value)
})
const timelineGroups = computed<TimelineGroup[]>(() => {
const groups = new Map<string, TimelineGroup>()
for (const item of filteredClouds.value) {
const date = new Date(item.captured_at || item.created_at)
const key = `${date.getFullYear()}-${date.getMonth() + 1}`
const existing = groups.get(key)
if (existing) {
existing.items.push(item)
continue
}
groups.set(key, {
key,
label: `${date.getFullYear()}${date.getMonth() + 1}`,
items: [item],
})
}
return Array.from(groups.values())
})
const selectedUploadCount = computed(() => {
if (!selectedUploadDate.value) return 0
return uploadDayCounts.value.get(selectedUploadDate.value) || 0
})
const contributionCells = computed(() => {
return Array.from(uploadDayCounts.value.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date))
})
const activeUploadDays = computed(() => {
return contributionCells.value.filter(cell => cell.count > 0).length
})
const currentUploadStreak = computed(() => {
let streak = 0
const cursor = startOfDay(new Date())
while (true) {
const key = toLocalDayKey(cursor)
if ((uploadDayCounts.value.get(key) || 0) <= 0) break
streak += 1
cursor.setDate(cursor.getDate() - 1)
}
return streak
})
const longestUploadStreak = computed(() => {
let best = 0
let current = 0
let previousDate: Date | null = null
for (const cell of contributionCells.value) {
if (cell.count <= 0) continue
const currentDate = startOfDay(new Date(cell.date))
if (!previousDate) {
current = 1
} else {
const diffDays = Math.round((currentDate.getTime() - previousDate.getTime()) / 86400000)
current = diffDays === 1 ? current + 1 : 1
}
if (current > best) best = current
previousDate = currentDate
}
return best
})
function toDayKey(iso: string) {
const date = new Date(iso)
return toLocalDayKey(date)
}
function toLocalDayKey(date: Date) {
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function formatDate(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatDayKey(dayKey: string | null) {
if (!dayKey) return ''
const [year, month, day] = dayKey.split('-').map(Number)
return new Date(year, (month || 1) - 1, day || 1).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatDateTime(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatCloudTime(item: ProfileCloudItem) {
return formatDateTime(item.captured_at || item.created_at)
}
function getProfileInitial(profile: Profile | null) {
return profile?.username?.slice(0, 1).toUpperCase() || '云'
}
function toggleSelectedUploadDate(date: string) {
selectedUploadDate.value = selectedUploadDate.value === date ? null : date
}
function clearSelectedUploadDate() {
selectedUploadDate.value = null
}
async function loadProfilePage(force = false) {
selectedCloud.value = null
selectedUploadDate.value = null
const targetUserId = viewedUserId.value
if (!targetUserId) {
return
}
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
if (isOwnProfile.value) {
await encyclopediaStore.fetchCloudTypes(force)
await encyclopediaStore.fetchMyCollection(force)
}
}
watch(
() => [route.params.id, authStore.user?.id],
() => {
loadProfilePage()
},
{ immediate: true },
)
watch(selectedUploadDate, async newValue => {
if (!newValue) return
await nextTick()
timelineSectionRef.value?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
})
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold text-gray-900 mb-4">个人主页</h1>
<p class="text-gray-500">个人主页开发中...</p>
<div class="relative">
<div class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
<div class="max-w-6xl mx-auto px-4 py-10">
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
<NCard>
<NSkeleton class="h-6 w-1/3" />
<NSkeleton class="mt-4 h-10 w-2/3" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
</NCard>
<NCard>
<NSkeleton class="h-5 w-1/3" />
<NSkeleton class="mt-4 h-8 w-1/2" />
<NSkeleton class="mt-5 h-3 w-full" />
</NCard>
</div>
<div v-else class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-start">
<div class="flex gap-5">
<div
class="flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-3xl font-bold text-slate-900 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
>
<img
v-if="profileData?.avatar_url"
:src="profileData.avatar_url"
:alt="profileData.username"
class="h-full w-full object-cover"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<div class="min-w-0">
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Sky Journal</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">
{{ pageTitle }}
</h1>
<p class="mt-3 text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }}
</p>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
{{ profileSubtitle }}
</p>
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
<NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag>
</div>
</div>
</div>
<NCard class="border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<template v-if="isOwnProfile">
<p class="text-sm text-slate-500">图鉴进度</p>
<div class="mt-3 flex items-end justify-between gap-4">
<div>
<p class="text-3xl font-bold text-slate-900">{{ encyclopediaStore.unlockProgress }}</p>
<p class="mt-2 text-sm text-slate-500">已解锁 {{ encyclopediaStore.unlockedCount }} 种基础云型</p>
</div>
<div class="border border-slate-900 bg-slate-900 px-4 py-3 text-xl font-semibold text-white">
{{ encyclopediaStore.unlockPercent }}%
</div>
</div>
<NProgress
class="mt-5"
type="line"
:show-indicator="false"
:percentage="encyclopediaStore.unlockPercent"
color="{stops:['#0ea5e9','#f59e0b']}"
:height="12"
/>
</template>
<template v-else>
<p class="text-sm text-slate-500">公开贡献</p>
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
</template>
</NCard>
</div>
</div>
</div>
<div class="max-w-6xl mx-auto px-4 py-8">
<NAlert
v-if="errorMsg"
type="error"
:show-icon="false"
:bordered="false"
title="个人主页加载失败"
>
{{ errorMsg }}
</NAlert>
<template v-else>
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard v-for="n in 4" :key="n">
<NSkeleton class="h-4 w-1/3" />
<NSkeleton class="mt-4 h-8 w-2/3" />
</NCard>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">总拍摄数</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
</NCard>
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">最常拍云型</p>
<p class="mt-2 text-2xl font-bold text-slate-900">{{ mostCommonCloudType.name }}</p>
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
{{ mostCommonCloudType.count }}
</p>
</NCard>
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">拍摄天数</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
</NCard>
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
<p class="mt-2 text-2xl font-bold text-slate-900">
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
<template v-else>{{ approvedShots }}</template>
</p>
</NCard>
</div>
<section ref="timelineSectionRef" class="mt-8">
<ContributionHeatmap
:cells="contributionCells"
:total-count="totalShots"
:active-days="activeUploadDays"
:current-streak="currentUploadStreak"
:best-streak="longestUploadStreak"
:active-date="selectedUploadDate"
:subtitle="isOwnProfile ? '按上传日期统计' : '按公开上传日期统计'"
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
@select-date="toggleSelectedUploadDate"
/>
</section>
<section class="mt-8">
<div class="mb-5 flex items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
<p class="mt-1 text-sm text-slate-500">
按拍摄时间倒序展示并按月份分组热力图统计按上传日期计算
</p>
</div>
<div class="flex items-center gap-3">
<NButton secondary strong @click="loadProfilePage(true)">
刷新数据
</NButton>
<NButton
v-if="selectedUploadDate"
secondary
strong
@click="clearSelectedUploadDate"
>
清除筛选
</NButton>
<RouterLink v-if="isOwnProfile" to="/upload">
<NButton secondary strong type="primary">继续上传</NButton>
</RouterLink>
</div>
</div>
<div
v-if="selectedUploadDate"
class="mb-5 border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800"
>
当前正在查看 <span class="font-semibold">{{ formatDayKey(selectedUploadDate) }}</span> 上传的图片
<span class="font-semibold">{{ selectedUploadCount }}</span>
</div>
<div v-if="loading" class="space-y-8">
<div v-for="n in 2" :key="n">
<NSkeleton class="h-6 w-40" />
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard v-for="m in 3" :key="m">
<NSkeleton class="h-52 w-full" />
<NSkeleton class="mt-4 h-5 w-2/3" />
<NSkeleton class="mt-3 h-4 w-1/2" />
</NCard>
</div>
</div>
</div>
<div v-else-if="timelineGroups.length" class="space-y-10">
<section v-for="group in timelineGroups" :key="group.key">
<div class="flex items-center gap-4">
<h3 class="text-xl font-bold text-slate-900">{{ group.label }}</h3>
<div class="h-px flex-1 bg-slate-200"></div>
</div>
<div class="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in group.items"
:key="item.id"
type="button"
@click="selectedCloud = item"
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'border-sky-500 shadow-sky-100' : 'border-slate-200'"
>
<div class="relative">
<img
:src="item.thumbnail_url || item.image_url"
:alt="item.cloudTypeName"
class="h-56 w-full object-cover transition duration-500 group-hover:scale-[1.03]"
/>
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/82 via-slate-950/32 to-transparent"></div>
<div class="absolute left-4 top-4 flex flex-wrap gap-2">
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[item.cloudTypeRarity].chip">
{{ rarityMeta[item.cloudTypeRarity].label }}
</span>
<span
v-if="isOwnProfile"
class="inline-flex border px-3 py-1 text-xs font-medium"
:class="statusMeta[item.status].chip"
>
{{ statusMeta[item.status].label }}
</span>
</div>
<div class="absolute bottom-4 left-4 right-4 text-white">
<p class="truncate text-lg font-semibold">{{ item.cloudTypeName }}</p>
<p class="mt-1 truncate text-xs text-white/80">{{ formatCloudTime(item) }}</p>
</div>
</div>
<div class="p-5">
<p class="truncate text-sm font-medium text-slate-900">
{{ item.location_name || '未填写位置名称' }}
</p>
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
{{ item.description || '没有留下额外说明。' }}
</p>
</div>
</button>
</div>
</section>
</div>
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
<template #extra>
<RouterLink v-if="isOwnProfile && !selectedUploadDate" to="/upload">
<NButton secondary strong type="primary">去上传第一张</NButton>
</RouterLink>
<NButton v-else-if="selectedUploadDate" secondary strong @click="clearSelectedUploadDate">返回全部记录</NButton>
</template>
</NEmpty>
</div>
</section>
</template>
</div>
<ImageDetailModal
v-if="selectedCloud"
:open="!!selectedCloud"
:image-url="selectedCloud.image_url"
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="`拍摄时间:${formatCloudTime(selectedCloud)}`"
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
@close="selectedCloud = null"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedCloud.created_at) }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedCloud.captured_at) }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
</div>
<div class="border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">{{ isOwnProfile ? '审核状态' : '可见性' }}</p>
<p class="mt-2 text-sm font-medium text-slate-900">
<template v-if="isOwnProfile">{{ statusMeta[selectedCloud.status].label }}</template>
<template v-else>公开可见</template>
</p>
</div>
</div>
<div class="mt-5 border border-slate-200 bg-white p-5">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
</ImageDetailModal>
</div>
</template>