Files
opencloud/src/views/upload/UploadView.vue
T

502 lines
21 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NAlert, NButton, NCard, NProgress, NResult, NTag } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds'
import { useAuthStore } from '@/stores/auth'
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
import { useUpload } from '@/composables/useUpload'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
const router = useRouter()
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
const activeId = ref<string | null>(null)
const dragOver = ref(false)
const successMsg = ref(false)
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
const errorMsg = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const mapPickerOpen = ref(false)
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
}
}
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
if (!activeItem.value) return
activeItem.value.latitude = latitude
activeItem.value.longitude = longitude
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
}
async function openMapPicker() {
if (!activeItem.value) return
mapPickerOpen.value = true
}
function closeMapPicker() {
mapPickerOpen.value = false
}
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
updateActiveCoordinates(payload.latitude, payload.longitude)
closeMapPicker()
}
function clearCoordinates() {
updateActiveCoordinates(null, null)
}
function updateActivePublicState(isPublic: boolean) {
if (!activeItem.value) return
activeItem.value.isHidden = !isPublic
}
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 result = await uploadAll()
if (result.ok) {
unlockedBadges.value = result.unlockedBadges
successMsg.value = true
} else {
errorMsg.value = '上传失败,请稍后重试'
}
}
function resetAfterSuccess() {
successMsg.value = false
unlockedBadges.value = []
errorMsg.value = ''
}
async function saveBadge(badge: NonNullable<typeof unlockedBadges.value[number]>) {
await downloadCloudBadgeCard({
cloudName: badge.cloudName,
cloudNameEn: badge.cloudNameEn,
unlockedAt: badge.unlockedAt,
username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
rarity: badge.rarity,
})
}
function rarityLabel(rarity: NonNullable<typeof unlockedBadges.value[number]>['rarity']) {
if (rarity === 'common') return '常见'
if (rarity === 'uncommon') return '少见'
return '罕见'
}
function formatUnlockedAt(iso: string) {
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function onLatInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (activeItem.value) {
activeItem.value.latitude = isNaN(val) ? null : val
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
}
}
function onLngInput(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value)
if (activeItem.value) {
activeItem.value.longitude = isNaN(val) ? null : val
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
}
}
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>
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
<div class="max-w-6xl mx-auto px-4 py-10">
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Cloud Upload</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">上传云图</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
批量整理你的云图记录类别和拍摄时间必填经纬度既可以手动输入也可以在迷你地图里点选回填
</p>
</div>
</section>
<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="w-full max-w-5xl">
<NResult status="success" title="已提交审核" class="border border-slate-200 bg-white shadow-sm">
<template #default>
<p class="text-slate-500">
<template v-if="unlockedBadges.length">
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章图片审核通过后会出现在画廊和地图中
</template>
<template v-else>
这批云图已进入待审核队列审核通过后会出现在画廊和地图中
</template>
</p>
</template>
</NResult>
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard
v-for="badge in unlockedBadges"
:key="badge.cloudTypeId"
class="border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] shadow-sm"
>
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white shadow-sm">
{{ badge.cloudName.slice(0, 1) }}
</div>
<div class="mt-5">
<div class="flex items-center gap-3">
<h3 class="text-2xl font-bold text-gray-900">{{ badge.cloudName }}</h3>
<NTag :bordered="false" type="warning">
{{ rarityLabel(badge.rarity) }}
</NTag>
</div>
<p class="mt-1 text-sm text-gray-500">{{ badge.cloudNameEn }}</p>
<p class="mt-4 text-sm text-gray-600">解锁时间{{ formatUnlockedAt(badge.unlockedAt) }}</p>
</div>
<div class="mt-6 flex gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
</div>
</NCard>
</div>
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="resetAfterSuccess">继续上传</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
</div>
</div>
</div>
<template v-else>
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
<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>
<NProgress
type="line"
:show-indicator="false"
:percentage="overallProgress"
color="#0ea5e9"
rail-color="#dbe4ee"
:height="10"
/>
</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 py-20 cursor-pointer transition-colors bg-white shadow-sm"
: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 overflow-hidden mb-3 border border-slate-900">
<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 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
: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"></div>
<button
@click.stop="handleRemove(item.id)"
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center bg-red-500 text-xs text-white"
style="opacity:0.8"
></button>
</div>
<div
@click="fileInput?.click()"
class="flex-shrink-0 w-16 h-16 border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer bg-white 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">
<NCard class="border border-gray-200 shadow-sm">
<div class="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>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-gray-700">经纬度</label>
<button
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
>
清空
</button>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
<div>
<input
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
@input="onLatInput"
type="number"
step="any"
placeholder="纬度(如 39.90"
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.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
</div>
<div>
<input
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
@input="onLngInput"
type="number"
step="any"
placeholder="经度(如 116.40"
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.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
</div>
<div>
<button
type="button"
@click="openMapPicker"
title="地图选点"
aria-label="地图选点"
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
>
<span class="text-lg leading-none">🗺</span>
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">选填可手动输入或点击右侧小地图图标选择</p>
</div>
<!-- 位置名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
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"
/>
</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="border border-slate-200 bg-slate-50 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<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>
</NCard>
</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">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
{{ uploading ? '上传中...' : '提交上传' }}
</NButton>
</div>
</div>
<NAlert v-if="errorMsg" class="mt-3" type="error" :show-icon="false" :bordered="false" title="上传失败">
{{ errorMsg }}
</NAlert>
</template>
</template>
</div>
</div>
<MapPickerModal
:open="mapPickerOpen"
:latitude="activeItem?.latitude ?? null"
:longitude="activeItem?.longitude ?? null"
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
footer-text="建议点选大致拍摄位置上传时会继续做模糊化处理"
@close="closeMapPicker"
@confirm="confirmMapPicker"
/>
</template>