diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..03bf1d6 --- /dev/null +++ b/AGENTS.md @@ -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` diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts new file mode 100644 index 0000000..60989d6 --- /dev/null +++ b/src/composables/useUpload.ts @@ -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 +} + +let nextId = 0 + +function readFileAsArrayBuffer(file: File): Promise { + 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([]) + 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 = {} + 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 { + 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 } +} diff --git a/src/stores/clouds.ts b/src/stores/clouds.ts new file mode 100644 index 0000000..5a7a29e --- /dev/null +++ b/src/stores/clouds.ts @@ -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([]) + 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 } +}) diff --git a/src/types/database.ts b/src/types/database.ts index 1a67d60..b16c01a 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -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 diff --git a/src/views/upload/UploadView.vue b/src/views/upload/UploadView.vue index d1c638d..29c2da8 100644 --- a/src/views/upload/UploadView.vue +++ b/src/views/upload/UploadView.vue @@ -1,11 +1,357 @@