From 2b7f393d7cfdd9fc3b00d600a526c5544a581570 Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 21 May 2026 16:54:38 +0800 Subject: [PATCH] feat: improve map and upload experience --- src/composables/useUpload.ts | 18 ++- src/lib/canvas.ts | 37 ++++++ src/main.ts | 6 +- src/types/amap.d.ts | 56 +++++++- src/views/map/MapView.vue | 228 +++++++++++++++++++++++++++++++- src/views/upload/UploadView.vue | 126 ++++++++---------- 6 files changed, 386 insertions(+), 85 deletions(-) create mode 100644 src/lib/canvas.ts diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts index 60989d6..46a3d6b 100644 --- a/src/composables/useUpload.ts +++ b/src/composables/useUpload.ts @@ -119,6 +119,13 @@ export function useUpload() { } } + function clearAll() { + for (const item of items.value) { + URL.revokeObjectURL(item.preview) + } + items.value = [] + } + function validateItem(item: UploadItem): boolean { const errors: Record = {} if (!item.cloudCategoryId) { @@ -130,6 +137,14 @@ export function useUpload() { if (!item.capturedAt) { errors.capturedAt = '请选择拍摄时间' } + const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude) + const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude) + if (hasLat && !hasLng) { + errors.longitude = '经纬度必须同时填写' + } + if (hasLng && !hasLat) { + errors.latitude = '经纬度必须同时填写' + } item.errors = errors return Object.keys(errors).length === 0 } @@ -198,6 +213,7 @@ export function useUpload() { location_name: item.locationName || null, description: item.description.trim() || null, captured_at: item.capturedAt, + status: 'approved', is_hidden: item.isHidden, }) if (dbError) throw dbError @@ -217,5 +233,5 @@ export function useUpload() { } } - return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, validateItem, validateAll, uploadAll } + return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll } } diff --git a/src/lib/canvas.ts b/src/lib/canvas.ts new file mode 100644 index 0000000..4190083 --- /dev/null +++ b/src/lib/canvas.ts @@ -0,0 +1,37 @@ +const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__' + +type CanvasLikePrototype = { + getContext?: (contextId: string, options?: unknown) => unknown + [PATCH_FLAG]?: boolean +} + +function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) { + const prototype = ctor?.prototype as CanvasLikePrototype | undefined + if (!prototype?.getContext || prototype[PATCH_FLAG]) return + + const originalGetContext = prototype.getContext + + prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) { + if (contextId === '2d') { + const nextOptions = + options && typeof options === 'object' + ? { ...options as Record, willReadFrequently: true } + : { willReadFrequently: true } + + return originalGetContext.call(this, contextId, nextOptions) + } + + return originalGetContext.call(this, contextId, options) + } + + prototype[PATCH_FLAG] = true +} + +export function enableCanvasReadbackHint() { + if (typeof window === 'undefined') return + + patch2DContextOptions(window.HTMLCanvasElement) + if (typeof window.OffscreenCanvas !== 'undefined') { + patch2DContextOptions(window.OffscreenCanvas) + } +} diff --git a/src/main.ts b/src/main.ts index ed7eb62..ccde5a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,19 +3,23 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import { useAuthStore } from './stores/auth' +import { enableCanvasReadbackHint } from './lib/canvas' import './style.css' +enableCanvasReadbackHint() + const app = createApp(App) const pinia = createPinia() app.use(pinia) -app.use(router) const authStore = useAuthStore() if (window.location.pathname === '/auth/confirm') { + app.use(router) app.mount('#app') } else { authStore.initialize().then(() => { + app.use(router) app.mount('#app') }) } diff --git a/src/types/amap.d.ts b/src/types/amap.d.ts index eec84e6..1fbfcb6 100644 --- a/src/types/amap.d.ts +++ b/src/types/amap.d.ts @@ -1,30 +1,69 @@ declare namespace AMap { class Map { - constructor(container: string | HTMLElement, opts?: Record) + constructor(container: string | HTMLElement, opts?: MapOptions) addControl(control: unknown): void add(layer: unknown): void remove(layer: unknown): void destroy(): void setCenter(center: [number, number]): void setZoom(zoom: number): void + setPitch(pitch: number): void + setRotation(rotation: number): void on(event: string, callback: (...args: unknown[]) => void): void off(event: string, callback: (...args: unknown[]) => void): void getCenter(): { lng: number; lat: number } getZoom(): number + getAllOverlays(type?: string): unknown[] + addOverlay(overlay: unknown): void + removeOverlay(overlay: unknown): void + clearMap(): void + } + + interface MapOptions { + viewMode?: '2D' | '3D' + pitch?: number + rotation?: number + zoom?: number + center?: [number, number] + mapStyle?: string + features?: string[] + layers?: unknown[] + resizeEnable?: boolean } class Marker { - constructor(opts?: Record) + constructor(opts?: MarkerOptions) on(event: string, callback: (...args: unknown[]) => void): void setPosition(position: [number, number]): void + getPosition(): { lng: number; lat: number } + setContent(content: string): void + setOpacity(opacity: number): void + setMap(map: Map | null): void + remove(): void + } + + interface MarkerOptions { + position?: [number, number] + content?: string | HTMLElement + offset?: Pixel + zIndex?: number + opacity?: number + title?: string } class InfoWindow { - constructor(opts?: Record) - open(map: Map, position: [number, number]): void + constructor(opts?: InfoWindowOptions) + open(map: Map, position?: [number, number]): void close(): void } + interface InfoWindowOptions { + content?: string | HTMLElement + offset?: Pixel + size?: Size + isCustom?: boolean + } + class Scale {} class ToolBar { constructor(opts?: Record) @@ -41,14 +80,19 @@ declare namespace AMap { } class TileLayer { - static Satellite: new () => unknown - static RoadNet: new () => unknown + static Satellite: new () => TileLayer + static RoadNet: new () => TileLayer + setOpacity(opacity: number): void } class Pixel { constructor(x: number, y: number) } + class Size { + constructor(width: number, height: number) + } + class MarkerCluster { constructor(map: Map, markers: Marker[], opts?: Record) } diff --git a/src/views/map/MapView.vue b/src/views/map/MapView.vue index b45882f..ff067fe 100644 --- a/src/views/map/MapView.vue +++ b/src/views/map/MapView.vue @@ -1,11 +1,231 @@ diff --git a/src/views/upload/UploadView.vue b/src/views/upload/UploadView.vue index 29c2da8..5013063 100644 --- a/src/views/upload/UploadView.vue +++ b/src/views/upload/UploadView.vue @@ -3,19 +3,16 @@ 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 { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload() const activeId = ref(null) const dragOver = ref(false) const successMsg = ref(false) const errorMsg = ref('') const fileInput = ref(null) -const locating = ref(false) -const locationError = ref('') const activeItem = computed(() => { if (!activeId.value) return null @@ -80,46 +77,21 @@ async function handleSubmit() { } } -async function getLocation() { - if (!activeItem.value) return - locating.value = true - locationError.value = '' +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 + } +} - try { - const AMap = await loadAMap() - const geolocation = new AMap.Geolocation({ - enableHighAccuracy: true, - timeout: 10000, - }) - await new Promise((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((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 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 } } @@ -158,10 +130,8 @@ onUnmounted(() => { for (const item of items.value) URL.revokeObjectURL(item.pre