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>