feat: enhance cloud image management
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
@@ -11,6 +12,8 @@ interface CloudGalleryItem {
|
||||
id: string
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
@@ -27,6 +30,7 @@ const encyclopediaStore = useEncyclopediaStore()
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const gallery = ref<CloudGalleryItem[]>([])
|
||||
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
|
||||
const publicCount = ref(0)
|
||||
|
||||
const rarityMeta = {
|
||||
@@ -57,10 +61,29 @@ function formatGalleryTime(item: CloudGalleryItem) {
|
||||
return formatDate(item.captured_at || item.created_at)
|
||||
}
|
||||
|
||||
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 openGalleryDetail(item: CloudGalleryItem) {
|
||||
selectedGalleryItem.value = item
|
||||
}
|
||||
|
||||
async function loadGallery(typeId: number) {
|
||||
const galleryQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,location_name,description,captured_at,created_at,profiles(username)')
|
||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
@@ -84,13 +107,15 @@ async function loadGallery(typeId: number) {
|
||||
if (countError) throw countError
|
||||
|
||||
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : []
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
id: row.id as string,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
latitude: (row.latitude as number | null) ?? null,
|
||||
longitude: (row.longitude as number | null) ?? null,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
@@ -132,6 +157,10 @@ async function loadPage() {
|
||||
|
||||
onMounted(loadPage)
|
||||
watch(() => route.params.id, loadPage)
|
||||
|
||||
watch(() => route.params.id, () => {
|
||||
selectedGalleryItem.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -236,10 +265,12 @@ watch(() => route.params.id, loadPage)
|
||||
</div>
|
||||
|
||||
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
<button
|
||||
v-for="item in gallery"
|
||||
:key="item.id"
|
||||
class="overflow-hidden border border-slate-200 bg-white shadow-sm"
|
||||
type="button"
|
||||
class="overflow-hidden border border-slate-200 bg-white text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
@click="openGalleryDetail(item)"
|
||||
>
|
||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
|
||||
<div class="p-5">
|
||||
@@ -252,7 +283,7 @@ watch(() => route.params.id, loadPage)
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="border border-dashed border-slate-300 bg-white p-8">
|
||||
@@ -263,6 +294,47 @@ watch(() => route.params.id, loadPage)
|
||||
</NEmpty>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedGalleryItem"
|
||||
:open="!!selectedGalleryItem"
|
||||
:image-url="selectedGalleryItem.image_url"
|
||||
:thumbnail-url="selectedGalleryItem.thumbnail_url"
|
||||
:image-alt="cloudType.name"
|
||||
:title="cloudType.name"
|
||||
:subtitle="`上传者:${selectedGalleryItem.profiles?.username || '匿名'}`"
|
||||
:badge-label="rarityMeta[cloudType.rarity].label"
|
||||
:badge-class="rarityMeta[cloudType.rarity].chip"
|
||||
@close="selectedGalleryItem = 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(selectedGalleryItem.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(selectedGalleryItem.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">{{ selectedGalleryItem.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(selectedGalleryItem.latitude) }} / 经度 {{ formatCoordinate(selectedGalleryItem.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">
|
||||
{{ selectedGalleryItem.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
</ImageDetailModal>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag } from 'naive-ui'
|
||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import { Clock, Location, Settings, User } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { Clock, User ,Location} from '@vicons/tabler'
|
||||
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
|
||||
@@ -18,6 +24,8 @@ interface GalleryCloud {
|
||||
longitude: number | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
username: string
|
||||
@@ -25,7 +33,10 @@ interface GalleryCloud {
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const cloudsStore = useCloudsStore()
|
||||
const profileStore = useProfileStore()
|
||||
const message = useMessage()
|
||||
|
||||
const loading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
@@ -36,6 +47,10 @@ const selectedCloud = ref<GalleryCloud | null>(null)
|
||||
const sentinel = ref<HTMLDivElement | null>(null)
|
||||
const totalLoaded = ref(0)
|
||||
const hasMore = ref(true)
|
||||
const manageError = ref('')
|
||||
const editModalOpen = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
@@ -76,6 +91,9 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
||||
|
||||
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,
|
||||
@@ -84,6 +102,8 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
||||
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) || '匿名',
|
||||
@@ -93,7 +113,7 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
||||
async function fetchPage(offset: number) {
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||
.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)')
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -167,6 +187,164 @@ function openDetail(cloud: GalleryCloud) {
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const filterTabs = computed(() => [
|
||||
@@ -226,6 +404,10 @@ onUnmounted(() => {
|
||||
{{ 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"
|
||||
@@ -307,6 +489,7 @@ onUnmounted(() => {
|
||||
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}`"
|
||||
@@ -341,7 +524,40 @@ onUnmounted(() => {
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -313,6 +313,7 @@ onUnmounted(() => {
|
||||
v-if="previewCloud"
|
||||
:open="!!previewCloud"
|
||||
:image-url="previewCloud.imageUrl"
|
||||
:thumbnail-url="previewCloud.thumbnailUrl"
|
||||
:image-alt="previewCloud.cloudTypeName"
|
||||
:title="previewCloud.cloudTypeName"
|
||||
:subtitle="`上传者:${previewCloud.username}`"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NEmpty, NProgress, NSkeleton, NTag } from 'naive-ui'
|
||||
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NProgress, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import { Settings } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.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'
|
||||
@@ -17,12 +20,20 @@ interface TimelineGroup {
|
||||
|
||||
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' },
|
||||
@@ -74,6 +85,21 @@ 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)),
|
||||
@@ -239,6 +265,10 @@ function formatDateTime(iso: string | null) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatCoordinate(value: number | null) {
|
||||
return value === null ? '未记录' : value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatCloudTime(item: ProfileCloudItem) {
|
||||
return formatDateTime(item.captured_at || item.created_at)
|
||||
}
|
||||
@@ -255,9 +285,156 @@ 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
|
||||
|
||||
@@ -268,6 +445,7 @@ async function loadProfilePage(force = false) {
|
||||
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
await cloudsStore.fetchCloudTypes()
|
||||
await encyclopediaStore.fetchCloudTypes(force)
|
||||
await encyclopediaStore.fetchMyCollection(force)
|
||||
}
|
||||
@@ -386,6 +564,17 @@ watch(selectedUploadDate, async newValue => {
|
||||
{{ 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">
|
||||
@@ -445,6 +634,23 @@ watch(selectedUploadDate, async newValue => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NButton
|
||||
v-if="isOwnProfile && selectedCloudCount"
|
||||
secondary
|
||||
strong
|
||||
type="error"
|
||||
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
||||
>
|
||||
删除已选 {{ selectedCloudCount }}
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="isOwnProfile"
|
||||
secondary
|
||||
strong
|
||||
@click="toggleSelectionMode"
|
||||
>
|
||||
{{ selectionMode ? '取消选择' : '选择图片' }}
|
||||
</NButton>
|
||||
<NButton secondary strong @click="loadProfilePage(true)">
|
||||
刷新数据
|
||||
</NButton>
|
||||
@@ -507,6 +713,19 @@ watch(selectedUploadDate, async newValue => {
|
||||
/>
|
||||
<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>
|
||||
@@ -517,6 +736,13 @@ watch(selectedUploadDate, async newValue => {
|
||||
>
|
||||
{{ 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>
|
||||
@@ -555,6 +781,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
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)}`"
|
||||
@@ -575,10 +802,18 @@ watch(selectedUploadDate, async newValue => {
|
||||
<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 }}</template>
|
||||
<template v-if="isOwnProfile">
|
||||
{{ statusMeta[selectedCloud.status].label }} / {{ selectedCloud.is_hidden ? '私密' : '公开' }}
|
||||
</template>
|
||||
<template v-else>公开可见</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -590,6 +825,41 @@ watch(selectedUploadDate, async newValue => {
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
+34
-174
@@ -5,8 +5,8 @@ import { useRouter } from 'vue-router'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -20,15 +20,6 @@ const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges
|
||||
const errorMsg = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const mapPickerOpen = ref(false)
|
||||
const mapPickerLoading = ref(false)
|
||||
const mapPickerError = ref('')
|
||||
const mapPickerLat = ref<number | null>(null)
|
||||
const mapPickerLng = ref<number | null>(null)
|
||||
const miniMapEl = ref<HTMLDivElement | null>(null)
|
||||
|
||||
let mapPickerAMap: typeof AMap | null = null
|
||||
let mapPickerInstance: AMap.Map | null = null
|
||||
let mapPickerMarker: AMap.Marker | null = null
|
||||
|
||||
const activeItem = computed(() => {
|
||||
if (!activeId.value) return null
|
||||
@@ -70,10 +61,6 @@ function handleRemove(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatCoordinate(value: number | null) {
|
||||
return value === null ? '未选择' : value.toFixed(6)
|
||||
}
|
||||
|
||||
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
|
||||
if (!activeItem.value) return
|
||||
|
||||
@@ -84,101 +71,18 @@ function updateActiveCoordinates(latitude: number | null, longitude: number | nu
|
||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
||||
}
|
||||
|
||||
function destroyMapPicker() {
|
||||
mapPickerMarker?.setMap(null)
|
||||
mapPickerMarker = null
|
||||
mapPickerInstance?.destroy()
|
||||
mapPickerInstance = null
|
||||
}
|
||||
|
||||
function placeMapPickerMarker(longitude: number, latitude: number) {
|
||||
if (!mapPickerAMap || !mapPickerInstance) return
|
||||
|
||||
if (!mapPickerMarker) {
|
||||
mapPickerMarker = new mapPickerAMap.Marker({
|
||||
position: [longitude, latitude],
|
||||
title: '拍摄位置',
|
||||
} as AMap.MarkerOptions)
|
||||
mapPickerMarker.setMap(mapPickerInstance)
|
||||
} else {
|
||||
mapPickerMarker.setPosition([longitude, latitude])
|
||||
}
|
||||
}
|
||||
|
||||
function extractLngLat(event: unknown) {
|
||||
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
||||
const lng = payload?.lnglat?.lng
|
||||
const lat = payload?.lnglat?.lat
|
||||
|
||||
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
||||
return { lng, lat }
|
||||
}
|
||||
|
||||
function handleMapPickerClick(event: unknown) {
|
||||
const picked = extractLngLat(event)
|
||||
if (!picked) return
|
||||
|
||||
mapPickerLng.value = picked.lng
|
||||
mapPickerLat.value = picked.lat
|
||||
placeMapPickerMarker(picked.lng, picked.lat)
|
||||
}
|
||||
|
||||
async function openMapPicker() {
|
||||
if (!activeItem.value) return
|
||||
|
||||
mapPickerOpen.value = true
|
||||
mapPickerLoading.value = true
|
||||
mapPickerError.value = ''
|
||||
mapPickerLat.value = activeItem.value.latitude
|
||||
mapPickerLng.value = activeItem.value.longitude
|
||||
|
||||
await nextTick()
|
||||
destroyMapPicker()
|
||||
|
||||
try {
|
||||
mapPickerAMap = await loadAMap()
|
||||
|
||||
const hasCoordinates =
|
||||
typeof activeItem.value.latitude === 'number' &&
|
||||
typeof activeItem.value.longitude === 'number'
|
||||
|
||||
const center: [number, number] = hasCoordinates
|
||||
? [activeItem.value.longitude as number, activeItem.value.latitude as number]
|
||||
: [104.07, 30.67]
|
||||
|
||||
mapPickerInstance = new mapPickerAMap.Map(miniMapEl.value!, {
|
||||
viewMode: '2D',
|
||||
pitch: 0,
|
||||
rotation: 0,
|
||||
zoom: hasCoordinates ? 11 : 4,
|
||||
center,
|
||||
mapStyle: 'amap://styles/normal',
|
||||
resizeEnable: true,
|
||||
} as AMap.MapOptions)
|
||||
|
||||
mapPickerInstance.on('click', handleMapPickerClick)
|
||||
|
||||
if (hasCoordinates) {
|
||||
placeMapPickerMarker(activeItem.value.longitude as number, activeItem.value.latitude as number)
|
||||
}
|
||||
} catch (error) {
|
||||
mapPickerError.value = error instanceof Error ? error.message : '地图加载失败'
|
||||
} finally {
|
||||
mapPickerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeMapPicker() {
|
||||
mapPickerOpen.value = false
|
||||
mapPickerLoading.value = false
|
||||
mapPickerError.value = ''
|
||||
destroyMapPicker()
|
||||
}
|
||||
|
||||
function confirmMapPicker() {
|
||||
if (mapPickerLat.value === null || mapPickerLng.value === null) return
|
||||
|
||||
updateActiveCoordinates(mapPickerLat.value, mapPickerLng.value)
|
||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||
updateActiveCoordinates(payload.latitude, payload.longitude)
|
||||
closeMapPicker()
|
||||
}
|
||||
|
||||
@@ -186,6 +90,11 @@ function clearCoordinates() {
|
||||
updateActiveCoordinates(null, null)
|
||||
}
|
||||
|
||||
function updateActivePublicState(isPublic: boolean) {
|
||||
if (!activeItem.value) return
|
||||
activeItem.value.isHidden = !isPublic
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMsg.value = ''
|
||||
const allValid = validateAll()
|
||||
@@ -289,7 +198,6 @@ function onCategoryChange(e: Event) {
|
||||
onMounted(() => { cloudsStore.fetchCloudTypes() })
|
||||
onUnmounted(() => {
|
||||
for (const item of items.value) URL.revokeObjectURL(item.preview)
|
||||
destroyMapPicker()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -316,9 +224,9 @@ onUnmounted(() => {
|
||||
<template v-if="unlockedBadges.length">
|
||||
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章。
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- <template v-else>
|
||||
这批云图已经进入你的收藏记录。
|
||||
</template>
|
||||
</template> -->
|
||||
</p>
|
||||
</template>
|
||||
</NResult>
|
||||
@@ -542,10 +450,21 @@ onUnmounted(() => {
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 隐身模式 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
|
||||
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式(不在地图上显示)</label>
|
||||
<!-- 公开状态 -->
|
||||
<div class="border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-800">公开展示</p>
|
||||
<p class="mt-1 text-xs text-gray-500">关闭后不会出现在画廊、地图和公开主页中。</p>
|
||||
</div>
|
||||
<input
|
||||
:checked="!activeItem.isHidden"
|
||||
type="checkbox"
|
||||
:id="'isPublic-' + activeItem.id"
|
||||
class="h-5 w-5 border-gray-300 text-sky-500 focus:ring-sky-500"
|
||||
@change="updateActivePublicState(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
@@ -570,72 +489,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="mapPickerOpen"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||
@click="closeMapPicker"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-start justify-between border-b border-gray-200 px-6 py-5">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-900">地图选点</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">点击地图即可回填当前图片的经纬度,也可以继续手动修改。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeMapPicker"
|
||||
class="rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span>当前选择:</span>
|
||||
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(mapPickerLat) }}</span>
|
||||
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(mapPickerLng) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-hidden border border-gray-200 bg-slate-100">
|
||||
<div ref="miniMapEl" class="h-[420px] w-full"></div>
|
||||
|
||||
<div
|
||||
v-if="mapPickerLoading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
||||
>
|
||||
正在加载地图...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="mapPickerError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
||||
>
|
||||
{{ mapPickerError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||
<p class="text-sm text-gray-500">建议点选大致拍摄位置,上传时会继续做模糊化处理。</p>
|
||||
<div class="flex gap-3">
|
||||
<NButton secondary strong @click="closeMapPicker">取消</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
secondary
|
||||
strong
|
||||
:disabled="mapPickerLat === null || mapPickerLng === null"
|
||||
@click="confirmMapPicker"
|
||||
>
|
||||
使用这个位置
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<MapPickerModal
|
||||
:open="mapPickerOpen"
|
||||
:latitude="activeItem?.latitude ?? null"
|
||||
:longitude="activeItem?.longitude ?? null"
|
||||
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
||||
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
||||
@close="closeMapPicker"
|
||||
@confirm="confirmMapPicker"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user