699 lines
25 KiB
Vue
699 lines
25 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||
import { Clock, Location, Search, Settings, User, X } 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 { supabase } from '@/lib/supabase'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useCloudsStore } from '@/stores/clouds'
|
||
import { useProfileStore } from '@/stores/profile'
|
||
|
||
import type { CloudType } from '@/types/database'
|
||
|
||
interface GalleryCloud {
|
||
id: string
|
||
user_id: string
|
||
cloud_type_id: number | null
|
||
custom_cloud_type: string | null
|
||
image_url: string
|
||
thumbnail_url: string | null
|
||
location_name: string | null
|
||
description: string | null
|
||
latitude: number | null
|
||
longitude: number | null
|
||
captured_at: string | null
|
||
created_at: string
|
||
status: 'pending' | 'approved' | 'rejected'
|
||
is_hidden: boolean
|
||
cloudTypeName: string
|
||
cloudTypeRarity: CloudType['rarity']
|
||
username: string
|
||
}
|
||
|
||
const PAGE_SIZE = 50
|
||
|
||
const authStore = useAuthStore()
|
||
const cloudsStore = useCloudsStore()
|
||
const profileStore = useProfileStore()
|
||
const message = useMessage()
|
||
|
||
const loading = ref(true)
|
||
const loadError = ref('')
|
||
const galleryItems = ref<GalleryCloud[]>([])
|
||
const selectedTypeId = ref<number | 'all'>('all')
|
||
const searchQuery = ref('')
|
||
const selectedCloud = ref<GalleryCloud | null>(null)
|
||
const currentPage = ref(1)
|
||
const totalCount = ref(0)
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
|
||
const manageError = ref('')
|
||
const editModalOpen = ref(false)
|
||
const editSaving = ref(false)
|
||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||
|
||
let searchTimer: number | 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 }>
|
||
|
||
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 formatUploadTime(cloud: GalleryCloud) {
|
||
return formatDateTime(cloud.created_at)
|
||
}
|
||
|
||
function formatCapturedTime(cloud: GalleryCloud) {
|
||
return formatDateTime(cloud.captured_at)
|
||
}
|
||
|
||
function formatCoordinate(value: number | null) {
|
||
return value === null ? '未记录' : value.toFixed(2)
|
||
}
|
||
|
||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||
|
||
function sanitizeSearchTerm(term: string) {
|
||
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
function getMatchedCloudTypeIds(term: string) {
|
||
const lowerTerm = term.toLocaleLowerCase('zh-CN')
|
||
return cloudsStore.cloudTypes
|
||
.filter(type => {
|
||
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
|
||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
|
||
})
|
||
.map(type => type.id)
|
||
}
|
||
|
||
async function fetchUserIdsBySearch(term: string) {
|
||
const sanitized = sanitizeSearchTerm(term)
|
||
if (!sanitized) return []
|
||
|
||
const { data, error } = await supabase
|
||
.from('profiles')
|
||
.select('id')
|
||
.ilike('username', `%${sanitized}%`)
|
||
.limit(100)
|
||
|
||
if (error) throw error
|
||
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
|
||
}
|
||
|
||
function toGalleryCloud(row: Record<string, unknown>) {
|
||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
|
||
const profile = profiles[0] as Record<string, unknown> | undefined
|
||
|
||
return {
|
||
id: row.id as string,
|
||
user_id: row.user_id as string,
|
||
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
|
||
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
|
||
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,
|
||
latitude: (row.latitude as number | null) ?? null,
|
||
longitude: (row.longitude as number | null) ?? null,
|
||
captured_at: (row.captured_at as string | null) ?? null,
|
||
created_at: row.created_at as string,
|
||
status: row.status as GalleryCloud['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',
|
||
username: (profile?.username as string) || '匿名',
|
||
} satisfies GalleryCloud
|
||
}
|
||
|
||
async function resolveSearchFilters() {
|
||
const search = normalizedSearch.value
|
||
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
|
||
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
|
||
|
||
if (isUserSearch.value && !usernameTerm) return null
|
||
if (search && !isUserSearch.value && !cloudTypeTerm) return null
|
||
|
||
let userIds: string[] = []
|
||
if (usernameTerm) {
|
||
userIds = await fetchUserIdsBySearch(usernameTerm)
|
||
if (userIds.length === 0) return null
|
||
}
|
||
|
||
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
|
||
|
||
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
|
||
}
|
||
|
||
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
|
||
|
||
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
|
||
let query = supabase
|
||
.from('clouds')
|
||
.select(selectStr, options as any)
|
||
.eq('status', 'approved')
|
||
.eq('is_hidden', false)
|
||
|
||
if (selectedTypeId.value !== 'all') {
|
||
query = query.eq('cloud_type_id', selectedTypeId.value)
|
||
}
|
||
|
||
return query
|
||
}
|
||
|
||
async function loadPage(page: number) {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
|
||
try {
|
||
const filters = await resolveSearchFilters()
|
||
if (filters === null) {
|
||
galleryItems.value = []
|
||
totalCount.value = 0
|
||
currentPage.value = 1
|
||
loading.value = false
|
||
return
|
||
}
|
||
|
||
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
|
||
let dataQuery = buildFilteredQuery(FULL_SELECT)
|
||
.order('created_at', { ascending: false })
|
||
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
|
||
|
||
if (filters.usernameTerm) {
|
||
countQuery = countQuery.in('user_id', filters.userIds)
|
||
dataQuery = dataQuery.in('user_id', filters.userIds)
|
||
} else if (filters.cloudTypeTerm) {
|
||
if (filters.matchedCloudTypeIds.length) {
|
||
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
|
||
countQuery = countQuery.or(orFilter)
|
||
dataQuery = dataQuery.or(orFilter)
|
||
} else {
|
||
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
|
||
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
|
||
}
|
||
}
|
||
|
||
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
|
||
|
||
if (error) throw error
|
||
|
||
totalCount.value = count ?? 0
|
||
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
|
||
currentPage.value = page
|
||
} catch (error) {
|
||
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function goToPage(page: number) {
|
||
if (page < 1 || page > totalPages.value || page === currentPage.value) return
|
||
await loadPage(page)
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
|
||
function openDetail(cloud: GalleryCloud) {
|
||
selectedCloud.value = cloud
|
||
}
|
||
|
||
function closeDetail() {
|
||
selectedCloud.value = null
|
||
editModalOpen.value = false
|
||
manageError.value = ''
|
||
}
|
||
|
||
const selectedCloudIsMine = computed(() => {
|
||
return !!selectedCloud.value && selectedCloud.value.user_id === authStore.user?.id
|
||
})
|
||
|
||
const cloudActionOptions = computed(() => [
|
||
{ label: '编辑图片信息', key: 'edit' },
|
||
{ label: '删除', key: 'delete' },
|
||
{ label: '更改为私密', key: 'toggle-privacy' },
|
||
])
|
||
|
||
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 blurCoordinate(value: number): number {
|
||
return Math.round(value * 100) / 100
|
||
}
|
||
|
||
function openEditModal() {
|
||
if (!selectedCloud.value) return
|
||
|
||
manageError.value = ''
|
||
editInitialValue.value = {
|
||
cloudCategoryId: selectedCloud.value.cloud_type_id ?? (selectedCloud.value.custom_cloud_type ? 'other' : null),
|
||
customCloudType: selectedCloud.value.custom_cloud_type || '',
|
||
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
||
latitude: selectedCloud.value.latitude,
|
||
longitude: selectedCloud.value.longitude,
|
||
locationName: selectedCloud.value.location_name || '',
|
||
description: selectedCloud.value.description || '',
|
||
isPublic: !selectedCloud.value.is_hidden,
|
||
}
|
||
editModalOpen.value = true
|
||
}
|
||
|
||
function closeEditModal() {
|
||
editModalOpen.value = false
|
||
editSaving.value = false
|
||
manageError.value = ''
|
||
}
|
||
|
||
function patchGalleryCloud(updated: GalleryCloud) {
|
||
galleryItems.value = galleryItems.value.map(item => (item.id === updated.id ? updated : item))
|
||
if (selectedCloud.value?.id === updated.id) {
|
||
selectedCloud.value = updated
|
||
}
|
||
}
|
||
|
||
async function submitEditModal(value: CloudEditFormValue) {
|
||
if (!selectedCloud.value || !authStore.user?.id) 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
|
||
}
|
||
|
||
const current = selectedCloud.value
|
||
editSaving.value = true
|
||
manageError.value = ''
|
||
|
||
try {
|
||
const updated = await profileStore.updateCloud(authStore.user.id, current.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,
|
||
})
|
||
|
||
if (updated.is_hidden || updated.status !== 'approved') {
|
||
removeGalleryCloud(updated.id)
|
||
} else {
|
||
patchGalleryCloud({
|
||
...current,
|
||
...updated,
|
||
user_id: current.user_id,
|
||
username: current.username,
|
||
})
|
||
}
|
||
closeEditModal()
|
||
message.success('图片信息已更新')
|
||
} catch (error) {
|
||
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
||
message.error(manageError.value)
|
||
} finally {
|
||
editSaving.value = false
|
||
}
|
||
}
|
||
|
||
function removeGalleryCloud(cloudId: string) {
|
||
galleryItems.value = galleryItems.value.filter(item => item.id !== cloudId)
|
||
if (selectedCloud.value?.id === cloudId) {
|
||
selectedCloud.value = null
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedCloud() {
|
||
if (!selectedCloud.value || !authStore.user?.id) return
|
||
const cloudId = selectedCloud.value.id
|
||
const confirmed = window.confirm('确定删除这张图片吗?删除后无法在页面中恢复。')
|
||
if (!confirmed) return
|
||
|
||
manageError.value = ''
|
||
|
||
try {
|
||
const deletedCount = await profileStore.deleteClouds(authStore.user.id, [cloudId])
|
||
removeGalleryCloud(cloudId)
|
||
message.success(`已成功删除 ${deletedCount} 张照片`)
|
||
} catch (error) {
|
||
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
||
message.error(manageError.value)
|
||
}
|
||
}
|
||
|
||
async function hideSelectedCloud() {
|
||
if (!selectedCloud.value || !authStore.user?.id) return
|
||
const cloudId = selectedCloud.value.id
|
||
|
||
manageError.value = ''
|
||
|
||
try {
|
||
await profileStore.updateCloudVisibility(authStore.user.id, cloudId, true)
|
||
removeGalleryCloud(cloudId)
|
||
message.success('已更改为私密图片')
|
||
} catch (error) {
|
||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||
message.error(manageError.value)
|
||
}
|
||
}
|
||
|
||
function handleCloudAction(key: string | number) {
|
||
if (key === 'edit') openEditModal()
|
||
if (key === 'delete') deleteSelectedCloud()
|
||
if (key === 'toggle-privacy') hideSelectedCloud()
|
||
}
|
||
|
||
function clearSearch() {
|
||
searchQuery.value = ''
|
||
}
|
||
|
||
const filterTabs = computed(() => [
|
||
{ id: 'all' as const, label: '全部' },
|
||
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
||
])
|
||
|
||
onMounted(async () => {
|
||
await cloudsStore.fetchCloudTypes()
|
||
await loadPage(1)
|
||
})
|
||
|
||
watch(selectedTypeId, async () => {
|
||
selectedCloud.value = null
|
||
await loadPage(1)
|
||
})
|
||
|
||
watch(searchQuery, () => {
|
||
if (searchTimer !== null) {
|
||
window.clearTimeout(searchTimer)
|
||
}
|
||
searchTimer = window.setTimeout(async () => {
|
||
selectedCloud.value = null
|
||
await loadPage(1)
|
||
searchTimer = null
|
||
}, 250)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (searchTimer !== null) {
|
||
window.clearTimeout(searchTimer)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
|
||
<div class="max-w-7xl mx-auto px-4 py-10">
|
||
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Community Gallery</p>
|
||
<h1 class="mt-3 text-4xl font-bold text-slate-900">云图画廊</h1>
|
||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||
<section>
|
||
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||
<div class="flex items-center gap-3">
|
||
<NIcon size="20" class="shrink-0 text-slate-400">
|
||
<Search />
|
||
</NIcon>
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
||
/>
|
||
<button
|
||
v-if="searchQuery"
|
||
type="button"
|
||
class="flex h-8 w-8 shrink-0 items-center justify-center text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||
title="清空搜索"
|
||
aria-label="清空搜索"
|
||
@click="clearSearch"
|
||
>
|
||
<NIcon size="18">
|
||
<X />
|
||
</NIcon>
|
||
</button>
|
||
</div>
|
||
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
||
<template v-if="isUserSearch">
|
||
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||
</template>
|
||
<template v-else>
|
||
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
||
</template>
|
||
</p>
|
||
</div>
|
||
|
||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||
<NButton
|
||
v-for="tab in filterTabs"
|
||
:key="tab.id"
|
||
secondary
|
||
strong
|
||
@click="selectedTypeId = tab.id"
|
||
class="shrink-0"
|
||
:class="selectedTypeId === tab.id ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||
type="default"
|
||
>
|
||
{{ tab.label }}
|
||
</NButton>
|
||
</div>
|
||
</section>
|
||
|
||
<NAlert v-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="画廊加载失败">
|
||
{{ loadError }}
|
||
</NAlert>
|
||
|
||
<NAlert v-if="manageError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="操作失败">
|
||
{{ manageError }}
|
||
</NAlert>
|
||
|
||
<section v-if="loading" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
||
<div
|
||
v-for="n in 8"
|
||
:key="n"
|
||
class="oc-panel-card mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
||
>
|
||
<NSkeleton class="h-44 w-full" />
|
||
<NSkeleton class="mt-4 h-4 w-3/5" />
|
||
<NSkeleton class="mt-3 h-3 w-2/5" />
|
||
<NSkeleton class="mt-2 h-3 w-4/5" />
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else-if="galleryItems.length" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
||
<button
|
||
v-for="cloud in galleryItems"
|
||
:key="cloud.id"
|
||
type="button"
|
||
@click="openDetail(cloud)"
|
||
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||
>
|
||
<img
|
||
:src="cloud.thumbnail_url || cloud.image_url"
|
||
:alt="cloud.cloudTypeName"
|
||
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
||
/>
|
||
|
||
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
|
||
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||
<div>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
||
<NTag size="small" :bordered="false" class="shrink-0 bg-white/12 text-white backdrop-blur">
|
||
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
||
</NTag>
|
||
</div>
|
||
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||
<NIcon size="14">
|
||
<User />
|
||
</NIcon>
|
||
<span class="truncate">{{ cloud.username }}</span>
|
||
</p>
|
||
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||
<NIcon size="14">
|
||
<Clock />
|
||
</NIcon>
|
||
<span class="truncate">{{ formatUploadTime(cloud) }}</span>
|
||
</p>
|
||
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||
<NIcon size="14">
|
||
<Location />
|
||
</NIcon>
|
||
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
</section>
|
||
|
||
<section v-else class="oc-empty-card mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
|
||
<NEmpty description="还没有符合条件的云图">
|
||
<template #extra>
|
||
<p class="text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
||
</template>
|
||
</NEmpty>
|
||
</section>
|
||
|
||
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
||
<NButton
|
||
secondary
|
||
strong
|
||
:disabled="currentPage <= 1"
|
||
class="oc-panel-button oc-panel-button--neutral"
|
||
@click="goToPage(currentPage - 1)"
|
||
>
|
||
上一页
|
||
</NButton>
|
||
|
||
<template v-for="page in totalPages" :key="page">
|
||
<NButton
|
||
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
|
||
secondary
|
||
strong
|
||
:class="page === currentPage ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||
type="default"
|
||
@click="goToPage(page)"
|
||
>
|
||
{{ page }}
|
||
</NButton>
|
||
<span
|
||
v-else-if="page === 2 || page === totalPages - 1"
|
||
class="px-1 text-sm text-slate-400"
|
||
>...</span>
|
||
</template>
|
||
|
||
<NButton
|
||
secondary
|
||
strong
|
||
:disabled="currentPage >= totalPages"
|
||
class="oc-panel-button oc-panel-button--neutral"
|
||
@click="goToPage(currentPage + 1)"
|
||
>
|
||
下一页
|
||
</NButton>
|
||
</div>
|
||
|
||
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
|
||
共 {{ totalCount }} 张照片,第 {{ currentPage }} / {{ totalPages }} 页
|
||
</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="`上传者:${selectedCloud.username}`"
|
||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||
@close="closeDetail"
|
||
>
|
||
<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">{{ formatUploadTime(selectedCloud) }}</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">{{ formatCapturedTime(selectedCloud) }}</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>
|
||
|
||
<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="selectedCloudIsMine" #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">这是你上传的公开图片</span> -->
|
||
</div>
|
||
</template>
|
||
</ImageDetailModal>
|
||
|
||
<CloudEditModal
|
||
:open="editModalOpen && !!selectedCloud"
|
||
:saving="editSaving"
|
||
:error="manageError"
|
||
:cloud-types="cloudsStore.cloudTypes"
|
||
:initial-value="editInitialValue"
|
||
@close="closeEditModal"
|
||
@save="submitEditModal"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|