Files
opencloud/src/components/cloud/QuickUploadModal.vue
T

403 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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 { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { NAlert, NButton, NIcon, NProgress } from 'naive-ui'
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
import { useCloudsStore } from '@/stores/clouds'
import { useUpload } from '@/composables/useUpload'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
uploaded: []
}>()
const cloudsStore = useCloudsStore()
const {
items,
uploading,
overallProgress,
currentItemIndex,
totalItems,
addFiles,
removeItem,
clearAll,
validateAll,
uploadAll,
} = useUpload()
const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<string | null>(null)
const dragOver = ref(false)
const errorMsg = ref('')
const successMsg = ref(false)
const mapPickerOpen = ref(false)
const activeItem = computed(() => {
if (!activeId.value) return null
return items.value.find(item => item.id === activeId.value) ?? null
})
watch(() => props.open, open => {
if (open) {
cloudsStore.fetchCloudTypes()
errorMsg.value = ''
successMsg.value = false
}
})
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
}
})
async function addSelectedFiles(files: File[]) {
const previousLength = items.value.length
await addFiles(files)
if (items.value.length > previousLength) {
activeId.value = items.value[previousLength].id
}
}
function handleDrop(event: DragEvent) {
dragOver.value = false
if (event.dataTransfer?.files) {
addSelectedFiles(Array.from(event.dataTransfer.files))
}
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
if (target.files?.length) {
addSelectedFiles(Array.from(target.files))
}
target.value = ''
}
function handleRemove(id: string) {
removeItem(id)
if (activeId.value === id) {
activeId.value = items.value[0]?.id ?? null
}
}
function onCategoryChange(event: Event) {
if (!activeItem.value) return
const value = (event.target as HTMLSelectElement).value
if (value === 'other') {
activeItem.value.cloudCategoryId = 'other'
} else if (value === '') {
activeItem.value.cloudCategoryId = null
} else {
activeItem.value.cloudCategoryId = Number(value)
}
activeItem.value.errors = {}
}
function formatDatetimeLocal(iso: string): string {
const date = new Date(iso)
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function onCapturedAtChange(event: Event) {
if (!activeItem.value) return
const value = (event.target as HTMLInputElement).value
if (!value) return
activeItem.value.capturedAt = new Date(value).toISOString()
delete activeItem.value.errors.capturedAt
}
function onLatInput(event: Event) {
if (!activeItem.value) return
const value = Number.parseFloat((event.target as HTMLInputElement).value)
activeItem.value.latitude = Number.isNaN(value) ? null : value
delete activeItem.value.errors.latitude
delete activeItem.value.errors.longitude
}
function onLngInput(event: Event) {
if (!activeItem.value) return
const value = Number.parseFloat((event.target as HTMLInputElement).value)
activeItem.value.longitude = Number.isNaN(value) ? null : value
delete activeItem.value.errors.latitude
delete activeItem.value.errors.longitude
}
function updatePublicState(isPublic: boolean) {
if (!activeItem.value) return
activeItem.value.isHidden = !isPublic
}
function openMapPicker() {
if (!activeItem.value) return
mapPickerOpen.value = true
}
function closeMapPicker() {
mapPickerOpen.value = false
}
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
if (!activeItem.value) return
activeItem.value.latitude = payload.latitude
activeItem.value.longitude = payload.longitude
delete activeItem.value.errors.latitude
delete activeItem.value.errors.longitude
closeMapPicker()
}
async function handleSubmit() {
errorMsg.value = ''
successMsg.value = false
if (!validateAll()) {
const firstInvalid = items.value.find(item => Object.keys(item.errors).length > 0)
if (firstInvalid) {
activeId.value = firstInvalid.id
await nextTick()
}
return
}
const result = await uploadAll()
if (!result.ok) {
errorMsg.value = '上传失败,请稍后重试'
return
}
successMsg.value = true
emit('uploaded')
}
function close() {
if (uploading.value) return
clearAll()
errorMsg.value = ''
successMsg.value = false
mapPickerOpen.value = false
emit('close')
}
onBeforeUnmount(() => {
clearAll()
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
@click="close"
>
<div class="w-full max-w-3xl overflow-hidden border border-slate-200 bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
<h2 class="mt-2 text-xl font-bold text-slate-950">快速上传云图</h2>
<p class="mt-1 text-sm text-slate-500">从地图当前位置带入经纬度提交后进入审核队列</p>
</div>
<button
type="button"
class="quick-upload-close flex h-9 w-9 items-center justify-center border border-slate-200 bg-slate-50 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800"
aria-label="关闭快捷上传"
title="关闭快捷上传"
@click="close"
>
<NIcon size="20"><X /></NIcon>
</button>
</div>
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileSelect" />
<NAlert v-if="successMsg" type="success" :bordered="false" title="已提交审核" class="mb-4">
图片审核通过后会出现在画廊和地图中
</NAlert>
<div v-if="uploading" class="mb-4 border border-sky-100 bg-sky-50 p-4">
<div class="mb-2 flex items-center justify-between text-sm">
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
</div>
<NProgress type="line" :percentage="overallProgress" :show-indicator="false" color="#0ea5e9" rail-color="#dbeafe" />
</div>
<div
v-if="items.length === 0"
class="flex cursor-pointer flex-col items-center justify-center border-2 border-dashed px-6 py-12 text-center transition-colors"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 bg-slate-50 hover:border-sky-300 hover:bg-sky-50'"
@click="fileInput?.click()"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop"
>
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
<p class="mt-1 text-sm text-slate-500">支持 JPGPNG可点击或拖拽上传</p>
</div>
<template v-else-if="activeItem">
<div class="grid gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
<div>
<div class="overflow-hidden border border-slate-900 bg-slate-950">
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="fileInput?.click()">重新选择</button>
<button type="button" class="text-sm font-medium text-rose-600 hover:text-rose-800" @click="handleRemove(activeItem.id)">移除</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">类别 <span class="text-rose-500">*</span></label>
<select
:value="activeItem.cloudCategoryId ?? ''"
class="w-full border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
@change="onCategoryChange"
>
<option value="" disabled>请选择</option>
<option v-for="cloudType in cloudsStore.cloudTypes" :key="cloudType.id" :value="cloudType.id">
{{ cloudType.name }}{{ cloudType.name_en }}
</option>
<option value="other">其他</option>
</select>
<input
v-if="activeItem.cloudCategoryId === 'other'"
v-model="activeItem.customCloudType"
type="text"
placeholder="输入云的类型"
class="mt-2 w-full border border-slate-300 px-3 py-2 text-sm outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
@input="activeItem.errors = {}"
/>
<p v-if="activeItem.errors.cloudCategory" class="mt-1 text-sm text-rose-600">{{ activeItem.errors.cloudCategory }}</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">拍摄时间 <span class="text-rose-500">*</span></label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
class="w-full border border-slate-300 px-3 py-2 text-sm outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
@change="onCapturedAtChange"
/>
<p v-if="activeItem.errors.capturedAt" class="mt-1 text-sm text-rose-600">{{ activeItem.errors.capturedAt }}</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">经纬度</label>
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2">
<div>
<input
:value="activeItem.latitude ?? ''"
type="number"
step="any"
placeholder="纬度"
class="w-full border border-slate-300 px-3 py-2 text-sm outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
@input="onLatInput"
/>
<p v-if="activeItem.errors.latitude" class="mt-1 text-sm text-rose-600">{{ activeItem.errors.latitude }}</p>
</div>
<div>
<input
:value="activeItem.longitude ?? ''"
type="number"
step="any"
placeholder="经度"
class="w-full border border-slate-300 px-3 py-2 text-sm outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
@input="onLngInput"
/>
<p v-if="activeItem.errors.longitude" class="mt-1 text-sm text-rose-600">{{ activeItem.errors.longitude }}</p>
</div>
<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="openMapPicker"
>
<NIcon size="20">
<MapIcon />
</NIcon>
</button>
</div>
<p class="mt-1 text-xs text-slate-400">选填可手动输入也可以点击右侧地图按钮选点</p>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
placeholder="如:北京、成都"
class="w-full border border-slate-300 px-3 py-2 text-sm outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-700">图片说明</label>
<textarea
v-model="activeItem.description"
rows="3"
placeholder="描述一下这张图片..."
class="w-full resize-none border border-slate-300 px-3 py-2 text-sm outline-none transition-colors focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
></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
:checked="!activeItem.isHidden"
type="checkbox"
class="h-5 w-5 border-slate-300 text-sky-600 focus:ring-sky-500"
@change="updatePublicState(($event.target as HTMLInputElement).checked)"
/>
</label>
</div>
</div>
<NAlert v-if="errorMsg" class="mt-4" type="error" :show-icon="false" title="上传失败">
{{ errorMsg }}
</NAlert>
</template>
</div>
<div class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4">
<NButton secondary strong :disabled="uploading" @click="close">关闭</NButton>
<NButton type="primary" secondary strong :disabled="uploading || items.length === 0" @click="handleSubmit">
{{ uploading ? '上传中...' : '提交审核' }}
</NButton>
</div>
</div>
</div>
<MapPickerModal
:open="mapPickerOpen"
:latitude="activeItem?.latitude ?? null"
:longitude="activeItem?.longitude ?? null"
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
footer-text="建议点选大致拍摄位置上传时会继续做模糊化处理"
@close="closeMapPicker"
@confirm="confirmMapPicker"
/>
</Teleport>
</template>
<style scoped>
.quick-upload-close.quick-upload-close {
border-radius: 9999px !important;
}
</style>