875 lines
32 KiB
Vue
875 lines
32 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, ref, watch } from 'vue'
|
||
import { RouterLink, useRoute } from 'vue-router'
|
||
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
|
||
import { Settings } from '@vicons/tabler'
|
||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useCloudsStore } from '@/stores/clouds'
|
||
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 cloudsStore = useCloudsStore()
|
||
const encyclopediaStore = useEncyclopediaStore()
|
||
const profileStore = useProfileStore()
|
||
const message = useMessage()
|
||
|
||
const selectedCloud = ref<ProfileCloudItem | null>(null)
|
||
const selectedUploadDate = ref<string | null>(null)
|
||
const timelineSectionRef = ref<HTMLElement | null>(null)
|
||
const selectionMode = ref(false)
|
||
const selectedCloudIds = ref<Set<string>>(new Set())
|
||
const editModalOpen = ref(false)
|
||
const editSaving = ref(false)
|
||
const manageError = ref('')
|
||
const editInitialValue = ref<CloudEditFormValue | 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 selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
||
|
||
const cloudActionOptions = computed(() => {
|
||
if (!selectedCloud.value) return []
|
||
|
||
return [
|
||
{ label: '编辑', key: 'edit' },
|
||
{ label: '删除', key: 'delete' },
|
||
{
|
||
label: selectedCloud.value.is_hidden ? '更改为公开' : '更改为私密',
|
||
key: 'toggle-privacy',
|
||
},
|
||
]
|
||
})
|
||
|
||
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 formatCoordinate(value: number | null) {
|
||
return value === null ? '未记录' : value.toFixed(2)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function setSelectedCloudIds(nextIds: Iterable<string>) {
|
||
selectedCloudIds.value = new Set(nextIds)
|
||
}
|
||
|
||
function toggleCloudSelection(cloudId: string, checked: boolean) {
|
||
const nextIds = new Set(selectedCloudIds.value)
|
||
if (checked) {
|
||
nextIds.add(cloudId)
|
||
} else {
|
||
nextIds.delete(cloudId)
|
||
}
|
||
setSelectedCloudIds(nextIds)
|
||
}
|
||
|
||
function clearCloudSelection() {
|
||
setSelectedCloudIds([])
|
||
}
|
||
|
||
function toggleSelectionMode() {
|
||
selectionMode.value = !selectionMode.value
|
||
if (!selectionMode.value) {
|
||
clearCloudSelection()
|
||
}
|
||
}
|
||
|
||
function formatDatetimeLocal(iso: string | null) {
|
||
if (!iso) return ''
|
||
const date = new Date(iso)
|
||
const pad = (value: number) => String(value).padStart(2, '0')
|
||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||
}
|
||
|
||
function openEditModal(cloud: ProfileCloudItem) {
|
||
manageError.value = ''
|
||
editInitialValue.value = {
|
||
cloudCategoryId: cloud.cloud_type_id ?? (cloud.custom_cloud_type ? 'other' : null),
|
||
customCloudType: cloud.custom_cloud_type || '',
|
||
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
||
latitude: cloud.latitude,
|
||
longitude: cloud.longitude,
|
||
locationName: cloud.location_name || '',
|
||
description: cloud.description || '',
|
||
isPublic: !cloud.is_hidden,
|
||
}
|
||
editModalOpen.value = true
|
||
}
|
||
|
||
function closeEditModal() {
|
||
editModalOpen.value = false
|
||
editSaving.value = false
|
||
manageError.value = ''
|
||
}
|
||
|
||
function blurCoordinate(value: number): number {
|
||
return Math.round(value * 100) / 100
|
||
}
|
||
|
||
async function submitEditModal(value: CloudEditFormValue) {
|
||
if (!selectedCloud.value || !viewedUserId.value) return
|
||
|
||
if (!value.cloudCategoryId) {
|
||
manageError.value = '请选择云型类别。'
|
||
return
|
||
}
|
||
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
||
manageError.value = '请输入自定义云型名称。'
|
||
return
|
||
}
|
||
if (!value.capturedAt) {
|
||
manageError.value = '请选择拍摄时间。'
|
||
return
|
||
}
|
||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
||
if (hasLat !== hasLng) {
|
||
manageError.value = '经纬度必须同时填写。'
|
||
return
|
||
}
|
||
|
||
editSaving.value = true
|
||
manageError.value = ''
|
||
|
||
try {
|
||
const updated = await profileStore.updateCloud(viewedUserId.value, selectedCloud.value.id, {
|
||
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
||
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
||
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
||
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
||
location_name: value.locationName.trim() || null,
|
||
description: value.description.trim() || null,
|
||
captured_at: new Date(value.capturedAt).toISOString(),
|
||
is_hidden: !value.isPublic,
|
||
})
|
||
selectedCloud.value = updated
|
||
closeEditModal()
|
||
} catch (error) {
|
||
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
||
} finally {
|
||
editSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleSelectedCloudPrivacy() {
|
||
if (!selectedCloud.value || !viewedUserId.value) return
|
||
|
||
manageError.value = ''
|
||
const nextHidden = !selectedCloud.value.is_hidden
|
||
|
||
try {
|
||
await profileStore.updateCloudVisibility(viewedUserId.value, selectedCloud.value.id, nextHidden)
|
||
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
||
} catch (error) {
|
||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||
}
|
||
}
|
||
|
||
async function deleteCloudByIds(cloudIds: string[]) {
|
||
if (!viewedUserId.value || !cloudIds.length) return
|
||
const confirmed = window.confirm(`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`)
|
||
if (!confirmed) return
|
||
|
||
manageError.value = ''
|
||
|
||
try {
|
||
const deletedCount = await profileStore.deleteClouds(viewedUserId.value, cloudIds)
|
||
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
||
selectedCloud.value = null
|
||
closeEditModal()
|
||
}
|
||
const nextSelected = new Set(selectedCloudIds.value)
|
||
for (const cloudId of cloudIds) nextSelected.delete(cloudId)
|
||
setSelectedCloudIds(nextSelected)
|
||
message.success(`已成功删除 ${deletedCount} 张照片`)
|
||
} catch (error) {
|
||
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
||
}
|
||
}
|
||
|
||
function handleCloudAction(key: string | number) {
|
||
if (!selectedCloud.value) return
|
||
if (key === 'edit') openEditModal(selectedCloud.value)
|
||
if (key === 'delete') deleteCloudByIds([selectedCloud.value.id])
|
||
if (key === 'toggle-privacy') toggleSelectedCloudPrivacy()
|
||
}
|
||
|
||
async function loadProfilePage(force = false) {
|
||
selectedCloud.value = null
|
||
selectedUploadDate.value = null
|
||
clearCloudSelection()
|
||
selectionMode.value = false
|
||
|
||
const targetUserId = viewedUserId.value
|
||
|
||
if (!targetUserId) {
|
||
return
|
||
}
|
||
|
||
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
|
||
|
||
if (isOwnProfile.value) {
|
||
await cloudsStore.fetchCloudTypes()
|
||
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="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 class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||
<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 class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||
<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>
|
||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||
<p class="text-lg font-medium text-slate-700">
|
||
@{{ profileData?.username || '未知用户' }}
|
||
</p>
|
||
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
||
<button
|
||
type="button"
|
||
class="inline-flex h-8 w-8 items-center justify-center border border-slate-300 bg-white text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
|
||
title="个人资料设置"
|
||
aria-label="个人资料设置"
|
||
>
|
||
<NIcon size="17">
|
||
<Settings />
|
||
</NIcon>
|
||
</button>
|
||
</RouterLink>
|
||
</div>
|
||
<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>
|
||
|
||
<EncyclopediaProgressCard
|
||
v-if="isOwnProfile"
|
||
:unlocked-count="encyclopediaStore.unlockedCount"
|
||
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
||
:percent="encyclopediaStore.unlockPercent"
|
||
:is-logged-in="true"
|
||
/>
|
||
|
||
<NCard v-else class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||
<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>
|
||
</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>
|
||
|
||
<NAlert
|
||
v-if="manageError"
|
||
class="mb-5"
|
||
type="error"
|
||
:show-icon="false"
|
||
:bordered="false"
|
||
title="操作失败"
|
||
>
|
||
{{ manageError }}
|
||
</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="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||
<p class="text-sm text-slate-500">总拍摄数</p>
|
||
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
|
||
</NCard>
|
||
|
||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||
<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="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||
<p class="text-sm text-slate-500">拍摄天数</p>
|
||
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
|
||
</NCard>
|
||
|
||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||
<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
|
||
v-if="isOwnProfile && selectedCloudCount"
|
||
secondary
|
||
strong
|
||
type="default"
|
||
class="oc-panel-button oc-panel-button--danger"
|
||
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
||
>
|
||
删除已选 {{ selectedCloudCount }}
|
||
</NButton>
|
||
<NButton
|
||
v-if="isOwnProfile"
|
||
secondary
|
||
strong
|
||
class="oc-panel-button oc-panel-button--neutral"
|
||
@click="toggleSelectionMode"
|
||
>
|
||
{{ selectionMode ? '取消选择' : '选择图片' }}
|
||
</NButton>
|
||
<NButton secondary strong class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
|
||
刷新数据
|
||
</NButton>
|
||
<NButton
|
||
v-if="selectedUploadDate"
|
||
secondary
|
||
strong
|
||
class="oc-panel-button oc-panel-button--neutral"
|
||
@click="clearSelectedUploadDate"
|
||
>
|
||
清除筛选
|
||
</NButton>
|
||
<RouterLink v-if="isOwnProfile" to="/upload">
|
||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">继续上传</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" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||
<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">
|
||
<label
|
||
v-if="isOwnProfile && selectionMode"
|
||
class="inline-flex items-center border border-white/60 bg-white/90 px-2 py-1 text-xs font-medium text-slate-700"
|
||
@click.stop
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
class="mr-2 h-3.5 w-3.5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
||
:checked="selectedCloudIds.has(item.id)"
|
||
@change="toggleCloudSelection(item.id, ($event.target as HTMLInputElement).checked)"
|
||
/>
|
||
选择
|
||
</label>
|
||
<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>
|
||
<span
|
||
v-if="isOwnProfile"
|
||
class="inline-flex border px-3 py-1 text-xs font-medium"
|
||
:class="item.is_hidden ? 'border-slate-200 bg-slate-100 text-slate-600' : 'border-emerald-200 bg-emerald-100 text-emerald-700'"
|
||
>
|
||
{{ item.is_hidden ? '私密' : '公开' }}
|
||
</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="oc-empty-card 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="default" class="oc-panel-button oc-panel-button--sky">去上传第一张</NButton>
|
||
</RouterLink>
|
||
<NButton v-else-if="selectedUploadDate" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
|
||
</template>
|
||
</NEmpty>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
|
||
<ImageDetailModal
|
||
v-if="selectedCloud"
|
||
:open="!!selectedCloud"
|
||
:image-url="selectedCloud.image_url"
|
||
:thumbnail-url="selectedCloud.thumbnail_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">模糊化经纬度</p>
|
||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
|
||
</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 }} / {{ selectedCloud.is_hidden ? '私密' : '公开' }}
|
||
</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>
|
||
<MiniLocationMap
|
||
:latitude="selectedCloud.latitude"
|
||
:longitude="selectedCloud.longitude"
|
||
:location-name="selectedCloud.location_name"
|
||
/>
|
||
|
||
<template v-if="isOwnProfile" #actions>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<NDropdown
|
||
trigger="click"
|
||
placement="top-start"
|
||
:options="cloudActionOptions"
|
||
@select="handleCloudAction"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="flex h-10 w-10 items-center justify-center border border-slate-200 bg-slate-50 text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
|
||
title="图片管理"
|
||
aria-label="图片管理"
|
||
>
|
||
<NIcon size="20">
|
||
<Settings />
|
||
</NIcon>
|
||
</button>
|
||
</NDropdown>
|
||
<!-- <span class="text-xs text-slate-500">
|
||
{{ selectedCloud.is_hidden ? '当前为私密图片' : '当前会公开展示' }}
|
||
</span> -->
|
||
</div>
|
||
</template>
|
||
</ImageDetailModal>
|
||
|
||
<CloudEditModal
|
||
:open="editModalOpen && !!selectedCloud"
|
||
:saving="editSaving"
|
||
:error="manageError"
|
||
:cloud-types="cloudsStore.cloudTypes"
|
||
:initial-value="editInitialValue"
|
||
@close="closeEditModal"
|
||
@save="submitEditModal"
|
||
/>
|
||
</div>
|
||
</template>
|