feat: enhance cloud image management

This commit is contained in:
2026-05-22 10:34:35 +08:00
parent 7cdf07447c
commit 7e4ee3d699
11 changed files with 1468 additions and 216 deletions
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NAlert, NButton } from 'naive-ui'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
import type { CloudType } from '@/types/database'
export interface CloudEditFormValue {
cloudCategoryId: number | 'other' | null
customCloudType: string
capturedAt: string
latitude: number | null
longitude: number | null
locationName: string
description: string
isPublic: boolean
}
const props = withDefaults(defineProps<{
open: boolean
saving?: boolean
error?: string
cloudTypes: CloudType[]
initialValue: CloudEditFormValue | null
}>(), {
saving: false,
error: '',
})
const emit = defineEmits<{
close: []
save: [value: CloudEditFormValue]
}>()
const form = ref<CloudEditFormValue>(createEmptyForm())
const mapPickerOpen = ref(false)
const hasCoordinates = computed(() => form.value.latitude !== null || form.value.longitude !== null)
function createEmptyForm(): CloudEditFormValue {
return {
cloudCategoryId: null,
customCloudType: '',
capturedAt: '',
latitude: null,
longitude: null,
locationName: '',
description: '',
isPublic: true,
}
}
function cloneForm(value: CloudEditFormValue | null) {
return value ? { ...value } : createEmptyForm()
}
function onCategoryChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'other') {
form.value.cloudCategoryId = 'other'
} else if (!value) {
form.value.cloudCategoryId = null
} else {
form.value.cloudCategoryId = Number(value)
}
}
function onLatInput(event: Event) {
const value = Number.parseFloat((event.target as HTMLInputElement).value)
form.value.latitude = Number.isNaN(value) ? null : value
}
function onLngInput(event: Event) {
const value = Number.parseFloat((event.target as HTMLInputElement).value)
form.value.longitude = Number.isNaN(value) ? null : value
}
function clearCoordinates() {
form.value.latitude = null
form.value.longitude = null
}
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
form.value.latitude = payload.latitude
form.value.longitude = payload.longitude
mapPickerOpen.value = false
}
function close() {
emit('close')
}
function save() {
emit('save', { ...form.value })
}
watch(() => props.open, open => {
if (open) {
form.value = cloneForm(props.initialValue)
} else {
mapPickerOpen.value = false
}
})
watch(() => props.initialValue, value => {
if (props.open) {
form.value = cloneForm(value)
}
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
@click="close"
>
<div class="w-full max-w-2xl bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
<div>
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
<p class="mt-1 text-sm text-slate-500">修改后会同步影响个人主页画廊和地图中的展示</p>
</div>
<button
type="button"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="close"
>
</button>
</div>
<div class="max-h-[70vh] overflow-y-auto px-6 py-5">
<NAlert
v-if="error"
class="mb-5"
type="error"
:show-icon="false"
:bordered="false"
>
{{ error }}
</NAlert>
<div class="grid gap-5">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">类别</label>
<select
:value="form.cloudCategoryId ?? ''"
class="w-full border border-slate-300 bg-white px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@change="onCategoryChange"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}{{ ct.name_en }}
</option>
<option value="other">其他</option>
</select>
</div>
<div v-if="form.cloudCategoryId === 'other'">
<label class="mb-1 block text-sm font-medium text-slate-700">自定义云型</label>
<input
v-model="form.customCloudType"
type="text"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">拍摄时间</label>
<input
v-model="form.capturedAt"
type="datetime-local"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">位置名称</label>
<input
v-model="form.locationName"
type="text"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="如:北京、成都"
/>
</div>
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-slate-700">经纬度</label>
<button
v-if="hasCoordinates"
type="button"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-700"
@click="clearCoordinates"
>
清空
</button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<input
:value="form.latitude !== null ? form.latitude : ''"
type="number"
step="any"
placeholder="纬度"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@input="onLatInput"
/>
<input
:value="form.longitude !== null ? form.longitude : ''"
type="number"
step="any"
placeholder="经度"
class="w-full border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
@input="onLngInput"
/>
<button
type="button"
class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
title="地图选点"
aria-label="地图选点"
@click="mapPickerOpen = true"
>
<span class="text-lg leading-none">🗺</span>
</button>
</div>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">图片描述</label>
<textarea
v-model="form.description"
rows="4"
class="w-full resize-none border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="描述一下这张图片..."
></textarea>
</div>
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
<span>
<span class="block text-sm font-medium text-slate-800">公开展示</span>
<span class="mt-1 block text-xs text-slate-500">关闭后不会出现在画廊地图和公开主页中</span>
</span>
<input
v-model="form.isPublic"
type="checkbox"
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
/>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
<NButton secondary strong @click="close">取消</NButton>
<NButton type="primary" secondary strong :loading="saving" @click="save">
保存修改
</NButton>
</div>
</div>
</div>
</Teleport>
<MapPickerModal
:open="mapPickerOpen"
:latitude="form.latitude"
:longitude="form.longitude"
description="点击地图即可回填这张图片的经纬度。"
footer-text="保存时会继续做模糊化处理"
@close="mapPickerOpen = false"
@confirm="confirmMapPicker"
/>
</template>
+183 -18
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
open: boolean open: boolean
imageUrl: string imageUrl: string
thumbnailUrl?: string | null
imageAlt: string imageAlt: string
title: string title: string
subtitle?: string subtitle?: string
@@ -12,9 +13,10 @@ const props = withDefaults(defineProps<{
panelWidth?: string panelWidth?: string
}>(), { }>(), {
subtitle: '', subtitle: '',
thumbnailUrl: null,
badgeLabel: '', badgeLabel: '',
badgeClass: '', badgeClass: '',
panelWidth: '20rem', panelWidth: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,23 +24,112 @@ const emit = defineEmits<{
}>() }>()
const collapsed = ref(false) const collapsed = ref(false)
const imageLoaded = ref(false)
const previewLoaded = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
const isDesktop = computed(() => viewportWidth.value >= 1024)
function clampPanelWidth(width: number) {
return Math.min(Math.max(width, 240), 400)
}
function parsePanelWidth(width: string) {
const normalized = width.trim()
if (!normalized) return null
if (normalized.endsWith('px')) return Number.parseFloat(normalized)
if (normalized.endsWith('rem')) return Number.parseFloat(normalized) * 16
return null
}
function updateViewport() {
if (typeof window === 'undefined') return
viewportWidth.value = window.innerWidth
viewportHeight.value = window.innerHeight
}
const layout = computed(() => {
const aspectRatio = naturalWidth.value > 0 && naturalHeight.value > 0
? naturalWidth.value / naturalHeight.value
: 4 / 3
const maxModalWidth = Math.max(viewportWidth.value * 0.92, 320)
const mobileImageMaxHeight = Math.max(viewportHeight.value * 0.48, 220)
const desktopImageMaxHeight = Math.max(viewportHeight.value * 0.8, 240)
if (!isDesktop.value) {
const imageWidth = Math.min(maxModalWidth, mobileImageMaxHeight * aspectRatio)
const imageHeight = imageWidth / aspectRatio
const panelHeight = collapsed.value ? 0 : Math.min(Math.max(imageHeight * 0.72, 180), viewportHeight.value * 0.34)
return {
imageWidth,
imageHeight,
panelWidth: imageWidth,
panelHeight,
}
}
const maxImageHeight = desktopImageMaxHeight
if (collapsed.value) {
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
panelWidth: 0,
panelHeight: imageWidth / aspectRatio,
}
}
const fixedPanelWidth = parsePanelWidth(props.panelWidth)
let panelWidth = fixedPanelWidth ?? clampPanelWidth(Math.min(maxModalWidth * 0.28, 320))
let imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
if (!fixedPanelWidth) {
for (let index = 0; index < 4; index += 1) {
panelWidth = clampPanelWidth(imageWidth * 0.34)
imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
}
}
return {
imageWidth,
imageHeight: imageWidth / aspectRatio,
panelWidth,
panelHeight: imageWidth / aspectRatio,
}
})
const rootStyle = computed(() => ({ const rootStyle = computed(() => ({
'--detail-panel-width': props.panelWidth, '--detail-panel-width': `${Math.round(layout.value.panelWidth)}px`,
'--detail-panel-height': `${Math.round(layout.value.panelHeight)}px`,
}) as Record<string, string>) }) as Record<string, string>)
const imageClass = computed(() => { const imageStyle = computed(() => ({
return collapsed.value width: `${Math.round(layout.value.imageWidth)}px`,
? 'block h-full w-auto max-w-full object-contain lg:max-w-[92vw]' height: `${Math.round(layout.value.imageHeight)}px`,
: 'block h-full w-auto max-w-full object-contain lg:max-w-[calc(92vw-var(--detail-panel-width))]' }) as Record<string, string>)
})
function resetImageState() {
imageLoaded.value = false
previewLoaded.value = false
naturalWidth.value = 0
naturalHeight.value = 0
}
watch(() => props.open, open => { watch(() => props.open, open => {
if (open) { if (open) {
collapsed.value = false collapsed.value = false
resetImageState()
} }
}) })
watch(() => props.imageUrl, () => {
resetImageState()
})
function close() { function close() {
emit('close') emit('close')
} }
@@ -46,6 +137,37 @@ function close() {
function togglePanel() { function togglePanel() {
collapsed.value = !collapsed.value collapsed.value = !collapsed.value
} }
function handleImageLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (!target) return
naturalWidth.value = target.naturalWidth
naturalHeight.value = target.naturalHeight
imageLoaded.value = true
}
function handlePreviewLoad(event: Event) {
const target = event.target as HTMLImageElement | null
if (!target) return
if (!naturalWidth.value || !naturalHeight.value) {
naturalWidth.value = target.naturalWidth
naturalHeight.value = target.naturalHeight
}
previewLoaded.value = true
}
onMounted(() => {
updateViewport()
if (typeof window !== 'undefined') {
window.addEventListener('resize', updateViewport)
}
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateViewport)
}
})
</script> </script>
<template> <template>
@@ -56,28 +178,67 @@ function togglePanel() {
@click="close" @click="close"
> >
<div <div
class="relative h-[80vh] w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl lg:flex" class="relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
:class="isDesktop ? 'lg:flex lg:items-start' : 'flex flex-col'"
:style="rootStyle" :style="rootStyle"
@click.stop @click.stop
>
<div class="flex min-h-0 flex-1 items-center justify-center overflow-hidden lg:flex-none">
<img :src="imageUrl" :alt="imageAlt" :class="imageClass" />
</div>
<div
class="relative h-[38vh] transition-[width] duration-300 lg:h-full lg:shrink-0"
:class="collapsed ? 'lg:w-0' : 'lg:w-[var(--detail-panel-width)]'"
> >
<button <button
v-if="isDesktop"
type="button" type="button"
@click="togglePanel" @click="togglePanel"
class="absolute left-0 top-1/2 z-10 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex" class="absolute top-1/2 z-30 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
:style="{ left: `${Math.round(layout.imageWidth)}px` }"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'" :aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'" :title="collapsed ? '展开详细信息' : '收起详细信息'"
> >
<span class="text-lg leading-none">{{ collapsed ? '' : '' }}</span> <span class="text-lg leading-none">{{ collapsed ? '' : '' }}</span>
</button> </button>
<div
class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-950 lg:flex-none"
:style="imageStyle"
>
<div
v-if="!previewLoaded && !imageLoaded"
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
></div>
<img
v-if="thumbnailUrl"
:src="thumbnailUrl"
:alt="imageAlt"
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
@load="handlePreviewLoad"
/>
<img
:src="imageUrl"
:alt="imageAlt"
class="relative block h-full w-full object-contain transition-opacity duration-300"
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
@load="handleImageLoad"
/>
</div>
<button
v-if="!isDesktop"
type="button"
@click="togglePanel"
class="flex items-center justify-center gap-2 border-t border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
:title="collapsed ? '展开详细信息' : '收起详细信息'"
>
<span>{{ collapsed ? '展开详细信息' : '收起详细信息' }}</span>
<span class="text-base leading-none">{{ collapsed ? '▼' : '▲' }}</span>
</button>
<div
class="relative overflow-hidden transition-[width,height] duration-300"
:class="[
collapsed ? 'h-0 lg:w-0' : 'w-full lg:w-[var(--detail-panel-width)]',
isDesktop ? 'lg:h-[var(--detail-panel-height)] lg:shrink-0' : 'h-[var(--detail-panel-height)]',
]"
>
<div <div
class="flex h-full flex-col overflow-hidden border-t border-slate-200 bg-white transition-[border-color] duration-300 lg:border-t-0" class="flex h-full flex-col overflow-hidden border-t border-slate-200 bg-white transition-[border-color] duration-300 lg:border-t-0"
:class="collapsed ? 'lg:border-l-0' : 'lg:border-l'" :class="collapsed ? 'lg:border-l-0' : 'lg:border-l'"
@@ -109,6 +270,10 @@ function togglePanel() {
<div class="flex-1 overflow-y-auto px-6 py-5"> <div class="flex-1 overflow-y-auto px-6 py-5">
<slot /> <slot />
</div> </div>
<div v-if="$slots.actions" class="border-t border-slate-200 px-6 py-4">
<slot name="actions" />
</div>
</div> </div>
</div> </div>
</div> </div>
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { NButton } from 'naive-ui'
import { loadAMap } from '@/lib/amap'
const props = withDefaults(defineProps<{
open: boolean
latitude: number | null
longitude: number | null
title?: string
description?: string
footerText?: string
}>(), {
title: '地图选点',
description: '点击地图即可回填经纬度,也可以继续手动修改。',
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
})
const emit = defineEmits<{
close: []
confirm: [payload: { latitude: number; longitude: number }]
}>()
const loading = ref(false)
const errorMsg = ref('')
const pickedLat = ref<number | null>(null)
const pickedLng = ref<number | null>(null)
const mapEl = ref<HTMLDivElement | null>(null)
let mapPickerAMap: typeof AMap | null = null
let mapPickerInstance: AMap.Map | null = null
let mapPickerMarker: AMap.Marker | null = null
function formatCoordinate(value: number | null) {
return value === null ? '未选择' : value.toFixed(6)
}
function destroyMapPicker() {
mapPickerMarker?.setMap(null)
mapPickerMarker = null
mapPickerInstance?.destroy()
mapPickerInstance = null
}
function placeMarker(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 handleMapClick(event: unknown) {
const picked = extractLngLat(event)
if (!picked) return
pickedLng.value = picked.lng
pickedLat.value = picked.lat
placeMarker(picked.lng, picked.lat)
}
async function initializeMap() {
loading.value = true
errorMsg.value = ''
pickedLat.value = props.latitude
pickedLng.value = props.longitude
await nextTick()
destroyMapPicker()
try {
mapPickerAMap = await loadAMap()
const hasCoordinates =
typeof props.latitude === 'number' &&
typeof props.longitude === 'number'
const center: [number, number] = hasCoordinates
? [props.longitude as number, props.latitude as number]
: [104.07, 30.67]
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
viewMode: '2D',
pitch: 0,
rotation: 0,
zoom: hasCoordinates ? 11 : 4,
center,
mapStyle: 'amap://styles/normal',
resizeEnable: true,
} as AMap.MapOptions)
mapPickerInstance.on('click', handleMapClick)
if (hasCoordinates) {
placeMarker(props.longitude as number, props.latitude as number)
}
} catch (error) {
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
} finally {
loading.value = false
}
}
function close() {
emit('close')
}
function confirm() {
if (pickedLat.value === null || pickedLng.value === null) return
emit('confirm', {
latitude: pickedLat.value,
longitude: pickedLng.value,
})
}
watch(() => props.open, open => {
if (open) {
initializeMap()
} else {
loading.value = false
errorMsg.value = ''
destroyMapPicker()
}
})
onBeforeUnmount(() => {
destroyMapPicker()
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
@click="close"
>
<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-slate-200 px-6 py-5">
<div>
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
<p class="mt-1 text-sm text-slate-500">{{ description }}</p>
</div>
<button
type="button"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="close"
>
</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(pickedLat) }}</span>
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(pickedLng) }}</span>
</div>
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
<div ref="mapEl" class="h-[420px] w-full"></div>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
>
正在加载地图...
</div>
<div
v-if="errorMsg"
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
>
{{ errorMsg }}
</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
<p class="text-sm text-slate-500">{{ footerText }}</p>
<div class="flex gap-3">
<NButton secondary strong @click="close">取消</NButton>
<NButton
type="primary"
secondary
strong
:disabled="pickedLat === null || pickedLng === null"
@click="confirm"
>
使用这个位置
</NButton>
</div>
</div>
</div>
</div>
</Teleport>
</template>
+42 -8
View File
@@ -88,7 +88,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-16'"> <div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
<header <header
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300" class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'" :class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
@@ -96,7 +96,7 @@ onMounted(() => {
@mouseleave="releasePinnedHeader" @mouseleave="releasePinnedHeader"
> >
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16"> <div class="flex h-16 items-center justify-between">
<RouterLink to="/" class="flex items-center gap-3"> <RouterLink to="/" class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-xl shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"> <div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-xl shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
@@ -107,7 +107,7 @@ onMounted(() => {
</div> </div>
</RouterLink> </RouterLink>
<nav class="hidden md:flex items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]"> <nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
<RouterLink <RouterLink
to="/" to="/"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors" class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
@@ -131,13 +131,13 @@ onMounted(() => {
</RouterLink> </RouterLink>
</nav> </nav>
<NSpace align="center" :size="12"> <NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
<RouterLink <RouterLink
v-if="authStore.isLoggedIn" v-if="authStore.isLoggedIn"
to="/upload" to="/upload"
class="no-underline" class="no-underline"
> >
<NButton type="primary" strong> <NButton type="primary" strong size="small" class="md:!h-10 md:!px-4 md:!text-sm">
上传 上传
</NButton> </NButton>
</RouterLink> </RouterLink>
@@ -147,12 +147,14 @@ onMounted(() => {
to="/profile" to="/profile"
class="no-underline" class="no-underline"
> >
<NTag type="success" bordered> <NTag type="success" bordered size="small" class="max-w-[7rem] truncate md:max-w-none">
{{ authStore.profile?.username }} {{ authStore.profile?.username }}
</NTag> </NTag>
</RouterLink> </RouterLink>
<NButton <NButton
quaternary quaternary
size="small"
class="md:!h-10 md:!px-4 md:!text-sm"
@click="authStore.logout()" @click="authStore.logout()"
> >
登出 登出
@@ -164,17 +166,49 @@ onMounted(() => {
to="/login" to="/login"
class="no-underline" class="no-underline"
> >
<NButton quaternary>登录</NButton> <NButton quaternary size="small" class="md:!h-10 md:!px-4 md:!text-sm">登录</NButton>
</RouterLink> </RouterLink>
<RouterLink <RouterLink
to="/register" to="/register"
class="no-underline" class="no-underline"
> >
<NButton type="primary" strong>注册</NButton> <NButton type="primary" strong size="small" class="md:!h-10 md:!px-4 md:!text-sm">注册</NButton>
</RouterLink> </RouterLink>
</template> </template>
</NSpace> </NSpace>
</div> </div>
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
<RouterLink
to="/"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'map' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
地图
</RouterLink>
<RouterLink
to="/encyclopedia"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
图鉴
</RouterLink>
<RouterLink
to="/gallery"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'gallery' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
画廊
</RouterLink>
<RouterLink
v-if="authStore.isLoggedIn"
to="/profile"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'profile' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
>
主页
</RouterLink>
</nav>
</div> </div>
</header> </header>
+3
View File
@@ -1,5 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useProfileStore } from '@/stores/profile'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
export interface UploadItem { export interface UploadItem {
@@ -214,6 +215,7 @@ async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlo
} }
export function useUpload() { export function useUpload() {
const profileStore = useProfileStore()
const items = ref<UploadItem[]>([]) const items = ref<UploadItem[]>([])
const uploading = ref(false) const uploading = ref(false)
const overallProgress = ref(0) const overallProgress = ref(0)
@@ -421,6 +423,7 @@ export function useUpload() {
URL.revokeObjectURL(item.preview) URL.revokeObjectURL(item.preview)
} }
items.value = [] items.value = []
profileStore.invalidateUser(userId)
return { return {
ok: true, ok: true,
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [], unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
+148 -1
View File
@@ -5,8 +5,12 @@ import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem { export interface ProfileCloudItem {
id: string id: string
cloud_type_id: number | null
custom_cloud_type: string | null
image_url: string image_url: string
thumbnail_url: string | null thumbnail_url: string | null
latitude: number | null
longitude: number | null
location_name: string | null location_name: string | null
description: string | null description: string | null
captured_at: string | null captured_at: string | null
@@ -23,8 +27,12 @@ function toProfileCloud(row: Record<string, unknown>) {
return { return {
id: row.id as string, id: row.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, image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null, 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, location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null, description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null, captured_at: (row.captured_at as string | null) ?? null,
@@ -36,6 +44,28 @@ function toProfileCloud(row: Record<string, unknown>) {
} satisfies ProfileCloudItem } satisfies ProfileCloudItem
} }
function getSupabaseErrorCode(error: unknown) {
if (!error || typeof error !== 'object' || !('code' in error)) return null
const code = (error as { code?: unknown }).code
return typeof code === 'string' ? code : null
}
function getCloudStoragePath(publicUrl: string | null) {
if (!publicUrl) return null
try {
const url = new URL(publicUrl)
const marker = '/storage/v1/object/public/clouds/'
const markerIndex = url.pathname.indexOf(marker)
if (markerIndex === -1) return null
const path = url.pathname.slice(markerIndex + marker.length)
return path ? decodeURIComponent(path) : null
} catch {
return null
}
}
export const useProfileStore = defineStore('profile-page', () => { export const useProfileStore = defineStore('profile-page', () => {
const profilesById = ref<Record<string, Profile>>({}) const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({}) const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
@@ -83,7 +113,7 @@ export const useProfileStore = defineStore('profile-page', () => {
let cloudsQuery = supabase let cloudsQuery = supabase
.from('clouds') .from('clouds')
.select('id,image_url,thumbnail_url,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)') .select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.eq('user_id', userId) .eq('user_id', userId)
.order('captured_at', { ascending: false, nullsFirst: false }) .order('captured_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -111,6 +141,119 @@ export const useProfileStore = defineStore('profile-page', () => {
return !!loadingKeys.value[makeKey(userId, isOwnProfile)] return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
} }
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
for (const isOwnProfile of [true, false]) {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current
.map(item => (item.id === cloudId ? { ...item, ...patch } : item))
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
}
}
function removeCachedClouds(userId: string, cloudIds: string[]) {
const idSet = new Set(cloudIds)
for (const isOwnProfile of [true, false]) {
const key = makeKey(userId, isOwnProfile)
const current = cloudsByKey.value[key]
if (!current) continue
cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
}
}
function invalidateUser(userId: string) {
delete cloudsByKey.value[makeKey(userId, true)]
delete cloudsByKey.value[makeKey(userId, false)]
}
async function updateCloud(userId: string, cloudId: string, patch: {
cloud_type_id: number | null
custom_cloud_type: string | null
latitude: number | null
longitude: number | null
location_name: string | null
description: string | null
captured_at: string | null
is_hidden: boolean
}) {
const { data, error } = await supabase
.from('clouds')
.update(patch)
.eq('id', cloudId)
.eq('user_id', userId)
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
.single()
if (error) throw error
const updated = toProfileCloud(data as Record<string, unknown>)
patchCachedCloud(userId, cloudId, updated)
return updated
}
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
const { data, error } = await supabase
.from('clouds')
.update({ is_hidden: isHidden })
.eq('id', cloudId)
.eq('user_id', userId)
.select('id')
if (error) throw error
if (!data?.length) {
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
}
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
}
async function deleteClouds(userId: string, cloudIds: string[]) {
if (!cloudIds.length) return 0
const { data, error } = await supabase
.from('clouds')
.delete()
.eq('user_id', userId)
.in('id', cloudIds)
.select('id,image_url,thumbnail_url')
if (error) {
if (getSupabaseErrorCode(error) === '23503') {
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
}
throw error
}
const deletedIds = (data || []).map(item => item.id as string)
if (deletedIds.length !== cloudIds.length) {
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
}
const storagePaths = Array.from(new Set(
(data || [])
.flatMap(item => [
getCloudStoragePath((item.image_url as string | null) ?? null),
getCloudStoragePath((item.thumbnail_url as string | null) ?? null),
])
.filter((path): path is string => !!path),
))
if (storagePaths.length) {
const { error: storageError } = await supabase.storage
.from('clouds')
.remove(storagePaths)
if (storageError) {
throw new Error(`图片数据库记录已删除,但 Supabase Storage 文件清理失败:${storageError.message}`)
}
}
removeCachedClouds(userId, deletedIds)
return deletedIds.length
}
return { return {
getProfile, getProfile,
getClouds, getClouds,
@@ -118,5 +261,9 @@ export const useProfileStore = defineStore('profile-page', () => {
isLoaded, isLoaded,
isLoading, isLoading,
fetchProfilePage, fetchProfilePage,
invalidateUser,
updateCloud,
updateCloudVisibility,
deleteClouds,
} }
}) })
+77 -5
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui' import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia' import { useEncyclopediaStore } from '@/stores/encyclopedia'
@@ -11,6 +12,8 @@ interface CloudGalleryItem {
id: string id: string
image_url: string image_url: string
thumbnail_url: string | null thumbnail_url: string | null
latitude: number | null
longitude: number | null
location_name: string | null location_name: string | null
description: string | null description: string | null
captured_at: string | null captured_at: string | null
@@ -27,6 +30,7 @@ const encyclopediaStore = useEncyclopediaStore()
const loading = ref(true) const loading = ref(true)
const loadError = ref('') const loadError = ref('')
const gallery = ref<CloudGalleryItem[]>([]) const gallery = ref<CloudGalleryItem[]>([])
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
const publicCount = ref(0) const publicCount = ref(0)
const rarityMeta = { const rarityMeta = {
@@ -57,10 +61,29 @@ function formatGalleryTime(item: CloudGalleryItem) {
return formatDate(item.captured_at || item.created_at) 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) { async function loadGallery(typeId: number) {
const galleryQuery = supabase const galleryQuery = supabase
.from('clouds') .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('cloud_type_id', typeId)
.eq('status', 'approved') .eq('status', 'approved')
.eq('is_hidden', false) .eq('is_hidden', false)
@@ -84,13 +107,15 @@ async function loadGallery(typeId: number) {
if (countError) throw countError if (countError) throw countError
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => { 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 const profile = profiles[0] as Record<string, unknown> | undefined
return { return {
id: row.id as string, id: row.id as string,
image_url: row.image_url as string, image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null, 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, location_name: (row.location_name as string | null) ?? null,
description: (row.description as string | null) ?? null, description: (row.description as string | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null, captured_at: (row.captured_at as string | null) ?? null,
@@ -132,6 +157,10 @@ async function loadPage() {
onMounted(loadPage) onMounted(loadPage)
watch(() => route.params.id, loadPage) watch(() => route.params.id, loadPage)
watch(() => route.params.id, () => {
selectedGalleryItem.value = null
})
</script> </script>
<template> <template>
@@ -236,10 +265,12 @@ watch(() => route.params.id, loadPage)
</div> </div>
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3"> <div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<article <button
v-for="item in gallery" v-for="item in gallery"
:key="item.id" :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" /> <img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
<div class="p-5"> <div class="p-5">
@@ -252,7 +283,7 @@ watch(() => route.params.id, loadPage)
{{ item.description }} {{ item.description }}
</p> </p>
</div> </div>
</article> </button>
</div> </div>
<div v-else class="border border-dashed border-slate-300 bg-white p-8"> <div v-else class="border border-dashed border-slate-300 bg-white p-8">
@@ -263,6 +294,47 @@ watch(() => route.params.id, loadPage)
</NEmpty> </NEmpty>
</div> </div>
</section> </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> </template>
</div> </div>
</template> </template>
+219 -3
View File
@@ -1,15 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' 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 ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds' import { useCloudsStore } from '@/stores/clouds'
import { Clock, User ,Location} from '@vicons/tabler' import { useProfileStore } from '@/stores/profile'
import type { CloudType } from '@/types/database' import type { CloudType } from '@/types/database'
interface GalleryCloud { interface GalleryCloud {
id: string id: string
user_id: string
cloud_type_id: number | null
custom_cloud_type: string | null
image_url: string image_url: string
thumbnail_url: string | null thumbnail_url: string | null
location_name: string | null location_name: string | null
@@ -18,6 +24,8 @@ interface GalleryCloud {
longitude: number | null longitude: number | null
captured_at: string | null captured_at: string | null
created_at: string created_at: string
status: 'pending' | 'approved' | 'rejected'
is_hidden: boolean
cloudTypeName: string cloudTypeName: string
cloudTypeRarity: CloudType['rarity'] cloudTypeRarity: CloudType['rarity']
username: string username: string
@@ -25,7 +33,10 @@ interface GalleryCloud {
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const authStore = useAuthStore()
const cloudsStore = useCloudsStore() const cloudsStore = useCloudsStore()
const profileStore = useProfileStore()
const message = useMessage()
const loading = ref(true) const loading = ref(true)
const loadingMore = ref(false) const loadingMore = ref(false)
@@ -36,6 +47,10 @@ const selectedCloud = ref<GalleryCloud | null>(null)
const sentinel = ref<HTMLDivElement | null>(null) const sentinel = ref<HTMLDivElement | null>(null)
const totalLoaded = ref(0) const totalLoaded = ref(0)
const hasMore = ref(true) 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 let observer: IntersectionObserver | null = null
@@ -76,6 +91,9 @@ function toGalleryCloud(row: Record<string, unknown>) {
return { return {
id: row.id as string, 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, image_url: row.image_url as string,
thumbnail_url: (row.thumbnail_url as string | null) ?? null, thumbnail_url: (row.thumbnail_url as string | null) ?? null,
location_name: (row.location_name 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, longitude: (row.longitude as number | null) ?? null,
captured_at: (row.captured_at as string | null) ?? null, captured_at: (row.captured_at as string | null) ?? null,
created_at: row.created_at as string, 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) || '未知', cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common', cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
username: (profile?.username as string) || '匿名', username: (profile?.username as string) || '匿名',
@@ -93,7 +113,7 @@ function toGalleryCloud(row: Record<string, unknown>) {
async function fetchPage(offset: number) { async function fetchPage(offset: number) {
let query = supabase let query = supabase
.from('clouds') .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('status', 'approved')
.eq('is_hidden', false) .eq('is_hidden', false)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -167,6 +187,164 @@ function openDetail(cloud: GalleryCloud) {
function closeDetail() { function closeDetail() {
selectedCloud.value = null 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(() => [ const filterTabs = computed(() => [
@@ -226,6 +404,10 @@ onUnmounted(() => {
{{ loadError }} {{ loadError }}
</NAlert> </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]"> <section v-if="loading" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
<div <div
v-for="n in 8" v-for="n in 8"
@@ -307,6 +489,7 @@ onUnmounted(() => {
v-if="selectedCloud" v-if="selectedCloud"
:open="!!selectedCloud" :open="!!selectedCloud"
:image-url="selectedCloud.image_url" :image-url="selectedCloud.image_url"
:thumbnail-url="selectedCloud.thumbnail_url"
:image-alt="selectedCloud.cloudTypeName" :image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName" :title="selectedCloud.cloudTypeName"
:subtitle="`上传者:${selectedCloud.username}`" :subtitle="`上传者:${selectedCloud.username}`"
@@ -341,7 +524,40 @@ onUnmounted(() => {
{{ selectedCloud.description || '上传者没有留下额外说明。' }} {{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p> </p>
</div> </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> </ImageDetailModal>
<CloudEditModal
:open="editModalOpen && !!selectedCloud"
:saving="editSaving"
:error="manageError"
:cloud-types="cloudsStore.cloudTypes"
:initial-value="editInitialValue"
@close="closeEditModal"
@save="submitEditModal"
/>
</div> </div>
</div> </div>
</template> </template>
+1
View File
@@ -313,6 +313,7 @@ onUnmounted(() => {
v-if="previewCloud" v-if="previewCloud"
:open="!!previewCloud" :open="!!previewCloud"
:image-url="previewCloud.imageUrl" :image-url="previewCloud.imageUrl"
:thumbnail-url="previewCloud.thumbnailUrl"
:image-alt="previewCloud.cloudTypeName" :image-alt="previewCloud.cloudTypeName"
:title="previewCloud.cloudTypeName" :title="previewCloud.cloudTypeName"
:subtitle="`上传者:${previewCloud.username}`" :subtitle="`上传者:${previewCloud.username}`"
+272 -2
View File
@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' 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 ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue' import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useEncyclopediaStore } from '@/stores/encyclopedia' import { useEncyclopediaStore } from '@/stores/encyclopedia'
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile' import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database' import type { CloudType, Profile } from '@/types/database'
@@ -17,12 +20,20 @@ interface TimelineGroup {
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const encyclopediaStore = useEncyclopediaStore() const encyclopediaStore = useEncyclopediaStore()
const profileStore = useProfileStore() const profileStore = useProfileStore()
const message = useMessage()
const selectedCloud = ref<ProfileCloudItem | null>(null) const selectedCloud = ref<ProfileCloudItem | null>(null)
const selectedUploadDate = ref<string | null>(null) const selectedUploadDate = ref<string | null>(null)
const timelineSectionRef = ref<HTMLElement | 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 = { const rarityMeta = {
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' }, 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 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(() => { const shootingDays = computed(() => {
return new Set( return new Set(
clouds.value.map(item => toDayKey(item.captured_at || item.created_at)), 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) { function formatCloudTime(item: ProfileCloudItem) {
return formatDateTime(item.captured_at || item.created_at) return formatDateTime(item.captured_at || item.created_at)
} }
@@ -255,9 +285,156 @@ function clearSelectedUploadDate() {
selectedUploadDate.value = null 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) { async function loadProfilePage(force = false) {
selectedCloud.value = null selectedCloud.value = null
selectedUploadDate.value = null selectedUploadDate.value = null
clearCloudSelection()
selectionMode.value = false
const targetUserId = viewedUserId.value const targetUserId = viewedUserId.value
@@ -268,6 +445,7 @@ async function loadProfilePage(force = false) {
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force) await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
if (isOwnProfile.value) { if (isOwnProfile.value) {
await cloudsStore.fetchCloudTypes()
await encyclopediaStore.fetchCloudTypes(force) await encyclopediaStore.fetchCloudTypes(force)
await encyclopediaStore.fetchMyCollection(force) await encyclopediaStore.fetchMyCollection(force)
} }
@@ -386,6 +564,17 @@ watch(selectedUploadDate, async newValue => {
{{ errorMsg }} {{ errorMsg }}
</NAlert> </NAlert>
<NAlert
v-if="manageError"
class="mb-5"
type="error"
:show-icon="false"
:bordered="false"
title="操作失败"
>
{{ manageError }}
</NAlert>
<template v-else> <template v-else>
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard v-for="n in 4" :key="n"> <NCard v-for="n in 4" :key="n">
@@ -445,6 +634,23 @@ watch(selectedUploadDate, async newValue => {
</p> </p>
</div> </div>
<div class="flex items-center gap-3"> <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 secondary strong @click="loadProfilePage(true)">
刷新数据 刷新数据
</NButton> </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 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"> <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"> <span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[item.cloudTypeRarity].chip">
{{ rarityMeta[item.cloudTypeRarity].label }} {{ rarityMeta[item.cloudTypeRarity].label }}
</span> </span>
@@ -517,6 +736,13 @@ watch(selectedUploadDate, async newValue => {
> >
{{ statusMeta[item.status].label }} {{ statusMeta[item.status].label }}
</span> </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>
<div class="absolute bottom-4 left-4 right-4 text-white"> <div class="absolute bottom-4 left-4 right-4 text-white">
<p class="truncate text-lg font-semibold">{{ item.cloudTypeName }}</p> <p class="truncate text-lg font-semibold">{{ item.cloudTypeName }}</p>
@@ -555,6 +781,7 @@ watch(selectedUploadDate, async newValue => {
v-if="selectedCloud" v-if="selectedCloud"
:open="!!selectedCloud" :open="!!selectedCloud"
:image-url="selectedCloud.image_url" :image-url="selectedCloud.image_url"
:thumbnail-url="selectedCloud.thumbnail_url"
:image-alt="selectedCloud.cloudTypeName" :image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName" :title="selectedCloud.cloudTypeName"
:subtitle="`拍摄时间:${formatCloudTime(selectedCloud)}`" :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="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> <p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
</div> </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"> <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="text-xs uppercase tracking-[0.18em] text-slate-500">{{ isOwnProfile ? '审核状态' : '可见性' }}</p>
<p class="mt-2 text-sm font-medium text-slate-900"> <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> <template v-else>公开可见</template>
</p> </p>
</div> </div>
@@ -590,6 +825,41 @@ watch(selectedUploadDate, async newValue => {
{{ selectedCloud.description || '上传者没有留下额外说明。' }} {{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p> </p>
</div> </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> </ImageDetailModal>
<CloudEditModal
:open="editModalOpen && !!selectedCloud"
:saving="editSaving"
:error="manageError"
:cloud-types="cloudsStore.cloudTypes"
:initial-value="editInitialValue"
@close="closeEditModal"
@save="submitEditModal"
/>
</div> </div>
</template> </template>
+34 -174
View File
@@ -5,8 +5,8 @@ import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds' import { useCloudsStore } from '@/stores/clouds'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { downloadCloudBadgeCard } from '@/lib/cloudBadges' import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
import { loadAMap } from '@/lib/amap'
import { useUpload } from '@/composables/useUpload' import { useUpload } from '@/composables/useUpload'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -20,15 +20,6 @@ const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges
const errorMsg = ref('') const errorMsg = ref('')
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const mapPickerOpen = ref(false) 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(() => { const activeItem = computed(() => {
if (!activeId.value) return null 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) { function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
if (!activeItem.value) return 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 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() { async function openMapPicker() {
if (!activeItem.value) return if (!activeItem.value) return
mapPickerOpen.value = true 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() { function closeMapPicker() {
mapPickerOpen.value = false mapPickerOpen.value = false
mapPickerLoading.value = false
mapPickerError.value = ''
destroyMapPicker()
} }
function confirmMapPicker() { function confirmMapPicker(payload: { latitude: number; longitude: number }) {
if (mapPickerLat.value === null || mapPickerLng.value === null) return updateActiveCoordinates(payload.latitude, payload.longitude)
updateActiveCoordinates(mapPickerLat.value, mapPickerLng.value)
closeMapPicker() closeMapPicker()
} }
@@ -186,6 +90,11 @@ function clearCoordinates() {
updateActiveCoordinates(null, null) updateActiveCoordinates(null, null)
} }
function updateActivePublicState(isPublic: boolean) {
if (!activeItem.value) return
activeItem.value.isHidden = !isPublic
}
async function handleSubmit() { async function handleSubmit() {
errorMsg.value = '' errorMsg.value = ''
const allValid = validateAll() const allValid = validateAll()
@@ -289,7 +198,6 @@ function onCategoryChange(e: Event) {
onMounted(() => { cloudsStore.fetchCloudTypes() }) onMounted(() => { cloudsStore.fetchCloudTypes() })
onUnmounted(() => { onUnmounted(() => {
for (const item of items.value) URL.revokeObjectURL(item.preview) for (const item of items.value) URL.revokeObjectURL(item.preview)
destroyMapPicker()
}) })
</script> </script>
@@ -316,9 +224,9 @@ onUnmounted(() => {
<template v-if="unlockedBadges.length"> <template v-if="unlockedBadges.length">
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章 新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章
</template> </template>
<template v-else> <!-- <template v-else>
这批云图已经进入你的收藏记录 这批云图已经进入你的收藏记录
</template> </template> -->
</p> </p>
</template> </template>
</NResult> </NResult>
@@ -542,10 +450,21 @@ onUnmounted(() => {
></textarea> ></textarea>
</div> </div>
<!-- 隐身模式 --> <!-- 公开状态 -->
<div class="flex items-center gap-2"> <div class="border border-slate-200 bg-slate-50 px-4 py-3">
<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" /> <div class="flex items-center justify-between gap-4">
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式不在地图上显示</label> <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>
</div> </div>
</NCard> </NCard>
@@ -570,72 +489,13 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<Teleport to="body"> <MapPickerModal
<div :open="mapPickerOpen"
v-if="mapPickerOpen" :latitude="activeItem?.latitude ?? null"
class="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/55 px-4 py-6" :longitude="activeItem?.longitude ?? null"
@click="closeMapPicker" description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
> footer-text="建议点选大致拍摄位置上传时会继续做模糊化处理"
<div @close="closeMapPicker"
class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @confirm="confirmMapPicker"
@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>
</template> </template>