Files
opencloud/src/views/gallery/GalleryView.vue
T

591 lines
20 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 { RouterLink } from 'vue-router'
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 { ApiError, apiRequest, toApiCloud } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useProfileStore } from '@/stores/profile'
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
interface GalleryCloud {
id: string
user_id: string
cloud_type_id: number | null
image_url: string
thumbnail_url: 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: number
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 hasNextPage = ref(false)
const manageError = ref('')
const editModalOpen = ref(false)
const editSaving = ref(false)
const editInitialValue = ref<CloudEditFormValue | null>(null)
let searchTimer: number | null = null
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
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 toGalleryCloud(row: ApiCloud) {
return {
id: row.id,
user_id: row.user_id,
cloud_type_id: row.cloud_type_id,
image_url: row.image_url,
thumbnail_url: row.thumbnail_url,
description: row.description,
latitude: row.latitude,
longitude: row.longitude,
captured_at: row.captured_at,
created_at: row.created_at,
status: row.status,
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || '未知',
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
username: row.username || '匿名',
} satisfies GalleryCloud
}
async function loadPage(page: number) {
loading.value = true
loadError.value = ''
try {
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (normalizedSearch.value) {
params.set('filter', isUserSearch.value ? 'owner' : 'type')
params.set('value', normalizedSearch.value)
} else if (selectedTypeId.value !== 'all') {
const typeName = cloudsStore.cloudTypes.find(type => type.id === selectedTypeId.value)?.name
if (typeName) {
params.set('filter', 'type')
params.set('value', typeName)
}
}
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
hasNextPage.value = rows.length === PAGE_SIZE
currentPage.value = page
} catch (error) {
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
galleryItems.value = []
hasNextPage.value = false
currentPage.value = page
return
}
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
} finally {
loading.value = false
}
}
async function goToPage(page: number) {
if (page < 1 || page === currentPage.value || (page > currentPage.value && !hasNextPage.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,
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
latitude: selectedCloud.value.latitude,
longitude: selectedCloud.value.longitude,
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
}
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
}
if (value.description.trim().length > 128) {
manageError.value = '图片描述不能超过 128 个字符。'
return
}
const current = selectedCloud.value
editSaving.value = true
manageError.value = ''
try {
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
type_id: value.cloudCategoryId,
latitude: blurCoordinate(value.latitude as number),
longitude: blurCoordinate(value.longitude as number),
description: value.description.trim(),
...(value.capturedAt ? { 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="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<p class="oc-page-eyebrow">Community Gallery</p>
<h1 class="oc-page-title">云图画廊</h1>
<p class="oc-page-description">
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
</p>
</div>
</section>
<div class="oc-container oc-container--content oc-page-content">
<section>
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
<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="oc-icon-button oc-icon-button--small"
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="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left transition-transform duration-300 hover:-translate-y-1"
>
<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">
稀有度 {{ cloud.cloudTypeRarity }}
</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">{{ formatCoordinate(cloud.latitude) }}, {{ formatCoordinate(cloud.longitude) }}</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="currentPage > 1 || hasNextPage" class="mt-8 flex items-center justify-center gap-2">
<NButton
secondary
strong
type="default"
:disabled="currentPage <= 1"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage - 1)"
>
上一页
</NButton>
<span class="border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600">第 {{ currentPage }} 页</span>
<NButton
secondary
strong
type="default"
:disabled="!hasNextPage"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage + 1)"
>
下一页
</NButton>
</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="`稀有度 ${selectedCloud.cloudTypeRarity}`"
:badge-class="rarityChip"
@close="closeDetail"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="oc-inset-panel">
<p class="oc-section-label">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</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="oc-section-label">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
<RouterLink
:to="{ name: 'user-profile', params: { id: selectedCloud.username } }"
class="oc-panel-button oc-panel-button--neutral mt-5 inline-flex"
>
查看 @{{ selectedCloud.username }} 的公开主页
</RouterLink>
<MiniLocationMap
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
/>
<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="oc-icon-button"
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>