feat: cloud upload - multi-image, category select, capture time, progress bar
This commit is contained in:
@@ -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">支持 JPG、PNG 格式,可一次选择多张</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>
|
||||
|
||||
Reference in New Issue
Block a user