feat: enhance profile heatmap and caching
This commit is contained in:
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NButton, NButtonGroup, NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
export interface ContributionHeatmapCell {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthStat {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
totalCount: number
|
||||||
|
activeDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const currentYear = today.getFullYear()
|
||||||
|
const currentMonth = today.getMonth() + 1
|
||||||
|
|
||||||
|
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<{
|
||||||
|
selectDate: [date: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const viewMode = ref<'year' | 'month'>('year')
|
||||||
|
const selectedYear = ref(currentYear)
|
||||||
|
const selectedMonth = ref(currentMonth)
|
||||||
|
const hoveredMonthKey = ref<string | null>(null)
|
||||||
|
|
||||||
|
function parseLocalDate(dateString: string) {
|
||||||
|
const [year, month, day] = dateString.split('-').map(Number)
|
||||||
|
return new Date(year, (month || 1) - 1, day || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayKey(date: Date) {
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMonthKey(dateString: string) {
|
||||||
|
const date = parseLocalDate(dateString)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeek(date: Date) {
|
||||||
|
const result = startOfDay(date)
|
||||||
|
const weekday = result.getDay()
|
||||||
|
const offset = weekday === 0 ? 6 : weekday - 1
|
||||||
|
result.setDate(result.getDate() - offset)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyCounts = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const cell of props.cells) {
|
||||||
|
counts.set(cell.date, cell.count)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years = new Set<number>([currentYear])
|
||||||
|
for (const cell of props.cells) {
|
||||||
|
years.add(parseLocalDate(cell.date).getFullYear())
|
||||||
|
}
|
||||||
|
return Array.from(years).sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearOptions = computed(() => {
|
||||||
|
return availableYears.value.map(year => ({
|
||||||
|
label: `${year}年`,
|
||||||
|
value: year,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
label: `${index + 1}月`,
|
||||||
|
value: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
watch(availableYears, years => {
|
||||||
|
if (!years.includes(selectedYear.value)) {
|
||||||
|
selectedYear.value = years[0] || currentYear
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const visibleCells = computed(() => {
|
||||||
|
if (viewMode.value === 'year') {
|
||||||
|
const start = new Date(selectedYear.value, 0, 1)
|
||||||
|
const end = new Date(selectedYear.value, 11, 31)
|
||||||
|
const alignedStart = startOfWeek(start)
|
||||||
|
const alignedEnd = startOfWeek(end)
|
||||||
|
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
||||||
|
const cursor = new Date(alignedStart)
|
||||||
|
|
||||||
|
while (cursor <= alignedEnd) {
|
||||||
|
const key = formatDayKey(cursor)
|
||||||
|
const inRange = cursor >= start && cursor <= end
|
||||||
|
cells.push({
|
||||||
|
date: key,
|
||||||
|
count: dailyCounts.value.get(key) || 0,
|
||||||
|
inRange,
|
||||||
|
isToday: key === formatDayKey(today),
|
||||||
|
})
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(selectedYear.value, selectedMonth.value - 1, 1)
|
||||||
|
const end = new Date(selectedYear.value, selectedMonth.value, 0)
|
||||||
|
const alignedStart = startOfWeek(start)
|
||||||
|
const alignedEnd = startOfWeek(end)
|
||||||
|
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
||||||
|
const cursor = new Date(alignedStart)
|
||||||
|
|
||||||
|
while (cursor <= alignedEnd) {
|
||||||
|
const key = formatDayKey(cursor)
|
||||||
|
const inRange = cursor >= start && cursor <= end
|
||||||
|
cells.push({
|
||||||
|
date: key,
|
||||||
|
count: dailyCounts.value.get(key) || 0,
|
||||||
|
inRange,
|
||||||
|
isToday: key === formatDayKey(today),
|
||||||
|
})
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxCount = computed(() => {
|
||||||
|
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getLevel(count: number) {
|
||||||
|
if (count <= 0) return 0
|
||||||
|
|
||||||
|
const max = Math.max(maxCount.value, 1)
|
||||||
|
const ratio = count / max
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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]'
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = computed(() => {
|
||||||
|
const buckets: Array<typeof visibleCells.value> = []
|
||||||
|
for (let index = 0; index < visibleCells.value.length; index += 7) {
|
||||||
|
buckets.push(visibleCells.value.slice(index, index + 7))
|
||||||
|
}
|
||||||
|
return buckets
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthStats = computed(() => {
|
||||||
|
const stats = new Map<string, MonthStat>()
|
||||||
|
|
||||||
|
for (const cell of visibleCells.value) {
|
||||||
|
if (!cell.inRange) continue
|
||||||
|
const monthKey = toMonthKey(cell.date)
|
||||||
|
const date = parseLocalDate(cell.date)
|
||||||
|
const label = `${date.getMonth() + 1}月`
|
||||||
|
const existing = stats.get(monthKey)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.totalCount += cell.count
|
||||||
|
if (cell.count > 0) existing.activeDays += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.set(monthKey, {
|
||||||
|
key: monthKey,
|
||||||
|
label,
|
||||||
|
totalCount: cell.count,
|
||||||
|
activeDays: cell.count > 0 ? 1 : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLabels = computed(() => {
|
||||||
|
if (viewMode.value !== 'year') return []
|
||||||
|
|
||||||
|
let lastMonth = -1
|
||||||
|
return weeks.value.map(week => {
|
||||||
|
const firstInRange = week.find(cell => cell.inRange)
|
||||||
|
if (!firstInRange) return null
|
||||||
|
|
||||||
|
const date = parseLocalDate(firstInRange.date)
|
||||||
|
const month = date.getMonth()
|
||||||
|
const labelMonth = month + 1
|
||||||
|
const monthKey = toMonthKey(firstInRange.date)
|
||||||
|
|
||||||
|
if (month === lastMonth) return null
|
||||||
|
|
||||||
|
lastMonth = month
|
||||||
|
return {
|
||||||
|
key: monthKey,
|
||||||
|
label: labelMonth === 1 ? `${selectedYear.value}/1月` : `${labelMonth}月`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthPreview = computed(() => {
|
||||||
|
if (!hoveredMonthKey.value) return null
|
||||||
|
return monthStats.value.get(hoveredMonthKey.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelSummary = computed(() => {
|
||||||
|
if (viewMode.value === 'year') {
|
||||||
|
return `${selectedYear.value} 年完整视图,包含尚未来到的日期。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${selectedYear.value} 年 ${selectedMonth.value} 月完整视图。`
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTooltip(cell: ContributionHeatmapCell & { inRange: boolean; isToday: boolean }) {
|
||||||
|
const date = parseLocalDate(cell.date)
|
||||||
|
const label = date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cell.inRange) {
|
||||||
|
return `${label}:不在当前视图范围内`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.count === 0) {
|
||||||
|
return `${label}${cell.isToday ? '(今天)' : ''}:当天没有上传`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label}${cell.isToday ? '(今天)' : ''}:上传 ${cell.count} 张`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCellClick(cell: ContributionHeatmapCell & { inRange: boolean }) {
|
||||||
|
if (!cell.inRange) return
|
||||||
|
emit('selectDate', cell.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthTooltip(monthKey: string) {
|
||||||
|
const stats = monthStats.value.get(monthKey)
|
||||||
|
if (!stats) return ''
|
||||||
|
return `${stats.label}:共上传 ${stats.totalCount} 张,活跃于 ${stats.activeDays} 天`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-6">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-500">
|
||||||
|
共上传 {{ totalCount }} 张,活跃于 {{ activeDays }} 天。颜色越深,代表当天上传的照片越多。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">{{ panelSummary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<NButtonGroup>
|
||||||
|
<NButton :type="viewMode === 'year' ? 'primary' : 'default'" secondary strong @click="viewMode = 'year'">
|
||||||
|
年度视图
|
||||||
|
</NButton>
|
||||||
|
<NButton :type="viewMode === 'month' ? 'primary' : 'default'" secondary strong @click="viewMode = 'month'">
|
||||||
|
月度视图
|
||||||
|
</NButton>
|
||||||
|
</NButtonGroup>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="selectedYear"
|
||||||
|
:options="yearOptions"
|
||||||
|
class="w-28"
|
||||||
|
/>
|
||||||
|
<NSelect
|
||||||
|
v-if="viewMode === 'month'"
|
||||||
|
v-model:value="selectedMonth"
|
||||||
|
:options="monthOptions"
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex min-h-6 items-center justify-between gap-4 text-xs text-slate-500">
|
||||||
|
<span>点击某一天可筛选当天上传记录。</span>
|
||||||
|
<span v-if="monthPreview" class="text-right">
|
||||||
|
{{ monthPreview.label }}:{{ monthPreview.totalCount }} 张 / {{ monthPreview.activeDays }} 天
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-right">
|
||||||
|
<template v-if="viewMode === 'year'">悬停月份标签可预览当月上传摘要。</template>
|
||||||
|
<template v-else>当前显示整个月的上传情况。</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 overflow-x-auto">
|
||||||
|
<div :class="viewMode === 'year' ? 'min-w-[980px]' : 'min-w-[360px]'">
|
||||||
|
<div
|
||||||
|
v-if="viewMode === 'year'"
|
||||||
|
class="mb-2 grid grid-flow-col auto-cols-[14px] gap-1 pl-8 text-[11px] text-slate-400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(month, index) in monthLabels"
|
||||||
|
:key="`${month?.key || 'empty'}-${index}`"
|
||||||
|
class="w-[14px] whitespace-nowrap"
|
||||||
|
:title="month ? formatMonthTooltip(month.key) : ''"
|
||||||
|
@mouseenter="hoveredMonthKey = month?.key || null"
|
||||||
|
@mouseleave="hoveredMonthKey = null"
|
||||||
|
>
|
||||||
|
{{ month?.label || '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid grid-rows-7 gap-1 pt-[2px] text-[11px] text-slate-400">
|
||||||
|
<span>一</span>
|
||||||
|
<span></span>
|
||||||
|
<span>三</span>
|
||||||
|
<span></span>
|
||||||
|
<span>五</span>
|
||||||
|
<span></span>
|
||||||
|
<span>日</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-flow-col auto-cols-[14px] gap-1">
|
||||||
|
<div
|
||||||
|
v-for="(week, weekIndex) in weeks"
|
||||||
|
:key="weekIndex"
|
||||||
|
class="grid grid-rows-7 gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="cell in week"
|
||||||
|
:key="cell.date"
|
||||||
|
type="button"
|
||||||
|
class="relative h-3.5 w-3.5 border transition-transform hover:scale-125"
|
||||||
|
:class="[
|
||||||
|
getCellClass(cell),
|
||||||
|
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
|
||||||
|
cell.isToday && cell.inRange ? 'outline outline-1 outline-slate-900 outline-offset-1' : '',
|
||||||
|
]"
|
||||||
|
:title="formatTooltip(cell)"
|
||||||
|
@click="handleCellClick(cell)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||||
|
<span>少</span>
|
||||||
|
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
|
||||||
|
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
|
||||||
|
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
|
||||||
|
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
|
||||||
|
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
|
||||||
|
<span>多</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
|
export interface ProfileCloudItem {
|
||||||
|
id: string
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
is_hidden: boolean
|
||||||
|
cloudTypeName: string
|
||||||
|
cloudTypeRarity: CloudType['rarity']
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfileCloud(row: Record<string, unknown>) {
|
||||||
|
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||||
|
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
image_url: row.image_url as string,
|
||||||
|
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||||
|
location_name: (row.location_name as string | null) ?? null,
|
||||||
|
description: (row.description as string | null) ?? null,
|
||||||
|
captured_at: (row.captured_at as string | null) ?? null,
|
||||||
|
created_at: row.created_at as string,
|
||||||
|
status: row.status as ProfileCloudItem['status'],
|
||||||
|
is_hidden: (row.is_hidden as boolean) ?? false,
|
||||||
|
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
|
||||||
|
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
|
||||||
|
} satisfies ProfileCloudItem
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProfileStore = defineStore('profile-page', () => {
|
||||||
|
const profilesById = ref<Record<string, Profile>>({})
|
||||||
|
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||||
|
const loadingKeys = ref<Record<string, boolean>>({})
|
||||||
|
const errorByKey = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
|
||||||
|
|
||||||
|
function getProfile(userId: string | null) {
|
||||||
|
if (!userId) return null
|
||||||
|
return profilesById.value[userId] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClouds(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
if (!userId) return []
|
||||||
|
return cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getError(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
if (!userId) return ''
|
||||||
|
return errorByKey.value[makeKey(userId, isOwnProfile)] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoaded(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
if (!userId) return false
|
||||||
|
return makeKey(userId, isOwnProfile) in cloudsByKey.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
|
||||||
|
const key = makeKey(userId, isOwnProfile)
|
||||||
|
if (!force && key in cloudsByKey.value && profilesById.value[userId]) return
|
||||||
|
|
||||||
|
loadingKeys.value[key] = true
|
||||||
|
errorByKey.value[key] = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: profile, error: profileError } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (profileError) throw profileError
|
||||||
|
profilesById.value[userId] = profile as Profile
|
||||||
|
|
||||||
|
let cloudsQuery = supabase
|
||||||
|
.from('clouds')
|
||||||
|
.select('id,image_url,thumbnail_url,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('captured_at', { ascending: false, nullsFirst: false })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (!isOwnProfile) {
|
||||||
|
cloudsQuery = cloudsQuery
|
||||||
|
.eq('status', 'approved')
|
||||||
|
.eq('is_hidden', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: cloudRows, error: cloudsError } = await cloudsQuery
|
||||||
|
if (cloudsError) throw cloudsError
|
||||||
|
|
||||||
|
cloudsByKey.value[key] = ((cloudRows || []) as Array<Record<string, unknown>>).map(toProfileCloud)
|
||||||
|
} catch (error) {
|
||||||
|
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||||
|
cloudsByKey.value[key] = []
|
||||||
|
} finally {
|
||||||
|
loadingKeys.value[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoading(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
if (!userId) return false
|
||||||
|
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getProfile,
|
||||||
|
getClouds,
|
||||||
|
getError,
|
||||||
|
isLoaded,
|
||||||
|
isLoading,
|
||||||
|
fetchProfilePage,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,9 +1,595 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-3xl mx-auto px-4 py-12">
|
<div class="relative">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">个人主页</h1>
|
<div class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
|
||||||
<p class="text-gray-500">个人主页开发中...</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user