feat: cloud upload - multi-image, category select, capture time, progress bar
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- `npm run dev` — Vite dev server with HMR
|
||||||
|
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
||||||
|
- `npx vue-tsc -b` — standalone typecheck (no script in package.json)
|
||||||
|
- No linter, formatter, or test runner exists
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Supabase + AMap (高德地图)**
|
||||||
|
- Path alias `@/` → `src/` (configured in both `vite.config.ts` and `tsconfig.app.json`)
|
||||||
|
- Supabase client singleton at `src/lib/supabase.ts` — throws at import if env vars missing
|
||||||
|
- AMap loaded lazily via `src/lib/amap.ts` with hand-rolled type declarations in `src/types/amap.d.ts`
|
||||||
|
- UI language is Chinese (zh-CN)
|
||||||
|
|
||||||
|
## Auth Flow (non-obvious)
|
||||||
|
|
||||||
|
- **`main.ts` races on pathname**: `/auth/confirm` mounts the app immediately without calling `authStore.initialize()`. All other routes wait for auth initialization before mounting. If you add new routes that need to bypass auth init, add them to the `if` check in `main.ts`.
|
||||||
|
- **AuthConfirmView uses a separate Supabase client**: It creates a temporary `createClient()` to call `setSession()` then `signOut()`, so the main auth store never sees a logged-in state. Do NOT consolidate with the singleton — the singleton isn't initialized at confirmation time.
|
||||||
|
- **Login sets `user.value` explicitly**: `login()` extracts `data.user` from `signInWithPassword` response and assigns it to the store directly, rather than relying on `onAuthStateChange`.
|
||||||
|
- **Profile is auto-created by DB trigger** (`handle_new_user` on `auth.users`), not by the frontend. The trigger uses `SECURITY DEFINER` with `SET search_path = public`.
|
||||||
|
- Auth error messages are translated to Chinese in the store.
|
||||||
|
|
||||||
|
## Supabase
|
||||||
|
|
||||||
|
- Env var is `VITE_SUPABASE_PUBLISHABLE_KEY` (not `VITE_SUPABASE_ANON_KEY`), using Supabase's `sb_publishable_` key format.
|
||||||
|
- All tables have RLS enabled. Check `plan.md` section 10 for the full schema and RLS policies.
|
||||||
|
- Storage bucket `clouds` is public read, authenticated upload.
|
||||||
|
- Profile `role` field (`user`/`admin`) controls admin access — checked in route guard, not in JWT metadata.
|
||||||
|
|
||||||
|
## MVP Constraints (from plan.md)
|
||||||
|
|
||||||
|
- No Supabase Realtime — refresh-based loading
|
||||||
|
- No OAuth — email/password only, email confirmation required
|
||||||
|
- No AI cloud identification — manual type selection
|
||||||
|
- AMap only (China-focused), no Mapbox fallback yet
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required now (app won't start without):
|
||||||
|
- `VITE_SUPABASE_URL`
|
||||||
|
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
||||||
|
|
||||||
|
Required for map features (views are stubs without):
|
||||||
|
- `VITE_AMAP_KEY`
|
||||||
|
- `VITE_AMAP_SECRET`
|
||||||
|
|
||||||
|
Future (Supabase Dashboard only, not in `.env`):
|
||||||
|
- `OPENAI_API_KEY`, `OPENWEATHERMAP_API_KEY`
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export interface UploadItem {
|
||||||
|
id: string
|
||||||
|
file: File
|
||||||
|
preview: string
|
||||||
|
cloudCategoryId: number | 'other' | null
|
||||||
|
customCloudType: string
|
||||||
|
locationName: string
|
||||||
|
description: string
|
||||||
|
isHidden: boolean
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
capturedAt: string
|
||||||
|
errors: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||||
|
|
||||||
|
let offset = 2
|
||||||
|
while (offset < view.byteLength - 2) {
|
||||||
|
const marker = view.getUint16(offset, false)
|
||||||
|
offset += 2
|
||||||
|
if ((marker & 0xff00) !== 0xff00) break
|
||||||
|
const length = view.getUint16(offset, false)
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
if (marker === 0xffe1) {
|
||||||
|
const exifStart = offset
|
||||||
|
const header = String.fromCharCode(
|
||||||
|
view.getUint8(exifStart),
|
||||||
|
view.getUint8(exifStart + 1),
|
||||||
|
view.getUint8(exifStart + 2),
|
||||||
|
view.getUint8(exifStart + 3),
|
||||||
|
)
|
||||||
|
if (header !== 'Exif') break
|
||||||
|
|
||||||
|
const tiffStart = exifStart + 6
|
||||||
|
const isLE = view.getUint16(tiffStart, false) === 0x4949
|
||||||
|
const ifd0Offset = view.getUint32(tiffStart + 4, isLE)
|
||||||
|
const ifdStart = tiffStart + ifd0Offset
|
||||||
|
const numEntries = view.getUint16(ifdStart, isLE)
|
||||||
|
|
||||||
|
for (let i = 0; i < numEntries; i++) {
|
||||||
|
const entryStart = ifdStart + 2 + i * 12
|
||||||
|
if (entryStart + 12 > buffer.byteLength) break
|
||||||
|
const tag = view.getUint16(entryStart, isLE)
|
||||||
|
if (tag === 0x9003) {
|
||||||
|
const dataOffset = view.getUint32(entryStart + 8, isLE)
|
||||||
|
const dateStr = Array.from({ length: 20 }, (_, j) =>
|
||||||
|
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
|
||||||
|
).join('')
|
||||||
|
const iso = dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (!isNaN(d.getTime())) return d.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += length - 2
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpload() {
|
||||||
|
const items = ref<UploadItem[]>([])
|
||||||
|
const uploading = ref(false)
|
||||||
|
const overallProgress = ref(0)
|
||||||
|
const currentItemIndex = ref(0)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
|
||||||
|
async function addFiles(files: File[]) {
|
||||||
|
const imageFiles = files.filter(f => f.type.startsWith('image/'))
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
let capturedAt = new Date().toISOString()
|
||||||
|
try {
|
||||||
|
const buffer = await readFileAsArrayBuffer(file)
|
||||||
|
const exifDate = extractExifDate(buffer)
|
||||||
|
if (exifDate) capturedAt = exifDate
|
||||||
|
} catch {
|
||||||
|
// fallback to now
|
||||||
|
}
|
||||||
|
|
||||||
|
items.value.push({
|
||||||
|
id: String(++nextId),
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
cloudCategoryId: null,
|
||||||
|
customCloudType: '',
|
||||||
|
locationName: '',
|
||||||
|
description: '',
|
||||||
|
isHidden: false,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
capturedAt,
|
||||||
|
errors: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(id: string) {
|
||||||
|
const idx = items.value.findIndex(i => i.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
URL.revokeObjectURL(items.value[idx].preview)
|
||||||
|
items.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateItem(item: UploadItem): boolean {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
if (!item.cloudCategoryId) {
|
||||||
|
errors.cloudCategory = '请选择类别'
|
||||||
|
}
|
||||||
|
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||||
|
errors.cloudCategory = '请输入自定义云型名称'
|
||||||
|
}
|
||||||
|
if (!item.capturedAt) {
|
||||||
|
errors.capturedAt = '请选择拍摄时间'
|
||||||
|
}
|
||||||
|
item.errors = errors
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
let allValid = true
|
||||||
|
for (const item of items.value) {
|
||||||
|
if (!validateItem(item)) {
|
||||||
|
allValid = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allValid
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurCoordinate(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAll(): Promise<boolean> {
|
||||||
|
if (!validateAll()) return false
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
totalItems.value = items.value.length
|
||||||
|
currentItemIndex.value = 0
|
||||||
|
overallProgress.value = 0
|
||||||
|
|
||||||
|
const userId = (await supabase.auth.getUser()).data.user?.id
|
||||||
|
if (!userId) {
|
||||||
|
uploading.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < items.value.length; i++) {
|
||||||
|
const item = items.value[i]
|
||||||
|
currentItemIndex.value = i + 1
|
||||||
|
|
||||||
|
overallProgress.value = Math.round(i / items.value.length * 100)
|
||||||
|
|
||||||
|
const ext = item.file.name.split('.').pop() || 'jpg'
|
||||||
|
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const imagePath = `${basePath}.${ext}`
|
||||||
|
|
||||||
|
const { error: imgError } = await supabase.storage
|
||||||
|
.from('clouds')
|
||||||
|
.upload(imagePath, item.file, { upsert: false })
|
||||||
|
if (imgError) throw imgError
|
||||||
|
|
||||||
|
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
||||||
|
|
||||||
|
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
||||||
|
|
||||||
|
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||||
|
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||||
|
|
||||||
|
const { error: dbError } = await supabase
|
||||||
|
.from('clouds')
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
||||||
|
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
|
||||||
|
image_url: imageUrl,
|
||||||
|
thumbnail_url: null,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
location_name: item.locationName || null,
|
||||||
|
description: item.description.trim() || null,
|
||||||
|
captured_at: item.capturedAt,
|
||||||
|
is_hidden: item.isHidden,
|
||||||
|
})
|
||||||
|
if (dbError) throw dbError
|
||||||
|
|
||||||
|
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items.value) {
|
||||||
|
URL.revokeObjectURL(item.preview)
|
||||||
|
}
|
||||||
|
items.value = []
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, validateItem, validateAll, uploadAll }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
|
export const useCloudsStore = defineStore('clouds', () => {
|
||||||
|
const cloudTypes = ref<CloudType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchCloudTypes() {
|
||||||
|
if (cloudTypes.value.length > 0) return
|
||||||
|
loading.value = true
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('cloud_types')
|
||||||
|
.select('*')
|
||||||
|
.order('id')
|
||||||
|
if (data) {
|
||||||
|
cloudTypes.value = data as CloudType[]
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cloudTypes, loading, fetchCloudTypes }
|
||||||
|
})
|
||||||
@@ -12,13 +12,15 @@ export interface CloudType {
|
|||||||
export interface Cloud {
|
export interface Cloud {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
cloud_type_id: number
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
image_url: string
|
image_url: string
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
location_name: string | null
|
location_name: string | null
|
||||||
weather: string | null
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
is_hidden: boolean
|
is_hidden: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -1,11 +1,357 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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 activeId = ref<string | null>(null)
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const successMsg = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const locating = ref(false)
|
||||||
|
const locationError = ref('')
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ok = await uploadAll()
|
||||||
|
if (ok) {
|
||||||
|
successMsg.value = true
|
||||||
|
setTimeout(() => router.push('/profile'), 2000)
|
||||||
|
} else {
|
||||||
|
errorMsg.value = '上传失败,请稍后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLocation() {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
locating.value = true
|
||||||
|
locationError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const AMap = await loadAMap()
|
||||||
|
const geolocation = new AMap.Geolocation({
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
await new Promise<void>((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<GeolocationPosition>((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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)]">
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
<div class="text-center">
|
<!-- 全局隐藏文件输入 -->
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">📷 上传云图</h1>
|
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
||||||
<p class="text-gray-500">上传功能开发中...</p>
|
|
||||||
|
<!-- 成功提示 -->
|
||||||
|
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="text-5xl block mb-4">✅</span>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">上传成功!</h2>
|
||||||
|
<p class="text-gray-500">正在跳转到个人主页...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">📷 上传云图</h1>
|
||||||
|
|
||||||
|
<!-- 上传进度 -->
|
||||||
|
<div v-if="uploading" class="mb-6">
|
||||||
|
<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>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
|
||||||
|
<div class="bg-sky-500 h-full rounded-full transition-all duration-300" :style="{ width: overallProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
</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 rounded-2xl py-20 cursor-pointer transition-colors"
|
||||||
|
: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 rounded-xl overflow-hidden mb-3">
|
||||||
|
<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 rounded-lg overflow-hidden cursor-pointer border-2 transition-colors"
|
||||||
|
: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 rounded-full"></div>
|
||||||
|
<button
|
||||||
|
@click.stop="handleRemove(item.id)"
|
||||||
|
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center"
|
||||||
|
style="opacity:0.8"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
class="flex-shrink-0 w-16 h-16 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer 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">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-xl p-6 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>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">位置</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="activeItem.locationName"
|
||||||
|
type="text"
|
||||||
|
placeholder="城市或区域名"
|
||||||
|
class="flex-1 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="getLocation"
|
||||||
|
type="button"
|
||||||
|
:disabled="locating"
|
||||||
|
class="px-3 py-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors text-sm whitespace-nowrap disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ locating ? '定位中...' : '📍 获取位置' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="locationError" class="text-xs text-red-400 mt-1">{{ locationError }}</p>
|
||||||
|
<p v-else-if="activeItem.latitude && activeItem.longitude" class="text-xs text-gray-400 mt-1">
|
||||||
|
已获取坐标 ({{ activeItem.latitude.toFixed(2) }}, {{ activeItem.longitude.toFixed(2) }})
|
||||||
|
</p>
|
||||||
|
</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="flex items-center gap-2">
|
||||||
|
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
|
||||||
|
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式(地图不显示位置)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<button
|
||||||
|
@click="() => { for (const i of items) URL.revokeObjectURL(i.preview); items = [] }"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="px-5 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleSubmit"
|
||||||
|
:disabled="uploading"
|
||||||
|
class="px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{{ uploading ? '上传中...' : '提交上传' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user