feat: enhance cloud image management
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user