Add image detail map and quick upload
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoCircle, X } from '@vicons/tabler'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -23,26 +25,18 @@ const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const infoOpen = ref(false)
|
||||
const imageLoaded = ref(false)
|
||||
const previewLoaded = ref(false)
|
||||
const naturalWidth = ref(0)
|
||||
const naturalHeight = ref(0)
|
||||
const viewportWidth = ref(0)
|
||||
const viewportHeight = ref(0)
|
||||
const isDesktop = computed(() => viewportWidth.value >= 1024)
|
||||
|
||||
function clampPanelWidth(width: number) {
|
||||
return Math.min(Math.max(width, 240), 400)
|
||||
}
|
||||
|
||||
function parsePanelWidth(width: string) {
|
||||
const normalized = width.trim()
|
||||
if (!normalized) return null
|
||||
if (normalized.endsWith('px')) return Number.parseFloat(normalized)
|
||||
if (normalized.endsWith('rem')) return Number.parseFloat(normalized) * 16
|
||||
return null
|
||||
}
|
||||
const modalVisible = ref(false)
|
||||
const closing = ref(false)
|
||||
let closeTimer: number | null = null
|
||||
const previousBodyOverflow = ref<string | null>(null)
|
||||
const previousDocumentOverflow = ref<string | null>(null)
|
||||
|
||||
function updateViewport() {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -54,59 +48,20 @@ const layout = computed(() => {
|
||||
const aspectRatio = naturalWidth.value > 0 && naturalHeight.value > 0
|
||||
? naturalWidth.value / naturalHeight.value
|
||||
: 4 / 3
|
||||
const maxModalWidth = Math.max(viewportWidth.value * 0.92, 320)
|
||||
const mobileImageMaxHeight = Math.max(viewportHeight.value * 0.48, 220)
|
||||
const desktopImageMaxHeight = Math.max(viewportHeight.value * 0.8, 240)
|
||||
|
||||
if (!isDesktop.value) {
|
||||
const imageWidth = Math.min(maxModalWidth, mobileImageMaxHeight * aspectRatio)
|
||||
const imageHeight = imageWidth / aspectRatio
|
||||
const panelHeight = collapsed.value ? 0 : Math.min(Math.max(imageHeight * 0.72, 180), viewportHeight.value * 0.34)
|
||||
|
||||
return {
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
panelWidth: imageWidth,
|
||||
panelHeight,
|
||||
}
|
||||
}
|
||||
|
||||
const maxImageHeight = desktopImageMaxHeight
|
||||
|
||||
if (collapsed.value) {
|
||||
const maxModalWidth = viewportWidth.value > 0
|
||||
? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280)
|
||||
: 320
|
||||
const maxImageHeight = viewportHeight.value > 0
|
||||
? Math.max(viewportHeight.value * 0.86, 240)
|
||||
: 360
|
||||
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
||||
return {
|
||||
imageWidth,
|
||||
imageHeight: imageWidth / aspectRatio,
|
||||
panelWidth: 0,
|
||||
panelHeight: imageWidth / aspectRatio,
|
||||
}
|
||||
}
|
||||
|
||||
const fixedPanelWidth = parsePanelWidth(props.panelWidth)
|
||||
let panelWidth = fixedPanelWidth ?? clampPanelWidth(Math.min(maxModalWidth * 0.28, 320))
|
||||
let imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
|
||||
|
||||
if (!fixedPanelWidth) {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
panelWidth = clampPanelWidth(imageWidth * 0.34)
|
||||
imageWidth = Math.min(maxModalWidth - panelWidth, maxImageHeight * aspectRatio)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imageWidth,
|
||||
imageHeight: imageWidth / aspectRatio,
|
||||
panelWidth,
|
||||
panelHeight: imageWidth / aspectRatio,
|
||||
}
|
||||
})
|
||||
|
||||
const rootStyle = computed(() => ({
|
||||
'--detail-panel-width': `${Math.round(layout.value.panelWidth)}px`,
|
||||
'--detail-panel-height': `${Math.round(layout.value.panelHeight)}px`,
|
||||
}) as Record<string, string>)
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
width: `${Math.round(layout.value.imageWidth)}px`,
|
||||
height: `${Math.round(layout.value.imageHeight)}px`,
|
||||
@@ -119,23 +74,63 @@ function resetImageState() {
|
||||
naturalHeight.value = 0
|
||||
}
|
||||
|
||||
function lockPageScroll() {
|
||||
if (typeof document === 'undefined' || previousBodyOverflow.value !== null) return
|
||||
previousBodyOverflow.value = document.body.style.overflow
|
||||
previousDocumentOverflow.value = document.documentElement.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.documentElement.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
function unlockPageScroll() {
|
||||
if (typeof document === 'undefined' || previousBodyOverflow.value === null) return
|
||||
document.body.style.overflow = previousBodyOverflow.value
|
||||
document.documentElement.style.overflow = previousDocumentOverflow.value ?? ''
|
||||
previousBodyOverflow.value = null
|
||||
previousDocumentOverflow.value = null
|
||||
}
|
||||
|
||||
watch(() => props.open, open => {
|
||||
if (open) {
|
||||
collapsed.value = false
|
||||
resetImageState()
|
||||
if (closeTimer !== null) {
|
||||
window.clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
})
|
||||
closing.value = false
|
||||
modalVisible.value = true
|
||||
lockPageScroll()
|
||||
infoOpen.value = false
|
||||
resetImageState()
|
||||
} else {
|
||||
modalVisible.value = false
|
||||
unlockPageScroll()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.imageUrl, () => {
|
||||
resetImageState()
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (closing.value) return
|
||||
closing.value = true
|
||||
modalVisible.value = false
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
unlockPageScroll()
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
collapsed.value = !collapsed.value
|
||||
closeTimer = window.setTimeout(() => {
|
||||
closeTimer = null
|
||||
unlockPageScroll()
|
||||
emit('close')
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function toggleInfo() {
|
||||
infoOpen.value = !infoOpen.value
|
||||
}
|
||||
|
||||
function handleImageLoad(event: Event) {
|
||||
@@ -164,6 +159,10 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (closeTimer !== null) {
|
||||
window.clearTimeout(closeTimer)
|
||||
}
|
||||
unlockPageScroll()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', updateViewport)
|
||||
}
|
||||
@@ -172,31 +171,33 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
appear
|
||||
name="image-detail-modal"
|
||||
>
|
||||
<div
|
||||
v-if="open"
|
||||
v-if="modalVisible"
|
||||
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
||||
@click="close"
|
||||
>
|
||||
<div
|
||||
class="relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
|
||||
:class="isDesktop ? 'lg:flex lg:items-start' : 'flex flex-col'"
|
||||
:style="rootStyle"
|
||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-if="isDesktop"
|
||||
type="button"
|
||||
@click="togglePanel"
|
||||
class="absolute top-1/2 z-30 hidden h-14 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-lg transition-colors hover:text-slate-800 lg:flex"
|
||||
:style="{ left: `${Math.round(layout.imageWidth)}px` }"
|
||||
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
|
||||
:title="collapsed ? '展开详细信息' : '收起详细信息'"
|
||||
@click="close"
|
||||
class="image-detail-close-button absolute right-4 top-4 z-30 flex h-10 min-h-10 w-10 min-w-10 max-w-10 appearance-none items-center justify-center overflow-hidden border border-white/30 bg-white/15 p-0 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-white/25"
|
||||
aria-label="关闭大图"
|
||||
title="关闭大图"
|
||||
>
|
||||
<span class="text-lg leading-none">{{ collapsed ? '‹' : '›' }}</span>
|
||||
<NIcon size="22">
|
||||
<X />
|
||||
</NIcon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-slate-950 lg:flex-none"
|
||||
class="relative flex min-h-0 items-center justify-center overflow-hidden bg-slate-950"
|
||||
:style="imageStyle"
|
||||
>
|
||||
<div
|
||||
@@ -218,35 +219,27 @@ onBeforeUnmount(() => {
|
||||
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isDesktop"
|
||||
type="button"
|
||||
@click="togglePanel"
|
||||
class="flex items-center justify-center gap-2 border-t border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
:aria-label="collapsed ? '展开详细信息' : '收起详细信息'"
|
||||
:title="collapsed ? '展开详细信息' : '收起详细信息'"
|
||||
@click="toggleInfo"
|
||||
class="absolute bottom-4 right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-transparent text-white drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-colors hover:text-sky-100"
|
||||
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||
>
|
||||
<span>{{ collapsed ? '展开详细信息' : '收起详细信息' }}</span>
|
||||
<span class="text-base leading-none">{{ collapsed ? '▼' : '▲' }}</span>
|
||||
<NIcon size="30">
|
||||
<InfoCircle />
|
||||
</NIcon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden transition-[width,height] duration-300"
|
||||
:class="[
|
||||
collapsed ? 'h-0 lg:w-0' : 'w-full lg:w-[var(--detail-panel-width)]',
|
||||
isDesktop ? 'lg:h-[var(--detail-panel-height)] lg:shrink-0' : 'h-[var(--detail-panel-height)]',
|
||||
]"
|
||||
v-if="infoOpen"
|
||||
class="absolute bottom-16 right-4 z-20 flex max-h-[min(70vh,560px)] w-[min(360px,calc(100%-2rem))] flex-col overflow-hidden border border-white/70 bg-white/95 shadow-2xl backdrop-blur"
|
||||
>
|
||||
<div
|
||||
class="flex h-full flex-col overflow-hidden border-t border-slate-200 bg-white transition-[border-color] duration-300 lg:border-t-0"
|
||||
:class="collapsed ? 'lg:border-l-0' : 'lg:border-l'"
|
||||
>
|
||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<div class="border-b border-slate-200 px-5 py-4">
|
||||
<div class="pr-8">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-2xl font-bold text-slate-900">{{ title }}</h2>
|
||||
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
|
||||
<span
|
||||
v-if="badgeLabel"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
@@ -260,23 +253,55 @@ onBeforeUnmount(() => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="close"
|
||||
class="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
@click="infoOpen = false"
|
||||
class="absolute right-3 top-3 rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label="隐藏详细信息"
|
||||
title="隐藏详细信息"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="border-t border-slate-200 px-6 py-4">
|
||||
<div v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.image-detail-modal-enter-active,
|
||||
.image-detail-modal-leave-active {
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.image-detail-modal-enter-active .image-detail-shell,
|
||||
.image-detail-modal-leave-active .image-detail-shell {
|
||||
transition:
|
||||
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.image-detail-modal-enter-from,
|
||||
.image-detail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.image-detail-modal-enter-from .image-detail-shell,
|
||||
.image-detail-modal-leave-to .image-detail-shell {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.97);
|
||||
}
|
||||
|
||||
.image-detail-close-button.image-detail-close-button {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
|
||||
const props = defineProps<{
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
locationName?: string | null
|
||||
}>()
|
||||
|
||||
const mapEl = ref<HTMLDivElement | null>(null)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
let amapLib: typeof AMap | null = null
|
||||
let mapInstance: AMap.Map | null = null
|
||||
let marker: AMap.Marker | null = null
|
||||
|
||||
const hasCoordinates = computed(() => {
|
||||
return Number.isFinite(props.latitude) && Number.isFinite(props.longitude)
|
||||
})
|
||||
|
||||
const center = computed<[number, number] | null>(() => {
|
||||
if (!hasCoordinates.value) return null
|
||||
return [props.longitude as number, props.latitude as number]
|
||||
})
|
||||
|
||||
function destroyMap() {
|
||||
marker?.setMap(null)
|
||||
marker = null
|
||||
mapInstance?.destroy()
|
||||
mapInstance = null
|
||||
}
|
||||
|
||||
async function initializeMap() {
|
||||
if (!center.value) {
|
||||
destroyMap()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
await nextTick()
|
||||
destroyMap()
|
||||
|
||||
if (!mapEl.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
amapLib = await loadAMap()
|
||||
if ('getConfig' in amapLib) {
|
||||
amapLib.getConfig().appname = 'amap-jsapi-skill'
|
||||
}
|
||||
|
||||
mapInstance = new amapLib.Map(mapEl.value, {
|
||||
viewMode: '2D',
|
||||
zoom: 11,
|
||||
center: center.value,
|
||||
mapStyle: 'amap://styles/normal',
|
||||
features: ['bg', 'road', 'building', 'point'],
|
||||
resizeEnable: true,
|
||||
dragEnable: true,
|
||||
zoomEnable: true,
|
||||
keyboardEnable: false,
|
||||
doubleClickZoom: true,
|
||||
scrollWheel: true,
|
||||
} as AMap.MapOptions)
|
||||
|
||||
mapInstance.addControl(new amapLib.ToolBar({
|
||||
position: 'RT',
|
||||
offset: [8, 8],
|
||||
} as Record<string, unknown>))
|
||||
|
||||
marker = new amapLib.Marker({
|
||||
position: center.value,
|
||||
title: props.locationName || '拍摄位置',
|
||||
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(20,184,166,0.22)]"></div>',
|
||||
offset: new amapLib.Pixel(-8, -8),
|
||||
} as AMap.MarkerOptions)
|
||||
marker.setMap(mapInstance)
|
||||
} catch (error) {
|
||||
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(center, () => {
|
||||
initializeMap()
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyMap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasCoordinates" class="mt-5 overflow-hidden border border-slate-200 bg-white">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置地图</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-900">{{ locationName || '拍摄位置' }}</p>
|
||||
</div>
|
||||
<p class="text-right text-[11px] leading-5 text-slate-500">
|
||||
{{ latitude?.toFixed(4) }}<br />
|
||||
{{ longitude?.toFixed(4) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative h-40 bg-sky-50">
|
||||
<div ref="mapEl" class="h-full w-full"></div>
|
||||
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-white/70 text-sm text-slate-500">
|
||||
地图加载中...
|
||||
</div>
|
||||
<div v-else-if="errorMsg" class="absolute inset-0 flex items-center justify-center bg-white/85 px-4 text-center text-sm text-slate-500">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,402 @@
|
||||
<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">支持 JPG、PNG,可点击或拖拽上传。</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>
|
||||
Vendored
+9
@@ -29,6 +29,11 @@ declare namespace AMap {
|
||||
features?: string[]
|
||||
layers?: unknown[]
|
||||
resizeEnable?: boolean
|
||||
dragEnable?: boolean
|
||||
zoomEnable?: boolean
|
||||
keyboardEnable?: boolean
|
||||
doubleClickZoom?: boolean
|
||||
scrollWheel?: boolean
|
||||
}
|
||||
|
||||
class Marker {
|
||||
@@ -104,6 +109,10 @@ declare function AMapLoader_load(opts: {
|
||||
plugins?: string[]
|
||||
}): Promise<typeof AMap>
|
||||
|
||||
declare namespace AMap {
|
||||
function getConfig(): { appname?: string }
|
||||
}
|
||||
|
||||
declare module '@amap/amap-jsapi-loader' {
|
||||
const AMapLoader: {
|
||||
load: typeof AMapLoader_load
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
@@ -792,6 +793,11 @@ onMounted(loadAdminData)
|
||||
{{ selectedImage.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
:latitude="selectedImage.latitude"
|
||||
:longitude="selectedImage.longitude"
|
||||
:location-name="selectedImage.location_name"
|
||||
/>
|
||||
</ImageDetailModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
@@ -334,6 +335,11 @@ watch(() => route.params.id, () => {
|
||||
{{ selectedGalleryItem.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
:latitude="selectedGalleryItem.latitude"
|
||||
:longitude="selectedGalleryItem.longitude"
|
||||
:location-name="selectedGalleryItem.location_name"
|
||||
/>
|
||||
</ImageDetailModal>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage
|
||||
import { Clock, Location, Settings, User } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
@@ -524,6 +525,11 @@ onUnmounted(() => {
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
:latitude="selectedCloud.latitude"
|
||||
:longitude="selectedCloud.longitude"
|
||||
:location-name="selectedCloud.location_name"
|
||||
/>
|
||||
|
||||
<template v-if="selectedCloudIsMine" #actions>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { Refresh,Map,Satellite} from '@vicons/tabler'
|
||||
import { CloudUpload, Refresh, Map, Satellite } from '@vicons/tabler'
|
||||
|
||||
interface CloudMarkerData {
|
||||
id: string
|
||||
@@ -25,6 +27,7 @@ const mapEl = ref<HTMLDivElement>()
|
||||
const previewCloud = ref<CloudMarkerData | null>(null)
|
||||
const satelliteOn = ref(false)
|
||||
const statusText = ref('加载中...')
|
||||
const quickUploadOpen = ref(false)
|
||||
|
||||
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||
const MIN_MARKER_OPACITY = 0.3
|
||||
@@ -250,6 +253,18 @@ function toggleSat() {
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickUpload() {
|
||||
quickUploadOpen.value = true
|
||||
}
|
||||
|
||||
function closeQuickUpload() {
|
||||
quickUploadOpen.value = false
|
||||
}
|
||||
|
||||
async function handleQuickUploaded() {
|
||||
await refresh()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
AMapLib = await loadAMap()
|
||||
@@ -293,7 +308,18 @@ onUnmounted(() => {
|
||||
<div class="relative h-[100dvh] min-h-screen">
|
||||
<div ref="mapEl" class="w-full h-full"></div>
|
||||
|
||||
<div class="absolute bottom-6 right-4 flex flex-col gap-2 z-10">
|
||||
<div class="absolute bottom-6 right-4 flex flex-col items-end gap-2 z-10">
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50"
|
||||
title="快速上传图片"
|
||||
aria-label="快速上传图片"
|
||||
@click="openQuickUpload"
|
||||
>
|
||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||
<CloudUpload />
|
||||
</NIcon>
|
||||
</button>
|
||||
<button @click="refresh" class="w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50" title="刷新">
|
||||
<span class="text-lg">
|
||||
<NIcon>
|
||||
@@ -348,6 +374,17 @@ onUnmounted(() => {
|
||||
{{ previewCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
:latitude="previewCloud.latitude"
|
||||
:longitude="previewCloud.longitude"
|
||||
:location-name="previewCloud.locationName"
|
||||
/>
|
||||
</ImageDetailModal>
|
||||
|
||||
<QuickUploadModal
|
||||
:open="quickUploadOpen"
|
||||
@close="closeQuickUpload"
|
||||
@uploaded="handleQuickUploaded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NProgress, NSkeleton,
|
||||
import { Settings } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
@@ -839,6 +840,11 @@ watch(selectedUploadDate, async newValue => {
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
:latitude="selectedCloud.latitude"
|
||||
:longitude="selectedCloud.longitude"
|
||||
:location-name="selectedCloud.location_name"
|
||||
/>
|
||||
|
||||
<template v-if="isOwnProfile" #actions>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
Reference in New Issue
Block a user