feat: cloud upload - multi-image, category select, capture time, progress bar

This commit is contained in:
2026-05-21 01:14:19 +08:00
parent d4b07fba58
commit cb2581afb2
5 changed files with 650 additions and 6 deletions
+350 -4
View File
@@ -1,11 +1,357 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds'
import { useUpload } from '@/composables/useUpload'
import { loadAMap } from '@/lib/amap'
const router = useRouter()
const cloudsStore = useCloudsStore()
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, validateAll, uploadAll } = useUpload()
const activeId = ref<string | null>(null)
const dragOver = ref(false)
const successMsg = ref(false)
const errorMsg = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const locating = ref(false)
const locationError = ref('')
const activeItem = computed(() => {
if (!activeId.value) return null
return items.value.find(i => i.id === activeId.value) ?? null
})
watch(() => items.value.length, () => {
if (items.value.length > 0 && !activeId.value) {
activeId.value = items.value[0].id
}
if (items.value.length === 0) {
activeId.value = null
}
})
function selectItem(id: string) {
activeId.value = id
}
function handleDrop(e: DragEvent) {
dragOver.value = false
if (e.dataTransfer?.files) {
addFiles(Array.from(e.dataTransfer.files))
}
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
addFiles(Array.from(target.files))
}
target.value = ''
}
function handleRemove(id: string) {
removeItem(id)
if (activeId.value === id) {
activeId.value = items.value[0]?.id ?? null
}
}
async function handleSubmit() {
errorMsg.value = ''
const allValid = validateAll()
if (!allValid) {
const firstInvalid = items.value.find(i => Object.keys(i.errors).length > 0)
if (firstInvalid) {
activeId.value = firstInvalid.id
await nextTick()
const el = document.getElementById(`field-${firstInvalid.id}-cloudCategory`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
return
}
const ok = await uploadAll()
if (ok) {
successMsg.value = true
setTimeout(() => router.push('/profile'), 2000)
} else {
errorMsg.value = '上传失败,请稍后重试'
}
}
async function getLocation() {
if (!activeItem.value) return
locating.value = true
locationError.value = ''
try {
const AMap = await loadAMap()
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true,
timeout: 10000,
})
await new Promise<void>((resolve, reject) => {
geolocation.getCurrentPosition(
(data: { position: { lng: number; lat: number } }) => {
if (activeItem.value) {
activeItem.value.latitude = data.position.lat
activeItem.value.longitude = data.position.lng
}
resolve()
},
() => reject(new Error('AMap error')),
)
})
} catch {
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
})
})
if (activeItem.value) {
activeItem.value.latitude = pos.coords.latitude
activeItem.value.longitude = pos.coords.longitude
}
} catch {
locationError.value = '无法获取位置,请确认定位权限已开启,或手动输入'
}
} finally {
locating.value = false
}
}
function formatDatetimeLocal(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function onCapturedAtChange(e: Event) {
const val = (e.target as HTMLInputElement).value
if (activeItem.value && val) {
activeItem.value.capturedAt = new Date(val).toISOString()
if (activeItem.value.errors.capturedAt) {
delete activeItem.value.errors.capturedAt
}
}
}
function onCategoryChange(e: Event) {
const val = (e.target as HTMLSelectElement).value
if (!activeItem.value) return
if (val === 'other') {
activeItem.value.cloudCategoryId = 'other'
} else if (val === '') {
activeItem.value.cloudCategoryId = null
} else {
activeItem.value.cloudCategoryId = Number(val)
}
activeItem.value.errors = {}
}
onMounted(() => { cloudsStore.fetchCloudTypes() })
onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.preview) })
</script>
<template>
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)]">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900 mb-2">📷 上传云图</h1>
<p class="text-gray-500">上传功能开发中...</p>
<div class="max-w-6xl mx-auto px-4 py-8">
<!-- 全局隐藏文件输入 -->
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
<!-- 成功提示 -->
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
<div class="text-center">
<span class="text-5xl block mb-4"></span>
<h2 class="text-2xl font-bold text-gray-900 mb-2">上传成功</h2>
<p class="text-gray-500">正在跳转到个人主页...</p>
</div>
</div>
<template v-else>
<h1 class="text-2xl font-bold text-gray-900 mb-6">📷 上传云图</h1>
<!-- 上传进度 -->
<div v-if="uploading" class="mb-6">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
<div class="bg-sky-500 h-full rounded-full transition-all duration-300" :style="{ width: overallProgress + '%' }"></div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="items.length === 0"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop"
@click="fileInput?.click()"
class="flex flex-col items-center justify-center border-2 border-dashed rounded-2xl py-20 cursor-pointer transition-colors"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-gray-300 hover:border-sky-400 hover:bg-gray-50'"
>
<span class="text-5xl mb-4"></span>
<p class="text-lg font-medium text-gray-700 mb-1">点击或拖拽图片到此处</p>
<p class="text-sm text-gray-400">支持 JPGPNG 格式可一次选择多张</p>
</div>
<!-- 编辑界面 -->
<template v-else>
<div class="flex gap-6">
<!-- 左侧 -->
<div class="flex-shrink-0 w-[480px]">
<div v-if="activeItem" class="bg-gray-900 rounded-xl overflow-hidden mb-3">
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
</div>
<div class="flex gap-2 overflow-x-auto pb-2">
<div
v-for="item in items"
:key="item.id"
@click="selectItem(item.id)"
class="relative flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden cursor-pointer border-2 transition-colors"
:class="activeId === item.id ? 'border-sky-500' : 'border-transparent hover:border-gray-300'"
>
<img :src="item.preview" alt="" class="w-full h-full object-cover" />
<div v-if="Object.keys(item.errors).length > 0" class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500 rounded-full"></div>
<button
@click.stop="handleRemove(item.id)"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center"
style="opacity:0.8"
></button>
</div>
<div
@click="fileInput?.click()"
class="flex-shrink-0 w-16 h-16 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer hover:border-sky-400 hover:bg-gray-50 transition-colors"
>
<span class="text-gray-400 text-xl">+</span>
</div>
</div>
</div>
<!-- 右侧表单 -->
<div class="flex-1 min-w-0" v-if="activeItem">
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">图片信息</h2>
<span class="text-sm text-gray-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
</div>
<!-- 类别 * -->
<div :id="`field-${activeItem.id}-cloudCategory`">
<label class="block text-sm font-medium text-gray-700 mb-1">
类别 <span class="text-red-500">*</span>
</label>
<select
:value="activeItem.cloudCategoryId ?? ''"
@change="onCategoryChange"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors bg-white"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
{{ ct.name }}{{ ct.name_en }}
</option>
<option value="other">其他</option>
</select>
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
<input
v-model="activeItem.customCloudType"
type="text"
placeholder="输入云型名称"
@input="activeItem.errors = {}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
</div>
<p v-if="activeItem.errors.cloudCategory" class="text-sm text-red-500 mt-1">{{ activeItem.errors.cloudCategory }}</p>
</div>
<!-- 拍摄时间 * -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
拍摄时间 <span class="text-red-500">*</span>
</label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@change="onCapturedAtChange"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 位置 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">位置</label>
<div class="flex gap-2">
<input
v-model="activeItem.locationName"
type="text"
placeholder="城市或区域名"
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
/>
<button
@click="getLocation"
type="button"
:disabled="locating"
class="px-3 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors text-sm whitespace-nowrap disabled:opacity-50"
>
{{ locating ? '定位中...' : '📍 获取位置' }}
</button>
</div>
<p v-if="locationError" class="text-xs text-red-400 mt-1">{{ locationError }}</p>
<p v-else-if="activeItem.latitude && activeItem.longitude" class="text-xs text-gray-400 mt-1">
已获取坐标 ({{ activeItem.latitude.toFixed(2) }}, {{ activeItem.longitude.toFixed(2) }})
</p>
</div>
<!-- 图片描述 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">图片描述</label>
<textarea
v-model="activeItem.description"
rows="3"
placeholder="描述一下这张图片..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors resize-none"
></textarea>
</div>
<!-- 隐身模式 -->
<div class="flex items-center gap-2">
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式地图不显示位置</label>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="mt-6 flex items-center justify-between">
<p class="text-sm text-gray-500"> {{ items.length }} 张图片类别和拍摄时间为必填项</p>
<div class="flex gap-3">
<button
@click="() => { for (const i of items) URL.revokeObjectURL(i.preview); items = [] }"
:disabled="uploading"
class="px-5 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
清空
</button>
<button
@click="handleSubmit"
:disabled="uploading"
class="px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors"
>
{{ uploading ? '上传中...' : '提交上传' }}
</button>
</div>
</div>
<div v-if="errorMsg" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ errorMsg }}</p>
</div>
</template>
</template>
</div>
</template>