Files
opencloud/src/views/profile/ProfileView.vue
T

875 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>