502 lines
21 KiB
Vue
502 lines
21 KiB
Vue
<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">支持 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 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>
|