Files
opencloud/src/views/gallery/GalleryView.vue
T
Mplan 6bce7276e2 fix: resolve build type errors in gallery pagination
Remove unused nextTick import, fix generic type assertion on
supabase.from select options, and add double type cast for
query result mapping.
2026-05-24 17:15:40 +08:00

695 lines
25 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, 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"
:type="selectedTypeId === tab.id ? 'primary' : '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="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="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"
@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
:type="page === currentPage ? 'primary' : '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"
@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>