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 {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
weather: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
created_at: string
|
||||
|
||||
@@ -1,11 +1,357 @@
|
||||
<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>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">📷 上传云图</h1>
|
||||
<p class="text-gray-500">上传功能开发中...</p>
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user