feat: cloud upload - multi-image, category select, capture time, progress bar

This commit is contained in:
2026-05-21 01:14:19 +08:00
parent d4b07fba58
commit cb2581afb2
5 changed files with 650 additions and 6 deletions
+51
View File
@@ -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`
+221
View File
@@ -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 }
}
+24
View File
@@ -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 }
})
+4 -2
View File
@@ -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
+349 -3
View File
@@ -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">
<!-- 全局隐藏文件输入 -->
<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"> <div class="text-center">
<h1 class="text-2xl font-bold text-gray-900 mb-2">📷 上传云图</h1> <span class="text-5xl block mb-4"></span>
<p class="text-gray-500">上传功能开发中...</p> <h2 class="text-2xl font-bold text-gray-900 mb-2">上传成功</h2>
<p class="text-gray-500">正在跳转到个人主页...</p>
</div> </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">支持 JPGPNG 格式可一次选择多张</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> </template>